feat: publish the Sandwich Hime source preview
Signed-off-by: Cole Speelman <gamertan@noreply.localhost>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
[{*.md,*.sando,*.json,*.yml,*.yaml,*.html,*.css,*.js}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
@@ -0,0 +1,13 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
* text=auto eol=lf
|
||||
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/bin/
|
||||
/dist/
|
||||
/coverage/
|
||||
*.test
|
||||
*.prof
|
||||
|
||||
# Local secrets and data never belong in a source snapshot.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.key
|
||||
*.pem
|
||||
*.p12
|
||||
*.pfx
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# himesan dev keeps candidates in the user cache, never in the project.
|
||||
@@ -0,0 +1,13 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# AI-assisted contributions
|
||||
|
||||
AI-assisted work is welcome when a responsible human or legal entity takes responsibility for the contribution and has authority to submit it.
|
||||
|
||||
The human contributor must review every material change, understand its behavior, investigate provenance appropriate to the risk, run appropriate tests, disclose material AI assistance in the change description, and personally make the DCO certification. The project does not accept an AI system as its contributor, reviewer, signatory, or maintainer. This is an intake rule, not a general legal conclusion about authorship in every jurisdiction.
|
||||
|
||||
Do not submit generated text or code whose license or training-source reproduction is reasonably in doubt. Security-sensitive, cryptographic, licensing, governance, and release changes require meaningful human review beyond accepting tool output. Maintainers may request prompts, intermediate rationale, or a narrower independently rewritten change when provenance or comprehension is unclear.
|
||||
|
||||
Do not disclose secrets, personal data, employer-confidential material, or third-party confidential prompts to satisfy a review request. A contributor may instead provide a human-written provenance summary or withdraw the contribution.
|
||||
|
||||
For emitted compiler scaffolding, the responsible contributor must also have authority to grant and record the `Himesan-Output-Permission: v1.0` trailer required by [CONTRIBUTING.md](CONTRIBUTING.md). Human or AI review cannot substitute for that rights grant.
|
||||
@@ -0,0 +1,25 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Changelog
|
||||
|
||||
Sandwich Hime follows semantic versioning after v1. Compiler and nested runtime releases are listed separately.
|
||||
|
||||
## Unreleased — v1 proof
|
||||
|
||||
### Added
|
||||
|
||||
- Typed one-component `.sando` language and explicit `<?sando go` target.
|
||||
- Context-annotated renderer IR with deterministic, atomic Go generation.
|
||||
- Read-only stale-output checking and structured diagnostics.
|
||||
- Independent Apache-2.0 `sando` component/runtime ABI.
|
||||
- Loopback-only last-good development supervisor with SSE reload and diagnostic overlay.
|
||||
- Synthetic EQL-shaped proof fixture and production pilot plan.
|
||||
- Multi-license, security, governance, trademark, AI contribution, and release policies.
|
||||
|
||||
### Removed
|
||||
|
||||
- Unpublished `.go.hime` syntax and 2025 generated API.
|
||||
- Injected helper directories, nested demo modules, Go plugins, and manually repaired generated output.
|
||||
- Placeholder novelty commands that did not perform project work.
|
||||
|
||||
Private prototype history is intentionally outside the sanitized public repository. The public changelog begins with the pre-1.0 source preview.
|
||||
@@ -0,0 +1,15 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Individual contribution agreement — counsel-review draft
|
||||
|
||||
**This draft is not active, is not offered for signature, and creates no rights or obligations. Public pre-1.0 contributions use DCO 1.1 instead.**
|
||||
|
||||
The intended agreement preserves contributor ownership. For each contribution, the contributor represents that they have the right to submit it and grants the project and recipients a perpetual, worldwide, non-exclusive, royalty-free copyright license to use, reproduce, modify, distribute, publicly perform, publicly display, and sublicense the contribution under the license identified by the contributed file.
|
||||
|
||||
The intended patent grant covers patent claims the contributor can license that are necessarily infringed by their contribution alone or in combination with the project as submitted. Patent litigation over the contribution terminates the patent grant to the extent counsel determines lawful and appropriate.
|
||||
|
||||
The intended agreement requires no copyright assignment. For AGPL-covered compiler contributions, it is not intended to grant a separate general right to proprietary-relicense contributor code; this does not restrict permissions already granted by a file's license, including Apache-2.0 or 0BSD. Emitted compiler scaffolding would need the narrow additional permission recorded in `OUTPUT_EXCEPTION.md`. The agreement does not promise that a contribution will be accepted or retained.
|
||||
|
||||
The contributor represents that disclosed material AI assistance was reviewed by a responsible human, that the human can make the same provenance and rights representations, and that no AI system is presented as the legal contributor.
|
||||
|
||||
If the project later considers activating a contribution agreement, qualified counsel review should address parties, definitions, governing law, notice, entity contributions, employer authorization, moral-rights treatment, patent language, and signature mechanics. Any active agreement would be announced prospectively. Until then, repository DCO sign-off is the operative contribution certification and does not activate this draft.
|
||||
@@ -0,0 +1,31 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Contributing
|
||||
|
||||
The canonical public project and only contribution venue is the Gamertan Gitea repository. Repository maintainers may temporarily disable issue or patch intake during the pre-1.0 preview; do not route around a closed intake channel by sending unsolicited private patches.
|
||||
|
||||
Public pre-1.0 contributions use Developer Certificate of Origin 1.1 sign-off. The proposed `CLA.md` is an inactive draft, is not a condition of contribution, and creates no contributor or project obligations. If a contribution agreement is ever activated after legal review, the project will announce its prospective terms rather than silently applying the draft.
|
||||
|
||||
For local work:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
go vet ./...
|
||||
(cd sando && go test -race ./... && go vet ./...)
|
||||
go run ./cmd/himesan check ./examples/eql-shaped
|
||||
```
|
||||
|
||||
Changes require focused tests, stable diagnostics, formatted generated goldens when applicable, documentation for public behavior, and a signed-off commit (`git commit -s`). The sign-off certifies the [DCO](DCO.txt); it is not a copyright assignment or acceptance of the inactive CLA. Do not commit an EQL database, production data, secrets, build candidates, or developer cache files.
|
||||
|
||||
The project requires no copyright assignment. Ownership remains determined by applicable law and any employer or other agreement. Contributors submit each file under the license identified for that repository area, and the DCO records their certification that they have the right to do so. Material AI assistance must follow [AI_CONTRIBUTIONS.md](AI_CONTRIBUTIONS.md). Review considers provenance, safety, maintenance cost, compatibility, and fit—not just whether code passes tests.
|
||||
|
||||
If a compiler contribution adds or changes contributor-owned scaffolding that Hime-san is intended to copy into generated output, its signed-off commit must also contain this trailer:
|
||||
|
||||
```text
|
||||
Himesan-Output-Permission: v1.0
|
||||
```
|
||||
|
||||
That trailer records the contributor's grant of the additional permission in [OUTPUT_EXCEPTION.md](OUTPUT_EXCEPTION.md) for the affected contribution. DCO sign-off does not supply that separate grant. Maintainers must preserve the signed grant in the private contribution record even when a sanitized public snapshot uses fresh history. A patch without it must not cause contributor-owned text to be emitted; maintainers must reject or redesign such a patch rather than assume permission.
|
||||
|
||||
Potential vulnerabilities follow [SECURITY.md](SECURITY.md), not the ordinary contribution channel. Do not place confidential vulnerability details in an issue or patch description.
|
||||
@@ -0,0 +1,16 @@
|
||||
SPDX-FileCopyrightText: 2025-2026 Cole Speelman
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
Original Sandwich Hime compiler, command-line, development-tooling, and
|
||||
project-documentation work is Copyright (c) 2025-2026 Cole Speelman except
|
||||
where a file or contribution identifies a different copyright holder.
|
||||
|
||||
The project requires no copyright assignment from contributors. Ownership of
|
||||
a contribution remains determined by applicable law and any existing
|
||||
agreement. This notice does not claim copyright in third-party license texts,
|
||||
the Developer Certificate of Origin, user-authored templates or application
|
||||
code, generated application output beyond the limited compiler-authored
|
||||
material addressed by OUTPUT_EXCEPTION.md, or work owned by another person.
|
||||
|
||||
The nested sando runtime and copyable example have their own notices and
|
||||
licenses. Project names and artwork are addressed separately in TRADEMARKS.md.
|
||||
@@ -0,0 +1,34 @@
|
||||
Developer Certificate of Origin
|
||||
Version 1.1
|
||||
|
||||
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
|
||||
Developer's Certificate of Origin 1.1
|
||||
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
(a) The contribution was created in whole or in part by me and I
|
||||
have the right to submit it under the open source license
|
||||
indicated in the file; or
|
||||
|
||||
(b) The contribution is based upon previous work that, to the best
|
||||
of my knowledge, is covered under an appropriate open source
|
||||
license and I have the right under that license to submit that
|
||||
work with modifications, whether created in whole or in part
|
||||
by me, under the same open source license (unless I am
|
||||
permitted to submit under a different license), as indicated
|
||||
in the file; or
|
||||
|
||||
(c) The contribution was provided directly to me by some other
|
||||
person who certified (a), (b) or (c) and I have not modified
|
||||
it.
|
||||
|
||||
(d) I understand and agree that this project and the contribution
|
||||
are public and that a record of the contribution (including all
|
||||
personal information I submit with it, including my sign-off) is
|
||||
maintained indefinitely and may be redistributed consistent with
|
||||
this project or the open source license(s) involved.
|
||||
@@ -0,0 +1,14 @@
|
||||
<!-- SPDX-FileCopyrightText: 2025-2026 Cole Speelman -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Generated code policy
|
||||
|
||||
`.sando.go` files are compiler-managed outputs only when they contain the exact Sandwich Hime generated marker and name the adjacent `.sando` source. “Compiler-managed” describes replacement behavior, not copyright ownership. The compiler may atomically replace that exact output; it never edits other Go files.
|
||||
|
||||
Generated files are committed by default so production builds and source audits do not require the compiler. `himesan check` recreates output in memory and reports missing, invalid, or stale files without writing.
|
||||
|
||||
Output must be deterministic for identical source, compiler version, runtime ABI, and platform-independent inputs. Unchanged bytes retain their existing timestamp. If any affected source cannot be parsed, context-checked, or formatted, no output in that operation is replaced and last-good files remain available.
|
||||
|
||||
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.
|
||||
|
||||
[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.
|
||||
@@ -0,0 +1,13 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Founder-led governance
|
||||
|
||||
Sandwich Hime is founder-led. Cole Speelman is lead maintainer and has final authority over product direction, merges, releases, project identity, and security coordination. Authority is exercised through reviewable public releases, written rationale for material public decisions, and the published compatibility and release policies. Publishing a source preview does not create a promise of support or transfer stewardship.
|
||||
|
||||
Pre-1.0 contributions use the DCO process described in `CONTRIBUTING.md`; the CLA draft is inactive. Acceptance is never guaranteed. Maintainers may close work that expands the product beyond an HTML-first Go component compiler, weakens fail-closed safety, couples the runtime to a framework, or imposes unsustainable maintenance.
|
||||
|
||||
Maintainers disclose relevant employment or financial conflicts and recuse when a conflict would compromise review. If the project reaches three maintainers, no single employer may control a majority of seats.
|
||||
|
||||
Repository ownership, domain control, package namespace, release signing, and security recovery must not depend on a sponsor account. Before a supported v1.0.0 release, at least two independent humans must hold documented recovery capability. Succession may transfer official stewardship to a person or mission-aligned organization committed to the license map, public source releases, and trademark policy.
|
||||
|
||||
The project does not accept copyright assignment as a condition of contribution. It will not represent that it can proprietary-relicense contributor-owned code unless it has permission from every rights holder whose permission is required. This governance statement does not alter rights already granted by an applicable file license.
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<!-- SPDX-FileCopyrightText: 2025-2026 Cole Speelman -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# License map and generated-output policy
|
||||
|
||||
Sandwich Hime deliberately separates the development tool from application runtime code.
|
||||
|
||||
| Path or material | License |
|
||||
| --- | --- |
|
||||
| Project-authored files in the repository root, `cmd/**`, `internal/**`, `docs/**`, `scripts/**`, and `site/**`, except the legal texts listed below | AGPL-3.0-only |
|
||||
| Nested `sando/**` runtime module, except its verbatim license text | Apache-2.0 |
|
||||
| Copyable `examples/**`, except their generated-file treatment described below | 0BSD |
|
||||
| `LICENSE`, `sando/LICENSE`, example `LICENSE` files, and `DCO.txt` | Their own stated copying terms and notices |
|
||||
| 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 |
|
||||
|
||||
Sandwich Hime claims no copyright in a user's template merely because the compiler processes it. Generated files contain application input, ordinary Go syntax, calls to the Apache-licensed runtime, and a small amount of compiler-authored scaffolding. [OUTPUT_EXCEPTION.md](OUTPUT_EXCEPTION.md) grants an additional permission for Cole Speelman-owned scaffolding copied into generated output. Under the terms granted by this project, generation alone does not require the generated file or surrounding application to use the AGPL. This permission does not grant rights in user inputs, third-party material, or additions owned by other compiler contributors.
|
||||
|
||||
The AGPL compiler is a separately installed development process. The Apache runtime must never import an AGPL package. Importing the Apache runtime or using generated output does not, by itself, incorporate the compiler into an application. Redistribution of the runtime remains subject to Apache-2.0 and any other applicable third-party obligations.
|
||||
|
||||
`COPYRIGHT` identifies Cole Speelman's original project work without claiming contributor-owned work. The nested runtime carries its own `sando/COPYRIGHT`, and the example identifies its original author in `examples/eql-shaped/LICENSE`. The project requires no copyright assignment; ownership of contributions remains determined by applicable law and existing agreements.
|
||||
|
||||
A compiler contribution that adds text intended to be copied into generated output must record the `Himesan-Output-Permission: v1.0` grant required by [CONTRIBUTING.md](CONTRIBUTING.md). Without that grant, the contribution must be designed so its contributor-owned text is not emitted. DCO sign-off alone does not grant the additional output permission.
|
||||
|
||||
Official flags, mascots, and badges are not covered merely because they use a project mark. Each published asset must identify its copyright holder and reuse license.
|
||||
|
||||
SPDX identifiers state the applicable license for comment-capable source and documentation. Directory-level maps cover generated files and formats such as JSON that cannot safely carry comments. Full license texts are at `LICENSE` and `sando/LICENSE`; examples carry their own `LICENSE` and `LICENSES.md`. License texts and the verbatim `DCO.txt` retain their own notices and are not relicensed as project documentation.
|
||||
|
||||
The snapshot exporter's `PUBLIC-SNAPSHOT.json` and `PUBLIC-SNAPSHOT.sha256` are generated factual provenance records and intentionally carry no inline SPDX comment. They do not change the license of any listed file.
|
||||
|
||||
These are practical project licensing terms, not legal advice or a prediction of how every jurisdiction will classify a particular work. The inactive CLA draft and pre-registration trademark policy say so explicitly. Qualified legal review remains prudent before changing these terms, activating a CLA, registering marks, or making a fact-specific licensing decision; it is not represented as a prerequisite to publishing the current unsupported source preview.
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Maintainers
|
||||
|
||||
| Maintainer | Role | Scope |
|
||||
| --- | --- | --- |
|
||||
| Cole Speelman | Founder and lead maintainer | Product direction, merges, releases, security coordination, and project identity |
|
||||
|
||||
Additional maintainers are appointed through a public governance change. Before v1.0.0, a second independent human must be documented for release-key and account recovery without receiving unilateral release authority.
|
||||
@@ -0,0 +1,35 @@
|
||||
<!-- SPDX-FileCopyrightText: 2025-2026 Cole Speelman -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Sandwich Hime generated-output additional permission, version 1.0
|
||||
|
||||
This permission is granted by Cole Speelman only for copyright that he owns in
|
||||
Sandwich Hime.
|
||||
|
||||
As an additional permission under section 7 of the GNU Affero General Public
|
||||
License version 3, when Sandwich Hime copies Cole Speelman-owned generator
|
||||
scaffolding or boilerplate into a generated `.sando.go` file, you may use,
|
||||
reproduce, modify, distribute, and sublicense those copied portions as part of
|
||||
the generated output under terms of your choice. You may license the rest of
|
||||
the generated file and application under terms of your choice to the extent
|
||||
you have the necessary rights. Cole Speelman does not require the generated
|
||||
file or the application containing it to be licensed under the AGPL merely
|
||||
because Sandwich Hime generated it or copied those covered portions into it.
|
||||
|
||||
This permission does not cover the Sandwich Hime compiler itself, AGPL-covered
|
||||
code copied manually or by a modification that is not ordinary generator
|
||||
scaffolding, additions owned by other compiler contributors unless they grant
|
||||
the same permission, template inputs, third-party material, or the separately
|
||||
licensed `sando` runtime. The runtime remains subject to Apache-2.0 when it is
|
||||
redistributed.
|
||||
|
||||
This is a copyright permission, not a conclusion about whether any particular
|
||||
output is a derivative work under applicable law. It grants no trademark
|
||||
rights and no rights in material the grantor does not own. It comes without
|
||||
warranty, to the extent permitted by applicable law.
|
||||
|
||||
A contributor can apply this same permission to contributor-owned emitted
|
||||
scaffolding by recording `Himesan-Output-Permission: v1.0` in the signed-off
|
||||
commit that adds it. That declaration means the identified contribution is
|
||||
licensed under AGPL-3.0-only with this additional permission. No maintainer may
|
||||
apply the permission to another person's material on that person's behalf.
|
||||
@@ -0,0 +1 @@
|
||||
{"schema_version":2,"project":"sandwich-hime","export_policy":"exact-allowlist-v1","export_mode":"release","file_count":98,"allowlist_sha256":"91213f72ddef67aaed7764f08f89fd13281bb79855f3680e1ec0d05cda0e133d","manifest_sha256":"474d6a21ee5ea38ae42d0a2aaa897b6fe95f2b1a8faa46e1884646c185ea9f6f"}
|
||||
@@ -0,0 +1,98 @@
|
||||
4fb00b82cbf99539f60ae2a990068152aaf0946276e26825bff5ef39e4e88ce0 ./.editorconfig
|
||||
658ba4b4645426f8c3249337f47669074ae9249a31703dcd9ea4c1afec45e20b ./.gitattributes
|
||||
d5ae411fb422b2388cac220f9655900eecbc49ece961b2bb2a6610347733b756 ./.gitignore
|
||||
98f663ab0f376b4550094465ec2e06192d1e0b0707604ec6794f20b0d10952c1 ./AI_CONTRIBUTIONS.md
|
||||
cf2e75a6e087570064dc3d8d8ed313676c4e8df1d422bf483d33d81751174bdc ./CHANGELOG.md
|
||||
b696cab3cf482ff5737501371cca749369b119351383e698ced42bcdbcbfc8ae ./CLA.md
|
||||
af0a256657e192641b3d7cee24ece36a76287baf225f2a045da16327214ac7ec ./CONTRIBUTING.md
|
||||
86d7e49d5d90e0f98a4ad0f14b5d8b9f11ed09a1e29ecdf27388316b28e195e8 ./COPYRIGHT
|
||||
f7ac75b443f4ca16b503241344b41aeff9503b0c30bedc2b119551d83cb0fa90 ./DCO.txt
|
||||
73cb1b092a40c56e522c5a0ebddf2b44f347cdb57bf6994cdb305d0e5697b55e ./GENERATED_CODE.md
|
||||
048f96acecd3af84d6abc4f8c38108dff2171445417e5132318a1d18de4279fe ./GOVERNANCE.md
|
||||
0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0 ./LICENSE
|
||||
1b398f6e2679a4c1d6c5a03468f66b86be34e82ed40e3a029935c74742502e7a ./LICENSES.md
|
||||
b6aa08e5ccaec3c5dccdc19d7cd7f54a70adae4d57966263c7aa353c7ba70e08 ./MAINTAINERS.md
|
||||
6638db2f1fba831c79de835ce95c847a5b36c5b5c693b99a28655b2d096cc440 ./OUTPUT_EXCEPTION.md
|
||||
793989c26290a8c0da15f7e4168942a15dad32e83ff5b826dcb5f6b6829b1065 ./README.md
|
||||
6007b80cd80f8f238a1e77de6417aa0c69c159dce6f5a5bd085ef87b8865f5c9 ./RELEASE.md
|
||||
f5fe4d5bf5f8b72ad974060faf458ad26bb4b2c05c2bd9539533247b7462f76b ./ROADMAP.md
|
||||
2c86f5b983dfeb97a02d46850fa42e18cab1ed23201822aa3c344b9d2e1b0c3f ./SECURITY.md
|
||||
53bd6eda804d6b782bdb07115ec197c890813cf2d5d0125dfe8f47f5f92f75b0 ./SPEC.md
|
||||
842beff8afa72d120fcad0ac73afb2049d580ff3000975f3b1786c4ade6a14d4 ./TRADEMARKS.md
|
||||
136a6d82db842547b342f8b0c9ffdc7c04f7c9b473b4ef6dca9dbc940cb24b54 ./cmd/himesan/main.go
|
||||
3011a435aaeb572d34e2d2b582865fd63268c92a9ae3f0ae1a56d1a59d83cd43 ./cmd/himesan/main_test.go
|
||||
1ecbba46f8b1b2d548a01d7e98afae17b2dd17a814338ff1f88db885655d1c07 ./docs/ARCHITECTURE.md
|
||||
318ffcf2463036cd533b1c694f1f4840ef4b0c7c15bc049ec714bbcf4850ba15 ./docs/BENCHMARKS.md
|
||||
5c3a62fed80ca28d56558b8c75e8b5be8ba7d2554127adf4609d96da314e85b0 ./docs/BRAND.md
|
||||
35f5b4b7d195a7b5c071d4665505afef189c7b386d4e3079e9ce8a96ace07f3a ./docs/COMPATIBILITY.md
|
||||
e4021b554ebc479954321586012add57a5fbfb58a1f7fce001d5638880912fc6 ./docs/DEVELOPMENT_SERVER.md
|
||||
51aa57a81131b64f76c45552122de842f22be92d81c8bba8f6fd38a18a7670d6 ./docs/DIAGNOSTICS.md
|
||||
f1a8e78c5aa521324ad2fcb386512158d0c0f9956e97f9a1bc8f97aa5d5e9844 ./docs/THREAT_MODEL.md
|
||||
7e2406acc98391ec126b13d512c00b930bbc2c19f4d5b0fe52286ae41bfbc92d ./examples/eql-shaped/LICENSE
|
||||
eaf381627e6020d7b87b3058ee9c2882bcca5280a8bd409547ecc955a33346db ./examples/eql-shaped/LICENSES.md
|
||||
78c58c00b93f8029cee67d8facdc81a30cc69feb7683952d1e72b54086485eb0 ./examples/eql-shaped/README.md
|
||||
2decbbebb78da922d39d0b74ad8358601526c1d36494c857d66d69116403b7ca ./examples/eql-shaped/cmd/example/main.go
|
||||
385bf9bd6a301723d123c2a52667d320889c47495f018fe0e7daa520c9fa89b0 ./examples/eql-shaped/go.mod
|
||||
6b53c55daf538b25fba2a60be9887fce086b8635a38120ab3f5bf6e9fcb67867 ./examples/eql-shaped/himesan.json
|
||||
2c0063ed8724520f146d6717bb7d828ce69be17b41cbfb8f4866664ba09a58a8 ./examples/eql-shaped/views/badge.sando
|
||||
6befc3b1bd216bb41c7da96e0e2f1c851f5531d2c6ac800f3d06b11de01c2878 ./examples/eql-shaped/views/badge.sando.go
|
||||
c5b402971618747c906c84508d5af9fd00aa33391b9b59f093b001a3cb0b2164 ./examples/eql-shaped/views/browse_results.sando
|
||||
065a88f19de5ba15fb7f9d2104e2a74fe2830f95b16c432e27e40f8aba000df9 ./examples/eql-shaped/views/browse_results.sando.go
|
||||
696c391538cdc50c5ab1abea23b561fa354e56eaf73b7ad6aabe130389715a8c ./examples/eql-shaped/views/home.sando
|
||||
cdfe566844319accf7a9eaae4b5b43ea32b7a0cb3d4fec2dad086d98fb3e9d0b ./examples/eql-shaped/views/home.sando.go
|
||||
c9a8102588cb94f946a36414291d12cb681e605acc93c2b5dc8d74fd33ae5603 ./examples/eql-shaped/views/layout.sando
|
||||
fea981d626bd696068b2384707a217527ad5e24e909d2358cc95444145c424fd ./examples/eql-shaped/views/layout.sando.go
|
||||
120fd733237ef1f15ce60b6ea27c21ce509f6ddc1a9727a9375bf2bb22b3629b ./examples/eql-shaped/views/model.go
|
||||
a8e647d7a9cff4804f97e2175c760cf04be6580a85a5eb946b733b46111b2e06 ./examples/eql-shaped/views/render_test.go
|
||||
8d59b45799d0ed2e9e6997f286315f2cb794ea660940978ec39efa3b9a3b94ab ./examples/eql-shaped/views/security_probe.sando
|
||||
9d5144c39442e29aded227828099cb4b1a32a69112559f8682b43fa04cfaae65 ./examples/eql-shaped/views/security_probe.sando.go
|
||||
f27c46ca63707bb8cc570eab1ea521824e94bc59b1d153998a5e91c2c7340d16 ./go.mod
|
||||
07d161772e9c6eec0dcc12179286e5c686dcabdc4e56a7cb8d112f640b072563 ./internal/compiler/backend.go
|
||||
0cb82706496f80e2a852917c047fb1f29138144c96323a23ba2565055aadcf03 ./internal/compiler/compiler_test.go
|
||||
d99ba263bf501ca81ed38ba88216c063d2fc22f4b45a3f28d5105957f449c4de ./internal/compiler/context.go
|
||||
b2a96ef1ad572ad9cd0e9247328ca261de6f9f3689da41e3f3e111d405a6dee6 ./internal/compiler/diagnostic.go
|
||||
42ccf512381e130bf593b065dccd7697560fb00240818efde6321c9095f6b4a6 ./internal/compiler/discover.go
|
||||
f5a6b31416027cb69a61d1a1421cba779ec3accae59c9ba9dd45d2c31b72149e ./internal/compiler/e2e_test.go
|
||||
eefb05a35bd07660a293c8af97949cd6f69a22709728f3fe2cc9132b863b7d5a ./internal/compiler/fuzz_test.go
|
||||
4c1625114f92f9cc097c2fb1394fa0e4d43a03156f3be0aa537a485ec8243a57 ./internal/compiler/model.go
|
||||
00180df94e3c73e1614eeae387ef00c3cec90e9f64b45a5a148c79408e2d10c2 ./internal/compiler/operation.go
|
||||
d7d8181455d5f37ef9bcc6bdbf86e0630f20e8a5b3b81688d12742687b434c99 ./internal/compiler/parse.go
|
||||
80cf170514a3b955d24440cb086d34e19f3a305510e3c5db95cb897be91f922a ./internal/compiler/replace_unix.go
|
||||
0fff1c67447bf5353ed1df6e7dfc4b14581b67adc1bf02f7a4a7c1f2680c392f ./internal/compiler/replace_windows.go
|
||||
eafbe9f7d8abb8fa792ec9e01f56655f9ec9d67279ffaac66d6035f9b2bfc404 ./internal/devserver/config.go
|
||||
99807040a870dd38ad1e04ae179243316778f94a41feb5d2c3076d463f52f9fe ./internal/devserver/config_test.go
|
||||
eddac51aecaac99bd11cfcf98f8a47cec5d51672efedad75d6f2a862c5d57fc1 ./internal/devserver/events.go
|
||||
36c4a415ee8201064b2365647f24b3cc7497bf74fefbc30db8e3d4dadcc2997b ./internal/devserver/process.go
|
||||
72074ddb02d2eae3cabe5a79c648fe0debbae3ffaa1b0800b897259eca74f209 ./internal/devserver/process_test.go
|
||||
5286e7ba5f59114e37265ab614d9dfc14bfb3003eccc93278e819e52d12f5e7a ./internal/devserver/process_unix.go
|
||||
c0f76ef5c14b0a28ed1e68d8d518102ffcbf067285b087eed4d13cd3c87b0e00 ./internal/devserver/process_windows.go
|
||||
6fbbe08813385ed43a9377b3772260e577dbd9f742f7a9ed5140a02f4c7991a4 ./internal/devserver/process_windows_32.go
|
||||
1dae73304532faac4aa8cedda5df65c6d199aaafc001708cc96529c07dee0588 ./internal/devserver/process_windows_64.go
|
||||
a9b7649562f39a707214dc4a67c2353bd29b2df7fce7a05cac9a6451d1a0fcca ./internal/devserver/proxy.go
|
||||
54f0fea40c0a19d268dcc33cab35d2c7d12f50134cd73ce9a609288e356f9fa8 ./internal/devserver/proxy_test.go
|
||||
3d85066927da7e88ccb0a05afa261e06568c007711dbd9fd0219569aff59f568 ./internal/devserver/supervisor.go
|
||||
6c53d6d10eaa36d9286471e21c61a9e80d858fa0ccfd47be0e72d1838ee440ea ./internal/devserver/supervisor_test.go
|
||||
e0a682c0153bf4f2a1f26cc6095d7893ad96e6199cbe76d0150785fc996f1141 ./internal/devserver/watch.go
|
||||
b7a7fabf9a6c497f7ac2262628c5fb37a6bd00da676e1b7d5088d5f649c9f14c ./internal/devserver/watch_test.go
|
||||
d8c6f37c94ef426fc2d95c82331265f7d700d2e2a23100ad78c92849280ff6d8 ./internal/version/version.go
|
||||
15cf04427c5239cf6985a24c62c888013137205b8159f8f24189a16c22ac6cf3 ./internal/version/version_test.go
|
||||
e8a3026ec920d7312f843e2001e50ae4e34fd1ba5f9b2ae25a6113de1fa88385 ./sando/COPYRIGHT
|
||||
c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4 ./sando/LICENSE
|
||||
b4a7bffe678a97209881e07989563a5085aa0ead9e1b67306087dac6b97bad70 ./sando/README.md
|
||||
a8131a53016401fe8cc3f1be660983c79bb8d267fabef262aca8c55667908279 ./sando/component.go
|
||||
ff905eacdef265e8ea04a8e462656f37b5f8ccd579b604b917143b0b1a7e5d6e ./sando/component_test.go
|
||||
ff76daee5b642ad84af31701833246d68b54d09580192312d750a7a2e893a692 ./sando/go.mod
|
||||
80ff53787919e809b8085d6ad9c3e183c9c7c1d74cfeda73369ac5c4607c236f ./sando/trust.go
|
||||
85621a44c730582f4410ac2c70418b739fb55e916f7e6b73a1a619982c459572 ./sando/write.go
|
||||
b188917e258890e6b6e4840a6fd946fc9a77cabc2068da3764f221e4a6a5df97 ./sando/write_test.go
|
||||
2d92e1578d8907ab434f1924f698b586f47d6c9bc6fab989639a5332fd235163 ./scripts/README.md
|
||||
c61456ce33eb6e6f7a1b44d436cf99c33e31f8a07b59fb4723096c6a41b7f215 ./scripts/check-licenses.sh
|
||||
e35895423494018d5e380c447a8fb9bd5410af82a4a12f9fbaae391c286dd32a ./scripts/check-site.sh
|
||||
d53d93df7e355ec45e6540703c6098c3fb49a610cb35600c5d525afa0f0d48cb ./scripts/release-check.sh
|
||||
be79b6bd1d18ce53a1b4cf301f7c4fa41342728d0a6333a766171cfc586a65d5 ./scripts/verify-public-install.sh
|
||||
b52ff6d1e0db9c4a72a587fc063d8022c51a439158679029c8df613b51905e51 ./scripts/verify.ps1
|
||||
5163a360b212de5abadeaa48db415e6f605e8b2a7f2fd2dc9ab7c1da19cec079 ./scripts/verify.sh
|
||||
7750a055be12b18c826d033f266a615bc1acdbddcfe5193f70521f7270a3e333 ./site/README.md
|
||||
1b28cf572543d61f89e7636f74b94fe96916c0dc80847831a9f42b57bebb83cb ./site/assets/site.css
|
||||
ecb00ac7fac0e161a3e4629c17209ddfaf0fbb8c78fd2615c2a7372808ae7cf7 ./site/index.html
|
||||
63cf8fc6f059c6a1164a70e9fd35564ff107282e01030d997f4e3e1e3dc0fdda ./site/sando/index.html
|
||||
@@ -0,0 +1,86 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Sandwich Hime
|
||||
|
||||
Sandwich Hime (Hime-san) is an HTML-first component compiler for Go. It keeps the direct, mixed-markup feeling of classic PHP while producing typed, deterministic Go that an ordinary `go build` can audit and deploy.
|
||||
|
||||
```sando
|
||||
<?sando go
|
||||
package views
|
||||
|
||||
func Profile(page ProfileView)
|
||||
?>
|
||||
<section class="profile">
|
||||
<h1><?= page.Name ?></h1>
|
||||
<? if page.IsAdmin { ?>
|
||||
<?~ AdminBadge() ?>
|
||||
<? } ?>
|
||||
</section>
|
||||
```
|
||||
|
||||
The generated API is ordinary Go:
|
||||
|
||||
```go
|
||||
func Profile(page ProfileView) sando.Component
|
||||
```
|
||||
|
||||
Hime-san is a development tool, not an application framework. A consuming project keeps its `.sando` sources, commits the adjacent `.sando.go` output, and imports only the small Apache-2.0 `sando` runtime. Hime-san never owns the router, middleware, layout policy, request object, or production server.
|
||||
|
||||
## Status
|
||||
|
||||
This repository is an unsupported public pre-1.0 source preview, not a supported v1 release. EQL Wiki remains the proof-of-production proving ground, and v1 is gated on security testing, cross-platform determinism, and a 14-day production soak with no renderer, security, or accessibility regression.
|
||||
|
||||
For repository development:
|
||||
|
||||
```sh
|
||||
go install ./cmd/himesan
|
||||
himesan generate ./examples/eql-shaped
|
||||
himesan check ./examples/eql-shaped
|
||||
go test ./...
|
||||
(cd sando && go test ./...)
|
||||
(cd examples/eql-shaped && himesan dev --config himesan.json)
|
||||
```
|
||||
|
||||
The example's development server remains the application's own `net/http`
|
||||
program. Hime-san builds it in the user cache, health-checks a random loopback
|
||||
upstream, and serves the last healthy candidate through
|
||||
`http://127.0.0.1:7331` with local-only reload diagnostics.
|
||||
|
||||
The eventual versioned installs are:
|
||||
|
||||
```sh
|
||||
go install gamertan.com/sandwich-hime/cmd/himesan@v1.0.0
|
||||
go get gamertan.com/sandwich-hime/sando@v1.0.0
|
||||
```
|
||||
|
||||
Those vanity paths must not be advertised as working until the corresponding signed releases and `gamertan.com` metadata exist.
|
||||
|
||||
## The contract
|
||||
|
||||
- One typed component per `.sando` file.
|
||||
- Go statements are trusted source code; rendered values are untrusted.
|
||||
- `<?= ... ?>` escapes for the statically known HTML context.
|
||||
- `<?~ ... ?>` composes another component and propagates errors.
|
||||
- Ambiguous or unsupported HTML contexts fail compilation.
|
||||
- Generation is deterministic, formatted, atomic, and never edits handwritten Go or `go.mod`.
|
||||
- `check` is read-only and detects invalid or stale generated output.
|
||||
- Production builds need no compiler binary.
|
||||
|
||||
The language is specified in [SPEC.md](SPEC.md). The security boundary is in [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md), the development loop is in [docs/DEVELOPMENT_SERVER.md](docs/DEVELOPMENT_SERVER.md), and the multi-license boundary is in [LICENSES.md](LICENSES.md).
|
||||
|
||||
User-authored templates and generated application files remain under terms chosen by their authors to the extent they hold the necessary rights. The compiler is AGPL-3.0-only, the runtime is Apache-2.0, and [OUTPUT_EXCEPTION.md](OUTPUT_EXCEPTION.md) grants an additional permission for Cole Speelman-owned generator scaffolding copied into output.
|
||||
|
||||
## Names matter
|
||||
|
||||
- Project: **Sandwich Hime / Hime-san**
|
||||
- CLI: `himesan`
|
||||
- Template: `page.sando`
|
||||
- Generated Go: `page.sando.go`
|
||||
- Runtime: `sando`
|
||||
- `.san`: reserved exclusively for the separate San language
|
||||
|
||||
The project is never marketed as bare “Hime”; that name is already used by an unrelated Go web framework.
|
||||
|
||||
## Why
|
||||
|
||||
This is a love letter to hand-built web development: the immediacy of a 2004 personal site, with typed interfaces, reproducible builds, modern contextual safety, and boring production operations. Performance claims will follow published measurements, never precede them.
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Release process
|
||||
|
||||
Sandwich Hime uses separate root and runtime version lines. Compiler tags are `vX.Y.Z`; runtime tags are `sando/vX.Y.Z`. Generated headers record both compiler version and runtime ABI.
|
||||
|
||||
The public pre-1.0 source snapshot is not a supported release and does not imply that the v1 gates below have passed.
|
||||
|
||||
No v1.0.0 release occurs until every gate in this repository is evidenced, including cross-platform deterministic generation, temporary-module compilation, fuzz/adversarial suites, race/vet/vulnerability/license checks on the latest two supported Go lines, development-supervisor failure tests, an EQL differential pilot, and a completed 14-day production soak without Hime render failures or security/accessibility regression.
|
||||
|
||||
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. The runtime is tagged and published independently before the compiler that references its ABI.
|
||||
|
||||
Gitea is the only canonical public forge. Public source is exported into a separate, sanitized Gitea repository with fresh history; private development history and the private-to-public commit mapping are not published. Release binaries and provenance are built from the reviewed public commit. Publishing documentation, binaries, runtime tag, EQL mark, and case study is one coordinated v1 launch step.
|
||||
|
||||
The hosting configuration must answer exact package discovery requests, not
|
||||
only module-root pages. In particular,
|
||||
`/sandwich-hime/cmd/himesan?go-get=1` returns compiler metadata and the
|
||||
`/sandwich-hime/sando` subtree returns runtime metadata. After signed tags and
|
||||
public metadata exist, run `scripts/verify-public-install.sh --version
|
||||
vX.Y.Z`; it exercises the documented `go install` and `go get` commands from
|
||||
fresh direct-fetch and public-proxy caches. This post-publication check 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 performance claims. “Fastest” or equivalent language is prohibited without durable, reproducible evidence.
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Proof-in-the-pudding roadmap
|
||||
|
||||
Unchecked items are release blockers, not aspirational marketing.
|
||||
|
||||
## Compiler and runtime
|
||||
|
||||
- [ ] Deterministic golden output repeated across Linux, macOS, and Windows.
|
||||
- [ ] Temporary consumer modules compile using committed Go and only the Apache runtime.
|
||||
- [ ] Parser, delimiter, context, path, and source-map fuzz targets survive the release campaign.
|
||||
- [ ] Adversarial escaping and filesystem cases are evidenced.
|
||||
- [ ] Latest two Go lines pass test, race, vet, vulnerability, and license gates.
|
||||
- [ ] Signed compiler/runtime release artifacts, checksums, and SBOMs reproduce.
|
||||
|
||||
## Development supervisor
|
||||
|
||||
- [ ] Generation/build/start/health failures keep the previous healthy server live.
|
||||
- [ ] SSE reconnect/reload and mapped overlay diagnostics pass browser-level tests.
|
||||
- [ ] CSP hash injection, fragment/API/download exclusion, and cache disabling pass.
|
||||
- [ ] Replaced and interrupted child processes leave no descendants on supported systems.
|
||||
|
||||
## EQL Wiki proof
|
||||
|
||||
- [ ] Separate `codex/himesan-pilot` worktree created after compiler gates.
|
||||
- [ ] Shared layout/home, browse fragment, and item page reach differential parity.
|
||||
- [ ] Accessibility, CSP, links/forms, malicious values, status, caching, and fragments pass.
|
||||
- [ ] Blue/green renderer flag and slot-switch rollback verified.
|
||||
- [ ] Fourteen continuous production days complete with zero Hime renderer failure or security/accessibility regression.
|
||||
- [ ] Honest before/after case study and reproducible benchmark report approved.
|
||||
|
||||
## Public launch
|
||||
|
||||
- [ ] Ownership notices, output permission, DCO contribution process, and pre-registration trademark terms receive final human review.
|
||||
- [ ] Name clearance, security mailbox, two-person credential recovery, and signing keys complete.
|
||||
- [ ] Gamertan vanity metadata and documentation verified from a clean machine.
|
||||
- [ ] Sanitized fresh-history public Gitea snapshot contains no private paths, identifiers, history, or unsupported release claims.
|
||||
- [ ] Canonical public Gitea source preview and Gamertan documentation launch together with no secondary forge mirror.
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Security policy
|
||||
|
||||
Sandwich Hime is an unsupported public pre-1.0 source preview. No version is yet supported for production use, and the project makes no vulnerability-response SLA or bug-bounty promise.
|
||||
|
||||
Do not put undisclosed vulnerability details, credentials, personal data, or a working exploit in a public issue. Until a dedicated confidential address is published, use the repository owner's published Gitea contact method to ask for a private channel without disclosing the issue. If no private contact method is available, retain the details rather than publishing them. A tested confidential contact and documented response targets remain blockers for a supported release.
|
||||
|
||||
The compiler treats templates and embedded Go as trusted source and rendered values as untrusted data. It does not sandbox template authors. The security boundary and known non-goals are specified in [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md).
|
||||
|
||||
For a future supported release, the intended process includes a private reproducer, regression test, coordinated disclosure when appropriate, checksums, and an advisory. Release artifacts and tags must be signed. Dependencies are minimized and scanned; generation/checking never fetch dependencies or execute project code.
|
||||
|
||||
This policy describes the project's current process and limitations; it is not legal advice and does not promise that every report can be accepted, embargoed, or fixed on a particular schedule.
|
||||
@@ -0,0 +1,69 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Sandwich Hime language specification
|
||||
|
||||
Status: v1 development draft. Implemented behavior and this document must change together.
|
||||
|
||||
## Source unit
|
||||
|
||||
A `.sando` file defines exactly one component. After optional UTF-8 BOM and whitespace, it starts with a target header and ends at EOF:
|
||||
|
||||
```sando
|
||||
<?sando go
|
||||
package views
|
||||
|
||||
import "example.com/site/model"
|
||||
|
||||
func Card(card model.Card)
|
||||
?>
|
||||
<article><h2><?= card.Title ?></h2></article>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Template tags
|
||||
|
||||
| Form | Meaning |
|
||||
| --- | --- |
|
||||
| `<? statements ?>` | Trusted Go statements inside the component renderer |
|
||||
| `<?= expression ?>` | Render a value escaped for the statically determined output context |
|
||||
| `<?~ expression ?>` | Render a `sando.Component` and propagate its error |
|
||||
| `<?# comment ?>` | Template-only comment; emits no output |
|
||||
|
||||
EOF closes the component. There is no inheritance DSL, implicit request, reflection registry, dynamic component lookup, or raw-output intrinsic.
|
||||
|
||||
## Generated API
|
||||
|
||||
For `func Card(card model.Card)`, generation emits:
|
||||
|
||||
```go
|
||||
func Card(card model.Card) 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.
|
||||
|
||||
## Trust and output contexts
|
||||
|
||||
Template files and embedded Go are trusted application source. Values rendered by the application are untrusted.
|
||||
|
||||
V1 recognizes:
|
||||
|
||||
- HTML text;
|
||||
- quoted attribute values;
|
||||
- quoted values of recognized URL attributes;
|
||||
- script and style data only when the Go expression has the conspicuous trusted runtime type;
|
||||
- component boundaries in ordinary HTML content.
|
||||
|
||||
`<?~ ... ?>` is illegal inside an attribute, script, style, tag, or comment. Dynamic tag names, attribute names, event-handler attributes, and unquoted dynamic attributes are rejected. Unsupported, malformed, or ambiguous contexts fail compilation. Static markup must return to the same neutral parser context at EOF and must be structurally balanced under the v1 HTML model.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
The source language and generated runtime ABI are versioned independently. See [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md).
|
||||
@@ -0,0 +1,15 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Trademark policy — pre-registration project terms
|
||||
|
||||
“Sandwich Hime,” “Hime-san,” the Sandwich Hime flag, mascot, and official project artwork identify the project stewarded by Cole Speelman. Code licenses do not grant trademark rights, and this policy does not limit anyone's rights to use the code under those licenses.
|
||||
|
||||
Truthful, non-confusing statements such as “Built with Sandwich Hime,” “compatible with Sandwich Hime,” and unmodified community badges are welcome. They must not imply sponsorship, certification, or official status.
|
||||
|
||||
If a modified distribution uses these project marks, it should use a distinct primary product name, clearly identify its changes, and avoid official artwork or presentation that could confuse users about origin. A fork that does not use the project marks is not restricted by this policy. Either kind of fork may truthfully describe compatibility.
|
||||
|
||||
Community discussion, commentary, criticism, tutorials, and truthful nominative references are welcome. For commercial merchandise, domains, events, certification, or prominent brand use, ask the project steward through the canonical Gitea contact before relying on permission beyond what applicable law already allows.
|
||||
|
||||
Trademark permission does not supply a copyright license for artwork. If the project publishes an official flag, mascot, or “Built with Sandwich Hime” badge, that asset must identify its own copyright holder and reuse license before others are asked to copy it.
|
||||
|
||||
This policy grants permission only to the extent the project steward controls the relevant rights. It is not a claim that a mark is registered, cleared in every territory, or legally exclusive, and it does not override uses permitted by applicable law. Professional clearance and qualified legal advice remain prudent before registration, enforcement, or material investment in a mark; they are not represented as prerequisites to publishing the unsupported source preview.
|
||||
@@ -0,0 +1,318 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"gamertan.com/sandwich-hime/internal/compiler"
|
||||
"gamertan.com/sandwich-hime/internal/devserver"
|
||||
"gamertan.com/sandwich-hime/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(run(context.Background(), os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
printHelp(stdout)
|
||||
return 0
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "generate", "gen":
|
||||
return runCompilerCommand(ctx, "generate", args[1:], stdout, stderr, compiler.Generate)
|
||||
case "check", "bless":
|
||||
return runCompilerCommand(ctx, args[0], args[1:], stdout, stderr, compiler.Check)
|
||||
case "dev":
|
||||
return runDev(ctx, args[1:], stdout, stderr)
|
||||
case "version":
|
||||
return runVersion(args[1:], stdout, stderr)
|
||||
case "help", "-h", "--help":
|
||||
printHelp(stdout)
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(stderr, "himesan: unknown command %q\n\n", args[0])
|
||||
printHelp(stderr)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
type compilerOperation func(context.Context, []string) (compiler.Result, error)
|
||||
|
||||
func runCompilerCommand(ctx context.Context, command string, args []string, stdout, stderr io.Writer, operation compilerOperation) int {
|
||||
flags := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
jsonOutput := flags.Bool("json", false, "emit one machine-readable JSON result")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
result, operationErr := operation(ctx, flags.Args())
|
||||
if *jsonOutput {
|
||||
payload := struct {
|
||||
Command string `json:"command"`
|
||||
OK bool `json:"ok"`
|
||||
Result compiler.Result `json:"result"`
|
||||
}{Command: command, OK: operationErr == nil, Result: result}
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(payload); err != nil {
|
||||
fmt.Fprintf(stderr, "himesan: encode JSON result: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
} else {
|
||||
printDiagnostics(stderr, result.Diagnostics)
|
||||
if operationErr == nil {
|
||||
switch command {
|
||||
case "generate":
|
||||
fmt.Fprintf(stdout, "generated %d, unchanged %d (%d .sando files)\n", result.Changed, result.Unchanged, result.Discovered)
|
||||
case "bless":
|
||||
fmt.Fprintf(stdout, "blessed: generated output is current and valid (%d files checked, no writes)\n", result.Discovered)
|
||||
default:
|
||||
fmt.Fprintf(stdout, "checked %d .sando files: %d current\n", result.Discovered, result.Unchanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
if operationErr != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func printDiagnostics(output io.Writer, diagnostics []compiler.Diagnostic) {
|
||||
for _, item := range diagnostics {
|
||||
fmt.Fprintf(output, "%s: %s\n", item.Severity, item.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func runVersion(args []string, stdout, stderr io.Writer) int {
|
||||
flags := flag.NewFlagSet("version", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
jsonOutput := flags.Bool("json", false, "emit machine-readable version information")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
fmt.Fprintln(stderr, "himesan version does not accept positional arguments")
|
||||
return 2
|
||||
}
|
||||
information := struct {
|
||||
Compiler string `json:"compiler"`
|
||||
RuntimeABI string `json:"runtime_abi"`
|
||||
Go string `json:"go"`
|
||||
}{Compiler: version.Compiler, RuntimeABI: version.RuntimeABI, Go: runtime.Version()}
|
||||
if *jsonOutput {
|
||||
if err := json.NewEncoder(stdout).Encode(information); err != nil {
|
||||
fmt.Fprintf(stderr, "himesan: encode version: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintf(stdout, "himesan %s (runtime ABI %s, %s)\n", information.Compiler, information.RuntimeABI, information.Go)
|
||||
return 0
|
||||
}
|
||||
|
||||
type stringList []string
|
||||
|
||||
func (values *stringList) String() string { return strings.Join(*values, ",") }
|
||||
func (values *stringList) Set(value string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return errors.New("value must not be empty")
|
||||
}
|
||||
*values = append(*values, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDev(parent context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
commandArgs, appArgs := splitAppArgs(args)
|
||||
flags := flag.NewFlagSet("dev", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
configPath := flags.String("config", "", "himesan.json path (defaults to ./himesan.json when present)")
|
||||
proxyAddress := flags.String("proxy", "", "stable loopback proxy address")
|
||||
listenEnvironment := flags.String("listen-env", "", "environment variable used to pass the random upstream address")
|
||||
healthPath := flags.String("health", "", "candidate health-check path")
|
||||
var sourceRoots stringList
|
||||
var watchRoots stringList
|
||||
flags.Var(&sourceRoots, "source", "source root to generate and watch (repeatable)")
|
||||
flags.Var(&watchRoots, "watch", "additional asset root to watch (repeatable)")
|
||||
if err := flags.Parse(commandArgs); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
if flags.NArg() > 1 {
|
||||
fmt.Fprintln(stderr, "himesan dev accepts at most one Go package before --")
|
||||
return 2
|
||||
}
|
||||
|
||||
rootDir, config, err := loadDevelopmentConfig(*configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "himesan dev: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if flags.NArg() == 1 {
|
||||
config.GoPackage = flags.Arg(0)
|
||||
}
|
||||
if len(sourceRoots) != 0 {
|
||||
config.SourceRoots = append([]string(nil), sourceRoots...)
|
||||
}
|
||||
if len(watchRoots) != 0 {
|
||||
config.AdditionalWatchRoots = append([]string(nil), watchRoots...)
|
||||
}
|
||||
if *proxyAddress != "" {
|
||||
config.ProxyAddress = *proxyAddress
|
||||
}
|
||||
if *listenEnvironment != "" {
|
||||
config.ListenAddressEnv = *listenEnvironment
|
||||
}
|
||||
if *healthPath != "" {
|
||||
config.HealthPath = *healthPath
|
||||
}
|
||||
if appArgs != nil {
|
||||
config.AppArgs = append([]string(nil), appArgs...)
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
fmt.Fprintf(stderr, "himesan dev: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
|
||||
resolvedSources := resolvePaths(rootDir, config.SourceRoots)
|
||||
generate := func(ctx context.Context) error {
|
||||
result, generateErr := compiler.Generate(ctx, resolvedSources)
|
||||
for _, item := range result.Diagnostics {
|
||||
if item.Severity == compiler.SeverityWarning {
|
||||
fmt.Fprintf(stderr, "warning: %s\n", item.Error())
|
||||
}
|
||||
}
|
||||
return generateErr
|
||||
}
|
||||
|
||||
supervisor, err := devserver.New(devserver.Options{
|
||||
RootDir: rootDir,
|
||||
Config: config,
|
||||
Generate: generate,
|
||||
MapDiagnostics: mapDevelopmentDiagnostics,
|
||||
OnEvent: func(event devserver.Event) {
|
||||
switch event.Type {
|
||||
case "ready":
|
||||
fmt.Fprintf(stdout, "himesan dev: %s\n", event.Message)
|
||||
case "reload":
|
||||
fmt.Fprintln(stdout, "himesan dev: healthy candidate activated")
|
||||
case "diagnostic":
|
||||
fmt.Fprintf(stderr, "himesan dev [%s]: %s\n", event.Phase, event.Message)
|
||||
}
|
||||
},
|
||||
Output: stdout,
|
||||
ErrorOutput: stderr,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "himesan dev: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := supervisor.Run(ctx); err != nil {
|
||||
fmt.Fprintf(stderr, "himesan dev: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func splitAppArgs(args []string) ([]string, []string) {
|
||||
for index, value := range args {
|
||||
if value == "--" {
|
||||
return args[:index], args[index+1:]
|
||||
}
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func loadDevelopmentConfig(requested string) (string, devserver.Config, error) {
|
||||
workingDirectory, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", devserver.Config{}, fmt.Errorf("get working directory: %w", err)
|
||||
}
|
||||
path := requested
|
||||
if path == "" {
|
||||
candidate := filepath.Join(workingDirectory, "himesan.json")
|
||||
if _, statErr := os.Stat(candidate); statErr == nil {
|
||||
path = candidate
|
||||
} else if !os.IsNotExist(statErr) {
|
||||
return "", devserver.Config{}, fmt.Errorf("inspect himesan.json: %w", statErr)
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
return workingDirectory, devserver.DefaultConfig(), nil
|
||||
}
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", devserver.Config{}, fmt.Errorf("resolve config: %w", err)
|
||||
}
|
||||
config, err := devserver.LoadConfig(absolute)
|
||||
if err != nil {
|
||||
return "", devserver.Config{}, err
|
||||
}
|
||||
return filepath.Dir(absolute), config, nil
|
||||
}
|
||||
|
||||
func resolvePaths(root string, paths []string) []string {
|
||||
resolved := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
if filepath.IsAbs(path) {
|
||||
resolved = append(resolved, filepath.Clean(path))
|
||||
} else {
|
||||
resolved = append(resolved, filepath.Join(root, filepath.Clean(path)))
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func mapDevelopmentDiagnostics(err error) []devserver.Diagnostic {
|
||||
var diagnosticsError *compiler.DiagnosticsError
|
||||
if !errors.As(err, &diagnosticsError) {
|
||||
return nil
|
||||
}
|
||||
result := make([]devserver.Diagnostic, 0, len(diagnosticsError.Diagnostics))
|
||||
for _, item := range diagnosticsError.Diagnostics {
|
||||
result = append(result, devserver.Diagnostic{
|
||||
Path: item.Path, Line: item.Line, Column: item.Column,
|
||||
Code: item.Code, Message: item.Message, Severity: string(item.Severity),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func printHelp(output io.Writer) {
|
||||
fmt.Fprintln(output, "Sandwich Hime / Hime-san — HTML-first typed components for Go")
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintln(output, "Usage:")
|
||||
fmt.Fprintln(output, " himesan generate [--json] [paths...] generate adjacent .sando.go files")
|
||||
fmt.Fprintln(output, " himesan gen [--json] [paths...] alias for generate")
|
||||
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 version [--json] print compiler and runtime ABI versions")
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintln(output, "Templates use .sando; .san remains exclusively San language source.")
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gamertan.com/sandwich-hime/internal/compiler"
|
||||
)
|
||||
|
||||
func TestRunHelpVersionAndUnknownCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run(context.Background(), nil, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("help exit code = %d", code)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Sandwich Hime") || !strings.Contains(stdout.String(), ".san remains exclusively San") {
|
||||
t.Fatalf("help does not preserve product boundary: %s", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
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
|
||||
if err := json.Unmarshal(stdout.Bytes(), &versionResult); err != nil {
|
||||
t.Fatalf("version JSON: %v", err)
|
||||
}
|
||||
if versionResult["compiler"] == "" || versionResult["runtime_abi"] != compiler.RuntimeABI {
|
||||
t.Fatalf("version result = %#v", versionResult)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := run(context.Background(), []string{"rebuke"}, &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("unknown command exit code = %d, want 2", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "unknown command") {
|
||||
t.Fatalf("unknown command diagnostic = %q", stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := run(context.Background(), []string{"check", "-h"}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("command help exit code = %d, want 0", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCheckBlessAndJSONDiagnostics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run(context.Background(), []string{"generate", sourcePath}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("generate exit code = %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String())
|
||||
}
|
||||
generatedPath := sourcePath + ".go"
|
||||
before, err := os.ReadFile(generatedPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := run(context.Background(), []string{"bless", sourcePath}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("bless exit code = %d: %s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "no writes") {
|
||||
t.Fatalf("bless did not identify read-only behavior: %q", stdout.String())
|
||||
}
|
||||
after, err := os.ReadFile(generatedPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("bless changed generated output")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(sourcePath, []byte("not a component"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := run(context.Background(), []string{"check", "--json", sourcePath}, &stdout, &stderr); code != 1 {
|
||||
t.Fatalf("invalid check exit code = %d, want 1", code)
|
||||
}
|
||||
var payload struct {
|
||||
OK bool `json:"ok"`
|
||||
Result struct {
|
||||
Diagnostics []compiler.Diagnostic `json:"diagnostics"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("diagnostic JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.OK || len(payload.Result.Diagnostics) == 0 || payload.Result.Diagnostics[0].Code == "" {
|
||||
t.Fatalf("diagnostic payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAppArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
command, application := splitAppArgs([]string{"--proxy", "127.0.0.1:7444", "./cmd/site", "--", "--verbose", "hello world"})
|
||||
if got := strings.Join(command, "|"); got != "--proxy|127.0.0.1:7444|./cmd/site" {
|
||||
t.Fatalf("command arguments = %q", got)
|
||||
}
|
||||
if got := strings.Join(application, "|"); got != "--verbose|hello world" {
|
||||
t.Fatalf("application arguments = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Architecture
|
||||
|
||||
Sandwich Hime is separated into three trust and deployment zones:
|
||||
|
||||
```text
|
||||
trusted .sando source
|
||||
|
|
||||
v
|
||||
AGPL himesan compiler: parse -> context-annotated renderer IR -> Go backend
|
||||
|
|
||||
v
|
||||
committed .sando.go + Apache sando runtime
|
||||
|
|
||||
v
|
||||
ordinary application/router/server chosen by the user
|
||||
```
|
||||
|
||||
The compiler is globally installed for development. `generate` and `check` read source and emit or compare ordinary Go; they do not load plugins, fetch modules, run application code, or mutate module metadata. Generated output is the only bridge from compiler internals into an application.
|
||||
|
||||
The renderer IR records source spans and output context independently of Go syntax. The only v1 backend is `go`, selected explicitly in the source header. A future San backend may consume the same IR, but v1 contains no San parser, `.san` handling, or compatibility promise.
|
||||
|
||||
The nested `sando` module is a small ABI with no HTTP opinions. It owns component invocation, contextual writer helpers, and opaque trusted values. The application owns buffering, status codes, headers, routing, authentication, caching, CSP, and deployment.
|
||||
|
||||
`himesan dev` is a separate local-only supervisor. Its loopback proxy and browser client never enter generated output or a production binary. Candidate application processes become live only after generation, build, startup, and health checks pass; otherwise the last healthy process remains upstream.
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Benchmark policy
|
||||
|
||||
Benchmarks compare equivalent typed views and output against Go's `html/template` baseline. Reports include hardware, operating system, Go version, repository commit, dataset identity, exact commands, warmup/run counts, `ns/op`, bytes and allocations per operation, end-to-end response latency where relevant, output size, and statistical method.
|
||||
|
||||
The v1 gate is no material regression for the selected EQL pages 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.
|
||||
|
||||
Benchmark fixtures must contain synthetic or approved public data. The EQL production database is never copied into this repository or a public artifact.
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Brand vocabulary
|
||||
|
||||
Use **Sandwich Hime** for the project and **Hime-san** as its friendly short form. Use `himesan` for the command, `.sando` for source, `.sando.go` for generated Go, and `sando` for the runtime.
|
||||
|
||||
Never call the project bare “Hime,” which can be confused with an existing Go framework. Never use `.san`; it belongs exclusively to the separate San language project. “Sando” evokes a sandwich and keeps both projects unmistakable in editors, tooling, and search results.
|
||||
|
||||
The preferred truthful attribution is “Built with Sandwich Hime.” It is optional and governed by [TRADEMARKS.md](../TRADEMARKS.md).
|
||||
@@ -0,0 +1,11 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Compatibility policy
|
||||
|
||||
Before v1.0.0, source syntax and generated ABI may change without compatibility shims, but each public change must be documented and deterministic. Private prototype history is intentionally outside the sanitized public repository and carries no public compatibility promise.
|
||||
|
||||
At v1, semantic versions apply independently to the compiler and `sando` runtime. Generated files record the exact compiler version and required runtime ABI. Patch releases do not intentionally change accepted source semantics or generated public signatures. Minor releases may add fail-closed syntax or API capabilities while continuing to render previously valid components. Major releases may remove or reinterpret behavior.
|
||||
|
||||
The compiler supports the latest two Go release lines validated in CI. A support change is announced before release. Generated files are source artifacts, not a stable interchange format across compiler versions; `himesan check` defines whether they are current.
|
||||
|
||||
The project makes no compatibility promise for internal packages, development SSE payloads before v1, or hand-edited generated files.
|
||||
@@ -0,0 +1,34 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Local development supervisor
|
||||
|
||||
`himesan dev [package] [-- app-args...]` is an explicitly local convenience. It generates templates, builds the selected Go package into the user cache, starts a candidate on a random `127.0.0.1` address, health-checks it, and only then switches a stable loopback reverse proxy. Generation, build, startup, and health failures leave the previous healthy child serving.
|
||||
|
||||
The application must read its listen address from the configured environment variable and expose the configured health path. It remains an ordinary application server; no development proxy code appears in generated files or production binaries.
|
||||
|
||||
## `himesan.json` schema version 1
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"sourceRoots": ["views"],
|
||||
"goPackage": "./cmd/site",
|
||||
"appArgs": ["--development"],
|
||||
"listenAddressEnv": "HIMESAN_LISTEN_ADDR",
|
||||
"healthPath": "/healthz",
|
||||
"proxyAddress": "127.0.0.1:7331",
|
||||
"additionalWatchRoots": ["assets"]
|
||||
}
|
||||
```
|
||||
|
||||
Unknown fields, non-loopback proxy addresses, invalid environment names, malformed health paths, NULs, and unsupported schema versions are rejected. Arguments are passed directly without a shell. The configuration contains no commands, secrets, credentials, or production bind address.
|
||||
|
||||
Simple projects can override the package and repeat `--source`/`--watch`, plus `--proxy`, `--listen-env`, and `--health`. When a config path is supplied, relative paths resolve from its directory.
|
||||
|
||||
## Browser behavior
|
||||
|
||||
The stable proxy reserves `/__himesan/events` for SSE. It injects a fixed reload/diagnostic client only into successful full HTML documents with positive document evidence. Fragments, APIs, encoded bodies, range responses, downloads, HEAD, and non-success responses are never injected. Development responses are non-cacheable.
|
||||
|
||||
When an existing CSP is present, the proxy adds the fixed script's SHA-256 source and same-origin SSE connection permission; it does not add `unsafe-inline` or `unsafe-eval`. The proxy and every candidate upstream are literal loopback addresses. Replaced process groups are terminated and waited for on Unix and Windows.
|
||||
|
||||
This is not a production proxy, TLS terminator, public preview server, process orchestrator, or deployment system. V1 refuses non-loopback binding.
|
||||
@@ -0,0 +1,23 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Diagnostics
|
||||
|
||||
Human diagnostics use stable `path:line:column` locations and `HIM####` codes. `--json` emits one object containing the operation result and structured diagnostics. Error diagnostics make generation/check exit nonzero; audit warnings do not.
|
||||
|
||||
Code families are intentionally coarse compatibility surfaces:
|
||||
|
||||
| Range | Area |
|
||||
| --- | --- |
|
||||
| `HIM10xx` | Source encoding |
|
||||
| `HIM11xx` | Component header and Go declaration |
|
||||
| `HIM12xx` | Template tags and expressions |
|
||||
| `HIM13xx` | HTML parser context and structure |
|
||||
| `HIM14xx` | Generated Go/backend validation |
|
||||
| `HIM15xx` | Component graph and package collisions |
|
||||
| `HIM19xx` | Trusted-value audit warnings |
|
||||
| `HIM20xx` | Discovery and cancellation |
|
||||
| `HIM21xx` | Owned atomic generation |
|
||||
| `HIM22xx` | Read-only freshness checking |
|
||||
| `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.
|
||||
@@ -0,0 +1,39 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Threat model
|
||||
|
||||
## Trusted
|
||||
|
||||
- `.sando` files and embedded Go statements;
|
||||
- handwritten application Go;
|
||||
- explicit calls to `sando.TrustHTML`, `TrustURL`, `TrustJS`, and `TrustCSS`;
|
||||
- the selected compiler binary and runtime module version.
|
||||
|
||||
## Untrusted
|
||||
|
||||
- values supplied to components unless deliberately wrapped in a trusted type;
|
||||
- filenames and directory entries encountered during discovery;
|
||||
- stale or manually modified generated output;
|
||||
- browser requests reaching the development proxy;
|
||||
- child process output and health failures.
|
||||
|
||||
## Guarantees sought by v1
|
||||
|
||||
- Context-sensitive escaping for supported HTML text, quoted attributes, URL attributes, and explicitly trusted script/style values.
|
||||
- Compilation failure for unsupported or ambiguous output contexts.
|
||||
- Dangerous normalized URL schemes fail rendering unless explicitly trusted.
|
||||
- Component calls cannot change the surrounding HTML parser context.
|
||||
- Dynamic `title` and `textarea` content uses a distinct RCDATA writer that escapes even `TrustedHTML`; trusted HTML cannot close those elements.
|
||||
- Writer failures propagate and partial output is visible to the caller as an error; applications can buffer when atomic responses matter.
|
||||
- Generation plans all outputs before atomic replacement, targets only owned files, preserves last-good output on failure, and follows neither symlinks nor nested-module traversal.
|
||||
- `generate` and `check` do not execute project code, invoke Go tooling, fetch dependencies, or alter `go.mod`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Templates are not a sandbox. A malicious template author can write malicious Go in a statement tag. Sandwich Hime does not validate business authorization, prevent unsafe application logic, make an arbitrary `io.Writer` transactional, or secure an application router/server. Trusted constructors are intentionally sharp tools and must remain conspicuous in review and `himesan check` reporting. A `TrustedHTML` fragment must be balanced and context-neutral; `TrustedJS` and `TrustedCSS` authors are responsible for excluding container-closing HTML sequences.
|
||||
|
||||
The v1 HTML state machine is deliberately smaller than a browser parser. Any construct it cannot prove safe is rejected rather than guessed. Differential testing against Go `html/template` is a baseline, not a claim of byte-identical output or universal parser equivalence.
|
||||
|
||||
## Principal attack classes
|
||||
|
||||
Tests cover delimiter confusion, malformed HTML, quote/entity injection, event attributes, dangerous and obfuscated URLs, script/style termination, Unicode and NUL handling, component context breaks, import/source-map injection, CRLF and path behavior, symlinks, nested modules, stale outputs, interrupted/read-only writes, writer failures, component cycles, development-proxy exposure, CSP weakening, compression/content-length mistakes, and orphaned child processes.
|
||||
@@ -0,0 +1,14 @@
|
||||
Zero-Clause BSD
|
||||
|
||||
Copyright (c) 2025-2026 Cole Speelman
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
@@ -0,0 +1,15 @@
|
||||
<!-- SPDX-FileCopyrightText: 2025-2026 Cole Speelman -->
|
||||
<!-- SPDX-License-Identifier: 0BSD -->
|
||||
|
||||
# Example license map
|
||||
|
||||
The original files in this synthetic, copyable example are licensed under the
|
||||
0BSD terms in `LICENSE`, including `.sando` templates, handwritten Go,
|
||||
`himesan.json`, and committed `.sando.go` output.
|
||||
|
||||
The generated files intentionally have no inline SPDX header because Hime-san
|
||||
replaces the whole file. This module-level license map covers them. Generator
|
||||
scaffolding copied into those files also has the additional permission in the
|
||||
root repository's `OUTPUT_EXCEPTION.md`; that permission is not required to
|
||||
keep this example under 0BSD when the example author already owns the relevant
|
||||
input code.
|
||||
@@ -0,0 +1,15 @@
|
||||
<!-- SPDX-License-Identifier: 0BSD -->
|
||||
|
||||
# Synthetic EQL-shaped fixture
|
||||
|
||||
This copyable 0BSD example exercises a shared document layout, typed page data,
|
||||
component composition, loops, text escaping, attribute escaping, and URL
|
||||
policy without containing EQL Wiki code, data, routes, or its database.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
go run ./cmd/himesan generate ./examples/eql-shaped
|
||||
go run ./cmd/himesan check ./examples/eql-shaped
|
||||
(cd examples/eql-shaped && go test ./... && go run ./cmd/example)
|
||||
```
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"example.com/eql-shaped/views"
|
||||
"gamertan.com/sandwich-hime/sando"
|
||||
)
|
||||
|
||||
func main() {
|
||||
address := os.Getenv("HIMESAN_LISTEN_ADDR")
|
||||
if address != "" {
|
||||
serve(address)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := renderPage(context.Background())
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
_, _ = output.WriteTo(os.Stdout)
|
||||
}
|
||||
|
||||
func serve(address string) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(response http.ResponseWriter, _ *http.Request) {
|
||||
response.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
response.WriteHeader(http.StatusOK)
|
||||
_, _ = response.Write([]byte("ok\n"))
|
||||
})
|
||||
mux.HandleFunc("GET /", func(response http.ResponseWriter, request *http.Request) {
|
||||
output, err := renderPage(request.Context())
|
||||
if err != nil {
|
||||
http.Error(response, "render failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
response.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
response.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'self'")
|
||||
response.WriteHeader(http.StatusOK)
|
||||
_, _ = output.WriteTo(response)
|
||||
})
|
||||
|
||||
server := &http.Server{Addr: address, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func renderPage(ctx context.Context) (*bytes.Buffer, error) {
|
||||
body := views.Home(views.HomeView{
|
||||
Heading: "EQL-shaped records",
|
||||
Intro: "Typed markup without making the template compiler your web framework.",
|
||||
Browse: views.BrowseView{
|
||||
Query: "pioneer & archivist",
|
||||
Records: []views.RecordView{
|
||||
{URL: "/items/1?from=home&kind=book", Title: "A <field> guide", Kind: "book", Featured: true},
|
||||
{URL: "/items/2", Title: "Community memory", Kind: "archive"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
page := views.Layout(views.LayoutView{
|
||||
SiteName: "EQL Wiki Fixture",
|
||||
Title: "Home",
|
||||
Body: body,
|
||||
})
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := sando.Render(ctx, &output, page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &output, nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
module example.com/eql-shaped
|
||||
|
||||
go 1.25
|
||||
|
||||
require gamertan.com/sandwich-hime/sando v0.0.0
|
||||
|
||||
replace gamertan.com/sandwich-hime/sando => ../../sando
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sourceRoots": ["views"],
|
||||
"goPackage": "./cmd/example",
|
||||
"listenAddressEnv": "HIMESAN_LISTEN_ADDR",
|
||||
"healthPath": "/healthz",
|
||||
"proxyAddress": "127.0.0.1:7331",
|
||||
"additionalWatchRoots": []
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?sando go
|
||||
package views
|
||||
|
||||
func Badge(label string)
|
||||
?>
|
||||
<?# SPDX-License-Identifier: 0BSD ?>
|
||||
<strong class="badge"><?= label ?></strong>
|
||||
@@ -0,0 +1,37 @@
|
||||
// Code generated by himesan; DO NOT EDIT.
|
||||
// himesan:compiler 0.1.0-dev
|
||||
// himesan:runtime-abi sando.v1
|
||||
// himesan:source-sha256 2c0063ed8724520f146d6717bb7d828ce69be17b41cbfb8f4866664ba09a58a8
|
||||
|
||||
package views
|
||||
|
||||
import (
|
||||
__himesan_context "context"
|
||||
__himesan_sando "gamertan.com/sandwich-hime/sando"
|
||||
__himesan_io "io"
|
||||
)
|
||||
|
||||
var _ = __himesan_sando.ABI
|
||||
|
||||
func Badge(label string) __himesan_sando.Component {
|
||||
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
|
||||
_ = __himesan_render_context
|
||||
//line views/badge.sando:5:3
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/badge.sando:6:37
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<strong class=\"badge\">"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/badge.sando:7:27
|
||||
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (label)); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/badge.sando:7:35
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</strong>\n"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?sando go
|
||||
package views
|
||||
|
||||
func BrowseResults(view BrowseView)
|
||||
?>
|
||||
<?# SPDX-License-Identifier: 0BSD ?>
|
||||
<section aria-label="Browse results" data-query="<?= view.Query ?>">
|
||||
<ul>
|
||||
<? for _, record := range view.Records { ?>
|
||||
<li>
|
||||
<a href="<?= record.URL ?>"><?= record.Title ?></a>
|
||||
<span class="kind"><?= record.Kind ?></span>
|
||||
<? if record.Featured { ?><?~ Badge("featured") ?><? } ?>
|
||||
</li>
|
||||
<? } ?>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -0,0 +1,85 @@
|
||||
// Code generated by himesan; DO NOT EDIT.
|
||||
// himesan:compiler 0.1.0-dev
|
||||
// himesan:runtime-abi sando.v1
|
||||
// himesan:source-sha256 c5b402971618747c906c84508d5af9fd00aa33391b9b59f093b001a3cb0b2164
|
||||
|
||||
package views
|
||||
|
||||
import (
|
||||
__himesan_context "context"
|
||||
__himesan_sando "gamertan.com/sandwich-hime/sando"
|
||||
__himesan_io "io"
|
||||
)
|
||||
|
||||
var _ = __himesan_sando.ABI
|
||||
|
||||
func BrowseResults(view BrowseView) __himesan_sando.Component {
|
||||
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
|
||||
_ = __himesan_render_context
|
||||
//line views/browse_results.sando:5:3
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/browse_results.sando:6:37
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<section aria-label=\"Browse results\" data-query=\""); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/browse_results.sando:7:54
|
||||
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Query)); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/browse_results.sando:7:67
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <ul>\n "); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/browse_results.sando:9:6
|
||||
for _, record := range view.Records {
|
||||
//line views/browse_results.sando:9:46
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <li>\n <a href=\""); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/browse_results.sando:11:20
|
||||
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (record.URL)); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/browse_results.sando:11:33
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/browse_results.sando:11:39
|
||||
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (record.Title)); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/browse_results.sando:11:54
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</a>\n <span class=\"kind\">"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/browse_results.sando:12:30
|
||||
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (record.Kind)); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/browse_results.sando:12:44
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</span>\n "); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/browse_results.sando:13:10
|
||||
if record.Featured {
|
||||
//line views/browse_results.sando:13:37
|
||||
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (Badge("featured"))); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/browse_results.sando:13:60
|
||||
}
|
||||
//line views/browse_results.sando:13:64
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </li>\n "); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/browse_results.sando:15:6
|
||||
}
|
||||
//line views/browse_results.sando:15:10
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </ul>\n</section>\n"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?sando go
|
||||
package views
|
||||
|
||||
func Home(view HomeView)
|
||||
?>
|
||||
<?# SPDX-License-Identifier: 0BSD ?>
|
||||
<section aria-labelledby="home-heading">
|
||||
<h1 id="home-heading"><?= view.Heading ?></h1>
|
||||
<p><?= view.Intro ?></p>
|
||||
<?~ BrowseResults(view.Browse) ?>
|
||||
</section>
|
||||
@@ -0,0 +1,53 @@
|
||||
// Code generated by himesan; DO NOT EDIT.
|
||||
// himesan:compiler 0.1.0-dev
|
||||
// himesan:runtime-abi sando.v1
|
||||
// himesan:source-sha256 696c391538cdc50c5ab1abea23b561fa354e56eaf73b7ad6aabe130389715a8c
|
||||
|
||||
package views
|
||||
|
||||
import (
|
||||
__himesan_context "context"
|
||||
__himesan_sando "gamertan.com/sandwich-hime/sando"
|
||||
__himesan_io "io"
|
||||
)
|
||||
|
||||
var _ = __himesan_sando.ABI
|
||||
|
||||
func Home(view HomeView) __himesan_sando.Component {
|
||||
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
|
||||
_ = __himesan_render_context
|
||||
//line views/home.sando:5:3
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/home.sando:6:37
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<section aria-labelledby=\"home-heading\">\n <h1 id=\"home-heading\">"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/home.sando:8:29
|
||||
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Heading)); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/home.sando:8:44
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</h1>\n <p>"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/home.sando:9:10
|
||||
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Intro)); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/home.sando:9:23
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p>\n "); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/home.sando:10:7
|
||||
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (BrowseResults(view.Browse))); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/home.sando:10:36
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n</section>\n"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?sando go
|
||||
package views
|
||||
|
||||
func Layout(view LayoutView)
|
||||
?>
|
||||
<?# SPDX-License-Identifier: 0BSD ?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= view.Title ?> · <?= view.SiteName ?></title>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main">Skip to content</a>
|
||||
<header><a href="/"><?= view.SiteName ?></a></header>
|
||||
<main id="main"><?~ view.Body ?></main>
|
||||
<footer>Built with Sandwich Hime.</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
// Code generated by himesan; DO NOT EDIT.
|
||||
// himesan:compiler 0.1.0-dev
|
||||
// himesan:runtime-abi sando.v1
|
||||
// himesan:source-sha256 c9a8102588cb94f946a36414291d12cb681e605acc93c2b5dc8d74fd33ae5603
|
||||
|
||||
package views
|
||||
|
||||
import (
|
||||
__himesan_context "context"
|
||||
__himesan_sando "gamertan.com/sandwich-hime/sando"
|
||||
__himesan_io "io"
|
||||
)
|
||||
|
||||
var _ = __himesan_sando.ABI
|
||||
|
||||
func Layout(view LayoutView) __himesan_sando.Component {
|
||||
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
|
||||
_ = __himesan_render_context
|
||||
//line views/layout.sando:5:3
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/layout.sando:6:37
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/layout.sando:12:14
|
||||
if __himesan_error := __himesan_sando.WriteRCDATA(__himesan_writer, (view.Title)); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/layout.sando:12:27
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, " · "); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/layout.sando:12:35
|
||||
if __himesan_error := __himesan_sando.WriteRCDATA(__himesan_writer, (view.SiteName)); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/layout.sando:12:51
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</title>\n</head>\n<body>\n <a href=\"#main\">Skip to content</a>\n <header><a href=\"/\">"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/layout.sando:16:27
|
||||
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.SiteName)); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/layout.sando:16:43
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</a></header>\n <main id=\"main\">"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/layout.sando:17:23
|
||||
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (view.Body)); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/layout.sando:17:35
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</main>\n <footer>Built with Sandwich Hime.</footer>\n</body>\n</html>\n"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
package views
|
||||
|
||||
import "gamertan.com/sandwich-hime/sando"
|
||||
|
||||
type LayoutView struct {
|
||||
SiteName string
|
||||
Title string
|
||||
Body sando.Component
|
||||
}
|
||||
|
||||
type HomeView struct {
|
||||
Heading string
|
||||
Intro string
|
||||
Browse BrowseView
|
||||
}
|
||||
|
||||
type BrowseView struct {
|
||||
Query string
|
||||
Records []RecordView
|
||||
}
|
||||
|
||||
type RecordView struct {
|
||||
URL string
|
||||
Title string
|
||||
Kind string
|
||||
Featured bool
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
package views
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gamertan.com/sandwich-hime/sando"
|
||||
)
|
||||
|
||||
func TestBrowseResultsEscapesUntrustedValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
component := BrowseResults(BrowseView{
|
||||
Query: `"><script>alert(1)</script>`,
|
||||
Records: []RecordView{{
|
||||
URL: `/item?q=" onclick="alert(1)`,
|
||||
Title: `<img src=x onerror=alert(1)>`,
|
||||
Kind: `" aria-label="injected`,
|
||||
}},
|
||||
})
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := sando.Render(context.Background(), &output, component); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := output.String()
|
||||
if strings.Contains(got, "<script>") || strings.Contains(got, "<img") || strings.Contains(got, `" onclick=`) {
|
||||
t.Fatalf("untrusted markup survived escaping: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowseResultsRejectsDangerousURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
component := BrowseResults(BrowseView{Records: []RecordView{{
|
||||
URL: " JaVaScRiPt:\talert(1)",
|
||||
Title: "unsafe",
|
||||
}}})
|
||||
var output bytes.Buffer
|
||||
err := sando.Render(context.Background(), &output, component)
|
||||
if !errors.Is(err, sando.ErrUnsafeURL) {
|
||||
t.Fatalf("expected unsafe URL error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedComponentPropagatesWriterFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
component := Badge("proof")
|
||||
if err := sando.Render(context.Background(), shortWriter{}, component); !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("short writer error = %v, want io.ErrShortWrite", err)
|
||||
}
|
||||
|
||||
want := errors.New("connection closed")
|
||||
if err := sando.Render(context.Background(), errorWriter{err: want}, component); !errors.Is(err, want) {
|
||||
t.Fatalf("writer error = %v, want %v", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextEscapingAgainstHTMLTemplateBaseline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
baseline := template.Must(template.New("probe").Parse(`<a href="{{ .URL }}" title="{{ .Title }}">{{ .Text }}</a>`))
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
title string
|
||||
text string
|
||||
}{
|
||||
{name: "entities", url: "/items?q=one&kind=book", title: `say "hello" & goodbye`, text: `<strong>not markup</strong>`},
|
||||
{name: "unicode", url: "/items/cole", title: "Café & 雪", text: "Cole's archive ♥"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := struct{ URL, Title, Text string }{test.url, test.title, test.text}
|
||||
var legacy bytes.Buffer
|
||||
if err := baseline.Execute(&legacy, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var generated bytes.Buffer
|
||||
if err := sando.Render(context.Background(), &generated, SecurityProbe(test.url, test.title, test.text)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := strings.TrimSpace(generated.String()), legacy.String(); got != want {
|
||||
t.Fatalf("generated escaping differs from html/template baseline\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDangerousURLIsBlockedAtLeastAsStrictlyAsHTMLTemplate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const dangerous = " JaVaScRiPt:\talert(1)"
|
||||
baseline := template.Must(template.New("probe").Parse(`<a href="{{ . }}">link</a>`))
|
||||
var legacy bytes.Buffer
|
||||
if err := baseline.Execute(&legacy, dangerous); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(legacy.String(), "#ZgotmplZ") {
|
||||
t.Fatalf("html/template baseline unexpectedly accepted dangerous URL: %s", legacy.String())
|
||||
}
|
||||
|
||||
var generated bytes.Buffer
|
||||
err := sando.Render(context.Background(), &generated, SecurityProbe(dangerous, "title", "link"))
|
||||
if !errors.Is(err, sando.ErrUnsafeURL) {
|
||||
t.Fatalf("generated renderer error = %v, want ErrUnsafeURL", err)
|
||||
}
|
||||
}
|
||||
|
||||
type shortWriter struct{}
|
||||
|
||||
func (shortWriter) Write(value []byte) (int, error) {
|
||||
if len(value) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return len(value) - 1, nil
|
||||
}
|
||||
|
||||
type errorWriter struct{ err error }
|
||||
|
||||
func (writer errorWriter) Write([]byte) (int, error) { return 0, writer.err }
|
||||
@@ -0,0 +1,7 @@
|
||||
<?sando go
|
||||
package views
|
||||
|
||||
func SecurityProbe(url string, title string, text string)
|
||||
?>
|
||||
<?# SPDX-License-Identifier: 0BSD ?>
|
||||
<a href="<?= url ?>" title="<?= title ?>"><?= text ?></a>
|
||||
@@ -0,0 +1,53 @@
|
||||
// Code generated by himesan; DO NOT EDIT.
|
||||
// himesan:compiler 0.1.0-dev
|
||||
// himesan:runtime-abi sando.v1
|
||||
// himesan:source-sha256 8d59b45799d0ed2e9e6997f286315f2cb794ea660940978ec39efa3b9a3b94ab
|
||||
|
||||
package views
|
||||
|
||||
import (
|
||||
__himesan_context "context"
|
||||
__himesan_sando "gamertan.com/sandwich-hime/sando"
|
||||
__himesan_io "io"
|
||||
)
|
||||
|
||||
var _ = __himesan_sando.ABI
|
||||
|
||||
func SecurityProbe(url string, title string, text string) __himesan_sando.Component {
|
||||
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
|
||||
_ = __himesan_render_context
|
||||
//line views/security_probe.sando:5:3
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/security_probe.sando:6:37
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<a href=\""); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/security_probe.sando:7:14
|
||||
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (url)); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/security_probe.sando:7:20
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" title=\""); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/security_probe.sando:7:33
|
||||
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (title)); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/security_probe.sando:7:41
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/security_probe.sando:7:47
|
||||
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (text)); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
//line views/security_probe.sando:7:54
|
||||
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</a>\n"); __himesan_error != nil {
|
||||
return __himesan_error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
module gamertan.com/sandwich-hime
|
||||
|
||||
go 1.25
|
||||
@@ -0,0 +1,318 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"go/scanner"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gamertan.com/sandwich-hime/internal/version"
|
||||
)
|
||||
|
||||
type backendImports struct {
|
||||
Context string
|
||||
IO string
|
||||
Sando string
|
||||
All []sourceImport
|
||||
}
|
||||
|
||||
// Compile parses, context-checks, and formats one .sando source entirely in
|
||||
// memory. It never reads project metadata, writes a file, or executes Go code.
|
||||
func Compile(path string, source []byte) (CompiledFile, []Diagnostic) {
|
||||
return compileWithMapping(path, source, filepath.ToSlash(filepath.Base(path)))
|
||||
}
|
||||
|
||||
func compileWithMapping(path string, source []byte, mapping string) (CompiledFile, []Diagnostic) {
|
||||
cleanPath := filepath.Clean(path)
|
||||
file, diagnostics := parseSource(cleanPath, source)
|
||||
if file == nil {
|
||||
sortDiagnostics(diagnostics)
|
||||
return CompiledFile{}, diagnostics
|
||||
}
|
||||
file.Mapping = mapping
|
||||
diagnostics = append(diagnostics, analyzeContexts(file)...)
|
||||
diagnostics = append(diagnostics, auditTrustCalls(file)...)
|
||||
if hasErrors(diagnostics) {
|
||||
sortDiagnostics(diagnostics)
|
||||
return CompiledFile{}, diagnostics
|
||||
}
|
||||
|
||||
code, backendDiagnostics := generateGo(file)
|
||||
diagnostics = append(diagnostics, backendDiagnostics...)
|
||||
if hasErrors(diagnostics) {
|
||||
sortDiagnostics(diagnostics)
|
||||
return CompiledFile{}, diagnostics
|
||||
}
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256(source))
|
||||
compiled := CompiledFile{
|
||||
SourcePath: cleanPath,
|
||||
OutputPath: cleanPath + ".go",
|
||||
Package: file.Package,
|
||||
Component: file.Name,
|
||||
Digest: digest,
|
||||
Code: code,
|
||||
source: file,
|
||||
}
|
||||
sortDiagnostics(diagnostics)
|
||||
return compiled, diagnostics
|
||||
}
|
||||
|
||||
func generateGo(file *sourceFile) ([]byte, []Diagnostic) {
|
||||
imports, diagnostics := prepareImports(file)
|
||||
if hasErrors(diagnostics) {
|
||||
return nil, diagnostics
|
||||
}
|
||||
usedIdentifiers := sourceIdentifiers(file.Source)
|
||||
contextName := uniqueIdentifier("__himesan_render_context", usedIdentifiers)
|
||||
writerName := uniqueIdentifier("__himesan_writer", usedIdentifiers)
|
||||
errorName := uniqueIdentifier("__himesan_error", usedIdentifiers)
|
||||
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256(file.Source))
|
||||
var output strings.Builder
|
||||
output.WriteString(generatedPrefix)
|
||||
output.WriteByte('\n')
|
||||
fmt.Fprintf(&output, "// himesan:compiler %s\n", version.Compiler)
|
||||
fmt.Fprintf(&output, "// himesan:runtime-abi %s\n", version.RuntimeABI)
|
||||
fmt.Fprintf(&output, "// himesan:source-sha256 %s\n\n", digest)
|
||||
fmt.Fprintf(&output, "package %s\n\n", file.Package)
|
||||
output.WriteString("import (\n")
|
||||
for _, imported := range imports.All {
|
||||
if imported.Alias != "" {
|
||||
fmt.Fprintf(&output, "\t%s %q\n", imported.Alias, imported.Path)
|
||||
} else {
|
||||
fmt.Fprintf(&output, "\t%q\n", imported.Path)
|
||||
}
|
||||
}
|
||||
output.WriteString(")\n\n")
|
||||
fmt.Fprintf(&output, "var _ = %s.ABI\n\n", imports.Sando)
|
||||
fmt.Fprintf(&output, "func %s%s%s %s.Component {\n", file.Name, file.TypeParams, file.Params, imports.Sando)
|
||||
fmt.Fprintf(&output, "\treturn %s.ComponentFunc(func(%s %s.Context, %s %s.Writer) error {\n", imports.Sando, contextName, imports.Context, writerName, imports.IO)
|
||||
fmt.Fprintf(&output, "\t\t_ = %s\n", contextName)
|
||||
|
||||
directiveName := sanitizeDirectivePath(file.Mapping)
|
||||
for _, node := range file.Nodes {
|
||||
if node.Kind == nodeComment {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&output, "//line %s:%d:%d\n", directiveName, node.Pos.Line, node.Pos.Column)
|
||||
switch node.Kind {
|
||||
case nodeText:
|
||||
if node.Text == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&output, "if %s := %s.WriteString(%s, %s); %s != nil { return %s }\n", errorName, imports.Sando, writerName, strconv.Quote(node.Text), errorName, errorName)
|
||||
case nodeStatement:
|
||||
output.WriteString(node.Text)
|
||||
output.WriteByte('\n')
|
||||
case nodeExpression:
|
||||
helper := "WriteText"
|
||||
switch node.Context {
|
||||
case ContextAttr:
|
||||
helper = "WriteAttr"
|
||||
case ContextRCDATA:
|
||||
helper = "WriteRCDATA"
|
||||
case ContextURL:
|
||||
helper = "WriteURL"
|
||||
case ContextJS:
|
||||
helper = "WriteJS"
|
||||
case ContextCSS:
|
||||
helper = "WriteCSS"
|
||||
}
|
||||
fmt.Fprintf(&output, "if %s := %s.%s(%s, (%s)); %s != nil { return %s }\n", errorName, imports.Sando, helper, writerName, node.Text, errorName, errorName)
|
||||
case nodeComponent:
|
||||
fmt.Fprintf(&output, "if %s := %s.Render(%s, %s, (%s)); %s != nil { return %s }\n", errorName, imports.Sando, contextName, writerName, node.Text, errorName, errorName)
|
||||
}
|
||||
}
|
||||
output.WriteString("return nil\n")
|
||||
output.WriteString("})\n")
|
||||
output.WriteString("}\n")
|
||||
|
||||
formatted, err := format.Source([]byte(output.String()))
|
||||
if err != nil {
|
||||
position := sourcePosition{Line: 1, Column: 1}
|
||||
message := err.Error()
|
||||
if list, ok := err.(scanner.ErrorList); ok && len(list) != 0 {
|
||||
position.Line = list[0].Pos.Line
|
||||
position.Column = list[0].Pos.Column
|
||||
message = list[0].Msg
|
||||
}
|
||||
return nil, []Diagnostic{diagnostic(file.Path, position, "HIM1401", "generated Go is invalid: "+message)}
|
||||
}
|
||||
return formatted, diagnostics
|
||||
}
|
||||
|
||||
func sanitizeDirectivePath(path string) string {
|
||||
path = filepath.ToSlash(path)
|
||||
var sanitized strings.Builder
|
||||
for index := 0; index < len(path); index++ {
|
||||
b := path[index]
|
||||
if b < 0x20 || b == 0x7f || b == '%' {
|
||||
fmt.Fprintf(&sanitized, "%%%02X", b)
|
||||
continue
|
||||
}
|
||||
sanitized.WriteByte(b)
|
||||
}
|
||||
if sanitized.Len() == 0 {
|
||||
return "source.sando"
|
||||
}
|
||||
return sanitized.String()
|
||||
}
|
||||
|
||||
func prepareImports(file *sourceFile) (backendImports, []Diagnostic) {
|
||||
imports := append([]sourceImport(nil), file.Imports...)
|
||||
identifiers := sourceIdentifiers(file.Source)
|
||||
for _, imported := range imports {
|
||||
if imported.Alias != "" && imported.Alias != "_" {
|
||||
identifiers[imported.Alias] = true
|
||||
} else if imported.Alias == "" {
|
||||
identifiers[defaultImportName(imported.Path)] = true
|
||||
}
|
||||
}
|
||||
|
||||
var diagnostics []Diagnostic
|
||||
ensure := func(path, base string) string {
|
||||
for _, imported := range imports {
|
||||
if imported.Path != path {
|
||||
continue
|
||||
}
|
||||
if imported.Alias == "_" {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, sourcePosition{Line: 1, Column: 1}, "HIM1410", fmt.Sprintf("internal dependency %q cannot be imported for side effects", path)))
|
||||
return ""
|
||||
}
|
||||
if imported.Alias != "" {
|
||||
return imported.Alias
|
||||
}
|
||||
return defaultImportName(path)
|
||||
}
|
||||
alias := uniqueIdentifier(base, identifiers)
|
||||
imports = append(imports, sourceImport{Alias: alias, Path: path})
|
||||
return alias
|
||||
}
|
||||
|
||||
contextAlias := ensure("context", "__himesan_context")
|
||||
ioAlias := ensure("io", "__himesan_io")
|
||||
sandoAlias := ensure(runtimeImportPath, "__himesan_sando")
|
||||
sort.SliceStable(imports, func(i, j int) bool {
|
||||
if imports[i].Path != imports[j].Path {
|
||||
return imports[i].Path < imports[j].Path
|
||||
}
|
||||
return imports[i].Alias < imports[j].Alias
|
||||
})
|
||||
return backendImports{Context: contextAlias, IO: ioAlias, Sando: sandoAlias, All: imports}, diagnostics
|
||||
}
|
||||
|
||||
func sourceIdentifiers(source []byte) map[string]bool {
|
||||
identifiers := make(map[string]bool)
|
||||
var lexical scanner.Scanner
|
||||
fileSet := token.NewFileSet()
|
||||
file := fileSet.AddFile("source.sando", -1, len(source))
|
||||
lexical.Init(file, source, nil, scanner.ScanComments)
|
||||
for {
|
||||
_, tok, literal := lexical.Scan()
|
||||
if tok == token.EOF {
|
||||
break
|
||||
}
|
||||
if tok == token.IDENT {
|
||||
identifiers[literal] = true
|
||||
}
|
||||
}
|
||||
return identifiers
|
||||
}
|
||||
|
||||
func uniqueIdentifier(base string, used map[string]bool) string {
|
||||
name := base
|
||||
for suffix := 2; used[name]; suffix++ {
|
||||
name = fmt.Sprintf("%s_%d", base, suffix)
|
||||
}
|
||||
used[name] = true
|
||||
return name
|
||||
}
|
||||
|
||||
func defaultImportName(path string) string {
|
||||
base := filepath.Base(path)
|
||||
if index := strings.IndexByte(base, '.'); index >= 0 {
|
||||
base = base[:index]
|
||||
}
|
||||
base = strings.ReplaceAll(base, "-", "_")
|
||||
return base
|
||||
}
|
||||
|
||||
func auditTrustCalls(file *sourceFile) []Diagnostic {
|
||||
trusted := map[string]bool{
|
||||
"TrustHTML": true,
|
||||
"TrustURL": true,
|
||||
"TrustJS": true,
|
||||
"TrustCSS": true,
|
||||
}
|
||||
var diagnostics []Diagnostic
|
||||
trustedTypeSeen := make(map[string]bool)
|
||||
var headerScanner scanner.Scanner
|
||||
headerFileSet := token.NewFileSet()
|
||||
headerFile := headerFileSet.AddFile(filepath.Base(file.Path), -1, file.HeaderEnd)
|
||||
headerScanner.Init(headerFile, file.Source[:file.HeaderEnd], nil, scanner.ScanComments)
|
||||
for {
|
||||
_, tok, literal := headerScanner.Scan()
|
||||
if tok == token.EOF {
|
||||
break
|
||||
}
|
||||
if tok == token.IDENT && (literal == "TrustedHTML" || literal == "TrustedURL" || literal == "TrustedJS" || literal == "TrustedCSS") && !trustedTypeSeen[literal] {
|
||||
trustedTypeSeen[literal] = true
|
||||
diagnostics = append(diagnostics, Diagnostic{
|
||||
Path: file.Path,
|
||||
Line: 1,
|
||||
Column: 1,
|
||||
Code: "HIM1903",
|
||||
Severity: SeverityWarning,
|
||||
Message: fmt.Sprintf("component signature names %s; audit every value supplied through this trust boundary", literal),
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, node := range file.Nodes {
|
||||
if node.Kind != nodeExpression && node.Kind != nodeComponent && node.Kind != nodeStatement {
|
||||
continue
|
||||
}
|
||||
if node.Kind == nodeExpression && (node.Context == ContextJS || node.Context == ContextCSS) {
|
||||
diagnostics = append(diagnostics, Diagnostic{
|
||||
Path: file.Path,
|
||||
Line: node.Pos.Line,
|
||||
Column: node.Pos.Column,
|
||||
Code: "HIM1902",
|
||||
Severity: SeverityWarning,
|
||||
Message: fmt.Sprintf("dynamic %s output requires an explicitly trusted runtime value; audit its provenance", node.Context),
|
||||
})
|
||||
}
|
||||
var lexical scanner.Scanner
|
||||
set := token.NewFileSet()
|
||||
goFile := set.AddFile(filepath.Base(file.Path), -1, len(node.Text))
|
||||
lexical.Init(goFile, []byte(node.Text), nil, scanner.ScanComments)
|
||||
for {
|
||||
_, tok, literal := lexical.Scan()
|
||||
if tok == token.EOF {
|
||||
break
|
||||
}
|
||||
if tok == token.IDENT && trusted[literal] {
|
||||
diagnostics = append(diagnostics, Diagnostic{
|
||||
Path: file.Path,
|
||||
Line: node.Pos.Line,
|
||||
Column: node.Pos.Column,
|
||||
Code: "HIM1901",
|
||||
Severity: SeverityWarning,
|
||||
Message: fmt.Sprintf("conspicuous trusted-value constructor %s is used; audit its provenance", literal),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
func bytesEqual(a, b []byte) bool {
|
||||
return bytes.Equal(a, b)
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const profileSource = `<?sando go
|
||||
package views
|
||||
|
||||
func Profile(name string, admin bool)
|
||||
?>
|
||||
<section class="profile" data-name="<?= name ?>">
|
||||
<h1><?= name ?></h1>
|
||||
<? if admin { ?>
|
||||
<strong>Admin</strong>
|
||||
<? } ?>
|
||||
</section>
|
||||
`
|
||||
|
||||
func TestCompileDeterministicContextAnnotatedBackend(t *testing.T) {
|
||||
t.Parallel()
|
||||
first, diagnostics := Compile("views/profile.sando", []byte(profileSource))
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
second, secondDiagnostics := Compile("views/profile.sando", []byte(profileSource))
|
||||
assertNoErrorDiagnostics(t, secondDiagnostics)
|
||||
if !bytes.Equal(first.Code, second.Code) {
|
||||
t.Fatal("repeated compilation was not deterministic")
|
||||
}
|
||||
generated := string(first.Code)
|
||||
for _, expected := range []string{
|
||||
generatedPrefix,
|
||||
"// himesan:compiler ",
|
||||
"// himesan:runtime-abi sando.v1",
|
||||
"// himesan:source-sha256 ",
|
||||
"func Profile(name string, admin bool)",
|
||||
".WriteAttr(",
|
||||
".WriteText(",
|
||||
".WriteString(",
|
||||
"ComponentFunc(func(",
|
||||
"//line profile.sando:",
|
||||
} {
|
||||
if !strings.Contains(generated, expected) {
|
||||
t.Fatalf("generated code does not contain %q:\n%s", expected, generated)
|
||||
}
|
||||
}
|
||||
if strings.Contains(generated, "AGPL") || strings.Contains(generated, "SPDX-License-Identifier") {
|
||||
t.Fatal("generated application code inherited the compiler license header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommittedGoldenOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
root := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
|
||||
sourcePath := filepath.Join(root, "examples", "eql-shaped", "views", "badge.sando")
|
||||
wantPath := sourcePath + ".go"
|
||||
compiled, diagnostics := compileWithMapping(sourcePath, mustRead(t, sourcePath), "views/badge.sando")
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
if want := mustRead(t, wantPath); !bytes.Equal(compiled.Code, want) {
|
||||
t.Fatalf("committed golden output is stale; run himesan generate\n--- got ---\n%s\n--- want ---\n%s", compiled.Code, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderAllowsBOMWhitespaceAndGoLexicalDelimiters(t *testing.T) {
|
||||
t.Parallel()
|
||||
source := "\xef\xbb\xbf \r\n\t<?sando go\npackage views\nfunc Lexical(v struct { Tag string `json:\"?>\"` })\n?>\n<p><?= \"?>\" ?></p>"
|
||||
compiled, diagnostics := Compile("lexical.sando", []byte(source))
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
if !strings.Contains(string(compiled.Code), `WriteText(`) || !strings.Contains(string(compiled.Code), `"?>"`) {
|
||||
t.Fatalf("lexically protected delimiter was not preserved:\n%s", compiled.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderTrailingLineCommentDoesNotConsumeSyntheticBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
source := `<?sando go
|
||||
package p
|
||||
func Commented() // ?> remains inside the Go line comment
|
||||
?>
|
||||
<p>ok</p>`
|
||||
if _, diagnostics := Compile("commented.sando", []byte(source)); hasErrors(diagnostics) {
|
||||
t.Fatalf("trailing header comment failed: %v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderRequiresMarkerWhitespace(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, diagnostics := Compile("bad.sando", []byte("<?sandogo\npackage p\nfunc Bad()\n?>"))
|
||||
assertDiagnosticCode(t, diagnostics, "HIM1105")
|
||||
}
|
||||
|
||||
func TestGoReservedFunctionNamesAreRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, test := range []struct {
|
||||
packageName string
|
||||
function string
|
||||
}{
|
||||
{packageName: "views", function: "init"},
|
||||
{packageName: "main", function: "main"},
|
||||
} {
|
||||
source := "<?sando go\npackage " + test.packageName + "\nfunc " + test.function + "()\n?>"
|
||||
_, diagnostics := Compile(test.function+".sando", []byte(source))
|
||||
assertDiagnosticCode(t, diagnostics, "HIM1123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCRLFAndGeneratedSyntaxDiagnostics(t *testing.T) {
|
||||
t.Parallel()
|
||||
crlf := strings.ReplaceAll(simpleSource("CRLF", "snow 雪"), "\n", "\r\n")
|
||||
if _, diagnostics := Compile("crlf.sando", []byte(crlf)); hasErrors(diagnostics) {
|
||||
t.Fatalf("CRLF source failed: %v", diagnostics)
|
||||
}
|
||||
invalid := `<?sando go
|
||||
package p
|
||||
func Invalid(value bool)
|
||||
?>
|
||||
<? if value { ?>ok<? definitely-not-go ?><? } ?>`
|
||||
_, diagnostics := Compile("mapped.sando", []byte(invalid))
|
||||
assertDiagnosticCode(t, diagnostics, "HIM1401")
|
||||
for _, item := range diagnostics {
|
||||
if item.Code == "HIM1401" && (item.Line < 5 || item.Column < 1) {
|
||||
t.Fatalf("generated syntax diagnostic was not mapped to source: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineDirectivePathCannotInjectGeneratedGo(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := "bad\ngo-build-injected.sando"
|
||||
compiled, diagnostics := Compile(path, []byte(simpleSource("SafePath", "safe")))
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
generated := string(compiled.Code)
|
||||
if strings.Contains(generated, "//line go-build-injected") || !strings.Contains(generated, "%0A") {
|
||||
t.Fatalf("unsafe source path was not encoded in //line directive:\n%s", generated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
code string
|
||||
}{
|
||||
{name: "dynamic tag", body: `<<?= "div" ?>>ok</div>`, code: "HIM1303"},
|
||||
{name: "unquoted attribute", body: `<p title=<?= "x" ?>></p>`, code: "HIM1328"},
|
||||
{name: "event attribute", body: `<p onclick="fixed"></p>`, code: "HIM1340"},
|
||||
{name: "component in attribute", body: `<p title="<?~ Child() ?>"></p>`, code: "HIM1302"},
|
||||
{name: "component in textarea", body: `<textarea><?~ Child() ?></textarea>`, code: "HIM1302"},
|
||||
{name: "unbalanced", body: `<div><span></div>`, code: "HIM1352"},
|
||||
{name: "unfinished", body: `<div>`, code: "HIM1311"},
|
||||
{name: "self closing nonvoid", body: `<div/>`, code: "HIM1354"},
|
||||
{name: "foreign SVG", body: `<svg></svg>`, code: "HIM1355"},
|
||||
{name: "ambiguous noscript", body: `<noscript>fallback</noscript>`, code: "HIM1356"},
|
||||
{name: "script escaped state", body: "<script><!--<script></script>\n<?= css ?>\n<!--\n</script>\n-->", code: "HIM1357"},
|
||||
{name: "script escaped state split by template comment", body: `<script><!<?# emits nothing ?>--alert(1)</script>`, code: "HIM1357"},
|
||||
{name: "dynamic iframe raw text", body: `<iframe><?= css ?></iframe>`, code: "HIM1303"},
|
||||
{name: "dynamic style attribute", body: `<p style="<?= css ?>"></p>`, code: "HIM1343"},
|
||||
{name: "dynamic srcdoc attribute", body: `<iframe srcdoc="<?= css ?>"></iframe>`, code: "HIM1345"},
|
||||
{name: "dynamic srcset attribute", body: `<img srcset="<?= css ?>">`, code: "HIM1345"},
|
||||
{name: "meta refresh", body: `<meta content="0;url=javascript:alert(1)" http-equiv="refresh">`, code: "HIM1346"},
|
||||
{name: "dynamic meta http equiv", body: `<meta http-equiv="<?= css ?>" content="safe">`, code: "HIM1346"},
|
||||
{name: "duplicate attribute", body: `<meta http-equiv="refresh" http-equiv="safe">`, code: "HIM1347"},
|
||||
{name: "dangerous static URL", body: `<a href="javascript:alert(1)">x</a>`, code: "HIM1344"},
|
||||
{name: "ambiguous URL pieces", body: `<a href="<?= scheme ?>:payload">x</a>`, code: "HIM1341"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
source := "<?sando go\npackage p\nfunc Example(css, scheme string)\n?>\n" + test.body
|
||||
_, diagnostics := Compile(test.name+".sando", []byte(source))
|
||||
assertDiagnosticCode(t, diagnostics, test.code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLCommentSyntaxInOtherRawTextStatesRemainsSupported(t *testing.T) {
|
||||
t.Parallel()
|
||||
source := `<?sando go
|
||||
package p
|
||||
func RawText()
|
||||
?>
|
||||
<style><!-- .old-browser { display: none } --></style>
|
||||
<textarea><!-- literal text --></textarea>
|
||||
<iframe><!-- literal text --></iframe>`
|
||||
_, diagnostics := Compile("raw-text.sando", []byte(source))
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
}
|
||||
|
||||
func TestTextareaExpressionUsesTextEscaping(t *testing.T) {
|
||||
t.Parallel()
|
||||
source := `<?sando go
|
||||
package p
|
||||
func Field(value string)
|
||||
?>
|
||||
<textarea><?= value ?></textarea>`
|
||||
compiled, diagnostics := Compile("field.sando", []byte(source))
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
if !strings.Contains(string(compiled.Code), ".WriteRCDATA(") {
|
||||
t.Fatalf("textarea expression did not use dedicated RCDATA escaping:\n%s", compiled.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedURLAndRawTextContexts(t *testing.T) {
|
||||
t.Parallel()
|
||||
source := `<?sando go
|
||||
package p
|
||||
import "gamertan.com/sandwich-hime/sando"
|
||||
func Safe(id string, js sando.TrustedJS, css sando.TrustedCSS)
|
||||
?>
|
||||
<a href="/item/<?= id ?>">relative</a>
|
||||
<a href="<?= sando.TrustURL("https://example.test/") ?>">trusted</a>
|
||||
<script><?= js ?></script>
|
||||
<style><?= css ?></style>`
|
||||
compiled, diagnostics := Compile("safe.sando", []byte(source))
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
assertDiagnosticCode(t, diagnostics, "HIM1901")
|
||||
assertDiagnosticCode(t, diagnostics, "HIM1902")
|
||||
assertDiagnosticCode(t, diagnostics, "HIM1903")
|
||||
generated := string(compiled.Code)
|
||||
for _, helper := range []string{".WriteURL(", ".WriteJS(", ".WriteCSS("} {
|
||||
if !strings.Contains(generated, helper) {
|
||||
t.Fatalf("generated code does not contain %s:\n%s", helper, generated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustWarningsDoNotFailGenerateOrCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
path := filepath.Join(directory, "trusted.sando")
|
||||
source := `<?sando go
|
||||
package p
|
||||
import "gamertan.com/sandwich-hime/sando"
|
||||
func Trusted()
|
||||
?>
|
||||
<?= sando.TrustHTML("<b>reviewed</b>") ?>`
|
||||
mustWrite(t, path, source)
|
||||
generated, err := Generate(context.Background(), []string{path})
|
||||
if err != nil {
|
||||
t.Fatalf("warning unexpectedly failed generation: %v", err)
|
||||
}
|
||||
assertDiagnosticCode(t, generated.Diagnostics, "HIM1901")
|
||||
checked, err := Check(context.Background(), []string{path})
|
||||
if err != nil {
|
||||
t.Fatalf("warning unexpectedly failed check: %v", err)
|
||||
}
|
||||
assertDiagnosticCode(t, checked.Diagnostics, "HIM1901")
|
||||
}
|
||||
|
||||
func TestGenerateCheckAndUnchangedTimestamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
path := filepath.Join(directory, "hello.sando")
|
||||
mustWrite(t, path, `<?sando go
|
||||
package demo
|
||||
func Hello(name string)
|
||||
?>
|
||||
<p>Hello <?= name ?></p>`)
|
||||
first, err := Generate(context.Background(), []string{directory})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v (%v)", err, first.Diagnostics)
|
||||
}
|
||||
if first.Changed != 1 || first.Discovered != 1 {
|
||||
t.Fatalf("unexpected first result: %+v", first)
|
||||
}
|
||||
outputPath := path + ".go"
|
||||
before, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := Generate(context.Background(), []string{directory})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Unchanged != 1 || !before.ModTime().Equal(after.ModTime()) {
|
||||
t.Fatalf("unchanged generation changed output metadata: before=%v after=%v result=%+v", before.ModTime(), after.ModTime(), second)
|
||||
}
|
||||
checked, err := Check(context.Background(), []string{directory})
|
||||
if err != nil || checked.Unchanged != 1 {
|
||||
t.Fatalf("fresh check failed: result=%+v err=%v", checked, err)
|
||||
}
|
||||
mustWrite(t, path, strings.ReplaceAll(string(mustRead(t, path)), "Hello", "Welcome"))
|
||||
stale, err := Check(context.Background(), []string{directory})
|
||||
if err == nil || stale.Stale != 1 {
|
||||
t.Fatalf("stale check did not fail: result=%+v err=%v", stale, err)
|
||||
}
|
||||
assertDiagnosticCode(t, stale.Diagnostics, "HIM2204")
|
||||
}
|
||||
|
||||
func TestCompileFailurePreservesEveryLastGoodOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
firstPath := filepath.Join(directory, "first.sando")
|
||||
secondPath := filepath.Join(directory, "second.sando")
|
||||
mustWrite(t, firstPath, simpleSource("First", "first"))
|
||||
mustWrite(t, secondPath, simpleSource("Second", "second"))
|
||||
if _, err := Generate(context.Background(), []string{directory}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstLastGood := mustRead(t, firstPath+".go")
|
||||
secondLastGood := mustRead(t, secondPath+".go")
|
||||
mustWrite(t, firstPath, simpleSource("First", "changed"))
|
||||
mustWrite(t, secondPath, "<?sando go\npackage demo\nfunc Second(\n?>")
|
||||
if result, err := Generate(context.Background(), []string{directory}); err == nil {
|
||||
t.Fatalf("invalid batch unexpectedly generated: %+v", result)
|
||||
}
|
||||
if !bytes.Equal(firstLastGood, mustRead(t, firstPath+".go")) || !bytes.Equal(secondLastGood, mustRead(t, secondPath+".go")) {
|
||||
t.Fatal("a last-good output changed after batch compilation failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRefusesUnownedOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
path := filepath.Join(directory, "page.sando")
|
||||
mustWrite(t, path, simpleSource("Page", "page"))
|
||||
mustWrite(t, path+".go", "package demo\n")
|
||||
result, err := Generate(context.Background(), []string{path})
|
||||
if err == nil {
|
||||
t.Fatalf("Generate overwrote an unowned output: %+v", result)
|
||||
}
|
||||
assertDiagnosticCode(t, result.Diagnostics, "HIM2104")
|
||||
if got := string(mustRead(t, path+".go")); got != "package demo\n" {
|
||||
t.Fatalf("unowned output changed to %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryBoundariesAndExplicitNestedFile(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation commonly requires additional Windows privileges")
|
||||
}
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
mustWrite(t, filepath.Join(directory, "root.sando"), simpleSource("Root", "root"))
|
||||
mustWrite(t, filepath.Join(directory, ".git", "ignored.sando"), simpleSource("Git", "git"))
|
||||
mustWrite(t, filepath.Join(directory, "vendor", "ignored.sando"), simpleSource("Vendor", "vendor"))
|
||||
mustWrite(t, filepath.Join(directory, "other-repository", ".git"), "gitdir: elsewhere\n")
|
||||
mustWrite(t, filepath.Join(directory, "other-repository", "ignored.sando"), simpleSource("OtherRepository", "other"))
|
||||
nested := filepath.Join(directory, "nested")
|
||||
mustWrite(t, filepath.Join(nested, "go.mod"), "module nested.test\n")
|
||||
nestedSource := filepath.Join(nested, "nested.sando")
|
||||
mustWrite(t, nestedSource, simpleSource("Nested", "nested"))
|
||||
discovered, diagnostics := discover(context.Background(), []string{directory})
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
if len(discovered) != 1 || filepath.Base(discovered[0]) != "root.sando" {
|
||||
t.Fatalf("unexpected discovery result: %v", discovered)
|
||||
}
|
||||
explicit, diagnostics := discover(context.Background(), []string{nestedSource})
|
||||
assertNoErrorDiagnostics(t, diagnostics)
|
||||
if len(explicit) != 1 || explicit[0] != nestedSource {
|
||||
t.Fatalf("explicit nested source was not accepted: %v", explicit)
|
||||
}
|
||||
symlink := filepath.Join(directory, "linked")
|
||||
if err := os.Symlink(nested, symlink); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, diagnostics = discover(context.Background(), []string{filepath.Join(symlink, "nested.sando")})
|
||||
assertDiagnosticCode(t, diagnostics, "HIM2003")
|
||||
}
|
||||
|
||||
func TestCheckRejectsSymlinkOutput(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation commonly requires additional Windows privileges")
|
||||
}
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
path := filepath.Join(directory, "page.sando")
|
||||
mustWrite(t, path, simpleSource("Page", "page"))
|
||||
target := filepath.Join(directory, "handwritten.go")
|
||||
mustWrite(t, target, "package demo\n")
|
||||
if err := os.Symlink(target, path+".go"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := Check(context.Background(), []string{path})
|
||||
if err == nil {
|
||||
t.Fatalf("symlink output unexpectedly passed check: %+v", result)
|
||||
}
|
||||
assertDiagnosticCode(t, result.Diagnostics, "HIM2205")
|
||||
}
|
||||
|
||||
func TestGenerateReportsReadOnlyDirectory(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX directory mode test")
|
||||
}
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
path := filepath.Join(directory, "page.sando")
|
||||
mustWrite(t, path, simpleSource("Page", "page"))
|
||||
if err := os.Chmod(directory, 0o555); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(directory, 0o755) })
|
||||
result, err := Generate(context.Background(), []string{path})
|
||||
if err == nil {
|
||||
t.Fatalf("read-only directory unexpectedly generated: %+v", result)
|
||||
}
|
||||
assertDiagnosticCode(t, result.Diagnostics, "HIM2110")
|
||||
}
|
||||
|
||||
func TestNestedNonRegularGoModIsBoundary(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation commonly requires additional Windows privileges")
|
||||
}
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
target := filepath.Join(directory, "actual.mod")
|
||||
mustWrite(t, target, "module nested.test\n")
|
||||
nested := filepath.Join(directory, "nested")
|
||||
mustWrite(t, filepath.Join(nested, "hidden.sando"), simpleSource("Hidden", "hidden"))
|
||||
if err := os.Symlink(target, filepath.Join(nested, "go.mod")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
discovered, diagnostics := discover(context.Background(), []string{directory})
|
||||
if len(discovered) != 0 {
|
||||
t.Fatalf("traversed nested module with symlink go.mod: %v", discovered)
|
||||
}
|
||||
assertDiagnosticCode(t, diagnostics, "HIM2008")
|
||||
}
|
||||
|
||||
func TestModuleRelativeLineMappings(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
mustWrite(t, filepath.Join(directory, "go.mod"), "module example.test/app\n")
|
||||
path := filepath.Join(directory, "views", "card.sando")
|
||||
mustWrite(t, path, simpleSource("Card", "card"))
|
||||
if _, err := Generate(context.Background(), []string{directory}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
generated := string(mustRead(t, path+".go"))
|
||||
if !strings.Contains(generated, "//line views/card.sando:") {
|
||||
t.Fatalf("line mapping was not module-relative:\n%s", generated)
|
||||
}
|
||||
if strings.Contains(generated, filepath.ToSlash(directory)) {
|
||||
t.Fatal("generated output contains an absolute build-machine path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticComponentCycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
mustWrite(t, filepath.Join(directory, "a.sando"), `<?sando go
|
||||
package demo
|
||||
func A()
|
||||
?>
|
||||
<?~ B() ?>`)
|
||||
mustWrite(t, filepath.Join(directory, "b.sando"), `<?sando go
|
||||
package demo
|
||||
func B()
|
||||
?>
|
||||
<?~ A() ?>`)
|
||||
result, err := Generate(context.Background(), []string{directory})
|
||||
if err == nil {
|
||||
t.Fatalf("component cycle unexpectedly generated: %+v", result)
|
||||
}
|
||||
assertDiagnosticCode(t, result.Diagnostics, "HIM1501")
|
||||
if _, statErr := os.Stat(filepath.Join(directory, "a.sando.go")); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("cycle wrote output: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateComponentNamesFailBeforeWrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := resolvedTempDir(t)
|
||||
mustWrite(t, filepath.Join(directory, "one.sando"), simpleSource("Duplicate", "one"))
|
||||
mustWrite(t, filepath.Join(directory, "two.sando"), simpleSource("Duplicate", "two"))
|
||||
result, err := Generate(context.Background(), []string{directory})
|
||||
if err == nil {
|
||||
t.Fatalf("duplicate component names unexpectedly generated: %+v", result)
|
||||
}
|
||||
assertDiagnosticCode(t, result.Diagnostics, "HIM1500")
|
||||
}
|
||||
|
||||
func simpleSource(component, text string) string {
|
||||
return "<?sando go\npackage demo\nfunc " + component + "()\n?>\n<p>" + text + "</p>\n"
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func resolvedTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
resolved, err := filepath.EvalSymlinks(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func mustRead(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func assertNoErrorDiagnostics(t *testing.T, diagnostics []Diagnostic) {
|
||||
t.Helper()
|
||||
if hasErrors(diagnostics) {
|
||||
t.Fatalf("unexpected diagnostics: %v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func assertDiagnosticCode(t *testing.T, diagnostics []Diagnostic, code string) {
|
||||
t.Helper()
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.Code == code {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("diagnostic %s not found in %v", code, diagnostics)
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type htmlState uint8
|
||||
|
||||
const (
|
||||
htmlData htmlState = iota
|
||||
htmlAfterLT
|
||||
htmlDeclarationStart
|
||||
htmlDeclarationDash
|
||||
htmlDeclaration
|
||||
htmlComment
|
||||
htmlTagName
|
||||
htmlBeforeAttribute
|
||||
htmlAttributeName
|
||||
htmlAfterAttributeName
|
||||
htmlBeforeAttributeValue
|
||||
htmlAttributeDoubleQuoted
|
||||
htmlAttributeSingleQuoted
|
||||
htmlSelfClosing
|
||||
htmlEndTagName
|
||||
htmlAfterEndTagName
|
||||
htmlRawText
|
||||
htmlRawAfterLT
|
||||
htmlRawEndTagName
|
||||
htmlRawAfterEndTagName
|
||||
)
|
||||
|
||||
type contextAnalyzer struct {
|
||||
file *sourceFile
|
||||
|
||||
state htmlState
|
||||
currentTag string
|
||||
currentAttr string
|
||||
stack []string
|
||||
selfClosing bool
|
||||
commentDash int
|
||||
rawTag string
|
||||
rawEndName string
|
||||
scriptTail string
|
||||
attributes map[string]string
|
||||
seenAttrs map[string]bool
|
||||
|
||||
attrLiteral strings.Builder
|
||||
attrDynamicNodes []int
|
||||
attrFirstDynamic int
|
||||
attrDynamicPrefix string
|
||||
}
|
||||
|
||||
var voidElements = map[string]bool{
|
||||
"area": true, "base": true, "br": true, "col": true, "embed": true,
|
||||
"hr": true, "img": true, "input": true, "link": true, "meta": true,
|
||||
"param": true, "source": true, "track": true, "wbr": true,
|
||||
}
|
||||
|
||||
var urlAttributes = map[string]bool{
|
||||
"action": true, "background": true, "cite": true, "classid": true,
|
||||
"code": true, "codebase": true, "data": true, "dynsrc": true,
|
||||
"formaction": true, "href": true, "icon": true, "itemid": true,
|
||||
"longdesc": true, "lowsrc": true, "manifest": true, "poster": true,
|
||||
"profile": true, "src": true, "usemap": true, "xlink:href": true,
|
||||
"xmlns": true,
|
||||
}
|
||||
|
||||
var unsupportedDynamicAttributes = map[string]string{
|
||||
"archive": "URL-list",
|
||||
"imagesrcset": "responsive-image URL-list",
|
||||
"itemtype": "URL-list",
|
||||
"ping": "URL-list",
|
||||
"srcdoc": "nested HTML",
|
||||
"srcset": "responsive-image URL-list",
|
||||
}
|
||||
|
||||
func analyzeContexts(file *sourceFile) []Diagnostic {
|
||||
analyzer := &contextAnalyzer{file: file, state: htmlData, attrFirstDynamic: -1}
|
||||
var diagnostics []Diagnostic
|
||||
for nodeIndex := range file.Nodes {
|
||||
node := &file.Nodes[nodeIndex]
|
||||
switch node.Kind {
|
||||
case nodeText:
|
||||
if d := analyzer.consumeText(node.Text, node.Pos); d != nil {
|
||||
diagnostics = append(diagnostics, *d)
|
||||
return diagnostics
|
||||
}
|
||||
case nodeComment:
|
||||
// Hime-san comments emit no bytes and cannot change HTML state.
|
||||
case nodeStatement:
|
||||
if analyzer.state != htmlData {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1301", "Go statements are only allowed at HTML content boundaries"))
|
||||
return diagnostics
|
||||
}
|
||||
node.Context = ContextNone
|
||||
case nodeComponent:
|
||||
if analyzer.state != htmlData {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1302", "component rendering (<?~) is only allowed at HTML content boundaries"))
|
||||
return diagnostics
|
||||
}
|
||||
node.Context = ContextHTMLText
|
||||
case nodeExpression:
|
||||
if analyzer.state == htmlBeforeAttributeValue {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1328", "attribute values must be quoted"))
|
||||
return diagnostics
|
||||
}
|
||||
if analyzer.inAttribute() && unsupportedDynamicAttributes[analyzer.currentAttr] != "" {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1345", fmt.Sprintf("dynamic %s attributes require an unsupported %s context in v1", analyzer.currentAttr, unsupportedDynamicAttributes[analyzer.currentAttr])))
|
||||
return diagnostics
|
||||
}
|
||||
if analyzer.inAttribute() && analyzer.currentTag == "meta" && analyzer.currentAttr == "http-equiv" {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1346", "dynamic meta http-equiv values are not supported in v1"))
|
||||
return diagnostics
|
||||
}
|
||||
if analyzer.inAttribute() && analyzer.currentAttr == "style" {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1343", "dynamic style attributes are not supported in v1; use a static style attribute or a class"))
|
||||
return diagnostics
|
||||
}
|
||||
context, ok := analyzer.dynamicContext()
|
||||
if !ok {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, node.Pos, "HIM1303", "dynamic output is not allowed while constructing markup; use HTML text or a quoted attribute value"))
|
||||
return diagnostics
|
||||
}
|
||||
node.Context = context
|
||||
if analyzer.inAttribute() {
|
||||
if analyzer.attrFirstDynamic < 0 {
|
||||
analyzer.attrFirstDynamic = nodeIndex
|
||||
analyzer.attrDynamicPrefix = analyzer.attrLiteral.String()
|
||||
}
|
||||
analyzer.attrDynamicNodes = append(analyzer.attrDynamicNodes, nodeIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if analyzer.state != htmlData {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, endPosition(file.Source), "HIM1310", "template ends in an incomplete or ambiguous HTML parser context"))
|
||||
}
|
||||
if len(analyzer.stack) != 0 {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, endPosition(file.Source), "HIM1311", fmt.Sprintf("component must finish in its starting HTML context; unclosed <%s>", analyzer.stack[len(analyzer.stack)-1])))
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) consumeText(text string, start sourcePosition) *Diagnostic {
|
||||
positionTable := newPositionTable(a.file.Source)
|
||||
for index := 0; index < len(text); index++ {
|
||||
b := text[index]
|
||||
position := positionTable.at(start.Offset + index)
|
||||
if a.rawTag == "script" {
|
||||
a.scriptTail += string(b)
|
||||
if len(a.scriptTail) > len("<!--") {
|
||||
a.scriptTail = a.scriptTail[len(a.scriptTail)-len("<!--"):]
|
||||
}
|
||||
if a.scriptTail == "<!--" {
|
||||
return a.problem(position, "HIM1357", "HTML comment syntax inside <script> is not supported because it enters ambiguous escaped script parser states; remove the <!-- sequence")
|
||||
}
|
||||
}
|
||||
reprocess := true
|
||||
for reprocess {
|
||||
reprocess = false
|
||||
switch a.state {
|
||||
case htmlData:
|
||||
if b == '<' {
|
||||
a.state = htmlAfterLT
|
||||
}
|
||||
case htmlAfterLT:
|
||||
switch {
|
||||
case b == '!':
|
||||
a.state = htmlDeclarationStart
|
||||
case b == '/':
|
||||
a.currentTag = ""
|
||||
a.state = htmlEndTagName
|
||||
case isTagNameStart(b):
|
||||
a.currentTag = strings.ToLower(string(b))
|
||||
a.attributes = make(map[string]string)
|
||||
a.seenAttrs = make(map[string]bool)
|
||||
a.selfClosing = false
|
||||
a.state = htmlTagName
|
||||
default:
|
||||
return a.problem(position, "HIM1320", "malformed HTML after '<'; dynamic tag construction is not supported")
|
||||
}
|
||||
case htmlDeclarationStart:
|
||||
if b == '-' {
|
||||
a.state = htmlDeclarationDash
|
||||
} else if isASCIILetter(b) {
|
||||
a.state = htmlDeclaration
|
||||
} else {
|
||||
return a.problem(position, "HIM1321", "unsupported HTML declaration")
|
||||
}
|
||||
case htmlDeclarationDash:
|
||||
if b != '-' {
|
||||
return a.problem(position, "HIM1322", "malformed HTML comment opening")
|
||||
}
|
||||
a.commentDash = 0
|
||||
a.state = htmlComment
|
||||
case htmlDeclaration:
|
||||
if b == '>' {
|
||||
a.state = htmlData
|
||||
} else if b == '<' {
|
||||
return a.problem(position, "HIM1323", "malformed HTML declaration")
|
||||
}
|
||||
case htmlComment:
|
||||
if b == '-' {
|
||||
a.commentDash++
|
||||
} else if b == '>' && a.commentDash >= 2 {
|
||||
a.commentDash = 0
|
||||
a.state = htmlData
|
||||
} else {
|
||||
a.commentDash = 0
|
||||
}
|
||||
case htmlTagName:
|
||||
switch {
|
||||
case isTagNameChar(b):
|
||||
a.currentTag += strings.ToLower(string(b))
|
||||
case isHTMLSpace(b):
|
||||
a.state = htmlBeforeAttribute
|
||||
case b == '>':
|
||||
if d := a.finishOpenTag(position); d != nil {
|
||||
return d
|
||||
}
|
||||
case b == '/':
|
||||
a.selfClosing = true
|
||||
a.state = htmlSelfClosing
|
||||
default:
|
||||
return a.problem(position, "HIM1324", "unsupported character in HTML tag name")
|
||||
}
|
||||
case htmlBeforeAttribute:
|
||||
switch {
|
||||
case isHTMLSpace(b):
|
||||
case isAttributeNameStart(b):
|
||||
a.beginAttribute(b)
|
||||
a.state = htmlAttributeName
|
||||
case b == '>':
|
||||
if d := a.finishOpenTag(position); d != nil {
|
||||
return d
|
||||
}
|
||||
case b == '/':
|
||||
a.selfClosing = true
|
||||
a.state = htmlSelfClosing
|
||||
default:
|
||||
return a.problem(position, "HIM1325", "dynamic or malformed attribute names are not supported")
|
||||
}
|
||||
case htmlAttributeName:
|
||||
switch {
|
||||
case isAttributeNameChar(b):
|
||||
a.currentAttr += strings.ToLower(string(b))
|
||||
case isHTMLSpace(b):
|
||||
if d := a.validateAttributeName(position); d != nil {
|
||||
return d
|
||||
}
|
||||
a.state = htmlAfterAttributeName
|
||||
case b == '=':
|
||||
if d := a.validateAttributeName(position); d != nil {
|
||||
return d
|
||||
}
|
||||
a.state = htmlBeforeAttributeValue
|
||||
case b == '>':
|
||||
if d := a.validateAttributeName(position); d != nil {
|
||||
return d
|
||||
}
|
||||
if d := a.finishOpenTag(position); d != nil {
|
||||
return d
|
||||
}
|
||||
case b == '/':
|
||||
if d := a.validateAttributeName(position); d != nil {
|
||||
return d
|
||||
}
|
||||
a.selfClosing = true
|
||||
a.state = htmlSelfClosing
|
||||
default:
|
||||
return a.problem(position, "HIM1326", "unsupported character in attribute name")
|
||||
}
|
||||
case htmlAfterAttributeName:
|
||||
switch {
|
||||
case isHTMLSpace(b):
|
||||
case b == '=':
|
||||
a.state = htmlBeforeAttributeValue
|
||||
case isAttributeNameStart(b):
|
||||
a.beginAttribute(b)
|
||||
a.state = htmlAttributeName
|
||||
case b == '>':
|
||||
if d := a.finishOpenTag(position); d != nil {
|
||||
return d
|
||||
}
|
||||
case b == '/':
|
||||
a.selfClosing = true
|
||||
a.state = htmlSelfClosing
|
||||
default:
|
||||
return a.problem(position, "HIM1327", "expected '=' or another attribute")
|
||||
}
|
||||
case htmlBeforeAttributeValue:
|
||||
switch {
|
||||
case isHTMLSpace(b):
|
||||
case b == '"':
|
||||
a.resetAttributeValue()
|
||||
a.state = htmlAttributeDoubleQuoted
|
||||
case b == '\'':
|
||||
a.resetAttributeValue()
|
||||
a.state = htmlAttributeSingleQuoted
|
||||
default:
|
||||
return a.problem(position, "HIM1328", "attribute values must be quoted")
|
||||
}
|
||||
case htmlAttributeDoubleQuoted:
|
||||
if b == '"' {
|
||||
if d := a.finishAttributeValue(position); d != nil {
|
||||
return d
|
||||
}
|
||||
a.state = htmlBeforeAttribute
|
||||
} else if b == '<' {
|
||||
return a.problem(position, "HIM1329", "'<' is not supported inside attribute values")
|
||||
} else {
|
||||
a.attrLiteral.WriteByte(b)
|
||||
}
|
||||
case htmlAttributeSingleQuoted:
|
||||
if b == '\'' {
|
||||
if d := a.finishAttributeValue(position); d != nil {
|
||||
return d
|
||||
}
|
||||
a.state = htmlBeforeAttribute
|
||||
} else if b == '<' {
|
||||
return a.problem(position, "HIM1329", "'<' is not supported inside attribute values")
|
||||
} else {
|
||||
a.attrLiteral.WriteByte(b)
|
||||
}
|
||||
case htmlSelfClosing:
|
||||
if isHTMLSpace(b) {
|
||||
continue
|
||||
}
|
||||
if b != '>' {
|
||||
return a.problem(position, "HIM1330", "expected '>' after '/' in a tag")
|
||||
}
|
||||
if d := a.finishOpenTag(position); d != nil {
|
||||
return d
|
||||
}
|
||||
case htmlEndTagName:
|
||||
switch {
|
||||
case isTagNameChar(b):
|
||||
a.currentTag += strings.ToLower(string(b))
|
||||
case isHTMLSpace(b) && a.currentTag != "":
|
||||
a.state = htmlAfterEndTagName
|
||||
case b == '>' && a.currentTag != "":
|
||||
if d := a.finishCloseTag(position); d != nil {
|
||||
return d
|
||||
}
|
||||
default:
|
||||
return a.problem(position, "HIM1331", "malformed closing tag")
|
||||
}
|
||||
case htmlAfterEndTagName:
|
||||
if isHTMLSpace(b) {
|
||||
continue
|
||||
}
|
||||
if b != '>' {
|
||||
return a.problem(position, "HIM1332", "unexpected content in closing tag")
|
||||
}
|
||||
if d := a.finishCloseTag(position); d != nil {
|
||||
return d
|
||||
}
|
||||
case htmlRawText:
|
||||
if b == '<' {
|
||||
a.state = htmlRawAfterLT
|
||||
}
|
||||
case htmlRawAfterLT:
|
||||
if b == '/' {
|
||||
a.rawEndName = ""
|
||||
a.state = htmlRawEndTagName
|
||||
} else if b != '<' {
|
||||
a.state = htmlRawText
|
||||
}
|
||||
case htmlRawEndTagName:
|
||||
if isTagNameChar(b) {
|
||||
a.rawEndName += strings.ToLower(string(b))
|
||||
continue
|
||||
}
|
||||
if a.rawEndName != a.rawTag {
|
||||
a.state = htmlRawText
|
||||
if b == '<' {
|
||||
a.state = htmlRawAfterLT
|
||||
}
|
||||
continue
|
||||
}
|
||||
if b == '>' {
|
||||
if d := a.finishRawClose(position); d != nil {
|
||||
return d
|
||||
}
|
||||
} else if isHTMLSpace(b) {
|
||||
a.state = htmlRawAfterEndTagName
|
||||
} else {
|
||||
a.state = htmlRawText
|
||||
}
|
||||
case htmlRawAfterEndTagName:
|
||||
if isHTMLSpace(b) {
|
||||
continue
|
||||
}
|
||||
if b != '>' {
|
||||
return a.problem(position, "HIM1333", "malformed script/style closing tag")
|
||||
}
|
||||
if d := a.finishRawClose(position); d != nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) beginAttribute(first byte) {
|
||||
a.currentAttr = strings.ToLower(string(first))
|
||||
a.resetAttributeValue()
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) resetAttributeValue() {
|
||||
a.attrLiteral.Reset()
|
||||
a.attrDynamicNodes = a.attrDynamicNodes[:0]
|
||||
a.attrFirstDynamic = -1
|
||||
a.attrDynamicPrefix = ""
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) validateAttributeName(position sourcePosition) *Diagnostic {
|
||||
if a.seenAttrs[a.currentAttr] {
|
||||
return a.problem(position, "HIM1347", fmt.Sprintf("duplicate attribute %q creates ambiguous browser parsing", a.currentAttr))
|
||||
}
|
||||
a.seenAttrs[a.currentAttr] = true
|
||||
if strings.HasPrefix(a.currentAttr, "on") {
|
||||
return a.problem(position, "HIM1340", fmt.Sprintf("event-handler attribute %q is not supported", a.currentAttr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) finishAttributeValue(position sourcePosition) *Diagnostic {
|
||||
if len(a.attrDynamicNodes) == 0 {
|
||||
a.attributes[a.currentAttr] = html.UnescapeString(a.attrLiteral.String())
|
||||
}
|
||||
if !urlAttributes[a.currentAttr] {
|
||||
return nil
|
||||
}
|
||||
if len(a.attrDynamicNodes) == 0 {
|
||||
if !safeStaticURL(a.attrLiteral.String()) {
|
||||
return a.problem(position, "HIM1344", "static URL uses a dangerous or ambiguous scheme; use a safe URL or an explicit full TrustURL value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
prefix := a.attrDynamicPrefix
|
||||
allLiteral := a.attrLiteral.String()
|
||||
suffix := strings.TrimPrefix(allLiteral, prefix)
|
||||
if prefix == "" && (len(a.attrDynamicNodes) != 1 || suffix != "") {
|
||||
return a.problem(position, "HIM1341", "a dynamic URL without a static safe prefix must occupy the entire quoted attribute")
|
||||
}
|
||||
if prefix != "" && !safeURLPrefix(prefix) {
|
||||
return a.problem(position, "HIM1342", "mixed static/dynamic URL attributes require a relative or explicit safe-scheme static prefix")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeStaticURL(value string) bool {
|
||||
decoded := strings.TrimSpace(html.UnescapeString(value))
|
||||
if decoded == "" {
|
||||
return true
|
||||
}
|
||||
for _, r := range decoded {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
lower := strings.ToLower(decoded)
|
||||
if strings.HasPrefix(lower, "/") || strings.HasPrefix(lower, "./") || strings.HasPrefix(lower, "../") || strings.HasPrefix(lower, "#") || strings.HasPrefix(lower, "?") {
|
||||
return true
|
||||
}
|
||||
colon := strings.IndexByte(lower, ':')
|
||||
boundary := len(lower)
|
||||
for _, separator := range []byte{'/', '?', '#'} {
|
||||
if index := strings.IndexByte(lower, separator); index >= 0 && index < boundary {
|
||||
boundary = index
|
||||
}
|
||||
}
|
||||
if colon < 0 || colon > boundary {
|
||||
return true
|
||||
}
|
||||
scheme := lower[:colon+1]
|
||||
return scheme == "http:" || scheme == "https:" || scheme == "mailto:" || scheme == "tel:"
|
||||
}
|
||||
|
||||
func safeURLPrefix(prefix string) bool {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(prefix))
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "./") || strings.HasPrefix(trimmed, "../") || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "?") {
|
||||
return true
|
||||
}
|
||||
for _, scheme := range []string{"http:", "https:", "mailto:", "tel:"} {
|
||||
if strings.HasPrefix(trimmed, scheme) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) finishOpenTag(position sourcePosition) *Diagnostic {
|
||||
if a.currentTag == "" {
|
||||
return a.problem(position, "HIM1350", "empty HTML tag name")
|
||||
}
|
||||
if a.currentTag == "svg" || a.currentTag == "math" {
|
||||
return a.problem(position, "HIM1355", fmt.Sprintf("foreign-content element <%s> is not supported by the v1 HTML context analyzer", a.currentTag))
|
||||
}
|
||||
if a.currentTag == "plaintext" || a.currentTag == "noscript" {
|
||||
return a.problem(position, "HIM1356", fmt.Sprintf("HTML element <%s> has environment-dependent or non-terminating parsing and is not supported in v1", a.currentTag))
|
||||
}
|
||||
if a.currentTag == "meta" && strings.EqualFold(strings.TrimSpace(a.attributes["http-equiv"]), "refresh") {
|
||||
return a.problem(position, "HIM1346", "meta refresh is an unsupported navigation context in v1")
|
||||
}
|
||||
if a.selfClosing && !voidElements[a.currentTag] {
|
||||
return a.problem(position, "HIM1354", fmt.Sprintf("self-closing syntax is not valid for non-void HTML element <%s>; use an explicit closing tag", a.currentTag))
|
||||
}
|
||||
if !a.selfClosing && !voidElements[a.currentTag] {
|
||||
a.stack = append(a.stack, a.currentTag)
|
||||
}
|
||||
if !a.selfClosing && (a.currentTag == "script" || a.currentTag == "style" || a.currentTag == "textarea" || a.currentTag == "title" || a.currentTag == "iframe" || a.currentTag == "noembed" || a.currentTag == "noframes" || a.currentTag == "xmp") {
|
||||
a.rawTag = a.currentTag
|
||||
a.scriptTail = ""
|
||||
a.state = htmlRawText
|
||||
} else {
|
||||
a.state = htmlData
|
||||
}
|
||||
a.currentAttr = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) finishCloseTag(position sourcePosition) *Diagnostic {
|
||||
if voidElements[a.currentTag] {
|
||||
return a.problem(position, "HIM1351", fmt.Sprintf("void element <%s> cannot have a closing tag", a.currentTag))
|
||||
}
|
||||
if len(a.stack) == 0 || a.stack[len(a.stack)-1] != a.currentTag {
|
||||
expected := "no closing tag"
|
||||
if len(a.stack) != 0 {
|
||||
expected = fmt.Sprintf("</%s>", a.stack[len(a.stack)-1])
|
||||
}
|
||||
return a.problem(position, "HIM1352", fmt.Sprintf("unbalanced closing tag </%s>; expected %s", a.currentTag, expected))
|
||||
}
|
||||
a.stack = a.stack[:len(a.stack)-1]
|
||||
a.state = htmlData
|
||||
a.currentTag = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) finishRawClose(position sourcePosition) *Diagnostic {
|
||||
if len(a.stack) == 0 || a.stack[len(a.stack)-1] != a.rawTag {
|
||||
return a.problem(position, "HIM1353", fmt.Sprintf("unbalanced </%s>", a.rawTag))
|
||||
}
|
||||
a.stack = a.stack[:len(a.stack)-1]
|
||||
a.state = htmlData
|
||||
a.currentTag = ""
|
||||
a.rawTag = ""
|
||||
a.rawEndName = ""
|
||||
a.scriptTail = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) dynamicContext() (Context, bool) {
|
||||
switch a.state {
|
||||
case htmlData:
|
||||
return ContextHTMLText, true
|
||||
case htmlAttributeDoubleQuoted, htmlAttributeSingleQuoted:
|
||||
if urlAttributes[a.currentAttr] {
|
||||
return ContextURL, true
|
||||
}
|
||||
return ContextAttr, true
|
||||
case htmlRawText:
|
||||
if a.rawTag == "script" {
|
||||
return ContextJS, true
|
||||
}
|
||||
if a.rawTag == "style" {
|
||||
return ContextCSS, true
|
||||
}
|
||||
if a.rawTag == "textarea" || a.rawTag == "title" {
|
||||
return ContextRCDATA, true
|
||||
}
|
||||
}
|
||||
return ContextNone, false
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) inAttribute() bool {
|
||||
return a.state == htmlAttributeDoubleQuoted || a.state == htmlAttributeSingleQuoted
|
||||
}
|
||||
|
||||
func (a *contextAnalyzer) problem(position sourcePosition, code, message string) *Diagnostic {
|
||||
d := diagnostic(a.file.Path, position, code, message)
|
||||
return &d
|
||||
}
|
||||
|
||||
func isASCIILetter(b byte) bool {
|
||||
return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z'
|
||||
}
|
||||
|
||||
func isTagNameStart(b byte) bool {
|
||||
return isASCIILetter(b)
|
||||
}
|
||||
|
||||
func isTagNameChar(b byte) bool {
|
||||
return isASCIILetter(b) || b >= '0' && b <= '9' || b == ':' || b == '-'
|
||||
}
|
||||
|
||||
func isAttributeNameStart(b byte) bool {
|
||||
return isASCIILetter(b) || b == '_' || b == ':'
|
||||
}
|
||||
|
||||
func isAttributeNameChar(b byte) bool {
|
||||
return isAttributeNameStart(b) || b >= '0' && b <= '9' || b == '-' || b == '.'
|
||||
}
|
||||
|
||||
func isHTMLSpace(b byte) bool {
|
||||
return b == ' ' || b == '\t' || b == '\r' || b == '\n' || b == '\f'
|
||||
}
|
||||
|
||||
func endPosition(source []byte) sourcePosition {
|
||||
return newPositionTable(source).at(len(source))
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Package compiler implements the Hime-san .sando compiler.
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Severity describes the impact of a diagnostic.
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
SeverityError Severity = "error"
|
||||
SeverityWarning Severity = "warning"
|
||||
)
|
||||
|
||||
// Diagnostic is a stable, machine-readable compiler message. Line and Column
|
||||
// are one-based. A diagnostic without a source position uses line and column 1.
|
||||
type Diagnostic struct {
|
||||
Path string `json:"path"`
|
||||
Line int `json:"line"`
|
||||
Column int `json:"column"`
|
||||
Code string `json:"code"`
|
||||
Severity Severity `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (d Diagnostic) Error() string {
|
||||
line, column := d.Line, d.Column
|
||||
if line < 1 {
|
||||
line = 1
|
||||
}
|
||||
if column < 1 {
|
||||
column = 1
|
||||
}
|
||||
return fmt.Sprintf("%s:%d:%d: %s: %s", d.Path, line, column, d.Code, d.Message)
|
||||
}
|
||||
|
||||
// DiagnosticsError reports one or more error diagnostics.
|
||||
type DiagnosticsError struct {
|
||||
Diagnostics []Diagnostic
|
||||
}
|
||||
|
||||
func (e *DiagnosticsError) Error() string {
|
||||
if e == nil || len(e.Diagnostics) == 0 {
|
||||
return "himesan: compilation failed"
|
||||
}
|
||||
if len(e.Diagnostics) == 1 {
|
||||
return e.Diagnostics[0].Error()
|
||||
}
|
||||
return fmt.Sprintf("%s (and %d more diagnostics)", e.Diagnostics[0].Error(), len(e.Diagnostics)-1)
|
||||
}
|
||||
|
||||
func errorFromDiagnostics(ds []Diagnostic) error {
|
||||
if !hasErrors(ds) {
|
||||
return nil
|
||||
}
|
||||
copyOfDiagnostics := append([]Diagnostic(nil), ds...)
|
||||
sortDiagnostics(copyOfDiagnostics)
|
||||
return &DiagnosticsError{Diagnostics: copyOfDiagnostics}
|
||||
}
|
||||
|
||||
func hasErrors(ds []Diagnostic) bool {
|
||||
for _, d := range ds {
|
||||
if d.Severity == SeverityError || d.Severity == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sortDiagnostics(ds []Diagnostic) {
|
||||
sort.SliceStable(ds, func(i, j int) bool {
|
||||
a, b := ds[i], ds[j]
|
||||
if a.Path != b.Path {
|
||||
return a.Path < b.Path
|
||||
}
|
||||
if a.Line != b.Line {
|
||||
return a.Line < b.Line
|
||||
}
|
||||
if a.Column != b.Column {
|
||||
return a.Column < b.Column
|
||||
}
|
||||
if a.Code != b.Code {
|
||||
return a.Code < b.Code
|
||||
}
|
||||
return a.Message < b.Message
|
||||
})
|
||||
}
|
||||
|
||||
func diagnostic(path string, pos sourcePosition, code, message string) Diagnostic {
|
||||
return Diagnostic{
|
||||
Path: path,
|
||||
Line: pos.Line,
|
||||
Column: pos.Column,
|
||||
Code: code,
|
||||
Severity: SeverityError,
|
||||
Message: strings.TrimSpace(message),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var excludedDirectories = map[string]bool{
|
||||
".git": true,
|
||||
".hg": true,
|
||||
".svn": true,
|
||||
"vendor": true,
|
||||
}
|
||||
|
||||
func discover(ctx context.Context, paths []string) ([]string, []Diagnostic) {
|
||||
if len(paths) == 0 {
|
||||
paths = []string{"."}
|
||||
}
|
||||
filesByAbsolutePath := make(map[string]string)
|
||||
var diagnostics []Diagnostic
|
||||
|
||||
for _, requested := range paths {
|
||||
if err := ctx.Err(); err != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(requested, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+err.Error()))
|
||||
break
|
||||
}
|
||||
clean := filepath.Clean(requested)
|
||||
if symlinkParent, symlinkErr := firstSymlinkComponent(clean); symlinkErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2002", "cannot inspect path ancestry: "+symlinkErr.Error()))
|
||||
continue
|
||||
} else if symlinkParent != "" {
|
||||
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2003", fmt.Sprintf("symlink paths are not followed (through %s)", symlinkParent)))
|
||||
continue
|
||||
}
|
||||
info, err := os.Lstat(clean)
|
||||
if err != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2002", "cannot inspect path: "+err.Error()))
|
||||
continue
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2003", "symlink paths are not followed"))
|
||||
continue
|
||||
}
|
||||
if !info.IsDir() {
|
||||
if filepath.Ext(clean) != ".sando" {
|
||||
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2004", "explicit source file must use the .sando extension"))
|
||||
continue
|
||||
}
|
||||
absolute, absoluteErr := filepath.Abs(clean)
|
||||
if absoluteErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(clean, sourcePosition{Line: 1, Column: 1}, "HIM2005", "cannot resolve source path: "+absoluteErr.Error()))
|
||||
continue
|
||||
}
|
||||
filesByAbsolutePath[absolute] = clean
|
||||
continue
|
||||
}
|
||||
|
||||
root := clean
|
||||
rootInfo := info
|
||||
walkErr := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if walkErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2006", "cannot inspect path during discovery: "+walkErr.Error()))
|
||||
if entry != nil && entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if path != root && entry.IsDir() && excludedDirectories[entry.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
if entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if path != root && entry.IsDir() {
|
||||
entryInfo, statErr := entry.Info()
|
||||
if statErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2007", "cannot inspect directory: "+statErr.Error()))
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if !sameFilesystem(rootInfo, entryInfo) {
|
||||
diagnostics = append(diagnostics, Diagnostic{Path: path, Line: 1, Column: 1, Code: "HIM2901", Severity: SeverityWarning, Message: "skipped mounted filesystem boundary"})
|
||||
return filepath.SkipDir
|
||||
}
|
||||
for _, marker := range []string{".git", ".hg", ".svn"} {
|
||||
markerPath := filepath.Join(path, marker)
|
||||
if _, markerErr := os.Lstat(markerPath); markerErr == nil {
|
||||
return filepath.SkipDir
|
||||
} else if !os.IsNotExist(markerErr) {
|
||||
diagnostics = append(diagnostics, diagnostic(markerPath, sourcePosition{Line: 1, Column: 1}, "HIM2011", "cannot inspect nested VCS boundary: "+markerErr.Error()))
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
modulePath := filepath.Join(path, "go.mod")
|
||||
if moduleInfo, moduleErr := os.Lstat(modulePath); moduleErr == nil {
|
||||
if !moduleInfo.Mode().IsRegular() {
|
||||
diagnostics = append(diagnostics, diagnostic(modulePath, sourcePosition{Line: 1, Column: 1}, "HIM2008", "nested go.mod boundary is not a regular file; directory was skipped"))
|
||||
}
|
||||
return filepath.SkipDir
|
||||
} else if !os.IsNotExist(moduleErr) {
|
||||
diagnostics = append(diagnostics, diagnostic(modulePath, sourcePosition{Line: 1, Column: 1}, "HIM2008", "cannot inspect nested module boundary: "+moduleErr.Error()))
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sando" {
|
||||
return nil
|
||||
}
|
||||
entryInfo, statErr := entry.Info()
|
||||
if statErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2009", "cannot inspect source: "+statErr.Error()))
|
||||
return nil
|
||||
}
|
||||
if !entryInfo.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
absolute, absoluteErr := filepath.Abs(path)
|
||||
if absoluteErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2005", "cannot resolve source path: "+absoluteErr.Error()))
|
||||
return nil
|
||||
}
|
||||
filesByAbsolutePath[absolute] = path
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil && ctx.Err() != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(root, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+ctx.Err().Error()))
|
||||
}
|
||||
}
|
||||
|
||||
absolutePaths := make([]string, 0, len(filesByAbsolutePath))
|
||||
for absolute := range filesByAbsolutePath {
|
||||
absolutePaths = append(absolutePaths, absolute)
|
||||
}
|
||||
sort.Strings(absolutePaths)
|
||||
discovered := make([]string, 0, len(absolutePaths))
|
||||
for _, absolute := range absolutePaths {
|
||||
discovered = append(discovered, filesByAbsolutePath[absolute])
|
||||
}
|
||||
sort.SliceStable(discovered, func(i, j int) bool {
|
||||
left, _ := filepath.Abs(discovered[i])
|
||||
right, _ := filepath.Abs(discovered[j])
|
||||
return filepath.ToSlash(left) < filepath.ToSlash(right)
|
||||
})
|
||||
sortDiagnostics(diagnostics)
|
||||
return discovered, diagnostics
|
||||
}
|
||||
|
||||
func firstSymlinkComponent(path string) (string, error) {
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
volume := filepath.VolumeName(absolute)
|
||||
remainder := strings.TrimPrefix(absolute, volume)
|
||||
remainder = strings.TrimPrefix(remainder, string(filepath.Separator))
|
||||
current := volume + string(filepath.Separator)
|
||||
for _, component := range strings.Split(remainder, string(filepath.Separator)) {
|
||||
if component == "" {
|
||||
continue
|
||||
}
|
||||
current = filepath.Join(current, component)
|
||||
info, lstatErr := os.Lstat(current)
|
||||
if lstatErr != nil {
|
||||
return "", lstatErr
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return current, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func sameFilesystem(root, candidate fs.FileInfo) bool {
|
||||
rootDevice, rootOK := deviceNumber(root.Sys())
|
||||
candidateDevice, candidateOK := deviceNumber(candidate.Sys())
|
||||
return !rootOK || !candidateOK || rootDevice == candidateDevice
|
||||
}
|
||||
|
||||
func deviceNumber(system any) (uint64, bool) {
|
||||
if system == nil {
|
||||
return 0, false
|
||||
}
|
||||
value := reflect.Indirect(reflect.ValueOf(system))
|
||||
if !value.IsValid() || value.Kind() != reflect.Struct {
|
||||
return 0, false
|
||||
}
|
||||
field := value.FieldByName("Dev")
|
||||
if !field.IsValid() {
|
||||
return 0, false
|
||||
}
|
||||
switch field.Kind() {
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
return field.Uint(), true
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
device := field.Int()
|
||||
if device < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return uint64(device), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeneratedOutputCompilesInTemporaryModule(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping temporary-module compilation in short mode")
|
||||
}
|
||||
t.Parallel()
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
repositoryRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
|
||||
runtimeRoot := filepath.Join(repositoryRoot, "sando")
|
||||
directory := resolvedTempDir(t)
|
||||
goMod := "module example.test/generated\n\ngo 1.25\n\nrequire gamertan.com/sandwich-hime/sando v0.0.0\n\nreplace gamertan.com/sandwich-hime/sando => " + filepath.ToSlash(runtimeRoot) + "\n"
|
||||
mustWrite(t, filepath.Join(directory, "go.mod"), goMod)
|
||||
mustWrite(t, filepath.Join(directory, "view.go"), `package generated
|
||||
|
||||
import "gamertan.com/sandwich-hime/sando"
|
||||
|
||||
type View struct {
|
||||
Name string
|
||||
URL string
|
||||
JS sando.TrustedJS
|
||||
HTML sando.TrustedHTML
|
||||
}
|
||||
`)
|
||||
mustWrite(t, filepath.Join(directory, "render_test.go"), `package generated
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gamertan.com/sandwich-hime/sando"
|
||||
)
|
||||
|
||||
func TestRCDATACannotBeBypassedByTrustedHTML(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
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()) }
|
||||
}
|
||||
`)
|
||||
templatePath := filepath.Join(directory, "page.sando")
|
||||
mustWrite(t, templatePath, `<?sando go
|
||||
package generated
|
||||
func Page(view View)
|
||||
?>
|
||||
<!doctype html>
|
||||
<html><body>
|
||||
<a href="<?= view.URL ?>"><?= view.Name ?></a>
|
||||
<script><?= view.JS ?></script>
|
||||
<textarea><?= view.HTML ?></textarea>
|
||||
</body></html>`)
|
||||
result, err := Generate(context.Background(), []string{templatePath})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v (%v)", err, result.Diagnostics)
|
||||
}
|
||||
command := exec.Command("go", "test", "./...")
|
||||
command.Dir = directory
|
||||
command.Env = append(os.Environ(), "GOWORK=off")
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("generated temporary module did not compile: %v\n%s\n--- generated ---\n%s", err, output, mustRead(t, templatePath+".go"))
|
||||
}
|
||||
if strings.Contains(string(mustRead(t, templatePath+".go")), repositoryRoot) {
|
||||
t.Fatal("generated output leaked the compiler checkout path")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import "testing"
|
||||
|
||||
func FuzzCompileNeverPanics(f *testing.F) {
|
||||
for _, seed := range []string{
|
||||
profileSource,
|
||||
"",
|
||||
"<?sando go\npackage p\nfunc F()\n?>",
|
||||
"<?sando go\npackage p\nfunc F(v string)\n?>\n<a href=\"<?= v ?>\">x</a>",
|
||||
"<?sando go\npackage p\nfunc F()\n?>\n<script><?= `?>` ?></script>",
|
||||
"<?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.Fuzz(func(t *testing.T, source string) {
|
||||
_, _ = Compile("fuzz.sando", []byte(source))
|
||||
})
|
||||
}
|
||||
|
||||
func FuzzGoDelimiterNeverPanics(f *testing.F) {
|
||||
for _, seed := range []string{`?>`, `"?>" ?>`, "`?>` ?>", `/* ?> */ ?>`, "// ?>\n?>", `'?' ?>`} {
|
||||
f.Add(seed, uint8(0))
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, source string, start uint8) {
|
||||
offset := int(start)
|
||||
if offset > len(source) {
|
||||
offset = len(source)
|
||||
}
|
||||
result := findGoDelimiter([]byte(source), offset)
|
||||
if result < -1 || result > len(source) {
|
||||
t.Fatalf("invalid delimiter offset %d for %d bytes", result, len(source))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
|
||||
"gamertan.com/sandwich-hime/internal/version"
|
||||
)
|
||||
|
||||
// CompilerVersion mirrors the release-injected compiler version for callers
|
||||
// that need to display provenance alongside generated output.
|
||||
var CompilerVersion = version.Compiler
|
||||
|
||||
// RuntimeABI is the generated-code/runtime compatibility boundary for v1.
|
||||
const RuntimeABI = version.RuntimeABI
|
||||
|
||||
const (
|
||||
runtimeImportPath = "gamertan.com/sandwich-hime/sando"
|
||||
generatedPrefix = "// Code generated by himesan; DO NOT EDIT."
|
||||
)
|
||||
|
||||
// Context identifies the escaping context assigned to a renderer operation.
|
||||
type Context string
|
||||
|
||||
const (
|
||||
ContextHTMLText Context = "html-text"
|
||||
ContextRCDATA Context = "rcdata"
|
||||
ContextAttr Context = "quoted-attribute"
|
||||
ContextURL Context = "url-attribute"
|
||||
ContextJS Context = "script"
|
||||
ContextCSS Context = "style"
|
||||
ContextNone Context = "none"
|
||||
)
|
||||
|
||||
type nodeKind uint8
|
||||
|
||||
const (
|
||||
nodeText nodeKind = iota
|
||||
nodeStatement
|
||||
nodeExpression
|
||||
nodeComponent
|
||||
nodeComment
|
||||
)
|
||||
|
||||
// rendererNode is the context-annotated renderer IR. Context is assigned by
|
||||
// analyzeContexts before any backend is allowed to consume the IR.
|
||||
type rendererNode struct {
|
||||
Kind nodeKind
|
||||
Text string
|
||||
Context Context
|
||||
Pos sourcePosition
|
||||
}
|
||||
|
||||
type sourcePosition struct {
|
||||
Offset int
|
||||
Line int
|
||||
Column int
|
||||
}
|
||||
|
||||
type sourceFile struct {
|
||||
Path string
|
||||
Mapping string
|
||||
Package string
|
||||
Name string
|
||||
TypeParams string
|
||||
Params string
|
||||
Imports []sourceImport
|
||||
Nodes []rendererNode
|
||||
Source []byte
|
||||
HeaderEnd int
|
||||
AST *ast.File
|
||||
}
|
||||
|
||||
type sourceImport struct {
|
||||
Alias string
|
||||
Path string
|
||||
}
|
||||
|
||||
// CompiledFile is a fully validated generated output held in memory.
|
||||
type CompiledFile struct {
|
||||
SourcePath string
|
||||
OutputPath string
|
||||
Package string
|
||||
Component string
|
||||
Digest string
|
||||
Code []byte
|
||||
source *sourceFile
|
||||
}
|
||||
|
||||
// FileResult describes one source/output pair processed by Generate or Check.
|
||||
type FileResult struct {
|
||||
SourcePath string `json:"source_path"`
|
||||
OutputPath string `json:"output_path"`
|
||||
Changed bool `json:"changed"`
|
||||
Stale bool `json:"stale"`
|
||||
Missing bool `json:"missing"`
|
||||
}
|
||||
|
||||
// Result is returned even when an operation reports diagnostics.
|
||||
type Result struct {
|
||||
Files []FileResult `json:"files"`
|
||||
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
|
||||
Discovered int `json:"discovered"`
|
||||
Changed int `json:"changed"`
|
||||
Unchanged int `json:"unchanged"`
|
||||
Stale int `json:"stale"`
|
||||
Missing int `json:"missing"`
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Generate compiles all discovered .sando files in memory, then atomically
|
||||
// replaces only changed, Hime-san-owned .sando.go outputs. Any parse, context,
|
||||
// format, cycle, or ownership error prevents every output write.
|
||||
func Generate(ctx context.Context, paths []string) (Result, error) {
|
||||
compiled, result := compileOperation(ctx, paths)
|
||||
if hasErrors(result.Diagnostics) {
|
||||
return result, errorFromDiagnostics(result.Diagnostics)
|
||||
}
|
||||
|
||||
// Validate every destination before performing the first mutation.
|
||||
for _, file := range compiled {
|
||||
info, err := os.Lstat(file.OutputPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2101", "cannot inspect generated output: "+err.Error()))
|
||||
continue
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2102", "refusing to replace a non-regular or symlink output"))
|
||||
continue
|
||||
}
|
||||
existing, readErr := os.ReadFile(file.OutputPath)
|
||||
if readErr != nil {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2103", "cannot read generated output: "+readErr.Error()))
|
||||
continue
|
||||
}
|
||||
if !bytes.HasPrefix(existing, []byte(generatedPrefix+"\n")) {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2104", "refusing to overwrite a file not owned by Hime-san"))
|
||||
}
|
||||
}
|
||||
sortDiagnostics(result.Diagnostics)
|
||||
if hasErrors(result.Diagnostics) {
|
||||
return result, errorFromDiagnostics(result.Diagnostics)
|
||||
}
|
||||
|
||||
for index, file := range compiled {
|
||||
if err := ctx.Err(); err != nil {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.SourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+err.Error()))
|
||||
break
|
||||
}
|
||||
existing, readErr := os.ReadFile(file.OutputPath)
|
||||
if readErr == nil && bytesEqual(existing, file.Code) {
|
||||
result.Files[index].Changed = false
|
||||
result.Unchanged++
|
||||
continue
|
||||
}
|
||||
mode := os.FileMode(0o644)
|
||||
if info, statErr := os.Stat(file.OutputPath); statErr == nil {
|
||||
mode = info.Mode().Perm()
|
||||
}
|
||||
if writeErr := atomicWrite(file.OutputPath, file.Code, mode); writeErr != nil {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2110", "atomic output replacement failed: "+writeErr.Error()))
|
||||
break
|
||||
}
|
||||
result.Files[index].Changed = true
|
||||
result.Changed++
|
||||
}
|
||||
sortDiagnostics(result.Diagnostics)
|
||||
return result, errorFromDiagnostics(result.Diagnostics)
|
||||
}
|
||||
|
||||
// Check validates sources and reports missing or stale generated output without
|
||||
// writing to the filesystem. Warnings, including trusted-value audit findings,
|
||||
// do not cause Check to fail by themselves.
|
||||
func Check(ctx context.Context, paths []string) (Result, error) {
|
||||
compiled, result := compileOperation(ctx, paths)
|
||||
if hasErrors(result.Diagnostics) {
|
||||
return result, errorFromDiagnostics(result.Diagnostics)
|
||||
}
|
||||
for index, file := range compiled {
|
||||
if err := ctx.Err(); err != nil {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.SourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+err.Error()))
|
||||
break
|
||||
}
|
||||
info, lstatErr := os.Lstat(file.OutputPath)
|
||||
if lstatErr == nil && (info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular()) {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2205", "generated output is a symlink or non-regular file"))
|
||||
continue
|
||||
}
|
||||
if lstatErr != nil && !os.IsNotExist(lstatErr) {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2202", "cannot inspect generated output: "+lstatErr.Error()))
|
||||
continue
|
||||
}
|
||||
existing, err := os.ReadFile(file.OutputPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
result.Files[index].Missing = true
|
||||
result.Missing++
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2201", "generated output is missing; run himesan generate"))
|
||||
} else {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2202", "cannot read generated output: "+err.Error()))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if bytesEqual(existing, file.Code) {
|
||||
result.Unchanged++
|
||||
continue
|
||||
}
|
||||
result.Files[index].Stale = true
|
||||
result.Stale++
|
||||
if !bytes.HasPrefix(existing, []byte(generatedPrefix+"\n")) {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2203", "expected output exists but is not owned by Hime-san"))
|
||||
} else {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(file.OutputPath, sourcePosition{Line: 1, Column: 1}, "HIM2204", "generated output is stale; run himesan generate"))
|
||||
}
|
||||
}
|
||||
sortDiagnostics(result.Diagnostics)
|
||||
return result, errorFromDiagnostics(result.Diagnostics)
|
||||
}
|
||||
|
||||
func compileOperation(ctx context.Context, paths []string) ([]CompiledFile, Result) {
|
||||
discovered, discoveryDiagnostics := discover(ctx, paths)
|
||||
result := Result{Discovered: len(discovered), Diagnostics: discoveryDiagnostics}
|
||||
compiled := make([]CompiledFile, 0, len(discovered))
|
||||
for _, sourcePath := range discovered {
|
||||
if err := ctx.Err(); err != nil {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(sourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+err.Error()))
|
||||
break
|
||||
}
|
||||
info, lstatErr := os.Lstat(sourcePath)
|
||||
if lstatErr != nil {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(sourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2010", "cannot inspect source before compilation: "+lstatErr.Error()))
|
||||
continue
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(sourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2012", "source changed into a symlink or non-regular file during discovery"))
|
||||
continue
|
||||
}
|
||||
source, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostic(sourcePath, sourcePosition{Line: 1, Column: 1}, "HIM2010", "cannot read source: "+err.Error()))
|
||||
continue
|
||||
}
|
||||
output, diagnostics := compileWithMapping(sourcePath, source, moduleRelativeSourcePath(sourcePath))
|
||||
result.Diagnostics = append(result.Diagnostics, diagnostics...)
|
||||
if output.Code != nil {
|
||||
compiled = append(compiled, output)
|
||||
result.Files = append(result.Files, FileResult{SourcePath: output.SourcePath, OutputPath: output.OutputPath})
|
||||
}
|
||||
}
|
||||
result.Diagnostics = append(result.Diagnostics, detectComponentCycles(compiled)...)
|
||||
sort.SliceStable(compiled, func(i, j int) bool { return compiled[i].SourcePath < compiled[j].SourcePath })
|
||||
sort.SliceStable(result.Files, func(i, j int) bool { return result.Files[i].SourcePath < result.Files[j].SourcePath })
|
||||
sortDiagnostics(result.Diagnostics)
|
||||
return compiled, result
|
||||
}
|
||||
|
||||
func moduleRelativeSourcePath(sourcePath string) string {
|
||||
absolute, err := filepath.Abs(sourcePath)
|
||||
if err != nil {
|
||||
return filepath.ToSlash(filepath.Base(sourcePath))
|
||||
}
|
||||
directory := filepath.Dir(absolute)
|
||||
for {
|
||||
modulePath := filepath.Join(directory, "go.mod")
|
||||
if info, statErr := os.Lstat(modulePath); statErr == nil && info.Mode().IsRegular() {
|
||||
if relative, relativeErr := filepath.Rel(directory, absolute); relativeErr == nil {
|
||||
return filepath.ToSlash(relative)
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(directory)
|
||||
if parent == directory {
|
||||
break
|
||||
}
|
||||
directory = parent
|
||||
}
|
||||
return filepath.ToSlash(filepath.Base(sourcePath))
|
||||
}
|
||||
|
||||
func atomicWrite(path string, content []byte, mode os.FileMode) (returnErr error) {
|
||||
directory := filepath.Dir(path)
|
||||
temporary, err := os.CreateTemp(directory, ".himesan-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
closed := false
|
||||
defer func() {
|
||||
var closeErr error
|
||||
if !closed {
|
||||
closeErr = temporary.Close()
|
||||
}
|
||||
removeErr := os.Remove(temporaryPath)
|
||||
if returnErr == nil && closeErr != nil {
|
||||
returnErr = closeErr
|
||||
}
|
||||
if returnErr == nil && removeErr != nil && !os.IsNotExist(removeErr) {
|
||||
returnErr = removeErr
|
||||
}
|
||||
}()
|
||||
if _, err := temporary.Write(content); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := temporary.Chmod(mode.Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
closed = true
|
||||
if err := replaceFile(temporaryPath, path); err != nil {
|
||||
return err
|
||||
}
|
||||
if directoryHandle, err := os.Open(directory); err == nil {
|
||||
_ = directoryHandle.Sync()
|
||||
_ = directoryHandle.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type componentKey struct {
|
||||
directory string
|
||||
packageID string
|
||||
name string
|
||||
}
|
||||
|
||||
type componentEdge struct {
|
||||
target componentKey
|
||||
position sourcePosition
|
||||
}
|
||||
|
||||
func detectComponentCycles(files []CompiledFile) []Diagnostic {
|
||||
byKey := make(map[componentKey]CompiledFile, len(files))
|
||||
var diagnostics []Diagnostic
|
||||
for _, file := range files {
|
||||
key := componentKey{directory: filepath.Clean(filepath.Dir(file.SourcePath)), packageID: file.Package, name: file.Component}
|
||||
if previous, exists := byKey[key]; exists {
|
||||
diagnostics = append(diagnostics,
|
||||
diagnostic(previous.SourcePath, sourcePosition{Line: 1, Column: 1}, "HIM1500", fmt.Sprintf("component %s is also declared by %s", file.Component, file.SourcePath)),
|
||||
diagnostic(file.SourcePath, sourcePosition{Line: 1, Column: 1}, "HIM1500", fmt.Sprintf("component %s is also declared by %s", file.Component, previous.SourcePath)),
|
||||
)
|
||||
continue
|
||||
}
|
||||
byKey[key] = file
|
||||
}
|
||||
edges := make(map[componentKey][]componentEdge)
|
||||
for key, file := range byKey {
|
||||
if file.source == nil {
|
||||
continue
|
||||
}
|
||||
for _, node := range file.source.Nodes {
|
||||
if node.Kind != nodeComponent {
|
||||
continue
|
||||
}
|
||||
expression, err := parser.ParseExpr(node.Text)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
calledName := rootCalledIdentifier(expression)
|
||||
if calledName == "" {
|
||||
continue
|
||||
}
|
||||
target := componentKey{directory: key.directory, packageID: key.packageID, name: calledName}
|
||||
if _, exists := byKey[target]; exists {
|
||||
edges[key] = append(edges[key], componentEdge{target: target, position: node.Pos})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(edges[key], func(i, j int) bool { return edges[key][i].target.name < edges[key][j].target.name })
|
||||
}
|
||||
|
||||
const (
|
||||
unvisited = iota
|
||||
visiting
|
||||
visited
|
||||
)
|
||||
state := make(map[componentKey]int)
|
||||
stack := make([]componentKey, 0)
|
||||
reported := make(map[componentKey]bool)
|
||||
var visit func(componentKey)
|
||||
visit = func(key componentKey) {
|
||||
state[key] = visiting
|
||||
stack = append(stack, key)
|
||||
for _, edge := range edges[key] {
|
||||
target := edge.target
|
||||
if state[target] == unvisited {
|
||||
visit(target)
|
||||
continue
|
||||
}
|
||||
if state[target] != visiting {
|
||||
continue
|
||||
}
|
||||
cycleStart := 0
|
||||
for cycleStart < len(stack) && stack[cycleStart] != target {
|
||||
cycleStart++
|
||||
}
|
||||
cycle := append(append([]componentKey(nil), stack[cycleStart:]...), target)
|
||||
names := make([]string, 0, len(cycle))
|
||||
for _, member := range cycle {
|
||||
names = append(names, member.name)
|
||||
}
|
||||
for memberIndex, member := range cycle[:len(cycle)-1] {
|
||||
if reported[member] {
|
||||
continue
|
||||
}
|
||||
reported[member] = true
|
||||
file := byKey[member]
|
||||
position := sourcePosition{Line: 1, Column: 1}
|
||||
next := cycle[memberIndex+1]
|
||||
for _, memberEdge := range edges[member] {
|
||||
if memberEdge.target == next {
|
||||
position = memberEdge.position
|
||||
break
|
||||
}
|
||||
}
|
||||
diagnostics = append(diagnostics, diagnostic(file.SourcePath, position, "HIM1501", "static component cycle detected: "+fmt.Sprint(names)))
|
||||
}
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
state[key] = visited
|
||||
}
|
||||
keys := make([]componentKey, 0, len(byKey))
|
||||
for key := range byKey {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.SliceStable(keys, func(i, j int) bool {
|
||||
if keys[i].directory != keys[j].directory {
|
||||
return keys[i].directory < keys[j].directory
|
||||
}
|
||||
if keys[i].packageID != keys[j].packageID {
|
||||
return keys[i].packageID < keys[j].packageID
|
||||
}
|
||||
return keys[i].name < keys[j].name
|
||||
})
|
||||
for _, key := range keys {
|
||||
if state[key] == unvisited {
|
||||
visit(key)
|
||||
}
|
||||
}
|
||||
sortDiagnostics(diagnostics)
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
func rootCalledIdentifier(expression ast.Expr) string {
|
||||
for {
|
||||
switch typed := expression.(type) {
|
||||
case *ast.ParenExpr:
|
||||
expression = typed.X
|
||||
case *ast.CallExpr:
|
||||
expression = typed.Fun
|
||||
case *ast.IndexExpr:
|
||||
expression = typed.X
|
||||
case *ast.IndexListExpr:
|
||||
expression = typed.X
|
||||
case *ast.Ident:
|
||||
return typed.Name
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/printer"
|
||||
"go/scanner"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const headerOpen = "<?sando"
|
||||
|
||||
type positionTable struct {
|
||||
lineStarts []int
|
||||
size int
|
||||
}
|
||||
|
||||
func newPositionTable(source []byte) positionTable {
|
||||
starts := []int{0}
|
||||
for i, b := range source {
|
||||
if b == '\n' {
|
||||
starts = append(starts, i+1)
|
||||
}
|
||||
}
|
||||
return positionTable{lineStarts: starts, size: len(source)}
|
||||
}
|
||||
|
||||
func (t positionTable) at(offset int) sourcePosition {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset > t.size {
|
||||
offset = t.size
|
||||
}
|
||||
lineIndex := sort.Search(len(t.lineStarts), func(i int) bool {
|
||||
return t.lineStarts[i] > offset
|
||||
}) - 1
|
||||
if lineIndex < 0 {
|
||||
lineIndex = 0
|
||||
}
|
||||
return sourcePosition{
|
||||
Offset: offset,
|
||||
Line: lineIndex + 1,
|
||||
Column: offset - t.lineStarts[lineIndex] + 1,
|
||||
}
|
||||
}
|
||||
|
||||
func parseSource(path string, source []byte) (*sourceFile, []Diagnostic) {
|
||||
table := newPositionTable(source)
|
||||
var diagnostics []Diagnostic
|
||||
|
||||
if !utf8.Valid(source) {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(0), "HIM1001", "source is not valid UTF-8"))
|
||||
return nil, diagnostics
|
||||
}
|
||||
if offset := bytes.IndexByte(source, 0); offset >= 0 {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(offset), "HIM1002", "NUL bytes are not permitted in .sando sources"))
|
||||
return nil, diagnostics
|
||||
}
|
||||
headerStart := 0
|
||||
if bytes.HasPrefix(source, []byte{0xef, 0xbb, 0xbf}) {
|
||||
headerStart = 3
|
||||
}
|
||||
for headerStart < len(source) && isSpace(source[headerStart]) {
|
||||
headerStart++
|
||||
}
|
||||
if !bytes.HasPrefix(source[headerStart:], []byte(headerOpen)) {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(headerStart), "HIM1101", "file must begin (after optional UTF-8 BOM and whitespace) with a <?sando go header"))
|
||||
return nil, diagnostics
|
||||
}
|
||||
afterMarker := headerStart + len(headerOpen)
|
||||
if afterMarker >= len(source) || !isSpace(source[afterMarker]) {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(afterMarker), "HIM1105", "whitespace is required between <?sando and the target name"))
|
||||
return nil, diagnostics
|
||||
}
|
||||
headerClose := findGoDelimiter(source, afterMarker)
|
||||
if headerClose < 0 {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(headerStart), "HIM1102", "unterminated <?sando header"))
|
||||
return nil, diagnostics
|
||||
}
|
||||
directiveBody := source[afterMarker:headerClose]
|
||||
directiveStart := afterMarker
|
||||
leading := len(directiveBody) - len(bytes.TrimLeft(directiveBody, " \t\r\n"))
|
||||
directiveBody = directiveBody[leading:]
|
||||
directiveStart += leading
|
||||
|
||||
if len(directiveBody) < len("go") || string(directiveBody[:2]) != "go" || (len(directiveBody) > 2 && !isSpace(directiveBody[2])) {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(directiveStart), "HIM1103", "unsupported or missing header target; v1 requires <?sando go"))
|
||||
return nil, diagnostics
|
||||
}
|
||||
declarations := directiveBody[2:]
|
||||
declarationsStart := directiveStart + 2
|
||||
declLeading := len(declarations) - len(bytes.TrimLeft(declarations, " \t\r\n"))
|
||||
declarations = declarations[declLeading:]
|
||||
declarationsStart += declLeading
|
||||
if len(declarations) == 0 {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(declarationsStart), "HIM1104", "header must declare a package and one component function"))
|
||||
return nil, diagnostics
|
||||
}
|
||||
|
||||
parsedHeader, headerDiagnostics := parseHeader(path, declarations, declarationsStart, table)
|
||||
diagnostics = append(diagnostics, headerDiagnostics...)
|
||||
if parsedHeader == nil {
|
||||
return nil, diagnostics
|
||||
}
|
||||
|
||||
file := &sourceFile{
|
||||
Path: path,
|
||||
Mapping: filepath.ToSlash(filepath.Base(path)),
|
||||
Package: parsedHeader.Package,
|
||||
Name: parsedHeader.Name,
|
||||
TypeParams: parsedHeader.TypeParams,
|
||||
Params: parsedHeader.Params,
|
||||
Imports: parsedHeader.Imports,
|
||||
Source: source,
|
||||
HeaderEnd: headerClose + 2,
|
||||
AST: parsedHeader.AST,
|
||||
}
|
||||
|
||||
templateDiagnostics := tokenizeTemplate(file, source[headerClose+2:], headerClose+2, table)
|
||||
diagnostics = append(diagnostics, templateDiagnostics...)
|
||||
if hasErrors(diagnostics) {
|
||||
return nil, diagnostics
|
||||
}
|
||||
return file, diagnostics
|
||||
}
|
||||
|
||||
func isSpace(b byte) bool {
|
||||
return b == ' ' || b == '\t' || b == '\r' || b == '\n'
|
||||
}
|
||||
|
||||
type parsedHeader struct {
|
||||
Package string
|
||||
Name string
|
||||
TypeParams string
|
||||
Params string
|
||||
Imports []sourceImport
|
||||
AST *ast.File
|
||||
}
|
||||
|
||||
func parseHeader(path string, declarations []byte, sourceOffset int, table positionTable) (*parsedHeader, []Diagnostic) {
|
||||
parseInput, syntheticBraceOffset := insertSyntheticFunctionBody(declarations)
|
||||
fset := token.NewFileSet()
|
||||
parsed, err := parser.ParseFile(fset, filepath.Base(path), parseInput, parser.AllErrors)
|
||||
if err != nil {
|
||||
return nil, parserDiagnostics(path, err, sourceOffset, table, "HIM1110", "invalid Go header")
|
||||
}
|
||||
|
||||
var function *ast.FuncDecl
|
||||
var imports []sourceImport
|
||||
var diagnostics []Diagnostic
|
||||
for _, declaration := range parsed.Decls {
|
||||
switch declaration := declaration.(type) {
|
||||
case *ast.GenDecl:
|
||||
if declaration.Tok != token.IMPORT {
|
||||
pos := fset.Position(declaration.Pos())
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+pos.Offset), "HIM1111", "header may contain only imports and one component function signature"))
|
||||
continue
|
||||
}
|
||||
for _, spec := range declaration.Specs {
|
||||
importSpec, ok := spec.(*ast.ImportSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
importPath, unquoteErr := strconv.Unquote(importSpec.Path.Value)
|
||||
if unquoteErr != nil || importPath == "" {
|
||||
pos := fset.Position(importSpec.Path.Pos())
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+pos.Offset), "HIM1112", "invalid import path"))
|
||||
continue
|
||||
}
|
||||
alias := ""
|
||||
if importSpec.Name != nil {
|
||||
alias = importSpec.Name.Name
|
||||
}
|
||||
if alias == "." {
|
||||
pos := fset.Position(importSpec.Pos())
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+pos.Offset), "HIM1113", "dot imports are not supported in .sando headers"))
|
||||
continue
|
||||
}
|
||||
imports = append(imports, sourceImport{Alias: alias, Path: importPath})
|
||||
}
|
||||
case *ast.FuncDecl:
|
||||
if function != nil {
|
||||
pos := fset.Position(declaration.Pos())
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+pos.Offset), "HIM1114", "a .sando file declares exactly one component"))
|
||||
continue
|
||||
}
|
||||
function = declaration
|
||||
default:
|
||||
pos := fset.Position(declaration.Pos())
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+pos.Offset), "HIM1111", "unsupported declaration in header"))
|
||||
}
|
||||
}
|
||||
|
||||
if function == nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset), "HIM1115", "header must contain one bodyless component function signature"))
|
||||
return nil, diagnostics
|
||||
}
|
||||
functionPosition := fset.Position(function.Pos())
|
||||
if function.Name.Name == "init" || parsed.Name.Name == "main" && function.Name.Name == "main" {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1123", fmt.Sprintf("%s.%s is reserved by Go and cannot be a component API", parsed.Name.Name, function.Name.Name)))
|
||||
}
|
||||
if function.Recv != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1116", "component functions cannot have receivers"))
|
||||
}
|
||||
if function.Type.Results != nil && len(function.Type.Results.List) != 0 {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1117", "component signatures do not declare results; Hime-san generates sando.Component"))
|
||||
}
|
||||
if function.Body == nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1118", "component signature could not be parsed"))
|
||||
} else {
|
||||
bodyOffset := fset.Position(function.Body.Lbrace).Offset
|
||||
if bodyOffset != syntheticBraceOffset {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+bodyOffset), "HIM1119", "component signature must be bodyless; ?> begins the template body"))
|
||||
}
|
||||
}
|
||||
if hasErrors(diagnostics) {
|
||||
return nil, diagnostics
|
||||
}
|
||||
|
||||
formattedType, formatErr := formatNode(fset, function.Type)
|
||||
if formatErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1120", "could not format component parameters: "+formatErr.Error()))
|
||||
return nil, diagnostics
|
||||
}
|
||||
typeParams, params, splitErr := splitFormattedFuncType(formattedType)
|
||||
if splitErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+functionPosition.Offset), "HIM1121", "could not recover formatted component signature: "+splitErr.Error()))
|
||||
return nil, diagnostics
|
||||
}
|
||||
|
||||
sort.SliceStable(imports, func(i, j int) bool {
|
||||
if imports[i].Path != imports[j].Path {
|
||||
return imports[i].Path < imports[j].Path
|
||||
}
|
||||
return imports[i].Alias < imports[j].Alias
|
||||
})
|
||||
for index := 1; index < len(imports); index++ {
|
||||
if imports[index-1].Path == imports[index].Path {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset), "HIM1122", fmt.Sprintf("import path %q is declared more than once", imports[index].Path)))
|
||||
}
|
||||
}
|
||||
if hasErrors(diagnostics) {
|
||||
return nil, diagnostics
|
||||
}
|
||||
|
||||
return &parsedHeader{
|
||||
Package: parsed.Name.Name,
|
||||
Name: function.Name.Name,
|
||||
TypeParams: typeParams,
|
||||
Params: params,
|
||||
Imports: imports,
|
||||
AST: parsed,
|
||||
}, diagnostics
|
||||
}
|
||||
|
||||
func insertSyntheticFunctionBody(declarations []byte) ([]byte, int) {
|
||||
trimmed := bytes.TrimRight(declarations, " \t\r\n")
|
||||
fileSet := token.NewFileSet()
|
||||
file := fileSet.AddFile("header.go", -1, len(trimmed))
|
||||
var lexical scanner.Scanner
|
||||
lexical.Init(file, trimmed, nil, scanner.ScanComments)
|
||||
seenFunction := false
|
||||
squareDepth := 0
|
||||
curlyDepth := 0
|
||||
parameterDepth := 0
|
||||
parameterListStarted := false
|
||||
insertion := -1
|
||||
for {
|
||||
position, tok, _ := lexical.Scan()
|
||||
if tok == token.EOF {
|
||||
break
|
||||
}
|
||||
if !seenFunction {
|
||||
if tok == token.FUNC {
|
||||
seenFunction = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if parameterListStarted {
|
||||
switch tok {
|
||||
case token.LPAREN:
|
||||
parameterDepth++
|
||||
case token.RPAREN:
|
||||
parameterDepth--
|
||||
if parameterDepth == 0 {
|
||||
insertion = fileSet.Position(position).Offset + 1
|
||||
}
|
||||
}
|
||||
if insertion >= 0 {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch tok {
|
||||
case token.LBRACK:
|
||||
squareDepth++
|
||||
case token.RBRACK:
|
||||
if squareDepth > 0 {
|
||||
squareDepth--
|
||||
}
|
||||
case token.LBRACE:
|
||||
curlyDepth++
|
||||
case token.RBRACE:
|
||||
if curlyDepth > 0 {
|
||||
curlyDepth--
|
||||
}
|
||||
case token.LPAREN:
|
||||
if squareDepth == 0 && curlyDepth == 0 {
|
||||
parameterListStarted = true
|
||||
parameterDepth = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if insertion < 0 || insertion > len(trimmed) {
|
||||
insertion = len(trimmed)
|
||||
}
|
||||
parseInput := make([]byte, 0, len(trimmed)+3)
|
||||
parseInput = append(parseInput, trimmed[:insertion]...)
|
||||
parseInput = append(parseInput, ' ', '{', '}')
|
||||
parseInput = append(parseInput, trimmed[insertion:]...)
|
||||
return parseInput, insertion + 1
|
||||
}
|
||||
|
||||
func splitFormattedFuncType(formatted string) (typeParams, params string, err error) {
|
||||
remainder := strings.TrimSpace(strings.TrimPrefix(formatted, "func"))
|
||||
bracketDepth := 0
|
||||
quote := byte(0)
|
||||
escaped := false
|
||||
for index := 0; index < len(remainder); index++ {
|
||||
b := remainder[index]
|
||||
if quote != 0 {
|
||||
if quote != '`' && escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if quote != '`' && b == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if b == quote {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
if b == '"' || b == '\'' || b == '`' {
|
||||
quote = b
|
||||
continue
|
||||
}
|
||||
switch b {
|
||||
case '[':
|
||||
bracketDepth++
|
||||
case ']':
|
||||
if bracketDepth > 0 {
|
||||
bracketDepth--
|
||||
}
|
||||
case '(':
|
||||
if bracketDepth == 0 {
|
||||
return strings.TrimSpace(remainder[:index]), strings.TrimSpace(remainder[index:]), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("formatted function type has no parameter list")
|
||||
}
|
||||
|
||||
func formatNode(fset *token.FileSet, node any) (string, error) {
|
||||
var output bytes.Buffer
|
||||
configuration := printer.Config{Mode: printer.UseSpaces | printer.TabIndent, Tabwidth: 8}
|
||||
if err := configuration.Fprint(&output, fset, node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return output.String(), nil
|
||||
}
|
||||
|
||||
func parserDiagnostics(path string, err error, sourceOffset int, table positionTable, code, prefix string) []Diagnostic {
|
||||
var diagnostics []Diagnostic
|
||||
if list, ok := err.(scanner.ErrorList); ok {
|
||||
for _, parseError := range list {
|
||||
offset := sourceOffset + parseError.Pos.Offset
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(offset), code, prefix+": "+parseError.Msg))
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
return []Diagnostic{diagnostic(path, table.at(sourceOffset), code, prefix+": "+err.Error())}
|
||||
}
|
||||
|
||||
func tokenizeTemplate(file *sourceFile, template []byte, sourceOffset int, table positionTable) []Diagnostic {
|
||||
var diagnostics []Diagnostic
|
||||
cursor := 0
|
||||
for cursor < len(template) {
|
||||
openRelative := bytes.Index(template[cursor:], []byte("<?"))
|
||||
if openRelative < 0 {
|
||||
if cursor < len(template) {
|
||||
file.Nodes = append(file.Nodes, rendererNode{Kind: nodeText, Text: string(template[cursor:]), Pos: table.at(sourceOffset + cursor)})
|
||||
}
|
||||
break
|
||||
}
|
||||
open := cursor + openRelative
|
||||
if open > cursor {
|
||||
file.Nodes = append(file.Nodes, rendererNode{Kind: nodeText, Text: string(template[cursor:open]), Pos: table.at(sourceOffset + cursor)})
|
||||
}
|
||||
close := -1
|
||||
if open+2 < len(template) && template[open+2] == '#' {
|
||||
if closeRelative := bytes.Index(template[open+2:], []byte("?>")); closeRelative >= 0 {
|
||||
close = open + 2 + closeRelative
|
||||
}
|
||||
} else {
|
||||
close = findGoDelimiter(template, open+2)
|
||||
}
|
||||
if close < 0 {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, table.at(sourceOffset+open), "HIM1201", "unterminated template tag"))
|
||||
break
|
||||
}
|
||||
kind := nodeStatement
|
||||
contentStart := open + 2
|
||||
if contentStart < close {
|
||||
switch template[contentStart] {
|
||||
case '=':
|
||||
kind = nodeExpression
|
||||
contentStart++
|
||||
case '~':
|
||||
kind = nodeComponent
|
||||
contentStart++
|
||||
case '#':
|
||||
kind = nodeComment
|
||||
contentStart++
|
||||
}
|
||||
}
|
||||
content := template[contentStart:close]
|
||||
trimmed := bytes.TrimSpace(content)
|
||||
trimLeading := len(content) - len(bytes.TrimLeft(content, " \t\r\n"))
|
||||
position := table.at(sourceOffset + contentStart + trimLeading)
|
||||
if bytes.HasPrefix(bytes.TrimSpace(template[open+2:close]), []byte("sando")) {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, table.at(sourceOffset+open), "HIM1202", "<?sando is only valid as the file header"))
|
||||
} else if kind != nodeComment && len(trimmed) == 0 {
|
||||
diagnostics = append(diagnostics, diagnostic(file.Path, table.at(sourceOffset+open), "HIM1203", "empty template tag"))
|
||||
} else {
|
||||
node := rendererNode{Kind: kind, Text: string(trimmed), Context: ContextNone, Pos: position}
|
||||
if kind == nodeExpression || kind == nodeComponent {
|
||||
if _, err := parser.ParseExprFrom(token.NewFileSet(), filepath.Base(file.Path), trimmed, parser.AllErrors); err != nil {
|
||||
diagnostics = append(diagnostics, expressionDiagnostics(file.Path, err, position, table, sourceOffset+contentStart+trimLeading)...)
|
||||
} else {
|
||||
file.Nodes = append(file.Nodes, node)
|
||||
}
|
||||
} else {
|
||||
file.Nodes = append(file.Nodes, node)
|
||||
}
|
||||
}
|
||||
cursor = close + 2
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
// findGoDelimiter returns the first ?> outside Go strings, rune literals, raw
|
||||
// strings, and comments. Statement tags may be structurally incomplete across
|
||||
// template regions, so requiring each region to parse independently would
|
||||
// reject ordinary `if { ?>...<? }` usage.
|
||||
func findGoDelimiter(source []byte, start int) int {
|
||||
type lexicalState uint8
|
||||
const (
|
||||
lexicalNormal lexicalState = iota
|
||||
lexicalString
|
||||
lexicalRune
|
||||
lexicalRawString
|
||||
lexicalLineComment
|
||||
lexicalBlockComment
|
||||
)
|
||||
state := lexicalNormal
|
||||
escaped := false
|
||||
for index := start; index < len(source); index++ {
|
||||
b := source[index]
|
||||
next := byte(0)
|
||||
if index+1 < len(source) {
|
||||
next = source[index+1]
|
||||
}
|
||||
switch state {
|
||||
case lexicalNormal:
|
||||
switch {
|
||||
case b == '?' && next == '>':
|
||||
return index
|
||||
case b == '"':
|
||||
state = lexicalString
|
||||
escaped = false
|
||||
case b == '\'':
|
||||
state = lexicalRune
|
||||
escaped = false
|
||||
case b == '`':
|
||||
state = lexicalRawString
|
||||
case b == '/' && next == '/':
|
||||
state = lexicalLineComment
|
||||
index++
|
||||
case b == '/' && next == '*':
|
||||
state = lexicalBlockComment
|
||||
index++
|
||||
}
|
||||
case lexicalString, lexicalRune:
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if b == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if (state == lexicalString && b == '"') || (state == lexicalRune && b == '\'') {
|
||||
state = lexicalNormal
|
||||
}
|
||||
case lexicalRawString:
|
||||
if b == '`' {
|
||||
state = lexicalNormal
|
||||
}
|
||||
case lexicalLineComment:
|
||||
if b == '\n' {
|
||||
state = lexicalNormal
|
||||
}
|
||||
case lexicalBlockComment:
|
||||
if b == '*' && next == '/' {
|
||||
state = lexicalNormal
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func expressionDiagnostics(path string, err error, fallback sourcePosition, table positionTable, sourceOffset int) []Diagnostic {
|
||||
if list, ok := err.(scanner.ErrorList); ok {
|
||||
diagnostics := make([]Diagnostic, 0, len(list))
|
||||
for _, parseError := range list {
|
||||
diagnostics = append(diagnostics, diagnostic(path, table.at(sourceOffset+parseError.Pos.Offset), "HIM1210", "invalid Go expression: "+parseError.Msg))
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
return []Diagnostic{diagnostic(path, fallback, "HIM1210", "invalid Go expression: "+strings.TrimSpace(err.Error()))}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//go:build !windows
|
||||
|
||||
package compiler
|
||||
|
||||
import "os"
|
||||
|
||||
func replaceFile(replacement, destination string) error {
|
||||
return os.Rename(replacement, destination)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//go:build windows
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var replaceFileW = syscall.NewLazyDLL("kernel32.dll").NewProc("ReplaceFileW")
|
||||
|
||||
// replaceFile uses ReplaceFileW when a destination exists because os.Rename is
|
||||
// not an atomic replacement primitive on Windows. A new destination can use
|
||||
// os.Rename: there is no last-good file whose visibility must be preserved.
|
||||
func replaceFile(replacement, destination string) error {
|
||||
if _, err := os.Lstat(destination); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return os.Rename(replacement, destination)
|
||||
}
|
||||
return err
|
||||
}
|
||||
destinationUTF16, err := syscall.UTF16PtrFromString(destination)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
replacementUTF16, err := syscall.UTF16PtrFromString(replacement)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, _, callErr := replaceFileW.Call(
|
||||
uintptr(unsafe.Pointer(destinationUTF16)),
|
||||
uintptr(unsafe.Pointer(replacementUTF16)),
|
||||
0,
|
||||
1, // REPLACEFILE_WRITE_THROUGH
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if result == 0 {
|
||||
return callErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Package devserver implements Hime-san's local-only development supervisor.
|
||||
// It is intentionally independent from the template compiler and production
|
||||
// runtime.
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// ConfigVersion is the himesan.json schema version understood by this
|
||||
// package.
|
||||
ConfigVersion = 1
|
||||
|
||||
defaultListenAddressEnv = "HIMESAN_LISTEN_ADDR"
|
||||
defaultHealthPath = "/"
|
||||
defaultProxyAddress = "127.0.0.1:7331"
|
||||
)
|
||||
|
||||
var environmentNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
// Config is the versioned, non-secret himesan.json development configuration.
|
||||
// Arguments are passed directly to the application; they are never interpreted
|
||||
// by a shell.
|
||||
type Config struct {
|
||||
Version int `json:"version"`
|
||||
SourceRoots []string `json:"sourceRoots"`
|
||||
GoPackage string `json:"goPackage"`
|
||||
AppArgs []string `json:"appArgs,omitempty"`
|
||||
ListenAddressEnv string `json:"listenAddressEnv"`
|
||||
HealthPath string `json:"healthPath"`
|
||||
ProxyAddress string `json:"proxyAddress"`
|
||||
AdditionalWatchRoots []string `json:"additionalWatchRoots,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultConfig returns safe defaults for a simple, single-module project.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Version: ConfigVersion,
|
||||
SourceRoots: []string{"."},
|
||||
GoPackage: ".",
|
||||
ListenAddressEnv: defaultListenAddressEnv,
|
||||
HealthPath: defaultHealthPath,
|
||||
ProxyAddress: defaultProxyAddress,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadConfig reads a himesan.json file, applies defaults for omitted optional
|
||||
// fields, rejects unknown fields, and validates the result. Paths remain
|
||||
// relative to the project root supplied later through Options.RootDir.
|
||||
func LoadConfig(path string) (Config, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("open development config: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
// Unlike optional fields, the schema version must be written explicitly so
|
||||
// future defaults cannot silently reinterpret an old file.
|
||||
cfg.Version = 0
|
||||
decoder := json.NewDecoder(f)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("decode development config: %w", err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return Config{}, errors.New("decode development config: multiple JSON values")
|
||||
}
|
||||
return Config{}, fmt.Errorf("decode development config: %w", err)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, fmt.Errorf("validate development config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Validate checks the schema and all values that do not require filesystem
|
||||
// access. In particular, the stable proxy is restricted to loopback.
|
||||
func (c Config) Validate() error {
|
||||
if c.Version != ConfigVersion {
|
||||
return fmt.Errorf("unsupported config version %d (want %d)", c.Version, ConfigVersion)
|
||||
}
|
||||
if len(c.SourceRoots) == 0 {
|
||||
return errors.New("sourceRoots must contain at least one path")
|
||||
}
|
||||
for _, root := range append(append([]string(nil), c.SourceRoots...), c.AdditionalWatchRoots...) {
|
||||
if err := validatePathValue(root); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(c.GoPackage) == "" {
|
||||
return errors.New("goPackage must not be empty")
|
||||
}
|
||||
if strings.ContainsAny(c.GoPackage, "\x00\r\n") {
|
||||
return errors.New("goPackage contains a control character")
|
||||
}
|
||||
for _, arg := range c.AppArgs {
|
||||
if strings.ContainsRune(arg, '\x00') {
|
||||
return errors.New("appArgs contains a NUL byte")
|
||||
}
|
||||
}
|
||||
if !environmentNamePattern.MatchString(c.ListenAddressEnv) {
|
||||
return fmt.Errorf("listenAddressEnv %q is not a valid environment variable name", c.ListenAddressEnv)
|
||||
}
|
||||
if !strings.HasPrefix(c.HealthPath, "/") || strings.HasPrefix(c.HealthPath, "//") {
|
||||
return errors.New("healthPath must be an absolute URL path")
|
||||
}
|
||||
if strings.ContainsAny(c.HealthPath, "\x00\r\n?#") {
|
||||
return errors.New("healthPath must not contain controls, a query, or a fragment")
|
||||
}
|
||||
if err := ValidateLoopbackAddress(c.ProxyAddress); err != nil {
|
||||
return fmt.Errorf("proxyAddress: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePathValue(path string) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return errors.New("watch paths must not be empty")
|
||||
}
|
||||
if strings.ContainsRune(path, '\x00') {
|
||||
return errors.New("watch path contains a NUL byte")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateLoopbackAddress rejects wildcard, public, malformed, and
|
||||
// hostname-based proxy bindings. Requiring a literal loopback IP prevents a
|
||||
// hosts-file or DNS change from broadening the development server's exposure.
|
||||
func ValidateLoopbackAddress(address string) error {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("must be host:port: %w", err)
|
||||
}
|
||||
portNumber, err := strconv.Atoi(port)
|
||||
if err != nil || portNumber < 0 || portNumber > 65535 {
|
||||
return fmt.Errorf("port %q is not numeric or is outside 0-65535", port)
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
return fmt.Errorf("host %q is not a loopback IP", host)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveProjectPath(rootDir, path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
return filepath.Join(rootDir, filepath.Clean(path))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfigDefaultsAndRejectsUnknownFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "himesan.json")
|
||||
if err := os.WriteFile(path, []byte(`{"version":1,"proxyAddress":"[::1]:0"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
if cfg.GoPackage != "." || cfg.ListenAddressEnv != defaultListenAddressEnv || cfg.HealthPath != "/" {
|
||||
t.Fatalf("LoadConfig() did not apply defaults: %#v", cfg)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(`{"version":1,"mystery":true}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadConfig(path); err == nil || !strings.Contains(err.Error(), "unknown field") {
|
||||
t.Fatalf("LoadConfig() unknown field error = %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(`{"proxyAddress":"127.0.0.1:0"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadConfig(path); err == nil || !strings.Contains(err.Error(), "version") {
|
||||
t.Fatalf("LoadConfig() missing version error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
}{
|
||||
{"public proxy", func(c *Config) { c.ProxyAddress = "0.0.0.0:7331" }},
|
||||
{"hostname proxy", func(c *Config) { c.ProxyAddress = "localhost:7331" }},
|
||||
{"bad port", func(c *Config) { c.ProxyAddress = "127.0.0.1:http" }},
|
||||
{"bad environment", func(c *Config) { c.ListenAddressEnv = "bad-name" }},
|
||||
{"health query", func(c *Config) { c.HealthPath = "/health?full=1" }},
|
||||
{"empty source roots", func(c *Config) { c.SourceRoots = nil }},
|
||||
{"nul argument", func(c *Config) { c.AppArgs = []string{"a\x00b"} }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
test.mutate(&cfg)
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("Validate() unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, address := range []string{"127.0.0.1:0", "127.12.3.4:65535", "[::1]:7331"} {
|
||||
if err := ValidateLoopbackAddress(address); err != nil {
|
||||
t.Errorf("ValidateLoopbackAddress(%q) = %v", address, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const eventsPath = "/__himesan/events"
|
||||
|
||||
// Diagnostic is a compiler/build diagnostic suitable for the development
|
||||
// browser overlay. The CLI may map its compiler's native diagnostics through
|
||||
// Options.MapDiagnostics without coupling this package to the compiler.
|
||||
type Diagnostic struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
Line int `json:"line,omitempty"`
|
||||
Column int `json:"column,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
}
|
||||
|
||||
// Event is delivered both to Options.OnEvent and to connected browser clients.
|
||||
// Type is currently one of "ready", "reload", or "diagnostic".
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
type eventHub struct {
|
||||
mu sync.Mutex
|
||||
subscribers map[chan Event]struct{}
|
||||
latest *Event
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newEventHub() *eventHub {
|
||||
return &eventHub{subscribers: make(map[chan Event]struct{})}
|
||||
}
|
||||
|
||||
func (h *eventHub) publish(event Event) {
|
||||
if event.At.IsZero() {
|
||||
event.At = time.Now().UTC()
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.closed {
|
||||
return
|
||||
}
|
||||
if event.Type == "diagnostic" {
|
||||
copy := event
|
||||
h.latest = ©
|
||||
} else if event.Type == "reload" {
|
||||
h.latest = nil
|
||||
}
|
||||
for subscriber := range h.subscribers {
|
||||
select {
|
||||
case subscriber <- event:
|
||||
default:
|
||||
// Reload and diagnostic events are snapshots, not a log. Replace the
|
||||
// oldest queued snapshot so a slow browser still receives the newest
|
||||
// state transition.
|
||||
select {
|
||||
case <-subscriber:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case subscriber <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *eventHub) subscribe() (<-chan Event, func()) {
|
||||
updates := make(chan Event, 8)
|
||||
h.mu.Lock()
|
||||
if h.closed {
|
||||
close(updates)
|
||||
h.mu.Unlock()
|
||||
return updates, func() {}
|
||||
}
|
||||
h.subscribers[updates] = struct{}{}
|
||||
if h.latest != nil {
|
||||
updates <- *h.latest
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
var once sync.Once
|
||||
return updates, func() {
|
||||
once.Do(func() {
|
||||
h.mu.Lock()
|
||||
if _, ok := h.subscribers[updates]; ok {
|
||||
delete(h.subscribers, updates)
|
||||
close(updates)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *eventHub) close() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.closed {
|
||||
return
|
||||
}
|
||||
h.closed = true
|
||||
for subscriber := range h.subscribers {
|
||||
close(subscriber)
|
||||
delete(h.subscribers, subscriber)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *eventHub) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming is unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := writeSSE(w, Event{Type: "ready", At: time.Now().UTC()}); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
|
||||
updates, unsubscribe := h.subscribe()
|
||||
defer unsubscribe()
|
||||
heartbeat := time.NewTicker(15 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case event, ok := <-updates:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := writeSSE(w, event); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
case <-heartbeat.C:
|
||||
if _, err := fmt.Fprint(w, ": heartbeat\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeSSE(w http.ResponseWriter, event Event) error {
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "event: %s\n", event.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(w, "data: %s\n\n", payload)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type candidateProcess struct {
|
||||
command *exec.Cmd
|
||||
address string
|
||||
binaryPath string
|
||||
processTree uintptr
|
||||
exited chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
waitErr error
|
||||
}
|
||||
|
||||
func taskkillArguments(pid int, force bool) []string {
|
||||
arguments := []string{"/PID", strconv.Itoa(pid), "/T"}
|
||||
if force {
|
||||
arguments = append(arguments, "/F")
|
||||
}
|
||||
return arguments
|
||||
}
|
||||
|
||||
func startManagedProcess(command *exec.Cmd, address, binaryPath string) (*candidateProcess, error) {
|
||||
configureProcess(command)
|
||||
if err := command.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
processTree, err := attachProcessTree(command)
|
||||
if err != nil {
|
||||
// Never return an unmanaged child. In particular, a Windows candidate
|
||||
// must be attached to its Job Object before it can be considered usable.
|
||||
_ = killProcess(command, 0)
|
||||
_ = command.Wait()
|
||||
return nil, errors.New("attach managed process tree: " + err.Error())
|
||||
}
|
||||
candidate := &candidateProcess{
|
||||
command: command,
|
||||
address: address,
|
||||
binaryPath: binaryPath,
|
||||
processTree: processTree,
|
||||
exited: make(chan struct{}),
|
||||
}
|
||||
go func() {
|
||||
err := command.Wait()
|
||||
candidate.mu.Lock()
|
||||
candidate.waitErr = err
|
||||
candidate.mu.Unlock()
|
||||
close(candidate.exited)
|
||||
}()
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
func (c *candidateProcess) result() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.waitErr
|
||||
}
|
||||
|
||||
func (c *candidateProcess) hasExited() bool {
|
||||
select {
|
||||
case <-c.exited:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *candidateProcess) cleanupProcessTree() error {
|
||||
c.mu.Lock()
|
||||
processTree := c.processTree
|
||||
c.processTree = 0
|
||||
c.mu.Unlock()
|
||||
return cleanupProcess(c.command, processTree)
|
||||
}
|
||||
|
||||
func (c *candidateProcess) stop(ctx context.Context) error {
|
||||
defer func() {
|
||||
if c.binaryPath != "" {
|
||||
_ = os.Remove(c.binaryPath)
|
||||
}
|
||||
}()
|
||||
if c.hasExited() {
|
||||
return errors.Join(acceptableStopError(c.result()), c.cleanupProcessTree())
|
||||
}
|
||||
if err := terminateProcess(c.command, c.processTree); err != nil {
|
||||
// A graceful signal is best effort. Failure to deliver it immediately
|
||||
// escalates to the platform's process-tree termination primitive.
|
||||
_ = killProcess(c.command, c.processTree)
|
||||
}
|
||||
select {
|
||||
case <-c.exited:
|
||||
return errors.Join(acceptableStopError(c.result()), c.cleanupProcessTree())
|
||||
case <-ctx.Done():
|
||||
killErr := killProcess(c.command, c.processTree)
|
||||
select {
|
||||
case <-c.exited:
|
||||
return errors.Join(ctx.Err(), killErr, acceptableStopError(c.result()), c.cleanupProcessTree())
|
||||
case <-time.After(2 * time.Second):
|
||||
// Closing a Windows Job Object configured with
|
||||
// KILL_ON_JOB_CLOSE is the final bounded fallback. On Unix this
|
||||
// repeats the process-group kill without retaining resources.
|
||||
return errors.Join(ctx.Err(), killErr, c.cleanupProcessTree())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func acceptableStopError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var exitError *exec.ExitError
|
||||
if errors.As(err, &exitError) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManagedProcessStopsAndWaits(t *testing.T) {
|
||||
command := exec.Command(os.Args[0], "-test.run=TestManagedProcessHelper$")
|
||||
command.Env = append(os.Environ(), "HIMESAN_PROCESS_HELPER=1")
|
||||
candidate, err := startManagedProcess(command, "127.0.0.1:1", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := candidate.stop(ctx); err != nil {
|
||||
t.Fatalf("stop() error = %v", err)
|
||||
}
|
||||
if !candidate.hasExited() {
|
||||
t.Fatal("candidate process was not reaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedProcessStopsDescendantTree(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("helper-process integration test")
|
||||
}
|
||||
directory := t.TempDir()
|
||||
gatePath := filepath.Join(directory, "start-child")
|
||||
readyPath := filepath.Join(directory, "child-address")
|
||||
command := exec.Command(os.Args[0], "-test.run=TestManagedProcessHelper$")
|
||||
command.Env = append(os.Environ(),
|
||||
"HIMESAN_PROCESS_HELPER=tree-parent",
|
||||
"HIMESAN_PROCESS_GATE="+gatePath,
|
||||
"HIMESAN_PROCESS_READY="+readyPath,
|
||||
)
|
||||
candidate, err := startManagedProcess(command, "127.0.0.1:1", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(gatePath, []byte("start"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := waitForChildAddress(t, readyPath)
|
||||
waitForChildListener(t, address)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := candidate.stop(ctx); err != nil {
|
||||
t.Fatalf("stop() error = %v", err)
|
||||
}
|
||||
if !candidate.hasExited() {
|
||||
t.Fatal("candidate root process was not reaped")
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
connection, dialErr := net.DialTimeout("tcp", address, 100*time.Millisecond)
|
||||
if dialErr != nil {
|
||||
break
|
||||
}
|
||||
_ = connection.Close()
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("managed descendant still accepts connections at %s", address)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskkillArguments(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got, want := taskkillArguments(42, false), []string{"/PID", "42", "/T"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("taskkillArguments(graceful) = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := taskkillArguments(42, true), []string{"/PID", "42", "/T", "/F"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("taskkillArguments(force) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedProcessHelper(t *testing.T) {
|
||||
switch os.Getenv("HIMESAN_PROCESS_HELPER") {
|
||||
case "":
|
||||
return
|
||||
case "tree-parent":
|
||||
runTreeParentHelper()
|
||||
case "tree-child":
|
||||
runTreeChildHelper()
|
||||
}
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals)
|
||||
<-signals
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func runTreeParentHelper() {
|
||||
gatePath := os.Getenv("HIMESAN_PROCESS_GATE")
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(gatePath); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
os.Exit(2)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
child := exec.Command(os.Args[0], "-test.run=TestManagedProcessHelper$")
|
||||
child.Env = replaceEnvironment(os.Environ(), "HIMESAN_PROCESS_HELPER", "tree-child")
|
||||
if err := child.Start(); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func runTreeChildHelper() {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
defer listener.Close()
|
||||
if err := os.WriteFile(os.Getenv("HIMESAN_PROCESS_READY"), []byte(listener.Addr().String()), 0o600); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
for {
|
||||
connection, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
os.Exit(0)
|
||||
}
|
||||
_ = connection.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func waitForChildAddress(t *testing.T, readyPath string) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
contents, err := os.ReadFile(readyPath)
|
||||
if err == nil && strings.TrimSpace(string(contents)) != "" {
|
||||
return strings.TrimSpace(string(contents))
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("managed descendant did not report its address: %v", err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForChildListener(t *testing.T, address string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
var lastErr error
|
||||
for {
|
||||
connection, err := net.DialTimeout("tcp", address, 100*time.Millisecond)
|
||||
if err == nil {
|
||||
_ = connection.Close()
|
||||
return
|
||||
}
|
||||
lastErr = err
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("managed descendant did not accept a connection at %s: %v", address, lastErr)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//go:build !windows
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func configureProcess(command *exec.Cmd) {
|
||||
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
}
|
||||
|
||||
func attachProcessTree(_ *exec.Cmd) (uintptr, error) { return 0, nil }
|
||||
|
||||
func terminateProcess(command *exec.Cmd, _ uintptr) error {
|
||||
if command.Process == nil {
|
||||
return nil
|
||||
}
|
||||
if err := syscall.Kill(-command.Process.Pid, syscall.SIGTERM); err != nil && !errors.Is(err, syscall.ESRCH) {
|
||||
return command.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func killProcess(command *exec.Cmd, _ uintptr) error {
|
||||
if command.Process == nil {
|
||||
return nil
|
||||
}
|
||||
if err := syscall.Kill(-command.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
|
||||
return command.Process.Kill()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupProcess(command *exec.Cmd, processTree uintptr) error {
|
||||
return killProcess(command, processTree)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//go:build windows
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
processSetQuota = 0x0100
|
||||
jobObjectExtendedLimitInformation = 9
|
||||
jobObjectLimitKillOnJobClose = 0x00002000
|
||||
)
|
||||
|
||||
type ioCounters struct {
|
||||
ReadOperationCount uint64
|
||||
WriteOperationCount uint64
|
||||
OtherOperationCount uint64
|
||||
ReadTransferCount uint64
|
||||
WriteTransferCount uint64
|
||||
OtherTransferCount uint64
|
||||
}
|
||||
|
||||
type jobObjectExtendedLimitInfo struct {
|
||||
BasicLimitInformation jobObjectBasicLimitInfo
|
||||
IOInfo ioCounters
|
||||
ProcessMemoryLimit uintptr
|
||||
JobMemoryLimit uintptr
|
||||
PeakProcessMemoryUsed uintptr
|
||||
PeakJobMemoryUsed uintptr
|
||||
}
|
||||
|
||||
var (
|
||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
assignProcessToJobObject = kernel32.NewProc("AssignProcessToJobObject")
|
||||
createJobObjectW = kernel32.NewProc("CreateJobObjectW")
|
||||
generateConsoleCtrlEvent = kernel32.NewProc("GenerateConsoleCtrlEvent")
|
||||
setInformationJobObject = kernel32.NewProc("SetInformationJobObject")
|
||||
terminateJobObject = kernel32.NewProc("TerminateJobObject")
|
||||
)
|
||||
|
||||
func configureProcess(command *exec.Cmd) {
|
||||
command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP}
|
||||
}
|
||||
|
||||
// attachProcessTree places the candidate in a Windows Job Object. Job
|
||||
// membership is inherited by descendants, so they remain terminable even when
|
||||
// the root process exits before cleanup reaches it.
|
||||
func attachProcessTree(command *exec.Cmd) (uintptr, error) {
|
||||
if command.Process == nil {
|
||||
return 0, errors.New("candidate process is unavailable")
|
||||
}
|
||||
job, _, createErr := createJobObjectW.Call(0, 0)
|
||||
if job == 0 {
|
||||
return 0, fmt.Errorf("CreateJobObjectW: %w", createErr)
|
||||
}
|
||||
limits := jobObjectExtendedLimitInfo{}
|
||||
limits.BasicLimitInformation.LimitFlags = jobObjectLimitKillOnJobClose
|
||||
configured, _, configureErr := setInformationJobObject.Call(
|
||||
job,
|
||||
jobObjectExtendedLimitInformation,
|
||||
uintptr(unsafe.Pointer(&limits)),
|
||||
unsafe.Sizeof(limits),
|
||||
)
|
||||
if configured == 0 {
|
||||
_ = syscall.CloseHandle(syscall.Handle(job))
|
||||
return 0, fmt.Errorf("SetInformationJobObject: %w", configureErr)
|
||||
}
|
||||
process, err := syscall.OpenProcess(processSetQuota|syscall.PROCESS_TERMINATE, false, uint32(command.Process.Pid))
|
||||
if err != nil {
|
||||
_ = syscall.CloseHandle(syscall.Handle(job))
|
||||
return 0, fmt.Errorf("open candidate for Job Object assignment: %w", err)
|
||||
}
|
||||
defer syscall.CloseHandle(process)
|
||||
assigned, _, assignErr := assignProcessToJobObject.Call(job, uintptr(process))
|
||||
if assigned == 0 {
|
||||
_ = syscall.CloseHandle(syscall.Handle(job))
|
||||
return 0, fmt.Errorf("AssignProcessToJobObject: %w", assignErr)
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func terminateProcess(command *exec.Cmd, _ uintptr) error {
|
||||
if command.Process == nil {
|
||||
return nil
|
||||
}
|
||||
result, _, callErr := generateConsoleCtrlEvent.Call(syscall.CTRL_BREAK_EVENT, uintptr(command.Process.Pid))
|
||||
if result == 0 {
|
||||
return fmt.Errorf("GenerateConsoleCtrlEvent: %w", callErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func killProcess(command *exec.Cmd, processTree uintptr) error {
|
||||
if processTree != 0 {
|
||||
result, _, callErr := terminateJobObject.Call(processTree, 1)
|
||||
if result != 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("TerminateJobObject: %w", callErr)
|
||||
}
|
||||
if command.Process == nil {
|
||||
return nil
|
||||
}
|
||||
if err := runTaskkill(command.Process.Pid, true); err != nil {
|
||||
return errors.Join(err, command.Process.Kill())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupProcess(command *exec.Cmd, processTree uintptr) error {
|
||||
if processTree == 0 {
|
||||
// A successfully returned Windows candidate always owns a Job Object.
|
||||
// Zero therefore means cleanup already ran; do not target a potentially
|
||||
// recycled process ID.
|
||||
return nil
|
||||
}
|
||||
terminateErr := killProcess(command, processTree)
|
||||
closeErr := syscall.CloseHandle(syscall.Handle(processTree))
|
||||
return errors.Join(terminateErr, closeErr)
|
||||
}
|
||||
|
||||
func runTaskkill(pid int, force bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
return exec.CommandContext(ctx, "taskkill", taskkillArguments(pid, force)...).Run()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//go:build windows && (386 || arm)
|
||||
|
||||
package devserver
|
||||
|
||||
import "unsafe"
|
||||
|
||||
// jobObjectBasicLimitInfo mirrors JOBOBJECT_BASIC_LIMIT_INFORMATION. Windows
|
||||
// 32-bit ABIs pad this structure to an eight-byte boundary.
|
||||
type jobObjectBasicLimitInfo struct {
|
||||
PerProcessUserTimeLimit int64
|
||||
PerJobUserTimeLimit int64
|
||||
LimitFlags uint32
|
||||
MinimumWorkingSetSize uintptr
|
||||
MaximumWorkingSetSize uintptr
|
||||
ActiveProcessLimit uint32
|
||||
Affinity uintptr
|
||||
PriorityClass uint32
|
||||
SchedulingClass uint32
|
||||
_ uint32
|
||||
}
|
||||
|
||||
var (
|
||||
_ [48 - unsafe.Sizeof(jobObjectBasicLimitInfo{})]byte
|
||||
_ [unsafe.Sizeof(jobObjectBasicLimitInfo{}) - 48]byte
|
||||
_ [112 - unsafe.Sizeof(jobObjectExtendedLimitInfo{})]byte
|
||||
_ [unsafe.Sizeof(jobObjectExtendedLimitInfo{}) - 112]byte
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//go:build windows && (amd64 || arm64)
|
||||
|
||||
package devserver
|
||||
|
||||
import "unsafe"
|
||||
|
||||
// jobObjectBasicLimitInfo mirrors JOBOBJECT_BASIC_LIMIT_INFORMATION on the
|
||||
// supported 64-bit Windows architectures.
|
||||
type jobObjectBasicLimitInfo struct {
|
||||
PerProcessUserTimeLimit int64
|
||||
PerJobUserTimeLimit int64
|
||||
LimitFlags uint32
|
||||
MinimumWorkingSetSize uintptr
|
||||
MaximumWorkingSetSize uintptr
|
||||
ActiveProcessLimit uint32
|
||||
Affinity uintptr
|
||||
PriorityClass uint32
|
||||
SchedulingClass uint32
|
||||
}
|
||||
|
||||
var (
|
||||
_ [64 - unsafe.Sizeof(jobObjectBasicLimitInfo{})]byte
|
||||
_ [unsafe.Sizeof(jobObjectBasicLimitInfo{}) - 64]byte
|
||||
_ [144 - unsafe.Sizeof(jobObjectExtendedLimitInfo{})]byte
|
||||
_ [unsafe.Sizeof(jobObjectExtendedLimitInfo{}) - 144]byte
|
||||
)
|
||||
@@ -0,0 +1,410 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
const (
|
||||
maxInjectableHTML = 16 << 20
|
||||
reloadClient = `(function(){var id="__himesan_overlay";function show(e){var d=document.getElementById(id);if(!d){d=document.createElement("dialog");d.id=id;var b=document.createElement("button");b.textContent="Close";b.addEventListener("click",function(){d.close()});var p=document.createElement("pre");d.appendChild(b);d.appendChild(p);document.body.appendChild(d)}var p=d.querySelector("pre"),xs=e.diagnostics||[];p.textContent=(e.phase?e.phase+": ":"")+(e.message||"Hime-san could not reload")+(xs.length?"\n\n"+xs.map(function(x){return (x.path||"")+(x.line?":"+x.line+(x.column?":"+x.column:""):"")+(x.code?" ["+x.code+"]":"")+" "+x.message}).join("\n"):"");if(!d.open)d.showModal()}var s=new EventSource("/__himesan/events");s.addEventListener("reload",function(){location.reload()});s.addEventListener("diagnostic",function(e){try{show(JSON.parse(e.data))}catch(_){show({message:e.data})}})})();`
|
||||
)
|
||||
|
||||
var (
|
||||
reloadClientTag = []byte("<script data-himesan-reload>" + reloadClient + "</script>")
|
||||
reloadClientHash = makeReloadClientHash()
|
||||
)
|
||||
|
||||
func makeReloadClientHash() string {
|
||||
digest := sha256.Sum256([]byte(reloadClient))
|
||||
return "'sha256-" + base64.StdEncoding.EncodeToString(digest[:]) + "'"
|
||||
}
|
||||
|
||||
type developmentProxy struct {
|
||||
target atomic.Pointer[url.URL]
|
||||
authority atomic.Pointer[localProxyAuthority]
|
||||
hub *eventHub
|
||||
proxy *httputil.ReverseProxy
|
||||
}
|
||||
|
||||
type localProxyAuthority struct {
|
||||
port int
|
||||
}
|
||||
|
||||
func newDevelopmentProxy(hub *eventHub) *developmentProxy {
|
||||
d := &developmentProxy{hub: hub}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
d.proxy = &httputil.ReverseProxy{
|
||||
Transport: transport,
|
||||
Rewrite: func(request *httputil.ProxyRequest) {
|
||||
target := d.target.Load()
|
||||
if target == nil {
|
||||
return
|
||||
}
|
||||
request.SetURL(target)
|
||||
request.SetXForwarded()
|
||||
request.Out.Header.Set("Accept-Encoding", "identity")
|
||||
request.Out.Header.Del("If-Modified-Since")
|
||||
request.Out.Header.Del("If-None-Match")
|
||||
request.Out.Header.Set("Cache-Control", "no-cache")
|
||||
},
|
||||
ModifyResponse: injectDevelopmentClient,
|
||||
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
|
||||
http.Error(w, "Hime-san development upstream is unavailable: "+err.Error(), http.StatusBadGateway)
|
||||
},
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *developmentProxy) closeIdleConnections() {
|
||||
if transport, ok := d.proxy.Transport.(interface{ CloseIdleConnections() }); ok {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *developmentProxy) setAuthority(address string) error {
|
||||
if err := ValidateLoopbackAddress(address); err != nil {
|
||||
return fmt.Errorf("development proxy authority %q: %w", address, err)
|
||||
}
|
||||
_, rawPort, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("split development proxy authority: %w", err)
|
||||
}
|
||||
port, err := strconv.Atoi(rawPort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse development proxy port: %w", err)
|
||||
}
|
||||
d.authority.Store(&localProxyAuthority{port: port})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *developmentProxy) setTarget(address string) error {
|
||||
if err := ValidateLoopbackAddress(address); err != nil {
|
||||
return fmt.Errorf("development upstream %q: %w", address, err)
|
||||
}
|
||||
target, err := url.Parse("http://" + address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse development upstream: %w", err)
|
||||
}
|
||||
d.target.Store(target)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *developmentProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if status, message := d.validateRequest(r); status != 0 {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
http.Error(w, message, status)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == eventsPath {
|
||||
d.hub.serveHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if d.target.Load() == nil {
|
||||
d.serveWaitingPage(w)
|
||||
return
|
||||
}
|
||||
d.proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (d *developmentProxy) validateRequest(r *http.Request) (int, string) {
|
||||
authority := d.authority.Load()
|
||||
if authority == nil {
|
||||
return http.StatusServiceUnavailable, "Hime-san development proxy is not ready"
|
||||
}
|
||||
requestAuthority, ok := canonicalLoopbackAuthority(r.Host, authority.port)
|
||||
if !ok {
|
||||
return http.StatusMisdirectedRequest, "Hime-san development proxy requires a loopback Host authority"
|
||||
}
|
||||
|
||||
if values := r.Header.Values("Sec-Fetch-Site"); len(values) > 1 {
|
||||
return http.StatusForbidden, "cross-origin development proxy request denied"
|
||||
} else if len(values) == 1 {
|
||||
switch strings.ToLower(strings.TrimSpace(values[0])) {
|
||||
case "", "none", "same-origin":
|
||||
default:
|
||||
return http.StatusForbidden, "cross-origin development proxy request denied"
|
||||
}
|
||||
}
|
||||
|
||||
origins := r.Header.Values("Origin")
|
||||
if len(origins) > 1 {
|
||||
return http.StatusForbidden, "cross-origin development proxy request denied"
|
||||
}
|
||||
if len(origins) == 1 {
|
||||
originAuthority, ok := canonicalHTTPOrigin(origins[0], authority.port)
|
||||
if !ok || originAuthority != requestAuthority {
|
||||
return http.StatusForbidden, "cross-origin development proxy request denied"
|
||||
}
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
func canonicalHTTPOrigin(raw string, port int) (string, bool) {
|
||||
if raw == "" || strings.TrimSpace(raw) != raw {
|
||||
return "", false
|
||||
}
|
||||
origin, err := url.Parse(raw)
|
||||
if err != nil || !strings.EqualFold(origin.Scheme, "http") || origin.Host == "" || origin.User != nil || origin.Opaque != "" || origin.Path != "" || origin.RawPath != "" || origin.RawQuery != "" || origin.Fragment != "" || origin.ForceQuery {
|
||||
return "", false
|
||||
}
|
||||
return canonicalLoopbackAuthority(origin.Host, port)
|
||||
}
|
||||
|
||||
func canonicalLoopbackAuthority(authority string, requiredPort int) (string, bool) {
|
||||
if authority == "" || strings.TrimSpace(authority) != authority {
|
||||
return "", false
|
||||
}
|
||||
host, rawPort, err := net.SplitHostPort(authority)
|
||||
if err != nil {
|
||||
if requiredPort != 80 {
|
||||
return "", false
|
||||
}
|
||||
host = authority
|
||||
rawPort = "80"
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
host = host[1 : len(host)-1]
|
||||
}
|
||||
}
|
||||
requestPort, err := strconv.Atoi(rawPort)
|
||||
if err != nil || requestPort != requiredPort {
|
||||
return "", false
|
||||
}
|
||||
|
||||
normalizedHost := strings.ToLower(host)
|
||||
if normalizedHost == "localhost" || normalizedHost == "localhost." {
|
||||
return "localhost:" + strconv.Itoa(requiredPort), true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
return "", false
|
||||
}
|
||||
return ip.String() + ":" + strconv.Itoa(requiredPort), true
|
||||
}
|
||||
|
||||
func (d *developmentProxy) serveWaitingPage(w http.ResponseWriter) {
|
||||
body := append([]byte("<!doctype html><html><body><h1>Hime-san is waiting for a healthy application build.</h1>"), reloadClientTag...)
|
||||
body = append(body, []byte("</body></html>")...)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src "+reloadClientHash+"; connect-src 'self'")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func injectDevelopmentClient(response *http.Response) error {
|
||||
disableDevelopmentCaching(response)
|
||||
if !eligibleHTMLResponse(response) {
|
||||
return nil
|
||||
}
|
||||
|
||||
prefix, err := io.ReadAll(io.LimitReader(response.Body, maxInjectableHTML+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read HTML for development reload injection: %w", err)
|
||||
}
|
||||
if len(prefix) > maxInjectableHTML {
|
||||
response.Body = &prefixedReadCloser{
|
||||
Reader: io.MultiReader(bytes.NewReader(prefix), response.Body),
|
||||
Closer: response.Body,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := response.Body.Close(); err != nil {
|
||||
return fmt.Errorf("close upstream HTML response: %w", err)
|
||||
}
|
||||
if !isFullHTMLDocument(prefix) {
|
||||
response.Body = io.NopCloser(bytes.NewReader(prefix))
|
||||
response.ContentLength = int64(len(prefix))
|
||||
response.Header.Set("Content-Length", strconv.Itoa(len(prefix)))
|
||||
return nil
|
||||
}
|
||||
|
||||
contents := insertReloadClient(prefix)
|
||||
response.Body = io.NopCloser(bytes.NewReader(contents))
|
||||
response.ContentLength = int64(len(contents))
|
||||
response.Header.Set("Content-Length", strconv.Itoa(len(contents)))
|
||||
response.Header.Del("ETag")
|
||||
response.Header.Del("Last-Modified")
|
||||
adjustCSP(response.Header, "Content-Security-Policy")
|
||||
adjustCSP(response.Header, "Content-Security-Policy-Report-Only")
|
||||
return nil
|
||||
}
|
||||
|
||||
func disableDevelopmentCaching(response *http.Response) {
|
||||
response.Header.Set("Cache-Control", "no-store")
|
||||
response.Header.Set("Pragma", "no-cache")
|
||||
response.Header.Set("Expires", "0")
|
||||
response.Header.Del("ETag")
|
||||
response.Header.Del("Last-Modified")
|
||||
}
|
||||
|
||||
func eligibleHTMLResponse(response *http.Response) bool {
|
||||
if response.StatusCode != http.StatusOK || response.Request == nil || response.Body == nil || response.Request.Method == http.MethodHead {
|
||||
return false
|
||||
}
|
||||
mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
|
||||
if err != nil || !strings.EqualFold(mediaType, "text/html") {
|
||||
return false
|
||||
}
|
||||
if encoding := strings.TrimSpace(response.Header.Get("Content-Encoding")); encoding != "" && !strings.EqualFold(encoding, "identity") {
|
||||
return false
|
||||
}
|
||||
rawDisposition := strings.TrimSpace(response.Header.Get("Content-Disposition"))
|
||||
disposition, _, dispositionErr := mime.ParseMediaType(rawDisposition)
|
||||
if strings.EqualFold(disposition, "attachment") || (dispositionErr != nil && strings.HasPrefix(strings.ToLower(rawDisposition), "attachment")) {
|
||||
return false
|
||||
}
|
||||
request := response.Request
|
||||
for _, header := range []string{"HX-Request", "Turbo-Frame", "X-PJAX", "X-Requested-With"} {
|
||||
if strings.TrimSpace(request.Header.Get(header)) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(response.Header.Get("X-Himesan-Fragment"), "true") {
|
||||
return false
|
||||
}
|
||||
destination := strings.TrimSpace(request.Header.Get("Sec-Fetch-Dest"))
|
||||
return destination == "" || strings.EqualFold(destination, "document")
|
||||
}
|
||||
|
||||
func isFullHTMLDocument(contents []byte) bool {
|
||||
remaining := bytes.TrimSpace(contents)
|
||||
remaining = bytes.TrimSpace(bytes.TrimPrefix(remaining, []byte{0xef, 0xbb, 0xbf}))
|
||||
for bytes.HasPrefix(remaining, []byte("<!--")) {
|
||||
end := bytes.Index(remaining[4:], []byte("-->"))
|
||||
if end < 0 {
|
||||
return false
|
||||
}
|
||||
remaining = bytes.TrimSpace(remaining[4+end+3:])
|
||||
}
|
||||
lower := bytes.ToLower(remaining)
|
||||
return hasHTMLTokenPrefix(lower, "<!doctype html") || hasHTMLTokenPrefix(lower, "<html")
|
||||
}
|
||||
|
||||
func hasHTMLTokenPrefix(contents []byte, prefix string) bool {
|
||||
if !bytes.HasPrefix(contents, []byte(prefix)) || len(contents) == len(prefix) {
|
||||
return false
|
||||
}
|
||||
next := contents[len(prefix)]
|
||||
return next == '>' || next == '/' || next == ' ' || next == '\t' || next == '\r' || next == '\n' || next == '\f'
|
||||
}
|
||||
|
||||
func insertReloadClient(contents []byte) []byte {
|
||||
lower := bytes.ToLower(contents)
|
||||
position := bytes.LastIndex(lower, []byte("</body>"))
|
||||
if position < 0 {
|
||||
position = bytes.LastIndex(lower, []byte("</html>"))
|
||||
}
|
||||
if position < 0 {
|
||||
position = len(contents)
|
||||
}
|
||||
result := make([]byte, 0, len(contents)+len(reloadClientTag))
|
||||
result = append(result, contents[:position]...)
|
||||
result = append(result, reloadClientTag...)
|
||||
result = append(result, contents[position:]...)
|
||||
return result
|
||||
}
|
||||
|
||||
func adjustCSP(header http.Header, name string) {
|
||||
policies := header.Values(name)
|
||||
if len(policies) == 0 {
|
||||
return
|
||||
}
|
||||
header.Del(name)
|
||||
for _, policy := range policies {
|
||||
policy = addCSPSource(policy, "script-src", reloadClientHash, "default-src")
|
||||
// CSP3 gives script-src-elem precedence over script-src for an inline
|
||||
// <script>. Preserve that directive's restrictions while granting the
|
||||
// same single hash, otherwise a policy such as script-src-elem 'none'
|
||||
// silently blocks the injected reload client.
|
||||
policy = addCSPSource(policy, "script-src-elem", reloadClientHash, "script-src")
|
||||
policy = addCSPSource(policy, "connect-src", "'self'", "default-src")
|
||||
header.Add(name, policy)
|
||||
}
|
||||
}
|
||||
|
||||
func addCSPSource(policy, directive, source, fallback string) string {
|
||||
parts := strings.Split(policy, ";")
|
||||
fallbackSources := []string(nil)
|
||||
fallbackSeen := false
|
||||
for index, raw := range parts {
|
||||
fields := strings.Fields(raw)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
// CSP ignores duplicate directives after the first occurrence. Mirror
|
||||
// that rule when deriving a missing directive from its fallback so an
|
||||
// ignored, more-permissive duplicate cannot broaden the development page.
|
||||
if !fallbackSeen && strings.EqualFold(fields[0], fallback) {
|
||||
fallbackSeen = true
|
||||
fallbackSources = append([]string(nil), fields[1:]...)
|
||||
}
|
||||
if !strings.EqualFold(fields[0], directive) {
|
||||
continue
|
||||
}
|
||||
for _, existing := range fields[1:] {
|
||||
if existing == source {
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
}
|
||||
currentSources := withoutCSPNone(fields[1:])
|
||||
parts[index] = fields[0]
|
||||
if len(currentSources) != 0 {
|
||||
parts[index] += " " + strings.Join(currentSources, " ")
|
||||
}
|
||||
parts[index] += " " + source
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
if !fallbackSeen {
|
||||
// Without this directive or a default-src fallback the resource is
|
||||
// already unrestricted; introducing a directive would unnecessarily
|
||||
// restrict the application under test.
|
||||
return policy
|
||||
}
|
||||
addition := directive
|
||||
if retained := withoutCSPNone(fallbackSources); len(retained) != 0 {
|
||||
addition += " " + strings.Join(retained, " ")
|
||||
}
|
||||
addition += " " + source
|
||||
if strings.TrimSpace(policy) == "" {
|
||||
return addition
|
||||
}
|
||||
if strings.HasSuffix(strings.TrimSpace(policy), ";") {
|
||||
return policy + " " + addition
|
||||
}
|
||||
return policy + "; " + addition
|
||||
}
|
||||
|
||||
func withoutCSPNone(sources []string) []string {
|
||||
result := make([]string, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
if !strings.EqualFold(source, "'none'") {
|
||||
result = append(result, source)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type prefixedReadCloser struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInjectDevelopmentClientAndCSP(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := "<!doctype html><html><body><h1>Hello</h1></body></html>"
|
||||
request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
|
||||
request.Header.Set("Sec-Fetch-Dest", "document")
|
||||
response := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
ContentLength: int64(len(body)),
|
||||
Request: request,
|
||||
}
|
||||
response.Header.Set("Content-Type", "text/html; charset=utf-8")
|
||||
response.Header.Set("Content-Length", strconv.Itoa(len(body)))
|
||||
response.Header.Set("ETag", `"old"`)
|
||||
response.Header.Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; script-src-elem 'none'")
|
||||
|
||||
if err := injectDevelopmentClient(response); err != nil {
|
||||
t.Fatalf("injectDevelopmentClient() error = %v", err)
|
||||
}
|
||||
got, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(got), string(reloadClientTag)) {
|
||||
t.Fatalf("injected body does not contain reload client: %s", got)
|
||||
}
|
||||
if strings.Index(string(got), string(reloadClientTag)) > strings.Index(string(got), "</body>") {
|
||||
t.Fatal("reload client was not inserted inside body")
|
||||
}
|
||||
policy := response.Header.Get("Content-Security-Policy")
|
||||
if !strings.Contains(policy, reloadClientHash) || strings.Contains(policy, "unsafe-inline") {
|
||||
t.Fatalf("CSP did not contain only the reload hash allowance: %q", policy)
|
||||
}
|
||||
scriptElementPolicy := cspDirective(policy, "script-src-elem")
|
||||
if !strings.Contains(scriptElementPolicy, reloadClientHash) || strings.Contains(scriptElementPolicy, "'none'") {
|
||||
t.Fatalf("CSP script-src-elem still blocks the reload client: %q", policy)
|
||||
}
|
||||
if !strings.Contains(policy, "connect-src") || !strings.Contains(policy, "'self'") {
|
||||
t.Fatalf("CSP does not allow same-origin SSE: %q", policy)
|
||||
}
|
||||
if response.Header.Get("Cache-Control") != "no-store" || response.Header.Get("ETag") != "" {
|
||||
t.Fatalf("development cache headers = %#v", response.Header)
|
||||
}
|
||||
if response.ContentLength != int64(len(got)) {
|
||||
t.Fatalf("ContentLength = %d, want %d", response.ContentLength, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func cspDirective(policy, name string) string {
|
||||
for _, raw := range strings.Split(policy, ";") {
|
||||
fields := strings.Fields(raw)
|
||||
if len(fields) != 0 && strings.EqualFold(fields[0], name) {
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestInjectionExcludesFragmentsAndNonHTML(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
contentType string
|
||||
header string
|
||||
method string
|
||||
status int
|
||||
}{
|
||||
{name: "unmarked HTML fragment", contentType: "text/html", status: http.StatusOK},
|
||||
{name: "htmx fragment", contentType: "text/html", header: "HX-Request", status: http.StatusOK},
|
||||
{name: "turbo fragment", contentType: "text/html", header: "Turbo-Frame", status: http.StatusOK},
|
||||
{name: "json api", contentType: "application/json", status: http.StatusOK},
|
||||
{name: "HEAD response", contentType: "text/html", method: http.MethodHead, status: http.StatusOK},
|
||||
{name: "no content", contentType: "text/html", status: http.StatusNoContent},
|
||||
{name: "not modified", contentType: "text/html", status: http.StatusNotModified},
|
||||
{name: "partial content", contentType: "text/html", status: http.StatusPartialContent},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
body := "<p>fragment</p>"
|
||||
method := test.method
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
request := httptest.NewRequest(method, "http://example.test/items", nil)
|
||||
if test.header != "" {
|
||||
request.Header.Set(test.header, "true")
|
||||
}
|
||||
response := &http.Response{
|
||||
StatusCode: test.status,
|
||||
Header: http.Header{"Content-Type": []string{test.contentType}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: request,
|
||||
}
|
||||
if err := injectDevelopmentClient(response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != body {
|
||||
t.Fatalf("fragment was modified: %q", got)
|
||||
}
|
||||
if response.Header.Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("fragment caching was not disabled")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullDocumentEvidenceAndCSPNone(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := " \n<!-- generated -->\n<!DOCTYPE HTML><html><body>page</body></html>"
|
||||
request := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
|
||||
response := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/html"},
|
||||
"Content-Security-Policy": []string{"default-src 'none'"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: request,
|
||||
}
|
||||
if err := injectDevelopmentClient(response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(got), string(reloadClientTag)) {
|
||||
t.Fatal("full document with a leading comment was not injected")
|
||||
}
|
||||
policy := response.Header.Get("Content-Security-Policy")
|
||||
if strings.Contains(policy, "script-src 'none'") || strings.Contains(policy, "connect-src 'none'") {
|
||||
t.Fatalf("CSP 'none' was combined with an allowance: %q", policy)
|
||||
}
|
||||
if !strings.Contains(policy, "script-src "+reloadClientHash) || !strings.Contains(policy, "connect-src 'self'") {
|
||||
t.Fatalf("CSP missing narrow development allowances: %q", policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSPFallbackUsesFirstDuplicateDirective(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, first := range []string{"'none'", ""} {
|
||||
header := make(http.Header)
|
||||
header.Set("Content-Security-Policy", "default-src "+first+"; default-src https://ignored-attacker.example")
|
||||
adjustCSP(header, "Content-Security-Policy")
|
||||
policy := header.Get("Content-Security-Policy")
|
||||
for _, directive := range []string{"script-src", "script-src-elem"} {
|
||||
value := cspDirective(policy, directive)
|
||||
if !strings.Contains(value, reloadClientHash) || strings.Contains(value, "ignored-attacker.example") {
|
||||
t.Fatalf("%s was broadened from an ignored duplicate fallback: %q", directive, policy)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStream(t *testing.T) {
|
||||
hub := newEventHub()
|
||||
hub.publish(Event{Type: "diagnostic", Phase: "generate", Message: "broken before connect"})
|
||||
server := httptest.NewServer(http.HandlerFunc(hub.serveHTTP))
|
||||
t.Cleanup(func() {
|
||||
hub.close()
|
||||
server.Close()
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
scanner := bufio.NewScanner(response.Body)
|
||||
ready := readSSEEvent(t, scanner)
|
||||
if ready.Type != "ready" {
|
||||
t.Fatalf("first event = %#v", ready)
|
||||
}
|
||||
replayed := readSSEEvent(t, scanner)
|
||||
if replayed.Type != "diagnostic" || replayed.Message != "broken before connect" {
|
||||
t.Fatalf("replayed event = %#v", replayed)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
hub.mu.Lock()
|
||||
count := len(hub.subscribers)
|
||||
hub.mu.Unlock()
|
||||
if count != 0 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("SSE handler did not subscribe")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
hub.publish(Event{Type: "diagnostic", Phase: "generate", Message: "broken"})
|
||||
event := readSSEEvent(t, scanner)
|
||||
if event.Type != "diagnostic" || event.Phase != "generate" || event.Message != "broken" {
|
||||
t.Fatalf("streamed event = %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitingPageConnectsToEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
proxy := newDevelopmentProxy(newEventHub())
|
||||
if err := proxy.setAuthority("127.0.0.1:7331"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:7331/", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(recorder, request)
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
body, err := io.ReadAll(result.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.StatusCode != http.StatusServiceUnavailable || !strings.Contains(string(body), string(reloadClientTag)) {
|
||||
t.Fatalf("waiting response status/body = %d %q", result.StatusCode, body)
|
||||
}
|
||||
policy := result.Header.Get("Content-Security-Policy")
|
||||
if !strings.Contains(policy, reloadClientHash) || strings.Contains(policy, "unsafe-inline") {
|
||||
t.Fatalf("waiting page CSP = %q", policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopmentProxyRequiresLocalAuthorityAndSameOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
var upstreamRequests atomic.Int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
upstreamRequests.Add(1)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
proxy := newDevelopmentProxy(newEventHub())
|
||||
if err := proxy.setAuthority("127.0.0.1:7331"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := proxy.setTarget(target.Host); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
origin string
|
||||
fetchSite string
|
||||
wantStatus int
|
||||
wantForwarded bool
|
||||
}{
|
||||
{name: "IPv4 loopback", host: "127.0.0.1:7331", wantStatus: http.StatusNoContent, wantForwarded: true},
|
||||
{name: "alternate loopback", host: "127.0.0.2:7331", wantStatus: http.StatusNoContent, wantForwarded: true},
|
||||
{name: "IPv6 loopback", host: "[::1]:7331", wantStatus: http.StatusNoContent, wantForwarded: true},
|
||||
{name: "localhost", host: "localhost:7331", wantStatus: http.StatusNoContent, wantForwarded: true},
|
||||
{name: "localhost trailing dot", host: "LOCALHOST.:7331", wantStatus: http.StatusNoContent, wantForwarded: true},
|
||||
{name: "same origin", host: "localhost:7331", origin: "http://localhost:7331", fetchSite: "same-origin", wantStatus: http.StatusNoContent, wantForwarded: true},
|
||||
{name: "DNS rebinding host", host: "attacker.example:7331", wantStatus: http.StatusMisdirectedRequest},
|
||||
{name: "localhost suffix", host: "localhost.attacker.example:7331", wantStatus: http.StatusMisdirectedRequest},
|
||||
{name: "public IP host", host: "192.0.2.1:7331", wantStatus: http.StatusMisdirectedRequest},
|
||||
{name: "wrong port", host: "127.0.0.1:7332", wantStatus: http.StatusMisdirectedRequest},
|
||||
{name: "missing port", host: "127.0.0.1", wantStatus: http.StatusMisdirectedRequest},
|
||||
{name: "cross origin", host: "127.0.0.1:7331", origin: "https://attacker.example", wantStatus: http.StatusForbidden},
|
||||
{name: "different local origin", host: "127.0.0.1:7331", origin: "http://localhost:7331", wantStatus: http.StatusForbidden},
|
||||
{name: "null origin", host: "127.0.0.1:7331", origin: "null", wantStatus: http.StatusForbidden},
|
||||
{name: "cross site metadata", host: "127.0.0.1:7331", fetchSite: "cross-site", wantStatus: http.StatusForbidden},
|
||||
{name: "same site but cross origin metadata", host: "localhost:7331", fetchSite: "same-site", wantStatus: http.StatusForbidden},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
before := upstreamRequests.Load()
|
||||
request := httptest.NewRequest(http.MethodGet, "http://"+test.host+"/", nil)
|
||||
request.Host = test.host
|
||||
if test.origin != "" {
|
||||
request.Header.Set("Origin", test.origin)
|
||||
}
|
||||
if test.fetchSite != "" {
|
||||
request.Header.Set("Sec-Fetch-Site", test.fetchSite)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(recorder, request)
|
||||
if recorder.Code != test.wantStatus {
|
||||
t.Fatalf("status = %d, want %d; body = %q", recorder.Code, test.wantStatus, recorder.Body.String())
|
||||
}
|
||||
forwarded := upstreamRequests.Load() != before
|
||||
if forwarded != test.wantForwarded {
|
||||
t.Fatalf("forwarded = %v, want %v", forwarded, test.wantForwarded)
|
||||
}
|
||||
if !test.wantForwarded && recorder.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("rejection was cacheable")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopmentProxyProtectsEventStream(t *testing.T) {
|
||||
t.Parallel()
|
||||
hub := newEventHub()
|
||||
t.Cleanup(hub.close)
|
||||
proxy := newDevelopmentProxy(hub)
|
||||
if err := proxy.setAuthority("127.0.0.1:7331"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rejected := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:7331"+eventsPath, nil)
|
||||
rejected.Header.Set("Origin", "https://attacker.example")
|
||||
rejectedRecorder := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(rejectedRecorder, rejected)
|
||||
if rejectedRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-origin event stream status = %d, want %d", rejectedRecorder.Code, http.StatusForbidden)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
allowed := httptest.NewRequest(http.MethodGet, "http://localhost:7331"+eventsPath, nil).WithContext(ctx)
|
||||
allowed.Header.Set("Origin", "http://localhost:7331")
|
||||
cancel()
|
||||
allowedRecorder := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(allowedRecorder, allowed)
|
||||
if allowedRecorder.Code != http.StatusOK || !strings.Contains(allowedRecorder.Body.String(), "event: ready") {
|
||||
t.Fatalf("same-origin event stream status/body = %d %q", allowedRecorder.Code, allowedRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventHubRetainsNewestEventForSlowSubscriber(t *testing.T) {
|
||||
t.Parallel()
|
||||
hub := newEventHub()
|
||||
updates, unsubscribe := hub.subscribe()
|
||||
defer unsubscribe()
|
||||
for index := 0; index < 20; index++ {
|
||||
hub.publish(Event{Type: "diagnostic", Message: strconv.Itoa(index)})
|
||||
}
|
||||
hub.publish(Event{Type: "reload"})
|
||||
var last Event
|
||||
for len(updates) != 0 {
|
||||
last = <-updates
|
||||
}
|
||||
if last.Type != "reload" {
|
||||
t.Fatalf("newest queued event = %#v, want reload", last)
|
||||
}
|
||||
}
|
||||
|
||||
func readSSEEvent(t *testing.T, scanner *bufio.Scanner) Event {
|
||||
t.Helper()
|
||||
var data string
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
data = strings.TrimPrefix(line, "data: ")
|
||||
}
|
||||
if line == "" && data != "" {
|
||||
var event Event
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
t.Fatalf("decode SSE event: %v", err)
|
||||
}
|
||||
return event
|
||||
}
|
||||
}
|
||||
t.Fatalf("SSE stream ended: %v", scanner.Err())
|
||||
return Event{}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxDiagnosticOutput = 64 << 10
|
||||
|
||||
// GenerateFunc regenerates affected .sando.go files. The supervisor does not
|
||||
// import the compiler: the CLI supplies this hook.
|
||||
type GenerateFunc func(context.Context) error
|
||||
|
||||
// Options configures a Supervisor. Durations and output writers have safe
|
||||
// defaults when omitted.
|
||||
type Options struct {
|
||||
RootDir string
|
||||
Config Config
|
||||
Generate GenerateFunc
|
||||
MapDiagnostics func(error) []Diagnostic
|
||||
OnEvent func(Event)
|
||||
|
||||
Output io.Writer
|
||||
ErrorOutput io.Writer
|
||||
GoCommand string
|
||||
CacheDir string
|
||||
|
||||
PollInterval time.Duration
|
||||
Debounce time.Duration
|
||||
BuildTimeout time.Duration
|
||||
StartupTimeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// Supervisor owns the local proxy, watcher, build candidates, and current
|
||||
// healthy application child.
|
||||
type Supervisor struct {
|
||||
options Options
|
||||
rootDir string
|
||||
cacheDir string
|
||||
|
||||
hub *eventHub
|
||||
proxy *developmentProxy
|
||||
|
||||
running atomic.Bool
|
||||
addressMu sync.RWMutex
|
||||
address string
|
||||
}
|
||||
|
||||
// New validates and normalizes a local development supervisor without opening
|
||||
// listeners or starting processes.
|
||||
func New(options Options) (*Supervisor, error) {
|
||||
if err := options.Config.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("development config: %w", err)
|
||||
}
|
||||
if options.Generate == nil {
|
||||
return nil, errors.New("development generate hook is required")
|
||||
}
|
||||
options.Config.SourceRoots = append([]string(nil), options.Config.SourceRoots...)
|
||||
options.Config.AppArgs = append([]string(nil), options.Config.AppArgs...)
|
||||
options.Config.AdditionalWatchRoots = append([]string(nil), options.Config.AdditionalWatchRoots...)
|
||||
rootDir := options.RootDir
|
||||
if rootDir == "" {
|
||||
var err error
|
||||
rootDir, err = os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get project directory: %w", err)
|
||||
}
|
||||
}
|
||||
rootDir, err := filepath.Abs(rootDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve project directory: %w", err)
|
||||
}
|
||||
info, err := os.Stat(rootDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect project directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("project root %q is not a directory", rootDir)
|
||||
}
|
||||
|
||||
applyOptionDefaults(&options)
|
||||
cacheDir := options.CacheDir
|
||||
if cacheDir == "" {
|
||||
userCache, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("locate user cache directory: %w", err)
|
||||
}
|
||||
key := sha256.Sum256([]byte(rootDir + "\x00" + options.Config.GoPackage))
|
||||
cacheDir = filepath.Join(userCache, "himesan", "dev", hex.EncodeToString(key[:8]))
|
||||
} else if !filepath.IsAbs(cacheDir) {
|
||||
cacheDir = filepath.Join(rootDir, cacheDir)
|
||||
}
|
||||
|
||||
hub := newEventHub()
|
||||
return &Supervisor{
|
||||
options: options,
|
||||
rootDir: rootDir,
|
||||
cacheDir: filepath.Clean(cacheDir),
|
||||
hub: hub,
|
||||
proxy: newDevelopmentProxy(hub),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func applyOptionDefaults(options *Options) {
|
||||
if options.Output == nil {
|
||||
options.Output = io.Discard
|
||||
}
|
||||
if options.ErrorOutput == nil {
|
||||
options.ErrorOutput = io.Discard
|
||||
}
|
||||
if options.GoCommand == "" {
|
||||
options.GoCommand = "go"
|
||||
}
|
||||
if options.PollInterval <= 0 {
|
||||
options.PollInterval = 250 * time.Millisecond
|
||||
}
|
||||
if options.Debounce <= 0 {
|
||||
options.Debounce = 150 * time.Millisecond
|
||||
}
|
||||
if options.BuildTimeout <= 0 {
|
||||
options.BuildTimeout = 2 * time.Minute
|
||||
}
|
||||
if options.StartupTimeout <= 0 {
|
||||
options.StartupTimeout = 10 * time.Second
|
||||
}
|
||||
if options.ShutdownTimeout <= 0 {
|
||||
options.ShutdownTimeout = 5 * time.Second
|
||||
}
|
||||
if options.HTTPClient == nil {
|
||||
options.HTTPClient = &http.Client{
|
||||
Transport: &http.Transport{Proxy: nil},
|
||||
Timeout: time.Second,
|
||||
}
|
||||
} else {
|
||||
copy := *options.HTTPClient
|
||||
options.HTTPClient = ©
|
||||
}
|
||||
// Health redirects are status results, not permission to leave loopback.
|
||||
options.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
}
|
||||
|
||||
// ProxyAddress reports the bound stable proxy address after Run has opened its
|
||||
// listener. It is useful when Config.ProxyAddress requests port zero in tests.
|
||||
func (s *Supervisor) ProxyAddress() string {
|
||||
s.addressMu.RLock()
|
||||
defer s.addressMu.RUnlock()
|
||||
return s.address
|
||||
}
|
||||
|
||||
// Run serves until ctx is canceled or the stable proxy fails. A generation,
|
||||
// build, startup, or health-check failure is reported as a diagnostic event and
|
||||
// leaves the last healthy child serving.
|
||||
func (s *Supervisor) Run(ctx context.Context) error {
|
||||
if !s.running.CompareAndSwap(false, true) {
|
||||
return errors.New("development supervisor may only be run once")
|
||||
}
|
||||
if err := os.MkdirAll(s.cacheDir, 0o700); err != nil {
|
||||
return fmt.Errorf("create development cache: %w", err)
|
||||
}
|
||||
if err := os.Chmod(s.cacheDir, 0o700); err != nil {
|
||||
return fmt.Errorf("secure development cache: %w", err)
|
||||
}
|
||||
listener, err := net.Listen("tcp", s.options.Config.ProxyAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on development proxy: %w", err)
|
||||
}
|
||||
if err := s.proxy.setAuthority(listener.Addr().String()); err != nil {
|
||||
_ = listener.Close()
|
||||
return err
|
||||
}
|
||||
s.addressMu.Lock()
|
||||
s.address = listener.Addr().String()
|
||||
s.addressMu.Unlock()
|
||||
|
||||
server := &http.Server{
|
||||
Handler: s.proxy,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 75 * time.Second,
|
||||
}
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() {
|
||||
err := server.Serve(listener)
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
err = nil
|
||||
}
|
||||
serverErrors <- err
|
||||
}()
|
||||
|
||||
var current *candidateProcess
|
||||
defer func() {
|
||||
s.hub.close()
|
||||
s.proxy.closeIdleConnections()
|
||||
serverCtx, cancelServer := context.WithTimeout(context.Background(), s.options.ShutdownTimeout)
|
||||
_ = server.Shutdown(serverCtx)
|
||||
cancelServer()
|
||||
if current != nil {
|
||||
processCtx, cancelProcess := context.WithTimeout(context.Background(), s.options.ShutdownTimeout)
|
||||
_ = current.stop(processCtx)
|
||||
cancelProcess()
|
||||
}
|
||||
}()
|
||||
|
||||
s.emit(Event{Type: "ready", Phase: "proxy", Message: "http://" + listener.Addr().String()})
|
||||
if candidate := s.buildHealthyCandidate(ctx); candidate != nil {
|
||||
current = s.activateCandidate(candidate, current)
|
||||
}
|
||||
|
||||
roots := makeWatchRoots(s.rootDir, s.options.Config)
|
||||
snapshot, snapshotErr := takeSnapshot(roots)
|
||||
lastWatchError := ""
|
||||
if snapshotErr != nil {
|
||||
lastWatchError = snapshotErr.Error()
|
||||
s.report("watch", snapshotErr)
|
||||
}
|
||||
ticker := time.NewTicker(s.options.PollInterval)
|
||||
defer ticker.Stop()
|
||||
var pending bool
|
||||
var changedAt time.Time
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case err := <-serverErrors:
|
||||
if err != nil {
|
||||
return fmt.Errorf("development proxy: %w", err)
|
||||
}
|
||||
return nil
|
||||
case now := <-ticker.C:
|
||||
next, watchErr := takeSnapshot(roots)
|
||||
watchError := ""
|
||||
if watchErr != nil {
|
||||
watchError = watchErr.Error()
|
||||
}
|
||||
if watchError != "" && watchError != lastWatchError {
|
||||
s.report("watch", watchErr)
|
||||
}
|
||||
lastWatchError = watchError
|
||||
if !snapshotsEqual(snapshot, next) {
|
||||
snapshot = next
|
||||
pending = true
|
||||
changedAt = now
|
||||
}
|
||||
if current != nil && current.hasExited() {
|
||||
exitErr := current.result()
|
||||
if exitErr == nil {
|
||||
exitErr = errors.New("application exited")
|
||||
} else {
|
||||
exitErr = fmt.Errorf("application exited: %w", exitErr)
|
||||
}
|
||||
s.report("run", exitErr)
|
||||
_ = current.cleanupProcessTree()
|
||||
_ = os.Remove(current.binaryPath)
|
||||
current = nil
|
||||
}
|
||||
if pending && now.Sub(changedAt) >= s.options.Debounce {
|
||||
pending = false
|
||||
if candidate := s.buildHealthyCandidate(ctx); candidate != nil {
|
||||
current = s.activateCandidate(candidate, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Supervisor) buildHealthyCandidate(ctx context.Context) *candidateProcess {
|
||||
if err := s.options.Generate(ctx); err != nil {
|
||||
s.report("generate", err)
|
||||
return nil
|
||||
}
|
||||
binaryPath, err := s.build(ctx)
|
||||
if err != nil {
|
||||
s.report("build", err)
|
||||
return nil
|
||||
}
|
||||
candidate, err := s.startAndCheck(ctx, binaryPath)
|
||||
if err != nil {
|
||||
_ = os.Remove(binaryPath)
|
||||
s.report("startup", err)
|
||||
return nil
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
func (s *Supervisor) build(ctx context.Context) (string, error) {
|
||||
buildCtx, cancel := context.WithTimeout(ctx, s.options.BuildTimeout)
|
||||
defer cancel()
|
||||
template := "candidate-*"
|
||||
if runtime.GOOS == "windows" {
|
||||
template += ".exe"
|
||||
}
|
||||
placeholder, err := os.CreateTemp(s.cacheDir, template)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reserve candidate binary: %w", err)
|
||||
}
|
||||
binaryPath := placeholder.Name()
|
||||
if err := placeholder.Close(); err != nil {
|
||||
_ = os.Remove(binaryPath)
|
||||
return "", fmt.Errorf("close candidate placeholder: %w", err)
|
||||
}
|
||||
if err := os.Remove(binaryPath); err != nil {
|
||||
return "", fmt.Errorf("prepare candidate binary: %w", err)
|
||||
}
|
||||
command := exec.CommandContext(buildCtx, s.options.GoCommand, "build", "-o", binaryPath, "--", s.options.Config.GoPackage)
|
||||
command.Dir = s.rootDir
|
||||
var diagnostics limitedDiagnosticBuffer
|
||||
command.Stdout = io.MultiWriter(s.options.Output, &diagnostics)
|
||||
command.Stderr = io.MultiWriter(s.options.ErrorOutput, &diagnostics)
|
||||
if err := command.Run(); err != nil {
|
||||
_ = os.Remove(binaryPath)
|
||||
message := diagnostics.String()
|
||||
if message == "" {
|
||||
message = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("go build failed: %s", message)
|
||||
}
|
||||
return binaryPath, nil
|
||||
}
|
||||
|
||||
func (s *Supervisor) startAndCheck(ctx context.Context, binaryPath string) (*candidateProcess, error) {
|
||||
address, err := unusedLoopbackAddress()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
command := exec.Command(binaryPath, s.options.Config.AppArgs...)
|
||||
command.Dir = s.rootDir
|
||||
command.Env = replaceEnvironment(os.Environ(), s.options.Config.ListenAddressEnv, address)
|
||||
command.Stdout = s.options.Output
|
||||
command.Stderr = s.options.ErrorOutput
|
||||
candidate, err := startManagedProcess(command, address, binaryPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("start candidate: %w", err)
|
||||
}
|
||||
startupCtx, cancel := context.WithTimeout(ctx, s.options.StartupTimeout)
|
||||
defer cancel()
|
||||
if err := s.waitUntilHealthy(startupCtx, candidate); err != nil {
|
||||
stopCtx, stopCancel := context.WithTimeout(context.Background(), s.options.ShutdownTimeout)
|
||||
defer stopCancel()
|
||||
_ = candidate.stop(stopCtx)
|
||||
return nil, err
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
func unusedLoopbackAddress() (string, error) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reserve candidate address: %w", err)
|
||||
}
|
||||
address := listener.Addr().String()
|
||||
if err := listener.Close(); err != nil {
|
||||
return "", fmt.Errorf("release candidate address: %w", err)
|
||||
}
|
||||
return address, nil
|
||||
}
|
||||
|
||||
func (s *Supervisor) waitUntilHealthy(ctx context.Context, candidate *candidateProcess) error {
|
||||
url := "http://" + candidate.address + s.options.Config.HealthPath
|
||||
ticker := time.NewTicker(75 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
var lastError error
|
||||
for {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create health request: %w", err)
|
||||
}
|
||||
response, err := s.options.HTTPClient.Do(request)
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10))
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode >= 200 && response.StatusCode < 400 {
|
||||
return nil
|
||||
}
|
||||
lastError = fmt.Errorf("health endpoint returned %s", response.Status)
|
||||
} else {
|
||||
lastError = err
|
||||
}
|
||||
select {
|
||||
case <-candidate.exited:
|
||||
exitErr := candidate.result()
|
||||
if exitErr == nil {
|
||||
return errors.New("candidate exited before becoming healthy")
|
||||
}
|
||||
return fmt.Errorf("candidate exited before becoming healthy: %w", exitErr)
|
||||
case <-ctx.Done():
|
||||
if lastError == nil {
|
||||
lastError = ctx.Err()
|
||||
}
|
||||
return fmt.Errorf("candidate did not become healthy: %w", lastError)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Supervisor) activateCandidate(candidate, previous *candidateProcess) *candidateProcess {
|
||||
if err := s.proxy.setTarget(candidate.address); err != nil {
|
||||
s.report("proxy", err)
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), s.options.ShutdownTimeout)
|
||||
defer cancel()
|
||||
_ = candidate.stop(stopCtx)
|
||||
return previous
|
||||
}
|
||||
s.emit(Event{Type: "reload", Phase: "serve", Message: "healthy application activated"})
|
||||
if previous != nil {
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), s.options.ShutdownTimeout)
|
||||
_ = previous.stop(stopCtx)
|
||||
cancel()
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
func (s *Supervisor) emit(event Event) {
|
||||
if event.At.IsZero() {
|
||||
event.At = time.Now().UTC()
|
||||
}
|
||||
s.hub.publish(event)
|
||||
if s.options.OnEvent != nil {
|
||||
s.options.OnEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Supervisor) report(phase string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
event := Event{Type: "diagnostic", Phase: phase, Message: truncateDiagnostic(err.Error())}
|
||||
if s.options.MapDiagnostics != nil {
|
||||
event.Diagnostics = s.options.MapDiagnostics(err)
|
||||
}
|
||||
s.emit(event)
|
||||
}
|
||||
|
||||
func replaceEnvironment(environment []string, name, value string) []string {
|
||||
result := make([]string, 0, len(environment)+1)
|
||||
for _, item := range environment {
|
||||
itemName, _, ok := strings.Cut(item, "=")
|
||||
matches := ok && itemName == name
|
||||
if runtime.GOOS == "windows" {
|
||||
matches = ok && strings.EqualFold(itemName, name)
|
||||
}
|
||||
if matches {
|
||||
continue
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return append(result, name+"="+value)
|
||||
}
|
||||
|
||||
func truncateDiagnostic(message string) string {
|
||||
message = strings.TrimSpace(message)
|
||||
if len(message) <= maxDiagnosticOutput {
|
||||
return message
|
||||
}
|
||||
return strings.ToValidUTF8(message[:maxDiagnosticOutput], "�") + "\n… diagnostic output truncated"
|
||||
}
|
||||
|
||||
type limitedDiagnosticBuffer struct {
|
||||
bytes.Buffer
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (b *limitedDiagnosticBuffer) Write(contents []byte) (int, error) {
|
||||
originalLength := len(contents)
|
||||
remaining := maxDiagnosticOutput - b.Buffer.Len()
|
||||
writtenLength := 0
|
||||
if remaining > 0 {
|
||||
if len(contents) > remaining {
|
||||
contents = contents[:remaining]
|
||||
}
|
||||
writtenLength, _ = b.Buffer.Write(contents)
|
||||
}
|
||||
if originalLength > writtenLength {
|
||||
b.truncated = true
|
||||
}
|
||||
return originalLength, nil
|
||||
}
|
||||
|
||||
func (b *limitedDiagnosticBuffer) String() string {
|
||||
message := strings.TrimSpace(strings.ToValidUTF8(b.Buffer.String(), "�"))
|
||||
if b.truncated {
|
||||
message += "\n… diagnostic output truncated"
|
||||
}
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSupervisorBuildsSwapsAndCleansUp(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("integration test builds temporary Go applications")
|
||||
}
|
||||
root := t.TempDir()
|
||||
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)
|
||||
}
|
||||
mainPath := filepath.Join(root, "main.go")
|
||||
writeTestApplication(t, mainPath, "version one", true)
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.ProxyAddress = "127.0.0.1:0"
|
||||
cfg.HealthPath = "/healthz"
|
||||
var generations atomic.Int32
|
||||
events := make(chan Event, 32)
|
||||
supervisor, err := New(Options{
|
||||
RootDir: root,
|
||||
Config: cfg,
|
||||
Generate: func(context.Context) error {
|
||||
generations.Add(1)
|
||||
return nil
|
||||
},
|
||||
OnEvent: func(event Event) { events <- event },
|
||||
CacheDir: filepath.Join(t.TempDir(), "cache"),
|
||||
PollInterval: 25 * time.Millisecond,
|
||||
Debounce: 25 * time.Millisecond,
|
||||
BuildTimeout: 30 * time.Second,
|
||||
StartupTimeout: 750 * time.Millisecond,
|
||||
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+"/", "version one")
|
||||
firstUpstream := supervisor.proxy.target.Load().Host
|
||||
|
||||
if err := os.WriteFile(mainPath, []byte("package main\nfunc"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForPhase(t, events, "build")
|
||||
waitForBody(t, "http://"+proxyAddress+"/", "version one")
|
||||
|
||||
writeTestApplication(t, mainPath, "unhealthy candidate", false)
|
||||
waitForPhase(t, events, "startup")
|
||||
waitForBody(t, "http://"+proxyAddress+"/", "version one")
|
||||
|
||||
writeTestApplication(t, mainPath, "version two", true)
|
||||
waitForBody(t, "http://"+proxyAddress+"/", "version two")
|
||||
if generations.Load() < 4 {
|
||||
t.Fatalf("Generate hook ran %d times, want at least 4", generations.Load())
|
||||
}
|
||||
secondUpstream := supervisor.proxy.target.Load().Host
|
||||
if firstUpstream == secondUpstream {
|
||||
t.Fatalf("healthy candidate was not swapped: %s", firstUpstream)
|
||||
}
|
||||
// The proxy target changes before graceful shutdown of the replaced child
|
||||
// completes. Wait for that bounded cleanup instead of racing the supervisor
|
||||
// immediately after the first response from the new target.
|
||||
waitForConnectionRefused(t, firstUpstream, 3*time.Second)
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-runResult:
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Run() did not stop after cancellation")
|
||||
}
|
||||
waitForConnectionRefused(t, secondUpstream, 3*time.Second)
|
||||
}
|
||||
|
||||
func TestGenerationFailureDoesNotMoveProxyTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
upstream := http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "last good")
|
||||
})}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = upstream.Close() })
|
||||
go func() { _ = upstream.Serve(listener) }()
|
||||
|
||||
wantError := errors.New("templates are invalid")
|
||||
events := make(chan Event, 1)
|
||||
supervisor, err := New(Options{
|
||||
RootDir: t.TempDir(),
|
||||
Config: DefaultConfig(),
|
||||
Generate: func(context.Context) error {
|
||||
return wantError
|
||||
},
|
||||
OnEvent: func(event Event) { events <- event },
|
||||
CacheDir: filepath.Join(t.TempDir(), "cache"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := supervisor.proxy.setTarget(listener.Addr().String()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTarget := supervisor.proxy.target.Load().String()
|
||||
if candidate := supervisor.buildHealthyCandidate(context.Background()); candidate != nil {
|
||||
t.Fatal("generation failure unexpectedly produced a candidate")
|
||||
}
|
||||
if got := supervisor.proxy.target.Load().String(); got != wantTarget {
|
||||
t.Fatalf("proxy target changed from %q to %q", wantTarget, got)
|
||||
}
|
||||
select {
|
||||
case event := <-events:
|
||||
if event.Type != "diagnostic" || event.Phase != "generate" || !strings.Contains(event.Message, wantError.Error()) {
|
||||
t.Fatalf("generation event = %#v", event)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("generation diagnostic was not emitted")
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestApplication(t *testing.T, path, message string, healthy bool) {
|
||||
t.Helper()
|
||||
healthStatus := "http.StatusNoContent"
|
||||
if !healthy {
|
||||
healthStatus = "http.StatusServiceUnavailable"
|
||||
}
|
||||
contents := fmt.Sprintf(`package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(%s) })
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, "<!doctype html><html><body>%s</body></html>")
|
||||
})
|
||||
if err := http.ListenAndServe(os.Getenv("HIMESAN_LISTEN_ADDR"), mux); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
`, healthStatus, message)
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPhase(t *testing.T, events <-chan Event, phase string) {
|
||||
t.Helper()
|
||||
timer := time.NewTimer(10 * time.Second)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case event := <-events:
|
||||
if event.Type == "diagnostic" && event.Phase == phase {
|
||||
return
|
||||
}
|
||||
case <-timer.C:
|
||||
t.Fatalf("did not receive %s diagnostic", phase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForProxyAddress(t *testing.T, supervisor *Supervisor) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if address := supervisor.ProxyAddress(); address != "" {
|
||||
return address
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("development proxy did not start")
|
||||
return ""
|
||||
}
|
||||
|
||||
func waitForBody(t *testing.T, url, want string) {
|
||||
t.Helper()
|
||||
client := &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: time.Second}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
var last string
|
||||
for time.Now().Before(deadline) {
|
||||
response, err := client.Get(url)
|
||||
if err == nil {
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
_ = response.Body.Close()
|
||||
if readErr == nil {
|
||||
last = string(body)
|
||||
if response.StatusCode == http.StatusOK && strings.Contains(last, want) && strings.Contains(last, string(reloadClientTag)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("proxy never served %q; last body = %q", want, last)
|
||||
}
|
||||
|
||||
func waitForConnectionRefused(t *testing.T, address string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
connection, err := net.DialTimeout("tcp", address, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = connection.Close()
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("replaced child still accepts connections at %s after %s", address, timeout)
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitedDiagnosticBuffer(t *testing.T) {
|
||||
t.Parallel()
|
||||
var buffer limitedDiagnosticBuffer
|
||||
contents := strings.Repeat("x", maxDiagnosticOutput+100)
|
||||
written, err := buffer.Write([]byte(contents))
|
||||
if err != nil || written != len(contents) {
|
||||
t.Fatalf("Write() = %d, %v", written, err)
|
||||
}
|
||||
if buffer.Buffer.Len() != maxDiagnosticOutput {
|
||||
t.Fatalf("stored bytes = %d, want %d", buffer.Buffer.Len(), maxDiagnosticOutput)
|
||||
}
|
||||
if !strings.HasSuffix(buffer.String(), "diagnostic output truncated") {
|
||||
t.Fatalf("String() did not report truncation: %q", buffer.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceEnvironment(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := replaceEnvironment([]string{"A=one", "HIMESAN_LISTEN_ADDR=old", "B=two"}, "HIMESAN_LISTEN_ADDR", "127.0.0.1:1")
|
||||
want := []string{"A=one", "B=two", "HIMESAN_LISTEN_ADDR=127.0.0.1:1"}
|
||||
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
|
||||
t.Fatalf("replaceEnvironment() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheckDoesNotFollowRedirectOffLoopback(t *testing.T) {
|
||||
t.Parallel()
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Location", "http://example.invalid/escaped")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
supervisor, err := New(Options{
|
||||
RootDir: t.TempDir(),
|
||||
Config: cfg,
|
||||
Generate: func(context.Context) error { return nil },
|
||||
CacheDir: filepath.Join(t.TempDir(), "cache"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
candidate := &candidateProcess{
|
||||
address: strings.TrimPrefix(upstream.URL, "http://"),
|
||||
exited: make(chan struct{}),
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := supervisor.waitUntilHealthy(ctx, candidate); err != nil {
|
||||
t.Fatalf("loopback redirect status should be observed without following it: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type watchRoot struct {
|
||||
path string
|
||||
allRegular bool
|
||||
}
|
||||
|
||||
type fileFingerprint struct {
|
||||
size int64
|
||||
mode fs.FileMode
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
type fileSnapshot map[string]fileFingerprint
|
||||
|
||||
func makeWatchRoots(rootDir string, cfg Config) []watchRoot {
|
||||
roots := make([]watchRoot, 0, 1+len(cfg.SourceRoots)+len(cfg.AdditionalWatchRoots))
|
||||
// GoPackage may live outside a narrowly configured template source root.
|
||||
// Watching the containing module (while respecting nested module boundaries)
|
||||
// makes ordinary Go edits rebuild without asking users to duplicate roots.
|
||||
rootDir = filepath.Clean(rootDir)
|
||||
roots = append(roots, watchRoot{path: rootDir})
|
||||
seen := map[string]bool{rootDir: true}
|
||||
for _, root := range cfg.SourceRoots {
|
||||
path := resolveProjectPath(rootDir, root)
|
||||
if !seen[path] {
|
||||
roots = append(roots, watchRoot{path: path})
|
||||
seen[path] = true
|
||||
}
|
||||
}
|
||||
for _, root := range cfg.AdditionalWatchRoots {
|
||||
path := resolveProjectPath(rootDir, root)
|
||||
if seen[path] {
|
||||
for index := range roots {
|
||||
if roots[index].path == path {
|
||||
roots[index].allRegular = true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
roots = append(roots, watchRoot{path: path, allRegular: true})
|
||||
seen[path] = true
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func takeSnapshot(roots []watchRoot) (fileSnapshot, error) {
|
||||
snapshot := make(fileSnapshot)
|
||||
var problems []error
|
||||
for _, root := range roots {
|
||||
rootInfo, err := os.Lstat(root.path)
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Errorf("watch %s: %w", root.path, err))
|
||||
continue
|
||||
}
|
||||
if rootInfo.Mode()&os.ModeSymlink != 0 {
|
||||
problems = append(problems, fmt.Errorf("watch %s: symbolic-link roots are not followed", root.path))
|
||||
continue
|
||||
}
|
||||
err = filepath.WalkDir(root.path, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
problems = append(problems, fmt.Errorf("watch %s: %w", path, walkErr))
|
||||
if entry != nil && entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if path != root.path && entry.IsDir() {
|
||||
if shouldSkipWatchDirectory(entry.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if !root.allRegular {
|
||||
if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 || entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(entry.Name()), ".sando.go") {
|
||||
return nil
|
||||
}
|
||||
if !root.allRegular && !isDevelopmentSource(path) {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Errorf("watch %s: %w", path, err))
|
||||
return nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
snapshot[path] = fileFingerprint{
|
||||
size: info.Size(),
|
||||
mode: info.Mode(),
|
||||
modTime: info.ModTime(),
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Errorf("watch %s: %w", root.path, err))
|
||||
}
|
||||
}
|
||||
return snapshot, errors.Join(problems...)
|
||||
}
|
||||
|
||||
func shouldSkipWatchDirectory(name string) bool {
|
||||
switch name {
|
||||
case ".git", ".hg", ".svn", ".himesan", "node_modules", "vendor":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isDevelopmentSource(path string) bool {
|
||||
name := filepath.Base(path)
|
||||
// Generated output is rebuilt from its .sando source and is therefore not a
|
||||
// separate watch trigger. Excluding it prevents generation from causing a
|
||||
// redundant build while still preserving edits made during an active build.
|
||||
if strings.HasSuffix(strings.ToLower(name), ".sando.go") {
|
||||
return false
|
||||
}
|
||||
switch name {
|
||||
case "go.mod", "go.sum", "go.work", "go.work.sum", "himesan.json":
|
||||
return true
|
||||
}
|
||||
extension := strings.ToLower(filepath.Ext(name))
|
||||
return extension == ".go" || extension == ".sando"
|
||||
}
|
||||
|
||||
func snapshotsEqual(left, right fileSnapshot) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for path, leftFingerprint := range left {
|
||||
if rightFingerprint, ok := right[path]; !ok || rightFingerprint != leftFingerprint {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package devserver
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSnapshotWatchesSourcesAssetsAndStopsAtNestedModules(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
writeWatchFile(t, filepath.Join(root, "main.go"), "package main")
|
||||
writeWatchFile(t, filepath.Join(root, "views", "home.sando"), "<h1>home</h1>")
|
||||
writeWatchFile(t, filepath.Join(root, "views", "home.sando.go"), "// generated")
|
||||
writeWatchFile(t, filepath.Join(root, "notes.txt"), "not watched")
|
||||
writeWatchFile(t, filepath.Join(root, "assets", "site.css"), "body{}")
|
||||
writeWatchFile(t, filepath.Join(root, "nested", "go.mod"), "module nested.test")
|
||||
writeWatchFile(t, filepath.Join(root, "nested", "ignored.go"), "package ignored")
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.SourceRoots = []string{"views"}
|
||||
cfg.AdditionalWatchRoots = []string{"assets"}
|
||||
snapshot, err := takeSnapshot(makeWatchRoots(root, cfg))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"main.go", filepath.Join("views", "home.sando"), filepath.Join("assets", "site.css")} {
|
||||
if _, ok := snapshot[filepath.Join(root, want)]; !ok {
|
||||
t.Errorf("snapshot does not contain %s", want)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"notes.txt", filepath.Join("views", "home.sando.go"), filepath.Join("nested", "go.mod"), filepath.Join("nested", "ignored.go")} {
|
||||
if _, ok := snapshot[filepath.Join(root, unwanted)]; ok {
|
||||
t.Errorf("snapshot unexpectedly contains %s", unwanted)
|
||||
}
|
||||
}
|
||||
|
||||
before := snapshot
|
||||
writeWatchFile(t, filepath.Join(root, "assets", "site.css"), "body{color:green}")
|
||||
after, err := takeSnapshot(makeWatchRoots(root, cfg))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshotsEqual(before, after) {
|
||||
t.Fatal("asset change was not detected")
|
||||
}
|
||||
}
|
||||
|
||||
func writeWatchFile(t *testing.T, path, contents string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Package version contains build identifiers shared by the compiler and CLI.
|
||||
package version
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const developmentCompilerVersion = "0.1.0-dev"
|
||||
|
||||
// Compiler may be replaced in release binaries with
|
||||
// `-X gamertan.com/sandwich-hime/internal/version.Compiler=vX.Y.Z`.
|
||||
// A versioned `go install module/package@vX.Y.Z` instead supplies the main
|
||||
// module version through Go build information; init adopts that version when
|
||||
// no explicit linker value was provided.
|
||||
var Compiler = developmentCompilerVersion
|
||||
|
||||
var (
|
||||
taggedCompilerVersionPattern = 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-]+)*))?$`)
|
||||
pseudoVersionSuffixPattern = regexp.MustCompile(`(?:^|[.-])(?:0\.)?[0-9]{14}-[0-9a-f]{12,}$`)
|
||||
)
|
||||
|
||||
func init() {
|
||||
if information, ok := debug.ReadBuildInfo(); ok {
|
||||
Compiler = selectCompilerVersion(Compiler, information.Main.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func selectCompilerVersion(linkerValue, moduleVersion string) string {
|
||||
if linkerValue != developmentCompilerVersion {
|
||||
return linkerValue
|
||||
}
|
||||
moduleVersion = strings.TrimSpace(moduleVersion)
|
||||
if !isTaggedCompilerVersion(moduleVersion) {
|
||||
return linkerValue
|
||||
}
|
||||
return moduleVersion
|
||||
}
|
||||
|
||||
func isTaggedCompilerVersion(value string) bool {
|
||||
matches := taggedCompilerVersionPattern.FindStringSubmatch(value)
|
||||
if matches == nil {
|
||||
// Build metadata is deliberately excluded. In particular, local VCS
|
||||
// builds can carry +dirty and must remain development builds.
|
||||
return false
|
||||
}
|
||||
prerelease := matches[1]
|
||||
if pseudoVersionSuffixPattern.MatchString(prerelease) {
|
||||
// Go synthesizes valid-semver pseudo-versions for local VCS builds. They
|
||||
// identify source, but they are not signed/tagged Hime-san releases.
|
||||
return false
|
||||
}
|
||||
for _, identifier := range strings.Split(prerelease, ".") {
|
||||
if len(identifier) > 1 && identifier[0] == '0' && allDecimal(identifier) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func allDecimal(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < '0' || character > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const (
|
||||
// RuntimeABI identifies the generated-code/runtime contract.
|
||||
RuntimeABI = "sando.v1"
|
||||
|
||||
// ConfigSchema is the supported himesan.json schema version.
|
||||
ConfigSchema = 1
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package version
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSelectCompilerVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
linkerValue string
|
||||
moduleVersion string
|
||||
want string
|
||||
}{
|
||||
{name: "local build", linkerValue: developmentCompilerVersion, moduleVersion: "(devel)", want: developmentCompilerVersion},
|
||||
{name: "missing build info", linkerValue: developmentCompilerVersion, moduleVersion: "", want: developmentCompilerVersion},
|
||||
{name: "versioned go install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0", want: "v1.0.0"},
|
||||
{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},
|
||||
{name: "pseudo version after release", linkerValue: developmentCompilerVersion, moduleVersion: "v1.2.4-0.20260811120000-0123456789ab", want: developmentCompilerVersion},
|
||||
{name: "pseudo version after prerelease", linkerValue: developmentCompilerVersion, moduleVersion: "v1.2.3-rc.1.0.20260811120000-0123456789ab", want: developmentCompilerVersion},
|
||||
{name: "dirty pseudo version", linkerValue: developmentCompilerVersion, moduleVersion: "v0.0.0-20260811123456-fedcba987654+dirty", want: developmentCompilerVersion},
|
||||
{name: "dirty release checkout", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0+dirty", want: developmentCompilerVersion},
|
||||
{name: "build metadata", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0+build.1", want: developmentCompilerVersion},
|
||||
{name: "leading zero release", linkerValue: developmentCompilerVersion, moduleVersion: "v01.0.0", want: developmentCompilerVersion},
|
||||
{name: "leading zero numeric prerelease", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-rc.01", want: developmentCompilerVersion},
|
||||
{name: "linker override wins", linkerValue: "v1.0.0-rc.1", moduleVersion: "v1.0.0", want: "v1.0.0-rc.1"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := selectCompilerVersion(test.linkerValue, test.moduleVersion); got != test.want {
|
||||
t.Fatalf("selectCompilerVersion(%q, %q) = %q, want %q", test.linkerValue, test.moduleVersion, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
SPDX-FileCopyrightText: 2025-2026 Cole Speelman
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
Original work in the Sandwich Hime sando runtime is Copyright (c) 2025-2026
|
||||
Cole Speelman except where a file or contribution identifies a different
|
||||
copyright holder. The project requires no copyright assignment; ownership of
|
||||
a contribution remains determined by applicable law and existing agreements.
|
||||
|
||||
This file is an ownership record, not an Apache NOTICE file and not an
|
||||
additional license term. The runtime is licensed under the Apache License,
|
||||
Version 2.0, in LICENSE.
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,19 @@
|
||||
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
||||
|
||||
# sando runtime
|
||||
|
||||
`gamertan.com/sandwich-hime/sando` is the independent Apache-2.0 runtime ABI for Go generated by Sandwich Hime. It contains no compiler, router, HTTP server, middleware, reflection registry, project discovery, or development supervisor.
|
||||
|
||||
The stable surface is:
|
||||
|
||||
```go
|
||||
type Component interface {
|
||||
Render(context.Context, io.Writer) error
|
||||
}
|
||||
|
||||
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 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,87 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package sando is the small production runtime for code generated by Hime-san.
|
||||
// It contains rendering contracts and context-specific output helpers, but no
|
||||
// router, HTTP server, middleware, or development tooling.
|
||||
package sando
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// ABI identifies the generated-code contract implemented by this version of
|
||||
// the runtime. Generated files should reference this constant so that their
|
||||
// expected ABI is visible to readers and tooling.
|
||||
const ABI = "sando.v1"
|
||||
|
||||
// RuntimeABI is a descriptive alias for ABI.
|
||||
const RuntimeABI = ABI
|
||||
|
||||
var (
|
||||
// ErrNilComponent is returned when Render is asked to render a nil
|
||||
// component, including a typed nil held in a Component interface.
|
||||
ErrNilComponent = errors.New("sando: nil component")
|
||||
// ErrNilContext is returned when a component is rendered with a nil
|
||||
// context.Context.
|
||||
ErrNilContext = errors.New("sando: nil context")
|
||||
// ErrNilWriter is returned when a component is rendered with a nil writer.
|
||||
ErrNilWriter = errors.New("sando: nil writer")
|
||||
)
|
||||
|
||||
// Component is the complete production rendering contract. Components are
|
||||
// values rather than HTTP handlers so applications retain ownership of
|
||||
// buffering, routing, headers, status codes, and error policy.
|
||||
type Component interface {
|
||||
Render(context.Context, io.Writer) error
|
||||
}
|
||||
|
||||
// ComponentFunc adapts a function to Component.
|
||||
type ComponentFunc func(context.Context, io.Writer) error
|
||||
|
||||
// Render calls f with ctx and w.
|
||||
func (f ComponentFunc) Render(ctx context.Context, w io.Writer) error {
|
||||
if f == nil {
|
||||
return ErrNilComponent
|
||||
}
|
||||
if ctx == nil {
|
||||
return ErrNilContext
|
||||
}
|
||||
if isNil(w) {
|
||||
return ErrNilWriter
|
||||
}
|
||||
return f(ctx, w)
|
||||
}
|
||||
|
||||
// Render renders component into w. It reports nil inputs as errors rather than
|
||||
// panicking, including typed nil component and writer values.
|
||||
func Render(ctx context.Context, w io.Writer, component Component) error {
|
||||
if ctx == nil {
|
||||
return ErrNilContext
|
||||
}
|
||||
if isNil(w) {
|
||||
return ErrNilWriter
|
||||
}
|
||||
if isNil(component) {
|
||||
return ErrNilComponent
|
||||
}
|
||||
return component.Render(ctx, w)
|
||||
}
|
||||
|
||||
// isNil recognizes typed nil values stored in interfaces. It is intentionally
|
||||
// confined to API boundary validation and is not a component registry.
|
||||
func isNil(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(value)
|
||||
switch rv.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
return rv.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package sando_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"gamertan.com/sandwich-hime/sando"
|
||||
)
|
||||
|
||||
func TestRender(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
component := sando.ComponentFunc(func(ctx context.Context, w io.Writer) error {
|
||||
_, err := io.WriteString(w, ctx.Value(contextKey{}).(string))
|
||||
return err
|
||||
})
|
||||
ctx := context.WithValue(context.Background(), contextKey{}, "rendered")
|
||||
var output bytes.Buffer
|
||||
|
||||
if err := sando.Render(ctx, &output, component); err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
if got, want := output.String(), "rendered"; got != want {
|
||||
t.Fatalf("Render() output = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPropagatesComponentError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := errors.New("render failed")
|
||||
component := sando.ComponentFunc(func(context.Context, io.Writer) error { return want })
|
||||
if got := sando.Render(context.Background(), io.Discard, component); !errors.Is(got, want) {
|
||||
t.Fatalf("Render() error = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRejectsNilInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
valid := sando.ComponentFunc(func(context.Context, io.Writer) error { return nil })
|
||||
var nilFunc sando.ComponentFunc
|
||||
var nilPointer *pointerComponent
|
||||
var nilWriter *bytes.Buffer
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
writer io.Writer
|
||||
component sando.Component
|
||||
want error
|
||||
}{
|
||||
{name: "nil context", writer: io.Discard, component: valid, want: sando.ErrNilContext},
|
||||
{name: "nil writer", ctx: context.Background(), component: valid, want: sando.ErrNilWriter},
|
||||
{name: "typed nil writer", ctx: context.Background(), writer: nilWriter, component: valid, want: sando.ErrNilWriter},
|
||||
{name: "nil component", ctx: context.Background(), writer: io.Discard, want: sando.ErrNilComponent},
|
||||
{name: "nil component func", ctx: context.Background(), writer: io.Discard, component: nilFunc, want: sando.ErrNilComponent},
|
||||
{name: "typed nil component", ctx: context.Background(), writer: io.Discard, component: nilPointer, want: sando.ErrNilComponent},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := sando.Render(test.ctx, test.writer, test.component); !errors.Is(got, test.want) {
|
||||
t.Fatalf("Render() error = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponentFuncDirectValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var nilFunc sando.ComponentFunc
|
||||
if got := nilFunc.Render(context.Background(), io.Discard); !errors.Is(got, sando.ErrNilComponent) {
|
||||
t.Fatalf("nil ComponentFunc.Render() error = %v", got)
|
||||
}
|
||||
|
||||
valid := sando.ComponentFunc(func(context.Context, io.Writer) error { return nil })
|
||||
if got := valid.Render(nil, io.Discard); !errors.Is(got, sando.ErrNilContext) {
|
||||
t.Fatalf("ComponentFunc.Render(nil, writer) error = %v", got)
|
||||
}
|
||||
if got := valid.Render(context.Background(), nil); !errors.Is(got, sando.ErrNilWriter) {
|
||||
t.Fatalf("ComponentFunc.Render(ctx, nil) error = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
type pointerComponent struct{}
|
||||
|
||||
func (*pointerComponent) Render(context.Context, io.Writer) error { return nil }
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
module gamertan.com/sandwich-hime/sando
|
||||
|
||||
go 1.25
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package sando
|
||||
|
||||
// TrustedHTML is HTML whose author has deliberately accepted responsibility
|
||||
// for its safety. Its contents are opaque outside this package.
|
||||
type TrustedHTML struct{ value string }
|
||||
|
||||
// TrustHTML marks value as deliberately trusted HTML. The caller is responsible
|
||||
// for supplying a balanced fragment that is valid at an HTML content boundary
|
||||
// and does not leave the parser in a different context. Prefer ordinary values
|
||||
// and WriteText whenever possible.
|
||||
func TrustHTML(value string) TrustedHTML { return TrustedHTML{value: value} }
|
||||
|
||||
// TrustedURL is a URL whose author has deliberately accepted responsibility
|
||||
// for its scheme and navigation behavior. HTML attribute syntax is still
|
||||
// escaped when the value is written.
|
||||
type TrustedURL struct{ value string }
|
||||
|
||||
// TrustURL marks value as a deliberately trusted URL, bypassing the ordinary
|
||||
// URL scheme allowlist. It does not bypass HTML attribute escaping.
|
||||
func TrustURL(value string) TrustedURL { return TrustedURL{value: value} }
|
||||
|
||||
// TrustedJS is JavaScript whose author has deliberately accepted
|
||||
// responsibility for its safety. Its contents are opaque outside this package.
|
||||
type TrustedJS struct{ value string }
|
||||
|
||||
// TrustJS marks value as deliberately trusted JavaScript. The caller is also
|
||||
// responsible for preventing an HTML </script sequence from terminating its
|
||||
// container. Plain strings cannot be passed to WriteJS.
|
||||
func TrustJS(value string) TrustedJS { return TrustedJS{value: value} }
|
||||
|
||||
// TrustedCSS is CSS whose author has deliberately accepted responsibility for
|
||||
// its safety. Its contents are opaque outside this package.
|
||||
type TrustedCSS struct{ value string }
|
||||
|
||||
// TrustCSS marks value as deliberately trusted CSS. The caller is also
|
||||
// responsible for preventing an HTML </style sequence from terminating its
|
||||
// container. Plain strings cannot be passed to WriteCSS.
|
||||
func TrustCSS(value string) TrustedCSS { return TrustedCSS{value: value} }
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package sando
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ErrUnsafeURL is the sentinel wrapped by URLSafetyError when an ordinary URL
|
||||
// value is ambiguous or uses a scheme outside the v1 allowlist.
|
||||
var ErrUnsafeURL = errors.New("sando: unsafe URL")
|
||||
|
||||
// URLSafetyError reports why an ordinary URL was rejected. It intentionally
|
||||
// does not retain or print the complete value, which may contain sensitive
|
||||
// application data.
|
||||
type URLSafetyError struct {
|
||||
Scheme string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Error implements error.
|
||||
func (e *URLSafetyError) Error() string {
|
||||
if e.Scheme != "" {
|
||||
return fmt.Sprintf("%v: scheme %q is not allowed", ErrUnsafeURL, e.Scheme)
|
||||
}
|
||||
if e.Reason != "" {
|
||||
return fmt.Sprintf("%v: %s", ErrUnsafeURL, e.Reason)
|
||||
}
|
||||
return ErrUnsafeURL.Error()
|
||||
}
|
||||
|
||||
// Unwrap permits errors.Is(err, ErrUnsafeURL).
|
||||
func (e *URLSafetyError) Unwrap() error { return ErrUnsafeURL }
|
||||
|
||||
// WriteString writes a compiler-owned static literal and reports both writer
|
||||
// errors and contract-violating short writes. Application data must use the
|
||||
// context-specific helpers below instead.
|
||||
func WriteString(w io.Writer, value string) error {
|
||||
return writeString(w, value)
|
||||
}
|
||||
|
||||
// WriteText writes value escaped for an HTML text node.
|
||||
func WriteText(w io.Writer, value any) error {
|
||||
if trusted, ok := value.(TrustedHTML); ok {
|
||||
return writeString(w, trusted.value)
|
||||
}
|
||||
return writeString(w, html.EscapeString(normalizeText(stringValue(value))))
|
||||
}
|
||||
|
||||
// WriteRCDATA writes value escaped for an HTML RCDATA element such as title
|
||||
// or textarea. Trusted wrappers are intentionally not honored in this context:
|
||||
// their contents are escaped like every other value so they cannot close the
|
||||
// containing element.
|
||||
func WriteRCDATA(w io.Writer, value any) error {
|
||||
return writeString(w, html.EscapeString(normalizeText(stringValue(value))))
|
||||
}
|
||||
|
||||
// WriteAttr writes value escaped for a quoted HTML attribute. Generated code
|
||||
// must always place this output inside a syntactically complete quoted value.
|
||||
func WriteAttr(w io.Writer, value any) error {
|
||||
return writeString(w, html.EscapeString(normalizeText(stringValue(value))))
|
||||
}
|
||||
|
||||
// WriteURL writes value escaped for a quoted URL-bearing HTML attribute.
|
||||
// Ordinary values are canonicalized and checked before any bytes are written.
|
||||
// TrustedURL bypasses the scheme check but never attribute escaping.
|
||||
func WriteURL(w io.Writer, value any) error {
|
||||
if trusted, ok := value.(TrustedURL); ok {
|
||||
return writeString(w, html.EscapeString(normalizeText(trusted.value)))
|
||||
}
|
||||
|
||||
canonical, err := canonicalURL(stringValue(value))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeString(w, html.EscapeString(canonical))
|
||||
}
|
||||
|
||||
// WriteHTML writes deliberately trusted HTML without escaping.
|
||||
func WriteHTML(w io.Writer, value TrustedHTML) error {
|
||||
return writeString(w, value.value)
|
||||
}
|
||||
|
||||
// WriteJS writes deliberately trusted JavaScript without escaping.
|
||||
func WriteJS(w io.Writer, value TrustedJS) error {
|
||||
return writeString(w, value.value)
|
||||
}
|
||||
|
||||
// WriteCSS writes deliberately trusted CSS without escaping.
|
||||
func WriteCSS(w io.Writer, value TrustedCSS) error {
|
||||
return writeString(w, value.value)
|
||||
}
|
||||
|
||||
func writeString(w io.Writer, value string) error {
|
||||
if isNil(w) {
|
||||
return ErrNilWriter
|
||||
}
|
||||
|
||||
n, err := io.WriteString(w, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != len(value) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
switch value := value.(type) {
|
||||
case TrustedHTML:
|
||||
return value.value
|
||||
case TrustedURL:
|
||||
return value.value
|
||||
case TrustedJS:
|
||||
return value.value
|
||||
case TrustedCSS:
|
||||
return value.value
|
||||
default:
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeText(value string) string {
|
||||
if !utf8.ValidString(value) {
|
||||
value = strings.ToValidUTF8(value, "\uFFFD")
|
||||
}
|
||||
return strings.ReplaceAll(value, "\x00", "\uFFFD")
|
||||
}
|
||||
|
||||
func canonicalURL(value string) (string, error) {
|
||||
value = normalizeText(value)
|
||||
value = strings.TrimFunc(value, func(r rune) bool {
|
||||
return unicode.IsSpace(r) || r == '\uFEFF'
|
||||
})
|
||||
|
||||
for _, r := range value {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return "", &URLSafetyError{Reason: "control characters are not allowed"}
|
||||
}
|
||||
}
|
||||
|
||||
colon := strings.IndexByte(value, ':')
|
||||
boundary := strings.IndexAny(value, "/?#")
|
||||
if colon < 0 || boundary >= 0 && boundary < colon {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
scheme := value[:colon]
|
||||
if !validScheme(scheme) {
|
||||
return "", &URLSafetyError{Reason: "ambiguous scheme syntax"}
|
||||
}
|
||||
|
||||
scheme = strings.ToLower(scheme)
|
||||
switch scheme {
|
||||
case "http", "https", "mailto", "tel":
|
||||
return value, nil
|
||||
default:
|
||||
return "", &URLSafetyError{Scheme: scheme}
|
||||
}
|
||||
}
|
||||
|
||||
func validScheme(value string) bool {
|
||||
if value == "" || !isASCIIAlpha(value[0]) {
|
||||
return false
|
||||
}
|
||||
for i := 1; i < len(value); i++ {
|
||||
c := value[i]
|
||||
if !isASCIIAlpha(c) && (c < '0' || c > '9') && c != '+' && c != '-' && c != '.' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isASCIIAlpha(c byte) bool {
|
||||
return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z'
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package sando_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"gamertan.com/sandwich-hime/sando"
|
||||
)
|
||||
|
||||
func TestWriteText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
want string
|
||||
}{
|
||||
{name: "quote and entity injection", value: `<script x="'&">alert(1)</script>`, want: `<script x="'&">alert(1)</script>`},
|
||||
{name: "unicode preserved", value: "姫 🍞 café", want: "姫 🍞 café"},
|
||||
{name: "NUL replaced", value: "left\x00right", want: "left\uFFFDright"},
|
||||
{name: "invalid UTF-8 replaced", value: string([]byte{'a', 0xff, 'b'}), want: "a\uFFFDb"},
|
||||
{name: "non-string formatted", value: 42, want: "42"},
|
||||
{name: "trusted HTML is deliberately raw in text context", value: sando.TrustHTML("<b>explicitly trusted</b>"), want: "<b>explicitly trusted</b>"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var output bytes.Buffer
|
||||
if err := sando.WriteText(&output, test.value); err != nil {
|
||||
t.Fatalf("WriteText() error = %v", err)
|
||||
}
|
||||
if got := output.String(); got != test.want {
|
||||
t.Fatalf("WriteText() = %q, want %q", got, test.want)
|
||||
}
|
||||
if !utf8.ValidString(output.String()) {
|
||||
t.Fatal("WriteText() emitted invalid UTF-8")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAttr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
want string
|
||||
}{
|
||||
{name: "breakout characters", value: `x" autofocus onfocus="alert(1)&`, want: `x" autofocus onfocus="alert(1)&`},
|
||||
{name: "angle and apostrophe", value: `<'value'>`, want: `<'value'>`},
|
||||
{name: "unicode and NUL", value: "姫\x00さん", want: "姫\uFFFDさん"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var output bytes.Buffer
|
||||
if err := sando.WriteAttr(&output, test.value); err != nil {
|
||||
t.Fatalf("WriteAttr() error = %v", err)
|
||||
}
|
||||
if got := output.String(); got != test.want {
|
||||
t.Fatalf("WriteAttr() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRCDATAAlwaysEscapesTrustedValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
want string
|
||||
}{
|
||||
{name: "plain", value: `</title><script>alert("x")</script>`, want: `</title><script>alert("x")</script>`},
|
||||
{name: "trusted HTML", value: sando.TrustHTML(`</textarea><script>alert(1)</script>`), want: `</textarea><script>alert(1)</script>`},
|
||||
{name: "trusted URL", value: sando.TrustURL(`javascript:</title>`), want: `javascript:</title>`},
|
||||
{name: "trusted JavaScript", value: sando.TrustJS(`</title><script>alert(1)</script>`), want: `</title><script>alert(1)</script>`},
|
||||
{name: "trusted CSS", value: sando.TrustCSS(`</textarea><style>*{display:none}</style>`), want: `</textarea><style>*{display:none}</style>`},
|
||||
{name: "unicode and NUL", value: "姫\x00さん", want: "姫\uFFFDさん"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var output bytes.Buffer
|
||||
if err := sando.WriteRCDATA(&output, test.value); err != nil {
|
||||
t.Fatalf("WriteRCDATA() error = %v", err)
|
||||
}
|
||||
if got := output.String(); got != test.want {
|
||||
t.Fatalf("WriteRCDATA() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRCDATAAlwaysEscapesTrustedHTML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var output bytes.Buffer
|
||||
value := sando.TrustHTML(`</textarea><script>alert(1)</script>`)
|
||||
if err := sando.WriteRCDATA(&output, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := output.String(), `</textarea><script>alert(1)</script>`; got != want {
|
||||
t.Fatalf("WriteRCDATA() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteURLAllowsAndCanonicalizesOrdinaryValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
want string
|
||||
}{
|
||||
{name: "root relative", value: "/items?q=tea&sort=name", want: "/items?q=tea&sort=name"},
|
||||
{name: "path relative", value: "../images/姫.png", want: "../images/姫.png"},
|
||||
{name: "fragment", value: "#section", want: "#section"},
|
||||
{name: "network path", value: "//static.example/assets", want: "//static.example/assets"},
|
||||
{name: "HTTP scheme case insensitive", value: "HTTP://example.test/a", want: "HTTP://example.test/a"},
|
||||
{name: "HTTPS", value: "https://example.test/", want: "https://example.test/"},
|
||||
{name: "mail", value: "mailto:hime@example.test", want: "mailto:hime@example.test"},
|
||||
{name: "telephone", value: "tel:+14165550123", want: "tel:+14165550123"},
|
||||
{name: "surrounding whitespace trimmed", value: " \n\thttps://example.test/path\r ", want: "https://example.test/path"},
|
||||
{name: "colon after query is relative", value: "/search?q=kind:value", want: "/search?q=kind:value"},
|
||||
{name: "empty", value: "", want: ""},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var output bytes.Buffer
|
||||
if err := sando.WriteURL(&output, test.value); err != nil {
|
||||
t.Fatalf("WriteURL() error = %v", err)
|
||||
}
|
||||
if got := output.String(); got != test.want {
|
||||
t.Fatalf("WriteURL() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteURLRejectsDangerousAndAmbiguousValuesBeforeWriting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
values := []string{
|
||||
"javascript:alert(1)",
|
||||
"JaVaScRiPt:alert(1)",
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
"vbscript:msgbox(1)",
|
||||
"file:///etc/passwd",
|
||||
"ftp://example.test/file",
|
||||
"java\nscript:alert(1)",
|
||||
"java\tscript:alert(1)",
|
||||
"java\x00script:alert(1)",
|
||||
"javascript\x7f:alert(1)",
|
||||
"java script:alert(1)",
|
||||
"%6aavascript:alert(1)",
|
||||
":ambiguous",
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
value := value
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var output bytes.Buffer
|
||||
output.WriteString("last-good")
|
||||
err := sando.WriteURL(&output, value)
|
||||
if !errors.Is(err, sando.ErrUnsafeURL) {
|
||||
t.Fatalf("WriteURL() error = %v, want ErrUnsafeURL", err)
|
||||
}
|
||||
if got := output.String(); got != "last-good" {
|
||||
t.Fatalf("WriteURL() modified writer on validation failure: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedWrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
write func(io.Writer) error
|
||||
want string
|
||||
}{
|
||||
{name: "HTML", write: func(w io.Writer) error {
|
||||
return sando.WriteHTML(w, sando.TrustHTML(`<strong data-x="1">Hime</strong>`))
|
||||
}, want: `<strong data-x="1">Hime</strong>`},
|
||||
{name: "URL bypasses scheme but not attribute escaping", write: func(w io.Writer) error { return sando.WriteURL(w, sando.TrustURL(`custom:"<&`)) }, want: `custom:"<&`},
|
||||
{name: "JavaScript", write: func(w io.Writer) error { return sando.WriteJS(w, sando.TrustJS(`window.hime = "<3";`)) }, want: `window.hime = "<3";`},
|
||||
{name: "CSS", write: func(w io.Writer) error { return sando.WriteCSS(w, sando.TrustCSS(`.hime::after { content: "<3"; }`)) }, want: `.hime::after { content: "<3"; }`},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var output bytes.Buffer
|
||||
if err := test.write(&output); err != nil {
|
||||
t.Fatalf("trusted write error = %v", err)
|
||||
}
|
||||
if got := output.String(); got != test.want {
|
||||
t.Fatalf("trusted write = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteHelpersPropagateWriterFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := errors.New("disk full")
|
||||
tests := []struct {
|
||||
name string
|
||||
write func(io.Writer) error
|
||||
}{
|
||||
{name: "static literal", write: func(w io.Writer) error { return sando.WriteString(w, "hello") }},
|
||||
{name: "text", write: func(w io.Writer) error { return sando.WriteText(w, "hello") }},
|
||||
{name: "RCDATA", write: func(w io.Writer) error { return sando.WriteRCDATA(w, "hello") }},
|
||||
{name: "attribute", write: func(w io.Writer) error { return sando.WriteAttr(w, "hello") }},
|
||||
{name: "URL", write: func(w io.Writer) error { return sando.WriteURL(w, "/hello") }},
|
||||
{name: "HTML", write: func(w io.Writer) error { return sando.WriteHTML(w, sando.TrustHTML("hello")) }},
|
||||
{name: "JavaScript", write: func(w io.Writer) error { return sando.WriteJS(w, sando.TrustJS("hello")) }},
|
||||
{name: "CSS", write: func(w io.Writer) error { return sando.WriteCSS(w, sando.TrustCSS("hello")) }},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := test.write(errorWriter{err: want}); !errors.Is(got, want) {
|
||||
t.Fatalf("write error = %v, want %v", got, want)
|
||||
}
|
||||
if got := test.write(shortWriter{}); !errors.Is(got, io.ErrShortWrite) {
|
||||
t.Fatalf("short write error = %v, want io.ErrShortWrite", got)
|
||||
}
|
||||
if got := test.write(nil); !errors.Is(got, sando.ErrNilWriter) {
|
||||
t.Fatalf("nil writer error = %v, want ErrNilWriter", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLSafetyErrorDoesNotEchoSensitiveValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const secret = "user:password@example.test"
|
||||
err := sando.WriteURL(io.Discard, "custom:"+secret)
|
||||
if err == nil {
|
||||
t.Fatal("WriteURL() unexpectedly accepted custom scheme")
|
||||
}
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Fatalf("error leaks URL contents: %q", err)
|
||||
}
|
||||
var safetyError *sando.URLSafetyError
|
||||
if !errors.As(err, &safetyError) {
|
||||
t.Fatalf("error type = %T, want *sando.URLSafetyError", err)
|
||||
}
|
||||
}
|
||||
|
||||
type errorWriter struct{ err error }
|
||||
|
||||
func (w errorWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||
|
||||
type shortWriter struct{}
|
||||
|
||||
func (shortWriter) Write(value []byte) (int, error) {
|
||||
if len(value) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return len(value) - 1, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Repository verification tools
|
||||
|
||||
These scripts are intentionally understandable shell and PowerShell rather than a release framework with hidden defaults.
|
||||
|
||||
- `verify.sh` runs root and nested-module tests and vet, builds `himesan`, tests each example module, checks committed generated 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 / 0BSD example boundary and prevents generated application Go from inheriting an AGPL identifier.
|
||||
- `check-site.sh` verifies vanity metadata, no-script/local-asset policy, pre-release honesty, and baseline accessibility scaffolding for the static site.
|
||||
- `eql-integration.sh` is opt-in. Until a repository-owned `internal/integration/eql` test exists it exits successfully without inspecting EQL. Later it requires external `HIMESAN_EQL_ROOT` and `HIMESAN_EQL_DB` paths and passes a read-only-mode contract to that package. It never copies a database.
|
||||
- `release-check.sh --version vX.Y.Z` is a clean-checkout technical preflight. Add `--public` and point `HIMESAN_RELEASE_EVIDENCE_DIR` at a human-reviewed evidence bundle for the public-launch gate. It 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.
|
||||
|
||||
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 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.
|
||||
|
||||
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. No secondary forge mirror or hosted workflow is planned.
|
||||
Executable
+201
@@ -0,0 +1,201 @@
|
||||
#!/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"
|
||||
|
||||
failures=0
|
||||
|
||||
fail() {
|
||||
printf 'license error: %s\n' "$*" >&2
|
||||
failures=$((failures + 1))
|
||||
}
|
||||
|
||||
expected_spdx() {
|
||||
case "$1" in
|
||||
examples/*)
|
||||
printf '0BSD\n'
|
||||
;;
|
||||
sando/*)
|
||||
printf 'Apache-2.0\n'
|
||||
;;
|
||||
*)
|
||||
printf 'AGPL-3.0-only\n'
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Read one complete SPDX expression from the first eight lines. Removing only
|
||||
# recognized comment closers makes expressions such as "AGPL-3.0-only OR MIT"
|
||||
# fail instead of passing a substring search.
|
||||
has_exact_spdx() {
|
||||
local path=$1
|
||||
local expected=$2
|
||||
local lines line value count
|
||||
|
||||
lines=$(head -n 8 -- "$path" | grep 'SPDX-License-Identifier:' || true)
|
||||
count=$(printf '%s\n' "$lines" | sed '/^$/d' | wc -l)
|
||||
count=${count//[[:space:]]/}
|
||||
[[ $count -eq 1 ]] || return 1
|
||||
line=$lines
|
||||
value=${line#*SPDX-License-Identifier:}
|
||||
value=${value%%-->*}
|
||||
value=${value%%\*/*}
|
||||
value=$(printf '%s' "$value" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
|
||||
[[ $value == "$expected" ]]
|
||||
}
|
||||
|
||||
check_sha256() {
|
||||
local path=$1
|
||||
local expected=$2
|
||||
local actual
|
||||
actual=$(sha256sum -- "$path" | awk '{print $1}')
|
||||
[[ $actual == "$expected" ]] || fail "$path does not match the reviewed legal text ($actual)"
|
||||
}
|
||||
|
||||
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)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
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#^\./##'
|
||||
fi
|
||||
}
|
||||
|
||||
[[ -f LICENSE ]] || fail 'root LICENSE is missing'
|
||||
[[ -f sando/LICENSE ]] || fail 'sando/LICENSE is missing'
|
||||
[[ -f examples/eql-shaped/LICENSE ]] || fail 'example LICENSE is missing'
|
||||
[[ -f DCO.txt ]] || fail 'DCO.txt is missing'
|
||||
|
||||
if [[ -f LICENSE ]]; then
|
||||
check_sha256 LICENSE 0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0
|
||||
fi
|
||||
if [[ -f sando/LICENSE ]]; then
|
||||
check_sha256 sando/LICENSE c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4
|
||||
fi
|
||||
if [[ -f examples/eql-shaped/LICENSE ]]; then
|
||||
check_sha256 examples/eql-shaped/LICENSE 7e2406acc98391ec126b13d512c00b930bbc2c19f4d5b0fe52286ae41bfbc92d
|
||||
grep -Fq 'Copyright (c) 2025-2026 Cole Speelman' examples/eql-shaped/LICENSE || \
|
||||
fail 'example LICENSE must identify its original author'
|
||||
fi
|
||||
if [[ -f DCO.txt ]]; then
|
||||
check_sha256 DCO.txt f7ac75b443f4ca16b503241344b41aeff9503b0c30bedc2b119551d83cb0fa90
|
||||
fi
|
||||
|
||||
for required in COPYRIGHT OUTPUT_EXCEPTION.md sando/COPYRIGHT examples/eql-shaped/LICENSES.md; do
|
||||
[[ -f $required ]] || fail "$required is required for ownership/output licensing"
|
||||
done
|
||||
|
||||
if [[ -f COPYRIGHT ]]; then
|
||||
has_exact_spdx COPYRIGHT AGPL-3.0-only || fail 'COPYRIGHT must carry exactly AGPL-3.0-only'
|
||||
grep -Fq 'SPDX-FileCopyrightText: 2025-2026 Cole Speelman' COPYRIGHT || \
|
||||
fail 'COPYRIGHT must identify Cole Speelman original work'
|
||||
fi
|
||||
if [[ -f sando/COPYRIGHT ]]; then
|
||||
has_exact_spdx sando/COPYRIGHT Apache-2.0 || fail 'sando/COPYRIGHT must carry exactly Apache-2.0'
|
||||
grep -Fq 'SPDX-FileCopyrightText: 2025-2026 Cole Speelman' sando/COPYRIGHT || \
|
||||
fail 'sando/COPYRIGHT must identify Cole Speelman original runtime work'
|
||||
fi
|
||||
if [[ -f OUTPUT_EXCEPTION.md ]]; then
|
||||
grep -Fq 'additional permission under section 7' OUTPUT_EXCEPTION.md || \
|
||||
fail 'OUTPUT_EXCEPTION.md must contain the AGPL section 7 additional permission'
|
||||
grep -Fq 'Himesan-Output-Permission: v1.0' OUTPUT_EXCEPTION.md || \
|
||||
fail 'OUTPUT_EXCEPTION.md must define the contributor grant marker'
|
||||
fi
|
||||
if [[ -f CONTRIBUTING.md ]]; then
|
||||
grep -Fq 'Himesan-Output-Permission: v1.0' CONTRIBUTING.md || \
|
||||
fail 'CONTRIBUTING.md must require the emitted-scaffolding permission grant'
|
||||
grep -Fq 'DCO sign-off does not supply that separate grant' CONTRIBUTING.md || \
|
||||
fail 'CONTRIBUTING.md must distinguish DCO from the output permission'
|
||||
fi
|
||||
|
||||
while IFS= read -r -d '' path; do
|
||||
[[ -f $path ]] || continue
|
||||
|
||||
case "$path" in
|
||||
LICENSE | sando/LICENSE | examples/*/LICENSE | DCO.txt)
|
||||
# These are reviewed legal texts with their own notices.
|
||||
continue
|
||||
;;
|
||||
*.sum)
|
||||
# Cryptographic dependency records are externally covered data.
|
||||
continue
|
||||
;;
|
||||
PUBLIC-SNAPSHOT.json | PUBLIC-SNAPSHOT.sha256)
|
||||
# Generated factual provenance; covered by LICENSES.md.
|
||||
continue
|
||||
;;
|
||||
*.sando.go)
|
||||
if grep -Fq 'SPDX-License-Identifier:' "$path"; then
|
||||
fail "$path is compiler-managed output and must use its module-level license map"
|
||||
fi
|
||||
if grep -Fq 'Copyright (c) 2025-2026 Cole Speelman' "$path"; then
|
||||
fail "$path must not receive a compiler copyright claim"
|
||||
fi
|
||||
continue
|
||||
;;
|
||||
*.sando)
|
||||
expected=$(expected_spdx "$path")
|
||||
count=$(grep -Fc "SPDX-License-Identifier: $expected" "$path" || true)
|
||||
[[ $count -eq 1 ]] || fail "$path must carry one template comment for $expected"
|
||||
continue
|
||||
;;
|
||||
*.json)
|
||||
if [[ $path != examples/eql-shaped/himesan.json ]]; then
|
||||
fail "$path cannot carry a comment and needs an explicit license-map entry"
|
||||
fi
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! is_comment_capable_project_file "$path"; then
|
||||
fail "$path has no fail-closed license policy"
|
||||
continue
|
||||
fi
|
||||
|
||||
expected=$(expected_spdx "$path")
|
||||
has_exact_spdx "$path" "$expected" || \
|
||||
fail "$path must carry exactly one SPDX identifier: $expected"
|
||||
done < <(list_project_files)
|
||||
|
||||
if [[ -f examples/eql-shaped/LICENSES.md ]]; then
|
||||
grep -Fq '`himesan.json`' examples/eql-shaped/LICENSES.md || \
|
||||
fail 'example license map must cover himesan.json'
|
||||
grep -Fq '`.sando.go`' examples/eql-shaped/LICENSES.md || \
|
||||
fail 'example license map must cover generated output'
|
||||
fi
|
||||
|
||||
if [[ -d examples ]] && find examples -type f -name go.mod -print -quit | grep -q .; then
|
||||
while IFS= read -r -d '' module_file; do
|
||||
module_dir=$(dirname -- "$module_file")
|
||||
license_file=$module_dir/LICENSE
|
||||
map_file=$module_dir/LICENSES.md
|
||||
[[ -f $license_file ]] || fail "example module $module_dir needs a local 0BSD LICENSE"
|
||||
[[ -f $map_file ]] || fail "example module $module_dir needs a local license map"
|
||||
done < <(find examples -type d \( -name .git -o -name vendor \) -prune -o -type f -name go.mod -print0)
|
||||
fi
|
||||
|
||||
if find sando -type f -name '*.go' -exec grep -En \
|
||||
'"gamertan\.com/sandwich-hime/(cmd|internal)(/|"|$)' {} + | grep -q .; then
|
||||
fail 'the Apache runtime imports AGPL compiler or CLI code'
|
||||
fi
|
||||
|
||||
if (( failures > 0 )); then
|
||||
printf '\n%d license/SPDX policy violation(s) found.\n' "$failures" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Reviewed license texts, ownership records, SPDX boundaries, generated-output permission, and runtime separation are consistent.\n'
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/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"
|
||||
|
||||
public_version=''
|
||||
if (( $# > 0 )); then
|
||||
if [[ "$1" != --public || $# -ne 2 ]]; then
|
||||
printf 'usage: scripts/check-site.sh [--public vX.Y.Z]\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
public_version=$2
|
||||
fi
|
||||
|
||||
fail() {
|
||||
printf 'site error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for page in site/index.html site/sando/index.html; do
|
||||
[[ -f "$page" ]] || fail "missing $page"
|
||||
grep -Fq '<html lang="en">' "$page" || fail "$page needs a document language"
|
||||
grep -Fq '<meta name="viewport"' "$page" || fail "$page needs responsive viewport metadata"
|
||||
grep -Fq 'class="skip-link"' "$page" || fail "$page needs a keyboard skip link"
|
||||
grep -Fq '<main id="main">' "$page" || fail "$page needs the skip-link target"
|
||||
grep -Fq '<h1>' "$page" || grep -Fq '<h1 ' "$page" || fail "$page needs an h1"
|
||||
grep -Fq 'Content-Security-Policy' "$page" || fail "$page needs a preview CSP"
|
||||
grep -Fq '<meta name="himesan-release-status" content="' "$page" || \
|
||||
fail "$page needs machine-readable release status"
|
||||
done
|
||||
|
||||
grep -Fq '<meta name="go-import" content="gamertan.com/sandwich-hime git https://gitea.speelman.ca/gamertan/sandwich-hime.git">' \
|
||||
site/index.html || fail 'compiler vanity-import metadata is missing or changed'
|
||||
grep -Fq '<meta name="go-import" content="gamertan.com/sandwich-hime/sando git https://gitea.speelman.ca/gamertan/sandwich-hime.git sando">' \
|
||||
site/sando/index.html || fail 'nested runtime vanity-import metadata is missing or changed'
|
||||
|
||||
if find site -type f -name '*.html' -exec grep -Ein '<script([[:space:]>])' {} + | grep -q .; then
|
||||
fail 'the static project site must not contain JavaScript'
|
||||
fi
|
||||
if find site -type f -name '*.html' -exec grep -Ein \
|
||||
'(src|href)="https?://[^" ]+\.(js|css)([?"#])' {} + | grep -q .; then
|
||||
fail 'the static project site must not load remote JavaScript or CSS'
|
||||
fi
|
||||
if find site -type f -name '*.css' -exec grep -Ein \
|
||||
"(@import|url\\()[[:space:]\"']*https?://" {} + | grep -q .; then
|
||||
fail 'the static project site must not load remote CSS assets'
|
||||
fi
|
||||
|
||||
grep -Fq 'prefers-reduced-motion' site/assets/site.css || fail 'site CSS needs a reduced-motion preference'
|
||||
grep -Fq 'forced-colors' site/assets/site.css || fail 'site CSS needs a forced-colors fallback'
|
||||
grep -Fq 'class="wordmark" role="img"' site/index.html || \
|
||||
fail 'the ASCII wordmark needs an accessible semantic role'
|
||||
grep -Fq 'class="code" tabindex="0" role="region"' site/index.html || \
|
||||
fail 'the scrollable code example needs keyboard access and a region role'
|
||||
|
||||
if [[ -n "$public_version" ]]; then
|
||||
for page in site/index.html site/sando/index.html; do
|
||||
grep -Fq "<meta name=\"himesan-release-status\" content=\"$public_version\">" "$page" || \
|
||||
fail "$page release status does not match $public_version"
|
||||
done
|
||||
if grep -Eiq 'not a public release|not released yet|private pre-release|public pre-1\.0|unsupported pre-1\.0|no (supported )?public .*tag' \
|
||||
site/index.html site/sando/index.html; then
|
||||
fail 'public-release site still contains a pre-release warning'
|
||||
fi
|
||||
else
|
||||
if grep -Fq '<meta name="himesan-release-status" content="pre-release">' site/index.html; then
|
||||
grep -Eiq 'public pre-1\.0|unsupported pre-1\.0|not released yet' site/index.html || \
|
||||
fail 'the pre-release landing page must state its status in human-readable text'
|
||||
fi
|
||||
fi
|
||||
|
||||
printf 'Static site metadata, local-asset policy, and accessibility scaffolding are present.\n'
|
||||
Executable
+174
@@ -0,0 +1,174 @@
|
||||
#!/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() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/release-check.sh --version 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 Additionally require the human-reviewed launch evidence bundle
|
||||
named by HIMESAN_RELEASE_EVIDENCE_DIR.
|
||||
EOF
|
||||
}
|
||||
|
||||
version=''
|
||||
public_release=0
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--version)
|
||||
[[ $# -ge 2 ]] || { usage >&2; exit 2; }
|
||||
version=$2
|
||||
shift 2
|
||||
;;
|
||||
--public)
|
||||
public_release=1
|
||||
shift
|
||||
;;
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! "$version" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]]; then
|
||||
printf 'error: --version must be a semantic version beginning with v\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
runtime_tag="sando/$version"
|
||||
|
||||
if [[ -n "$(git status --porcelain=v1 --untracked-files=all)" ]]; then
|
||||
printf 'error: release preflight requires a clean canonical checkout\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
origin_url=$(git remote get-url origin)
|
||||
case "$origin_url" in
|
||||
ssh://git@gitea.speelman.ca:2222/gamertan/sandwich-hime.git | \
|
||||
git@gitea.speelman.ca:gamertan/sandwich-hime.git | \
|
||||
https://gitea.speelman.ca/gamertan/sandwich-hime.git)
|
||||
;;
|
||||
*)
|
||||
printf 'error: origin is not the canonical Gamertan Gitea repository: %s\n' "$origin_url" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
branch=$(git symbolic-ref --quiet --short HEAD || true)
|
||||
if [[ "$branch" != main ]]; then
|
||||
printf 'error: release preflight must run from canonical main, not %s\n' "${branch:-detached HEAD}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for tag in "$version" "$runtime_tag"; 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
|
||||
fi
|
||||
if ! remote_tags=$(git ls-remote --tags origin "refs/tags/$tag" "refs/tags/$tag^{}" 2>/dev/null); then
|
||||
printf 'error: could not verify candidate tag against canonical origin: %s\n' "$tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$remote_tags" ]]; then
|
||||
printf 'error: candidate tag already exists on canonical origin: %s\n' "$tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
./scripts/check-licenses.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
|
||||
|
||||
printf '\n==> vulnerability scan (pinned golang.org/x/vuln v1.6.0)\n'
|
||||
go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./...
|
||||
(
|
||||
cd sando
|
||||
go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./...
|
||||
)
|
||||
|
||||
artifact_dir=$(mktemp -d "${TMPDIR:-/tmp}/himesan-release-check.XXXXXXXX")
|
||||
cleanup() {
|
||||
if [[ -n "${artifact_dir:-}" && -d "$artifact_dir" ]]; then
|
||||
rm -rf -- "$artifact_dir"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
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 -o "$artifact_dir/himesan-$target_os-$target_arch$extension" ./cmd/himesan
|
||||
done
|
||||
|
||||
for required in \
|
||||
site/index.html \
|
||||
site/sando/index.html \
|
||||
site/README.md \
|
||||
scripts/verify-public-install.sh \
|
||||
RELEASE.md \
|
||||
SECURITY.md \
|
||||
TRADEMARKS.md \
|
||||
CLA.md; do
|
||||
[[ -f "$required" ]] || { printf 'error: required release file is missing: %s\n' "$required" >&2; exit 1; }
|
||||
done
|
||||
|
||||
if ! grep -Fq 'gamertan.com/sandwich-hime/sando git' site/sando/index.html; then
|
||||
printf 'error: nested runtime vanity-import metadata is missing\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (( public_release == 1 )); then
|
||||
evidence_dir=${HIMESAN_RELEASE_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 \
|
||||
counsel-review.md \
|
||||
cross-platform.md \
|
||||
eql-production-soak.md \
|
||||
security-and-accessibility.md \
|
||||
benchmark-methodology.md \
|
||||
vanity-and-mirror.md; do
|
||||
if [[ ! -s "$evidence_dir/$evidence" ]]; then
|
||||
printf 'error: public release evidence is missing or empty: %s\n' "$evidence_dir/$evidence" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
./scripts/check-site.sh --public "$version"
|
||||
printf '\nHuman review is still required; evidence presence is not automatic approval.\n'
|
||||
else
|
||||
printf '\nTechnical preflight passed. Public launch remains blocked until --public evidence review passes.\n'
|
||||
fi
|
||||
|
||||
printf 'No tag, push, publication, or deployment was performed for %s / %s.\n' "$version" "$runtime_tag"
|
||||
Executable
+170
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/verify-public-install.sh --version vX.Y.Z
|
||||
|
||||
Post-publication verification for Gamertan vanity metadata and the exact
|
||||
documented install commands. It uses fresh temporary Go caches and never writes
|
||||
to the repository. Signed compiler and sando tags must already be public.
|
||||
EOF
|
||||
}
|
||||
|
||||
version=''
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--version)
|
||||
[[ $# -ge 2 ]] || { usage >&2; exit 2; }
|
||||
version=$2
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! "$version" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]]; then
|
||||
printf 'error: --version must be a semantic version beginning with v\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
for command_name in curl go git false; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || {
|
||||
printf 'error: required command is unavailable: %s\n' "$command_name" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
false_command=$(command -v false)
|
||||
|
||||
public_origin=${HIMESAN_PUBLIC_ORIGIN:-https://gamertan.com}
|
||||
public_origin=${public_origin%/}
|
||||
case "$public_origin" in
|
||||
https://*) ;;
|
||||
*)
|
||||
printf 'error: HIMESAN_PUBLIC_ORIGIN must use HTTPS\n' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
compiler_meta='<meta name="go-import" content="gamertan.com/sandwich-hime git https://gitea.speelman.ca/gamertan/sandwich-hime.git">'
|
||||
runtime_meta='<meta name="go-import" content="gamertan.com/sandwich-hime/sando git https://gitea.speelman.ca/gamertan/sandwich-hime.git sando">'
|
||||
|
||||
check_metadata() {
|
||||
local path=$1
|
||||
local expected=$2
|
||||
local body
|
||||
body=$(curl --fail --silent --show-error --location \
|
||||
--proto '=https' --max-redirs 3 --connect-timeout 10 --max-time 30 \
|
||||
"$public_origin$path")
|
||||
if [[ "$body" != *"$expected"* ]]; then
|
||||
printf 'error: expected vanity metadata missing at %s%s\n' "$public_origin" "$path" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
printf '==> exact vanity-import discovery routes\n'
|
||||
check_metadata '/sandwich-hime?go-get=1' "$compiler_meta"
|
||||
check_metadata '/sandwich-hime/cmd/himesan?go-get=1' "$compiler_meta"
|
||||
check_metadata '/sandwich-hime/sando?go-get=1' "$runtime_meta"
|
||||
check_metadata '/sandwich-hime/sando/future-package?go-get=1' "$runtime_meta"
|
||||
|
||||
browser_status=$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
|
||||
--proto '=https' --max-redirs 0 --connect-timeout 10 --max-time 30 \
|
||||
"$public_origin/sandwich-hime/not-a-browser-route")
|
||||
if [[ "$browser_status" != 404 ]]; then
|
||||
printf 'error: query-scoped metadata fallback leaked into ordinary browser routing (status %s)\n' "$browser_status" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
scratch_dir=$(mktemp -d "${TMPDIR:-/tmp}/himesan-public-install.XXXXXXXX")
|
||||
cleanup() {
|
||||
if [[ -n "${scratch_dir:-}" && -d "$scratch_dir" ]]; then
|
||||
rm -rf -- "$scratch_dir"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
run_install_pair() {
|
||||
local mode=$1
|
||||
local proxy=$2
|
||||
local no_sum_db=$3
|
||||
local mode_dir="$scratch_dir/$mode"
|
||||
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"
|
||||
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 install "gamertan.com/sandwich-hime/cmd/himesan@$version"
|
||||
|
||||
go_executable_suffix=$(go env GOEXE)
|
||||
installed_binary="$mode_dir/gopath/bin/himesan$go_executable_suffix"
|
||||
installed_version=$("$installed_binary" version --json)
|
||||
if [[ "$installed_version" != *"\"compiler\":\"$version\""* ]]; then
|
||||
printf 'error: installed compiler did not report module version %s: %s\n' "$version" "$installed_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
cat >"$mode_dir/version_probe.sando" <<'EOF'
|
||||
<?sando go
|
||||
package probe
|
||||
func VersionProbe()
|
||||
?>
|
||||
<p>version probe</p>
|
||||
EOF
|
||||
"$installed_binary" generate "$mode_dir/version_probe.sando" >/dev/null
|
||||
if ! grep -Fq "// himesan:compiler $version" "$mode_dir/version_probe.sando.go"; then
|
||||
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
|
||||
run_install_pair public-proxy 'https://proxy.golang.org' ''
|
||||
|
||||
printf '\nPublic vanity metadata and exact install commands passed for %s.\n' "$version"
|
||||
@@ -0,0 +1,154 @@
|
||||
# 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 "examples" -PathType Container)) {
|
||||
return @()
|
||||
}
|
||||
|
||||
return @(Get-ChildItem "examples" -Recurse -File -Filter "*.sando" |
|
||||
Where-Object { $_.FullName -notmatch '[\\/](vendor|\.git)[\\/]' } |
|
||||
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"
|
||||
|
||||
if (Test-Path "examples" -PathType Container) {
|
||||
$ExampleModules = @(Get-ChildItem "examples" -Recurse -File -Filter "go.mod" |
|
||||
Where-Object { $_.FullName -notmatch '[\\/](vendor|\.git)[\\/]' } |
|
||||
Sort-Object FullName)
|
||||
}
|
||||
else {
|
||||
$ExampleModules = @()
|
||||
}
|
||||
|
||||
$Sources = @(Get-SandoSources)
|
||||
if ($Sources.Count -eq 0) {
|
||||
Write-Host "`n==> generation: no .sando examples exist yet; skipping deterministic-generation check"
|
||||
}
|
||||
else {
|
||||
$SourcePaths = @($Sources | ForEach-Object { $_.FullName })
|
||||
$CheckArgs = @("run", "./cmd/himesan", "check") + $SourcePaths
|
||||
$GenerateArgs = @("run", "./cmd/himesan", "generate") + $SourcePaths
|
||||
Invoke-Checked "generation: read-only freshness check" {
|
||||
& go $CheckArgs
|
||||
}
|
||||
$Before = Get-GeneratedManifest
|
||||
|
||||
Invoke-Checked "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 "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 "generation: final freshness check" {
|
||||
& go $CheckArgs
|
||||
}
|
||||
}
|
||||
|
||||
# Do not execute generated application code until check and both generation
|
||||
# passes prove that it is compiler-owned, current, and deterministic.
|
||||
if ($ExampleModules.Count -eq 0) {
|
||||
Write-Host "`n==> examples: no example module exists yet; skipping module tests"
|
||||
}
|
||||
else {
|
||||
foreach ($module in $ExampleModules) {
|
||||
Invoke-ModuleChecks $module.Directory.FullName "example module $($module.Directory.FullName)"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/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"
|
||||
|
||||
log() {
|
||||
printf '\n==> %s\n' "$*"
|
||||
}
|
||||
|
||||
run_module_checks() {
|
||||
local module_dir=$1
|
||||
local label=$2
|
||||
|
||||
log "$label: go test"
|
||||
(
|
||||
cd "$module_dir"
|
||||
go test ./...
|
||||
)
|
||||
|
||||
log "$label: go vet"
|
||||
(
|
||||
cd "$module_dir"
|
||||
go vet ./...
|
||||
)
|
||||
}
|
||||
|
||||
example_sources() {
|
||||
if [[ ! -d examples ]]; then
|
||||
return 0
|
||||
fi
|
||||
find examples \
|
||||
-type d \( -name .git -o -name vendor \) -prune -o \
|
||||
-type f -name '*.sando' -print | LC_ALL=C sort
|
||||
}
|
||||
|
||||
example_modules() {
|
||||
if [[ ! -d examples ]]; then
|
||||
return 0
|
||||
fi
|
||||
find examples \
|
||||
-type d \( -name .git -o -name vendor \) -prune -o \
|
||||
-type f -name go.mod -print | LC_ALL=C sort
|
||||
}
|
||||
|
||||
generated_manifest() {
|
||||
local source output digest modified
|
||||
while IFS= read -r source; do
|
||||
[[ -n "$source" ]] || continue
|
||||
output="${source}.go"
|
||||
if [[ ! -f "$output" ]]; then
|
||||
printf 'missing %s\n' "$output"
|
||||
continue
|
||||
fi
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
digest=$(sha256sum -- "$output" | awk '{print $1}')
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
digest=$(shasum -a 256 -- "$output" | awk '{print $1}')
|
||||
else
|
||||
printf 'error: sha256sum or shasum is required for generation verification\n' >&2
|
||||
return 1
|
||||
fi
|
||||
if stat -c '%Y' -- "$output" >/dev/null 2>&1; then
|
||||
modified=$(stat -c '%Y' -- "$output")
|
||||
else
|
||||
modified=$(stat -f '%m' -- "$output")
|
||||
fi
|
||||
printf '%s %s %s\n' "$digest" "$modified" "$output"
|
||||
done < <(example_sources)
|
||||
}
|
||||
|
||||
log "repository scripts: shell syntax"
|
||||
bash -n scripts/*.sh
|
||||
|
||||
run_module_checks . "compiler module"
|
||||
|
||||
log "compiler module: go build"
|
||||
build_dir=$(mktemp -d "${TMPDIR:-/tmp}/himesan-verify.XXXXXXXX")
|
||||
cleanup() {
|
||||
if [[ -n "${build_dir:-}" && -d "$build_dir" ]]; then
|
||||
rm -rf -- "$build_dir"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
go build -trimpath -o "$build_dir/himesan" ./cmd/himesan
|
||||
|
||||
if [[ ! -f sando/go.mod ]]; then
|
||||
printf 'error: nested Apache runtime module sando/go.mod is missing\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
run_module_checks sando "sando runtime module"
|
||||
|
||||
log "static project site"
|
||||
./scripts/check-site.sh
|
||||
|
||||
sources=()
|
||||
while IFS= read -r source; do
|
||||
[[ -n "$source" ]] || continue
|
||||
sources[${#sources[@]}]=$source
|
||||
done < <(example_sources)
|
||||
if (( ${#sources[@]} == 0 )); then
|
||||
log "generation: no .sando examples exist yet; skipping deterministic-generation check"
|
||||
else
|
||||
log "generation: read-only freshness check"
|
||||
go run ./cmd/himesan check "${sources[@]}"
|
||||
|
||||
manifest_before="$build_dir/generated-before.txt"
|
||||
manifest_first="$build_dir/generated-first.txt"
|
||||
manifest_second="$build_dir/generated-second.txt"
|
||||
generated_manifest >"$manifest_before"
|
||||
|
||||
log "generation: first deterministic pass"
|
||||
go run ./cmd/himesan generate "${sources[@]}"
|
||||
generated_manifest >"$manifest_first"
|
||||
if ! cmp -s "$manifest_before" "$manifest_first"; then
|
||||
printf 'error: generation changed committed output after check declared it fresh\n' >&2
|
||||
diff -u "$manifest_before" "$manifest_first" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "generation: second deterministic pass"
|
||||
go run ./cmd/himesan generate "${sources[@]}"
|
||||
generated_manifest >"$manifest_second"
|
||||
if ! cmp -s "$manifest_first" "$manifest_second"; then
|
||||
printf 'error: repeated generation changed output bytes or an unchanged timestamp\n' >&2
|
||||
diff -u "$manifest_first" "$manifest_second" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
go run ./cmd/himesan check "${sources[@]}"
|
||||
fi
|
||||
|
||||
# Generated application code is not executed until the read-only freshness
|
||||
# check and both deterministic passes prove it is compiler-owned and current.
|
||||
module_count=0
|
||||
while IFS= read -r module_file; do
|
||||
[[ -n "$module_file" ]] || continue
|
||||
module_count=$((module_count + 1))
|
||||
module_dir=$(dirname -- "$module_file")
|
||||
run_module_checks "$module_dir" "example module $module_dir"
|
||||
done < <(example_modules)
|
||||
if (( module_count == 0 )); then
|
||||
log "examples: no example module exists yet; skipping module tests"
|
||||
fi
|
||||
|
||||
if [[ "${HIMESAN_RACE:-0}" == 1 ]]; then
|
||||
log "compiler module: race tests"
|
||||
go test -race ./...
|
||||
log "sando runtime module: race tests"
|
||||
(
|
||||
cd sando
|
||||
go test -race ./...
|
||||
)
|
||||
fi
|
||||
|
||||
log "verification complete"
|
||||
@@ -0,0 +1,74 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Gamertan site and vanity imports
|
||||
|
||||
This directory is a static, no-JavaScript site for the unsupported Sandwich Hime pre-1.0 public source preview. It is source material only: repository automation must not deploy it. The page is deliberately honest that no supported v1 version exists yet.
|
||||
|
||||
## Intended routes
|
||||
|
||||
The hosting layer must serve these files over HTTPS without an authentication challenge:
|
||||
|
||||
| Request path | File | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `/sandwich-hime` and `/sandwich-hime/` | `index.html` | Project page and compiler-module `go-import` metadata |
|
||||
| `/sandwich-hime/sando` and `/sandwich-hime/sando/` | `sando/index.html` | Nested-runtime metadata using the Go 1.25 `subdirectory` field |
|
||||
| `/sandwich-hime/assets/site.css` | `assets/site.css` | Local-only presentation |
|
||||
|
||||
The Go command requests the exact import path; it does not retry at a parent
|
||||
path after a 404. The hosting layer therefore needs query-scoped metadata
|
||||
fallbacks:
|
||||
|
||||
- any `/sandwich-hime/...` request with `go-get=1`, except the nested runtime
|
||||
subtree, returns the compiler `index.html` with HTTP 200;
|
||||
- `/sandwich-hime/sando` and every path below it with `go-get=1` return
|
||||
`sando/index.html` with HTTP 200;
|
||||
- ordinary browser requests for nonexistent paths continue to return 404.
|
||||
|
||||
This includes `/sandwich-hime/cmd/himesan?go-get=1`, which is the path queried
|
||||
by the documented `go install` command. The `go-import` tags occur early in
|
||||
each document because the Go command uses a restricted HTML parser.
|
||||
|
||||
The nested metadata is intentionally:
|
||||
|
||||
```html
|
||||
<meta name="go-import" content="gamertan.com/sandwich-hime/sando git https://gitea.speelman.ca/gamertan/sandwich-hime.git sando">
|
||||
```
|
||||
|
||||
The fourth field maps the vanity path to the repository’s `sando` subdirectory. It is supported by the project’s minimum Go line, Go 1.25. Runtime versions must use tags such as `sando/v1.0.0`.
|
||||
|
||||
## Hosting setup
|
||||
|
||||
1. Keep the public DNS and TLS authority for `gamertan.com` under founder control. Use an `A`/`AAAA` record or a narrowly scoped `CNAME` appropriate to the chosen static host; do not delegate the whole zone to a project contributor.
|
||||
2. Configure the exact browser routes plus the query-scoped `go-get=1`
|
||||
fallbacks above. Preserve ordinary 404 behavior and do not use a client-side
|
||||
redirect for metadata requests.
|
||||
3. Return `Content-Type: text/html; charset=utf-8` for HTML and `text/css; charset=utf-8` for CSS.
|
||||
4. Add server headers at least equivalent to `Content-Security-Policy: default-src 'none'; style-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`, `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`, and a conservative `Permissions-Policy`.
|
||||
5. Keep deployment credentials outside this repository. A future deploy workflow needs a separately reviewed, least-privilege credential and protected environment approval.
|
||||
6. Verify from an uncached public network before announcing installs:
|
||||
|
||||
```sh
|
||||
curl -fsS 'https://gamertan.com/sandwich-hime?go-get=1'
|
||||
curl -fsS 'https://gamertan.com/sandwich-hime/cmd/himesan?go-get=1'
|
||||
curl -fsS 'https://gamertan.com/sandwich-hime/sando?go-get=1'
|
||||
./scripts/verify-public-install.sh --version v1.0.0
|
||||
```
|
||||
|
||||
## Launch blockers
|
||||
|
||||
Do not deploy or remove the pre-release warning until all of these are evidenced:
|
||||
|
||||
- canonical Gitea is public, the security contact works, and protected release-key controls are active;
|
||||
- root `v1.0.0` and nested `sando/v1.0.0` are signed and accompanied by checksums, SBOMs, release binaries, and reproducibility notes;
|
||||
- Go 1.25 and Go 1.26 pass deterministic generation and tests on Linux, macOS, and Windows, including the canonical manual cross-platform workflow;
|
||||
- vulnerability, race, fuzz, adversarial, license, accessibility, and CSP checks pass;
|
||||
- EQL Wiki completes its differential pilot and 14-day production soak without Hime render, security, or accessibility regressions;
|
||||
- the DCO contribution process, license map, governance, output permission, and trademark policy have final human review;
|
||||
- `scripts/release-check.sh --version v1.0.0 --public` passes against a human-reviewed evidence bundle;
|
||||
- the exact public `go install` and `go get` commands pass
|
||||
`scripts/verify-public-install.sh` from clean direct-fetch and public-proxy
|
||||
caches without repository credentials.
|
||||
|
||||
At launch, replace each page’s `himesan-release-status` value with the exact compiler release (for example, `v1.0.0`), replace the human-facing pre-release copy with verified install information, and run `scripts/check-site.sh --public v1.0.0`. The public release preflight enforces that transition so a green evidence bundle cannot accidentally publish a page that still says the runtime does not exist.
|
||||
|
||||
Gitea is the sole public forge. Cross-platform evidence must come from reviewed local or Gitea-runner execution; no secondary mirror or hosted workflow is part of the release plan.
|
||||
@@ -0,0 +1,398 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-only */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--ink: #fff9eb;
|
||||
--muted: #c9c2dd;
|
||||
--night: #100a25;
|
||||
--deep: #251849;
|
||||
--panel: #30235a;
|
||||
--panel-light: #453476;
|
||||
--pink: #ff74b8;
|
||||
--cyan: #70e6e1;
|
||||
--yellow: #ffe38a;
|
||||
--line: #8e75c4;
|
||||
--shadow: #090617;
|
||||
--focus: #fff3a8;
|
||||
font-family: Verdana, Geneva, Tahoma, sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 20rem;
|
||||
background: var(--night);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
background-color: var(--night);
|
||||
background-image:
|
||||
linear-gradient(45deg, rgb(112 230 225 / 4%) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgb(255 116 184 / 4%) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgb(112 230 225 / 4%) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgb(255 116 184 / 4%) 75%);
|
||||
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
||||
background-size: 16px 16px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--cyan);
|
||||
text-underline-offset: 0.18em;
|
||||
text-decoration-thickness: 0.1em;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
color: var(--night);
|
||||
background: var(--focus);
|
||||
outline: 0.2rem solid var(--focus);
|
||||
outline-offset: 0.2rem;
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
}
|
||||
|
||||
code {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
top: 0.8rem;
|
||||
left: 0.8rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
color: var(--night);
|
||||
background: var(--focus);
|
||||
transform: translateY(-180%);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.masthead {
|
||||
border-bottom: 0.3rem ridge var(--pink);
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgb(112 230 225 / 16%), transparent 28rem),
|
||||
linear-gradient(180deg, var(--deep), var(--night));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
padding: 0.45rem 1rem;
|
||||
color: var(--night);
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
var(--yellow),
|
||||
var(--yellow) 0.7rem,
|
||||
#ffd35a 0.7rem,
|
||||
#ffd35a 1.4rem
|
||||
);
|
||||
border-bottom: 0.15rem solid #6c5414;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.masthead-inner,
|
||||
main,
|
||||
footer {
|
||||
width: min(70rem, calc(100% - 2rem));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.masthead-inner {
|
||||
padding: 4rem 0 4.5rem;
|
||||
}
|
||||
|
||||
.compact .masthead-inner {
|
||||
padding-block: 3rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.8rem;
|
||||
color: var(--pink);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 0 auto 1.5rem;
|
||||
overflow: hidden;
|
||||
color: var(--cyan);
|
||||
font-size: clamp(0.72rem, 2.8vw, 1.05rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
text-align: left;
|
||||
text-shadow: 0.12rem 0.12rem 0 var(--pink);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 19ch;
|
||||
margin: 0 auto;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: clamp(2.25rem, 7vw, 4.8rem);
|
||||
line-height: 1.02;
|
||||
text-wrap: balance;
|
||||
text-shadow: 0.18rem 0.18rem 0 var(--shadow);
|
||||
}
|
||||
|
||||
.compact h1 {
|
||||
font-size: clamp(2rem, 6vw, 3.8rem);
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-width: 47rem;
|
||||
margin: 1.5rem auto 0;
|
||||
color: var(--muted);
|
||||
font-size: clamp(1.05rem, 2.5vw, 1.28rem);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.8rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.button-row a {
|
||||
padding: 0.7rem 1rem;
|
||||
color: var(--night);
|
||||
background: var(--cyan);
|
||||
border: 0.15rem outset #c6ffff;
|
||||
box-shadow: 0.18rem 0.18rem 0 var(--shadow);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button-row a:nth-child(2) {
|
||||
background: var(--pink);
|
||||
border-color: #ffc2df;
|
||||
}
|
||||
|
||||
.button-row a:active {
|
||||
border-style: inset;
|
||||
box-shadow: none;
|
||||
transform: translate(0.12rem, 0.12rem);
|
||||
}
|
||||
|
||||
.mirror-note {
|
||||
margin: 1rem auto 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
padding-block: 2rem 4rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: relative;
|
||||
padding: clamp(1.25rem, 4vw, 2.5rem);
|
||||
background: linear-gradient(135deg, var(--panel), #271c4a);
|
||||
border: 0.2rem ridge var(--line);
|
||||
box-shadow: 0.35rem 0.35rem 0 var(--shadow);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--yellow);
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: clamp(1.55rem, 4vw, 2.35rem);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.panel h3 {
|
||||
margin-top: 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.panel p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.code-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.8fr) minmax(20rem, 1.2fr);
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sticker {
|
||||
display: inline-block;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
color: var(--night);
|
||||
background: var(--pink);
|
||||
border: 0.12rem outset #ffd2e8;
|
||||
font: 700 0.72rem/1 Verdana, sans-serif;
|
||||
text-transform: uppercase;
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
.code {
|
||||
margin: 0;
|
||||
padding: 1.25rem;
|
||||
overflow-x: auto;
|
||||
color: var(--ink);
|
||||
background: #0c0919;
|
||||
border: 0.15rem inset var(--line);
|
||||
font-size: clamp(0.78rem, 1.8vw, 0.96rem);
|
||||
line-height: 1.5;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.code code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.cards article {
|
||||
padding: 1rem;
|
||||
background: rgb(16 10 37 / 55%);
|
||||
border: 0.1rem solid var(--line);
|
||||
}
|
||||
|
||||
.origin {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pixel-heart {
|
||||
color: var(--pink);
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: clamp(4rem, 12vw, 7rem);
|
||||
line-height: 1;
|
||||
text-shadow:
|
||||
0.16rem 0 var(--pink),
|
||||
-0.16rem 0 var(--pink),
|
||||
0 0.16rem var(--pink),
|
||||
0.3rem 0.3rem 0 var(--shadow);
|
||||
}
|
||||
|
||||
.checklist {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.checklist span {
|
||||
margin-right: 0.5rem;
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.honesty-box {
|
||||
padding: 1rem;
|
||||
color: var(--night);
|
||||
background: var(--yellow);
|
||||
border: 0.18rem outset #fff0b9;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.names dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.names dl div {
|
||||
padding: 0.8rem;
|
||||
background: rgb(16 10 37 / 55%);
|
||||
border-left: 0.3rem solid var(--pink);
|
||||
}
|
||||
|
||||
.names dt {
|
||||
color: var(--cyan);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.names dd {
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding-block: 2rem 3rem;
|
||||
color: var(--muted);
|
||||
border-top: 0.1rem dashed var(--line);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fine-print {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.code-panel,
|
||||
.cards,
|
||||
.names dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.origin {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.panel,
|
||||
.cards article,
|
||||
.names dl div,
|
||||
.button-row a,
|
||||
.honesty-box {
|
||||
border: 0.12rem solid CanvasText;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
<!doctype html>
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="go-import" content="gamertan.com/sandwich-hime git https://gitea.speelman.ca/gamertan/sandwich-hime.git">
|
||||
<meta name="go-source" content="gamertan.com/sandwich-hime https://gitea.speelman.ca/gamertan/sandwich-hime https://gitea.speelman.ca/gamertan/sandwich-hime/src/branch/main{/dir} https://gitea.speelman.ca/gamertan/sandwich-hime/src/branch/main{/dir}/{file}#L{line}">
|
||||
<meta name="himesan-release-status" content="pre-release">
|
||||
<meta name="description" content="Sandwich Hime is an HTML-first, PHP-feeling Go component compiler published as an unsupported pre-1.0 source preview.">
|
||||
<meta name="theme-color" content="#251849">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'">
|
||||
<title>Sandwich Hime — typed Go templates with a hand-built-web heart</title>
|
||||
<link rel="stylesheet" href="./assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to the sandwich</a>
|
||||
|
||||
<header class="masthead">
|
||||
<div class="status-strip" role="status">
|
||||
<span aria-hidden="true">◆</span>
|
||||
Public pre-1.0 source preview — unsupported, no v1 release
|
||||
<span aria-hidden="true">◆</span>
|
||||
</div>
|
||||
<div class="masthead-inner">
|
||||
<p class="eyebrow">Gamertan dot com presents</p>
|
||||
<pre class="wordmark" role="img" aria-label="Sandwich Hime">
|
||||
╭────────────────────────╮
|
||||
│ SANDWICH HIME-SAN │
|
||||
╰──────────┬─────────────╯
|
||||
HTML ♥ GO
|
||||
</pre>
|
||||
<h1>A tiny compiler for people who still want to touch the web.</h1>
|
||||
<p class="lede">
|
||||
Sandwich Hime turns one <code>.sando</code> component into ordinary,
|
||||
reviewable Go. It aims for the immediacy of classic mixed markup,
|
||||
while keeping typed data, contextual safety, and boring deployments.
|
||||
</p>
|
||||
<nav aria-label="Project destinations" class="button-row">
|
||||
<a href="https://gitea.speelman.ca/gamertan/sandwich-hime">Canonical Gitea</a>
|
||||
<a href="#status">Read the release status</a>
|
||||
</nav>
|
||||
<p class="mirror-note">
|
||||
Source and contributions live only on canonical Gitea.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section class="panel code-panel" aria-labelledby="taste-heading">
|
||||
<div>
|
||||
<p class="sticker" aria-hidden="true">view source!</p>
|
||||
<h2 id="taste-heading">A taste of <code>.sando</code></h2>
|
||||
<p>
|
||||
Markup is the main language. Go appears exactly where the page needs
|
||||
it, and every rendered value is escaped for its known HTML context.
|
||||
</p>
|
||||
</div>
|
||||
<pre class="code" tabindex="0" role="region" aria-label="Profile component template example"><code><?sando go
|
||||
package views
|
||||
|
||||
func Profile(page ProfileView)
|
||||
?>
|
||||
<section class="profile">
|
||||
<h1><?= page.Name ?></h1>
|
||||
<? if page.IsAdmin { ?>
|
||||
<?~ AdminBadge() ?>
|
||||
<? } ?>
|
||||
</section></code></pre>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="boundary-heading">
|
||||
<h2 id="boundary-heading">Compiler at the workbench. Small runtime in production.</h2>
|
||||
<div class="cards">
|
||||
<article>
|
||||
<h3><code>himesan</code></h3>
|
||||
<p>
|
||||
A globally installed development tool that checks, generates, and
|
||||
supervises a local server. It does not own your router or app.
|
||||
</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3><code>page.sando.go</code></h3>
|
||||
<p>
|
||||
Deterministic generated Go, committed beside its source so code
|
||||
review and production builds do not need the compiler.
|
||||
</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3><code>sando</code></h3>
|
||||
<p>
|
||||
A deliberately small Apache-2.0 runtime: components, error-aware
|
||||
rendering, and stable escaping helpers—no production server.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel origin" aria-labelledby="origin-heading">
|
||||
<div class="pixel-heart" aria-hidden="true">♥</div>
|
||||
<div>
|
||||
<h2 id="origin-heading">From a 2004 PSO game-club page, with love</h2>
|
||||
<p>
|
||||
The spirit is personal-site webmaking: open the file, understand the
|
||||
page, change the page. Sandwich Hime is a love letter to a first
|
||||
Geocities-era site last updated in 2004—rebuilt with the safety and
|
||||
repeatability expected from a serious modern Go project.
|
||||
</p>
|
||||
<p>
|
||||
The checkerboard, bevels, and tiny status lights are intentional.
|
||||
The security model is also intentional.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="status" aria-labelledby="status-heading">
|
||||
<h2 id="status-heading">Proof first. Release second.</h2>
|
||||
<p>
|
||||
Hime-san has no supported tagged public version yet. EQL Wiki is the
|
||||
production proving ground, and v1 remains unreleased until the compiler,
|
||||
runtime, development supervisor, and contextual escaping have durable
|
||||
evidence behind them.
|
||||
</p>
|
||||
<ul class="checklist">
|
||||
<li><span aria-hidden="true">◌</span> Cross-platform deterministic generation on Go 1.25 and 1.26</li>
|
||||
<li><span aria-hidden="true">◌</span> Adversarial, fuzz, race, vulnerability, and accessibility gates</li>
|
||||
<li><span aria-hidden="true">◌</span> EQL differential pilot and a clean 14-day production soak</li>
|
||||
<li><span aria-hidden="true">◌</span> Working security contact and human-reviewed project policies</li>
|
||||
<li><span aria-hidden="true">◌</span> Signed tags, checksums, SBOMs, binaries, and vanity paths</li>
|
||||
</ul>
|
||||
<p class="honesty-box">
|
||||
No “fastest template engine” promises live here. Any performance claim
|
||||
will ship with the hardware, commit, dataset, commands, latency, and
|
||||
allocation results needed to reproduce it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="panel names" aria-labelledby="names-heading">
|
||||
<h2 id="names-heading">Please keep the name tags on the right guests</h2>
|
||||
<dl>
|
||||
<div><dt>Project</dt><dd>Sandwich Hime / Hime-san</dd></div>
|
||||
<div><dt>Command</dt><dd><code>himesan</code></dd></div>
|
||||
<div><dt>Template</dt><dd><code>page.sando</code></dd></div>
|
||||
<div><dt>Runtime</dt><dd><code>sando</code></dd></div>
|
||||
<div><dt>San language</dt><dd><code>.san</code>—reserved, separate, and never a Hime-san template</dd></div>
|
||||
</dl>
|
||||
<p>
|
||||
“Hime” on its own already belongs to an unrelated Go framework. The
|
||||
compound Sandwich Hime and Hime-san identity avoids stepping on it.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
<span aria-hidden="true">[</span>
|
||||
Hand-built for the web by Gamertan
|
||||
<span aria-hidden="true">]</span>
|
||||
</p>
|
||||
<p class="fine-print">
|
||||
Unsupported pre-1.0 preview. Compiler sources are AGPL-3.0-only; the
|
||||
nested runtime is Apache-2.0; copyable examples are 0BSD.
|
||||
</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="go-import" content="gamertan.com/sandwich-hime/sando git https://gitea.speelman.ca/gamertan/sandwich-hime.git sando">
|
||||
<meta name="go-source" content="gamertan.com/sandwich-hime/sando https://gitea.speelman.ca/gamertan/sandwich-hime/src/branch/main/sando https://gitea.speelman.ca/gamertan/sandwich-hime/src/branch/main/sando{/dir} https://gitea.speelman.ca/gamertan/sandwich-hime/src/branch/main/sando{/dir}/{file}#L{line}">
|
||||
<meta name="himesan-release-status" content="pre-release">
|
||||
<meta name="description" content="Vanity import metadata for the Apache-2.0 Sandwich Hime sando runtime.">
|
||||
<meta name="theme-color" content="#251849">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; base-uri 'none'; form-action 'none'">
|
||||
<title>sando runtime — Sandwich Hime</title>
|
||||
<link rel="stylesheet" href="../assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to the runtime note</a>
|
||||
<header class="masthead compact">
|
||||
<div class="status-strip" role="status">Public source-preview metadata — no public runtime tag yet</div>
|
||||
<div class="masthead-inner">
|
||||
<p class="eyebrow">Sandwich Hime / Hime-san</p>
|
||||
<h1><code>sando</code> is the small production runtime.</h1>
|
||||
<p class="lede">
|
||||
This page carries Go 1.25+ vanity-import metadata for the nested module
|
||||
at <code>gamertan.com/sandwich-hime/sando</code>.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<main id="main">
|
||||
<section class="panel">
|
||||
<h2>Not released yet</h2>
|
||||
<p>
|
||||
The runtime will be tagged independently as <code>sando/vX.Y.Z</code>
|
||||
and licensed Apache-2.0. Do not add this vanity path to production until
|
||||
a signed release exists and the install check is published.
|
||||
</p>
|
||||
<p>
|
||||
<a href="../">Return to the Sandwich Hime project page</a> or inspect
|
||||
the <a href="https://gitea.speelman.ca/gamertan/sandwich-hime/src/branch/main/sando">canonical runtime source on Gitea</a>.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
<footer>
|
||||
<p>HTML-first templates. Ordinary Go. A hand-built-web heart.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user