feat: publish the Sandwich Hime source preview

Signed-off-by: Cole Speelman <gamertan@noreply.localhost>
This commit is contained in:
2026-08-11 20:15:06 -04:00
commit 9b29b3d7f8
100 changed files with 10989 additions and 0 deletions
+16
View File
@@ -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
+13
View File
@@ -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
View File
@@ -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.
+13
View File
@@ -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.
+25
View File
@@ -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.
+15
View File
@@ -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.
+31
View File
@@ -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.
+16
View File
@@ -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.
+34
View File
@@ -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.
+14
View File
@@ -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.
+13
View File
@@ -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.
+661
View File
@@ -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
View File
@@ -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.
+9
View File
@@ -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.
+35
View File
@@ -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.
+1
View File
@@ -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"}
+98
View File
@@ -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
+86
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+69
View File
@@ -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).
+15
View File
@@ -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.
+318
View File
@@ -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.")
}
+125
View File
@@ -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)
}
}
+26
View File
@@ -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.
+9
View File
@@ -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.
+9
View File
@@ -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).
+11
View File
@@ -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.
+34
View File
@@ -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.
+23
View File
@@ -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.
+39
View File
@@ -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.
+14
View File
@@ -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.
+15
View File
@@ -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.
+15
View File
@@ -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)
```
+83
View File
@@ -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
}
+9
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
{
"version": 1,
"sourceRoots": ["views"],
"goPackage": "./cmd/example",
"listenAddressEnv": "HIMESAN_LISTEN_ADDR",
"healthPath": "/healthz",
"proxyAddress": "127.0.0.1:7331",
"additionalWatchRoots": []
}
+7
View File
@@ -0,0 +1,7 @@
<?sando go
package views
func Badge(label string)
?>
<?# SPDX-License-Identifier: 0BSD ?>
<strong class="badge"><?= label ?></strong>
+37
View File
@@ -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
})
}
+11
View File
@@ -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>
+53
View File
@@ -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
})
}
+20
View File
@@ -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>
+61
View File
@@ -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
})
}
+29
View File
@@ -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
}
+131
View File
@@ -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
})
}
+5
View File
@@ -0,0 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-only
module gamertan.com/sandwich-hime
go 1.25
+318
View File
@@ -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)
}
+537
View File
@@ -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&#58;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)
}
+620
View File
@@ -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))
}
+103
View File
@@ -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),
}
}
+215
View File
@@ -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
}
}
+83
View File
@@ -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")
}
}
+38
View File
@@ -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))
}
})
}
+109
View File
@@ -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"`
}
+370
View File
@@ -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 ""
}
}
}
+544
View File
@@ -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()))}
}
+10
View File
@@ -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)
}
+44
View File
@@ -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
}
+165
View File
@@ -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))
}
+69
View File
@@ -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)
}
}
}
+178
View File
@@ -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 = &copy
} 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
}
+127
View File
@@ -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
}
+173
View File
@@ -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)
}
}
+40
View File
@@ -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)
}
+134
View File
@@ -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()
}
+28
View File
@@ -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
)
+27
View File
@@ -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
)
+410
View File
@@ -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
}
+390
View File
@@ -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{}
}
+505
View File
@@ -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 = &copy
}
// 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
}
+295
View File
@@ -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)
}
}
+157
View File
@@ -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
}
+59
View File
@@ -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)
}
}
+82
View File
@@ -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
)
+40
View File
@@ -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)
}
})
}
}
+11
View File
@@ -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
View File
@@ -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.
+19
View File
@@ -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.
+87
View File
@@ -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
}
}
+98
View File
@@ -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 }
+5
View File
@@ -0,0 +1,5 @@
// SPDX-License-Identifier: Apache-2.0
module gamertan.com/sandwich-hime/sando
go 1.25
+40
View File
@@ -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
View File
@@ -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'
}
+288
View File
@@ -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: `&lt;script x=&#34;&#39;&amp;&#34;&gt;alert(1)&lt;/script&gt;`},
{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&#34; autofocus onfocus=&#34;alert(1)&amp;`},
{name: "angle and apostrophe", value: `<'value'>`, want: `&lt;&#39;value&#39;&gt;`},
{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: `&lt;/title&gt;&lt;script&gt;alert(&#34;x&#34;)&lt;/script&gt;`},
{name: "trusted HTML", value: sando.TrustHTML(`</textarea><script>alert(1)</script>`), want: `&lt;/textarea&gt;&lt;script&gt;alert(1)&lt;/script&gt;`},
{name: "trusted URL", value: sando.TrustURL(`javascript:</title>`), want: `javascript:&lt;/title&gt;`},
{name: "trusted JavaScript", value: sando.TrustJS(`</title><script>alert(1)</script>`), want: `&lt;/title&gt;&lt;script&gt;alert(1)&lt;/script&gt;`},
{name: "trusted CSS", value: sando.TrustCSS(`</textarea><style>*{display:none}</style>`), want: `&lt;/textarea&gt;&lt;style&gt;*{display:none}&lt;/style&gt;`},
{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(), `&lt;/textarea&gt;&lt;script&gt;alert(1)&lt;/script&gt;`; 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&amp;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:&#34;&lt;&amp;`},
{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
}
+23
View File
@@ -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.
+201
View File
@@ -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'
+75
View File
@@ -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'
+174
View File
@@ -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"
+170
View File
@@ -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"
+154
View File
@@ -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
}
}
+158
View File
@@ -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"
+74
View File
@@ -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 repositorys `sando` subdirectory. It is supported by the projects 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 pages `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.
+398
View File
@@ -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
View File
@@ -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>&lt;?sando go
package views
func Profile(page ProfileView)
?&gt;
&lt;section class="profile"&gt;
&lt;h1&gt;&lt;?= page.Name ?&gt;&lt;/h1&gt;
&lt;? if page.IsAdmin { ?&gt;
&lt;?~ AdminBadge() ?&gt;
&lt;? } ?&gt;
&lt;/section&gt;</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>
+47
View File
@@ -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>