commit 9b29b3d7f8208199a1d99445d195ca7a56af332f Author: Cole Speelman Date: Tue Aug 11 20:15:06 2026 -0400 feat: publish the Sandwich Hime source preview Signed-off-by: Cole Speelman diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e2e8bdf --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3952a4e --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e81aabd --- /dev/null +++ b/.gitignore @@ -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. diff --git a/AI_CONTRIBUTIONS.md b/AI_CONTRIBUTIONS.md new file mode 100644 index 0000000..6c1da41 --- /dev/null +++ b/AI_CONTRIBUTIONS.md @@ -0,0 +1,13 @@ + + +# 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. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..95be382 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ + + +# 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 ` + +# 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d1eefb0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ + + +# 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. diff --git a/COPYRIGHT b/COPYRIGHT new file mode 100644 index 0000000..a66761e --- /dev/null +++ b/COPYRIGHT @@ -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. diff --git a/DCO.txt b/DCO.txt new file mode 100644 index 0000000..49b8cb0 --- /dev/null +++ b/DCO.txt @@ -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. diff --git a/GENERATED_CODE.md b/GENERATED_CODE.md new file mode 100644 index 0000000..9b5405b --- /dev/null +++ b/GENERATED_CODE.md @@ -0,0 +1,14 @@ + + + +# 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. diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..bda215d --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,13 @@ + + +# 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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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 +. diff --git a/LICENSES.md b/LICENSES.md new file mode 100644 index 0000000..ba31f8d --- /dev/null +++ b/LICENSES.md @@ -0,0 +1,31 @@ + + + +# 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. diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..977e4f7 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,9 @@ + + +# 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. diff --git a/OUTPUT_EXCEPTION.md b/OUTPUT_EXCEPTION.md new file mode 100644 index 0000000..1a3ce8a --- /dev/null +++ b/OUTPUT_EXCEPTION.md @@ -0,0 +1,35 @@ + + + +# 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. diff --git a/PUBLIC-SNAPSHOT.json b/PUBLIC-SNAPSHOT.json new file mode 100644 index 0000000..aba1521 --- /dev/null +++ b/PUBLIC-SNAPSHOT.json @@ -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"} diff --git a/PUBLIC-SNAPSHOT.sha256 b/PUBLIC-SNAPSHOT.sha256 new file mode 100644 index 0000000..4efe1ef --- /dev/null +++ b/PUBLIC-SNAPSHOT.sha256 @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c2233f --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ + + +# 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 + +
+

+ + + +
+``` + +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. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..c3beab8 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,24 @@ + + +# 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. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..ed8261e --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,38 @@ + + +# 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. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ad68bfc --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ + + +# 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. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..e807ff3 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,69 @@ + + +# 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 + +

+``` + +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 | +| --- | --- | +| `` | Trusted Go statements inside the component renderer | +| `` | Render a value escaped for the statically determined output context | +| `` | Render a `sando.Component` and propagate its error | +| `` | 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). diff --git a/TRADEMARKS.md b/TRADEMARKS.md new file mode 100644 index 0000000..844aacc --- /dev/null +++ b/TRADEMARKS.md @@ -0,0 +1,15 @@ + + +# 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. diff --git a/cmd/himesan/main.go b/cmd/himesan/main.go new file mode 100644 index 0000000..b013791 --- /dev/null +++ b/cmd/himesan/main.go @@ -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.") +} diff --git a/cmd/himesan/main_test.go b/cmd/himesan/main_test.go new file mode 100644 index 0000000..988f3c1 --- /dev/null +++ b/cmd/himesan/main_test.go @@ -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 := "\n

\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) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..1b4233e --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,26 @@ + + +# 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. diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md new file mode 100644 index 0000000..9c35475 --- /dev/null +++ b/docs/BENCHMARKS.md @@ -0,0 +1,9 @@ + + +# 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. diff --git a/docs/BRAND.md b/docs/BRAND.md new file mode 100644 index 0000000..faa458e --- /dev/null +++ b/docs/BRAND.md @@ -0,0 +1,9 @@ + + +# 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). diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md new file mode 100644 index 0000000..23075f7 --- /dev/null +++ b/docs/COMPATIBILITY.md @@ -0,0 +1,11 @@ + + +# 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. diff --git a/docs/DEVELOPMENT_SERVER.md b/docs/DEVELOPMENT_SERVER.md new file mode 100644 index 0000000..25ebc48 --- /dev/null +++ b/docs/DEVELOPMENT_SERVER.md @@ -0,0 +1,34 @@ + + +# 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. diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md new file mode 100644 index 0000000..de2b8bd --- /dev/null +++ b/docs/DIAGNOSTICS.md @@ -0,0 +1,23 @@ + + +# 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. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md new file mode 100644 index 0000000..352c092 --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -0,0 +1,39 @@ + + +# 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. diff --git a/examples/eql-shaped/LICENSE b/examples/eql-shaped/LICENSE new file mode 100644 index 0000000..586736e --- /dev/null +++ b/examples/eql-shaped/LICENSE @@ -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. diff --git a/examples/eql-shaped/LICENSES.md b/examples/eql-shaped/LICENSES.md new file mode 100644 index 0000000..6afb5c5 --- /dev/null +++ b/examples/eql-shaped/LICENSES.md @@ -0,0 +1,15 @@ + + + +# 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. diff --git a/examples/eql-shaped/README.md b/examples/eql-shaped/README.md new file mode 100644 index 0000000..f49e8a9 --- /dev/null +++ b/examples/eql-shaped/README.md @@ -0,0 +1,15 @@ + + +# 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) +``` diff --git a/examples/eql-shaped/cmd/example/main.go b/examples/eql-shaped/cmd/example/main.go new file mode 100644 index 0000000..ddfd624 --- /dev/null +++ b/examples/eql-shaped/cmd/example/main.go @@ -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 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 +} diff --git a/examples/eql-shaped/go.mod b/examples/eql-shaped/go.mod new file mode 100644 index 0000000..64e9965 --- /dev/null +++ b/examples/eql-shaped/go.mod @@ -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 diff --git a/examples/eql-shaped/himesan.json b/examples/eql-shaped/himesan.json new file mode 100644 index 0000000..174bc58 --- /dev/null +++ b/examples/eql-shaped/himesan.json @@ -0,0 +1,9 @@ +{ + "version": 1, + "sourceRoots": ["views"], + "goPackage": "./cmd/example", + "listenAddressEnv": "HIMESAN_LISTEN_ADDR", + "healthPath": "/healthz", + "proxyAddress": "127.0.0.1:7331", + "additionalWatchRoots": [] +} diff --git a/examples/eql-shaped/views/badge.sando b/examples/eql-shaped/views/badge.sando new file mode 100644 index 0000000..5328e05 --- /dev/null +++ b/examples/eql-shaped/views/badge.sando @@ -0,0 +1,7 @@ + + + diff --git a/examples/eql-shaped/views/badge.sando.go b/examples/eql-shaped/views/badge.sando.go new file mode 100644 index 0000000..c0b04ea --- /dev/null +++ b/examples/eql-shaped/views/badge.sando.go @@ -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"); __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, "\n"); __himesan_error != nil { + return __himesan_error + } + return nil + }) +} diff --git a/examples/eql-shaped/views/browse_results.sando b/examples/eql-shaped/views/browse_results.sando new file mode 100644 index 0000000..42febd8 --- /dev/null +++ b/examples/eql-shaped/views/browse_results.sando @@ -0,0 +1,17 @@ + + +
+
    + +
  • + + + +
  • + +
+
diff --git a/examples/eql-shaped/views/browse_results.sando.go b/examples/eql-shaped/views/browse_results.sando.go new file mode 100644 index 0000000..99f8a15 --- /dev/null +++ b/examples/eql-shaped/views/browse_results.sando.go @@ -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
\n \n
\n"); __himesan_error != nil { + return __himesan_error + } + return nil + }) +} diff --git a/examples/eql-shaped/views/home.sando b/examples/eql-shaped/views/home.sando new file mode 100644 index 0000000..ef2f8c9 --- /dev/null +++ b/examples/eql-shaped/views/home.sando @@ -0,0 +1,11 @@ + + +
+

+

+ +
diff --git a/examples/eql-shaped/views/home.sando.go b/examples/eql-shaped/views/home.sando.go new file mode 100644 index 0000000..086bb06 --- /dev/null +++ b/examples/eql-shaped/views/home.sando.go @@ -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
\n

"); __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, "

\n

"); __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, "

\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
\n"); __himesan_error != nil { + return __himesan_error + } + return nil + }) +} diff --git a/examples/eql-shaped/views/layout.sando b/examples/eql-shaped/views/layout.sando new file mode 100644 index 0000000..ccdb53b --- /dev/null +++ b/examples/eql-shaped/views/layout.sando @@ -0,0 +1,20 @@ + + + + + + + + <?= view.Title ?> · <?= view.SiteName ?> + + + Skip to content +
+
+
Built with Sandwich Hime.
+ + diff --git a/examples/eql-shaped/views/layout.sando.go b/examples/eql-shaped/views/layout.sando.go new file mode 100644 index 0000000..45d5a12 --- /dev/null +++ b/examples/eql-shaped/views/layout.sando.go @@ -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\n\n\n \n \n "); __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, "\n\n\n Skip to content\n
"); __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, "
\n
"); __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, "
\n
Built with Sandwich Hime.
\n\n\n"); __himesan_error != nil { + return __himesan_error + } + return nil + }) +} diff --git a/examples/eql-shaped/views/model.go b/examples/eql-shaped/views/model.go new file mode 100644 index 0000000..d61e1bd --- /dev/null +++ b/examples/eql-shaped/views/model.go @@ -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 +} diff --git a/examples/eql-shaped/views/render_test.go b/examples/eql-shaped/views/render_test.go new file mode 100644 index 0000000..ee16c9f --- /dev/null +++ b/examples/eql-shaped/views/render_test.go @@ -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: `">`, + Records: []RecordView{{ + URL: `/item?q=" onclick="alert(1)`, + Title: ``, + 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, "\n-->", code: "HIM1357"}, + {name: "script escaped state split by template comment", body: ``, code: "HIM1357"}, + {name: "dynamic iframe raw text", body: ``, code: "HIM1303"}, + {name: "dynamic style attribute", body: `

`, code: "HIM1343"}, + {name: "dynamic srcdoc attribute", body: ``, code: "HIM1345"}, + {name: "dynamic srcset attribute", body: ``, code: "HIM1345"}, + {name: "meta refresh", body: ``, code: "HIM1346"}, + {name: "dynamic meta http equiv", body: ``, code: "HIM1346"}, + {name: "duplicate attribute", body: ``, code: "HIM1347"}, + {name: "dangerous static URL", body: `x`, code: "HIM1344"}, + {name: "ambiguous URL pieces", body: `x`, code: "HIM1341"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + source := "\n" + test.body + _, diagnostics := Compile(test.name+".sando", []byte(source)) + assertDiagnosticCode(t, diagnostics, test.code) + }) + } +} + +func TestHTMLCommentSyntaxInOtherRawTextStatesRemainsSupported(t *testing.T) { + t.Parallel() + source := ` + + +` + _, diagnostics := Compile("raw-text.sando", []byte(source)) + assertNoErrorDiagnostics(t, diagnostics) +} + +func TestTextareaExpressionUsesTextEscaping(t *testing.T) { + t.Parallel() + source := ` +` + 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 := ` +relative +">trusted + +` + 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 := ` +reviewed") ?>` + 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, ` +

Hello

`) + 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, "") + 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"), ` +`) + mustWrite(t, filepath.Join(directory, "b.sando"), ` +`) + 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 "\n

" + text + "

\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) +} diff --git a/internal/compiler/context.go b/internal/compiler/context.go new file mode 100644 index 0000000..666e25e --- /dev/null +++ b/internal/compiler/context.go @@ -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 (", 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("", + "\xef\xbb\xbf\r\n\r\n

x

", + } { + 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)) + } + }) +} diff --git a/internal/compiler/model.go b/internal/compiler/model.go new file mode 100644 index 0000000..b7945fe --- /dev/null +++ b/internal/compiler/model.go @@ -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"` +} diff --git a/internal/compiler/operation.go b/internal/compiler/operation.go new file mode 100644 index 0000000..2db4b02 --- /dev/null +++ b/internal/compiler/operation.go @@ -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 "" + } + } +} diff --git a/internal/compiler/parse.go b/internal/compiler/parse.go new file mode 100644 index 0000000..ef985bc --- /dev/null +++ b/internal/compiler/parse.go @@ -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 = " 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 = len(source) || !isSpace(source[afterMarker]) { + diagnostics = append(diagnostics, diagnostic(path, table.at(afterMarker), "HIM1105", "whitespace is required between 2 && !isSpace(directiveBody[2])) { + diagnostics = append(diagnostics, diagnostic(path, table.at(directiveStart), "HIM1103", "unsupported or missing header target; v1 requires 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(" 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", " 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 { ?>...': + 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()))} +} diff --git a/internal/compiler/replace_unix.go b/internal/compiler/replace_unix.go new file mode 100644 index 0000000..4c2b668 --- /dev/null +++ b/internal/compiler/replace_unix.go @@ -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) +} diff --git a/internal/compiler/replace_windows.go b/internal/compiler/replace_windows.go new file mode 100644 index 0000000..b357619 --- /dev/null +++ b/internal/compiler/replace_windows.go @@ -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 +} diff --git a/internal/devserver/config.go b/internal/devserver/config.go new file mode 100644 index 0000000..19f4cd9 --- /dev/null +++ b/internal/devserver/config.go @@ -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)) +} diff --git a/internal/devserver/config_test.go b/internal/devserver/config_test.go new file mode 100644 index 0000000..9effccc --- /dev/null +++ b/internal/devserver/config_test.go @@ -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) + } + } +} diff --git a/internal/devserver/events.go b/internal/devserver/events.go new file mode 100644 index 0000000..d178923 --- /dev/null +++ b/internal/devserver/events.go @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package devserver + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" + "time" +) + +const eventsPath = "/__himesan/events" + +// Diagnostic is a compiler/build diagnostic suitable for the development +// browser overlay. The CLI may map its compiler's native diagnostics through +// Options.MapDiagnostics without coupling this package to the compiler. +type Diagnostic struct { + Path string `json:"path,omitempty"` + Line int `json:"line,omitempty"` + Column int `json:"column,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message"` + Severity string `json:"severity,omitempty"` +} + +// Event is delivered both to Options.OnEvent and to connected browser clients. +// Type is currently one of "ready", "reload", or "diagnostic". +type Event struct { + Type string `json:"type"` + Phase string `json:"phase,omitempty"` + Message string `json:"message,omitempty"` + Diagnostics []Diagnostic `json:"diagnostics,omitempty"` + At time.Time `json:"at"` +} + +type eventHub struct { + mu sync.Mutex + subscribers map[chan Event]struct{} + latest *Event + closed bool +} + +func newEventHub() *eventHub { + return &eventHub{subscribers: make(map[chan Event]struct{})} +} + +func (h *eventHub) publish(event Event) { + if event.At.IsZero() { + event.At = time.Now().UTC() + } + h.mu.Lock() + defer h.mu.Unlock() + if h.closed { + return + } + if event.Type == "diagnostic" { + copy := event + h.latest = © + } else if event.Type == "reload" { + h.latest = nil + } + for subscriber := range h.subscribers { + select { + case subscriber <- event: + default: + // Reload and diagnostic events are snapshots, not a log. Replace the + // oldest queued snapshot so a slow browser still receives the newest + // state transition. + select { + case <-subscriber: + default: + } + select { + case subscriber <- event: + default: + } + } + } +} + +func (h *eventHub) subscribe() (<-chan Event, func()) { + updates := make(chan Event, 8) + h.mu.Lock() + if h.closed { + close(updates) + h.mu.Unlock() + return updates, func() {} + } + h.subscribers[updates] = struct{}{} + if h.latest != nil { + updates <- *h.latest + } + h.mu.Unlock() + + var once sync.Once + return updates, func() { + once.Do(func() { + h.mu.Lock() + if _, ok := h.subscribers[updates]; ok { + delete(h.subscribers, updates) + close(updates) + } + h.mu.Unlock() + }) + } +} + +func (h *eventHub) close() { + h.mu.Lock() + defer h.mu.Unlock() + if h.closed { + return + } + h.closed = true + for subscriber := range h.subscribers { + close(subscriber) + delete(h.subscribers, subscriber) + } +} + +func (h *eventHub) serveHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming is unavailable", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + if err := writeSSE(w, Event{Type: "ready", At: time.Now().UTC()}); err != nil { + return + } + flusher.Flush() + + updates, unsubscribe := h.subscribe() + defer unsubscribe() + heartbeat := time.NewTicker(15 * time.Second) + defer heartbeat.Stop() + for { + select { + case <-r.Context().Done(): + return + case event, ok := <-updates: + if !ok { + return + } + if err := writeSSE(w, event); err != nil { + return + } + flusher.Flush() + case <-heartbeat.C: + if _, err := fmt.Fprint(w, ": heartbeat\n\n"); err != nil { + return + } + flusher.Flush() + } + } +} + +func writeSSE(w http.ResponseWriter, event Event) error { + payload, err := json.Marshal(event) + if err != nil { + return err + } + if _, err := fmt.Fprintf(w, "event: %s\n", event.Type); err != nil { + return err + } + _, err = fmt.Fprintf(w, "data: %s\n\n", payload) + return err +} diff --git a/internal/devserver/process.go b/internal/devserver/process.go new file mode 100644 index 0000000..33abcc8 --- /dev/null +++ b/internal/devserver/process.go @@ -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 +} diff --git a/internal/devserver/process_test.go b/internal/devserver/process_test.go new file mode 100644 index 0000000..7a9e4d2 --- /dev/null +++ b/internal/devserver/process_test.go @@ -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) + } +} diff --git a/internal/devserver/process_unix.go b/internal/devserver/process_unix.go new file mode 100644 index 0000000..9c1b7f3 --- /dev/null +++ b/internal/devserver/process_unix.go @@ -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) +} diff --git a/internal/devserver/process_windows.go b/internal/devserver/process_windows.go new file mode 100644 index 0000000..4570119 --- /dev/null +++ b/internal/devserver/process_windows.go @@ -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() +} diff --git a/internal/devserver/process_windows_32.go b/internal/devserver/process_windows_32.go new file mode 100644 index 0000000..5e15443 --- /dev/null +++ b/internal/devserver/process_windows_32.go @@ -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 +) diff --git a/internal/devserver/process_windows_64.go b/internal/devserver/process_windows_64.go new file mode 100644 index 0000000..a656490 --- /dev/null +++ b/internal/devserver/process_windows_64.go @@ -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 +) diff --git a/internal/devserver/proxy.go b/internal/devserver/proxy.go new file mode 100644 index 0000000..81be9ca --- /dev/null +++ b/internal/devserver/proxy.go @@ -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("") + 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("

Hime-san is waiting for a healthy application build.

"), reloadClientTag...) + body = append(body, []byte("")...) + 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("")) + if end < 0 { + return false + } + remaining = bytes.TrimSpace(remaining[4+end+3:]) + } + lower := bytes.ToLower(remaining) + return hasHTMLTokenPrefix(lower, "' || next == '/' || next == ' ' || next == '\t' || next == '\r' || next == '\n' || next == '\f' +} + +func insertReloadClient(contents []byte) []byte { + lower := bytes.ToLower(contents) + position := bytes.LastIndex(lower, []byte("")) + if position < 0 { + position = bytes.LastIndex(lower, []byte("")) + } + 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 + // = 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' +} diff --git a/sando/write_test.go b/sando/write_test.go new file mode 100644 index 0000000..86e13a9 --- /dev/null +++ b/sando/write_test.go @@ -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: ``, want: `<script x="'&">alert(1)</script>`}, + {name: "unicode preserved", value: "姫 🍞 café", want: "姫 🍞 café"}, + {name: "NUL replaced", value: "left\x00right", want: "left\uFFFDright"}, + {name: "invalid UTF-8 replaced", value: string([]byte{'a', 0xff, 'b'}), want: "a\uFFFDb"}, + {name: "non-string formatted", value: 42, want: "42"}, + {name: "trusted HTML is deliberately raw in text context", value: sando.TrustHTML("explicitly trusted"), want: "explicitly trusted"}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + var output bytes.Buffer + if err := sando.WriteText(&output, test.value); err != nil { + t.Fatalf("WriteText() error = %v", err) + } + if got := output.String(); got != test.want { + t.Fatalf("WriteText() = %q, want %q", got, test.want) + } + if !utf8.ValidString(output.String()) { + t.Fatal("WriteText() emitted invalid UTF-8") + } + }) + } +} + +func TestWriteAttr(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value any + want string + }{ + {name: "breakout characters", value: `x" autofocus onfocus="alert(1)&`, want: `x" autofocus onfocus="alert(1)&`}, + {name: "angle and apostrophe", value: `<'value'>`, want: `<'value'>`}, + {name: "unicode and NUL", value: "姫\x00さん", want: "姫\uFFFDさん"}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + var output bytes.Buffer + if err := sando.WriteAttr(&output, test.value); err != nil { + t.Fatalf("WriteAttr() error = %v", err) + } + if got := output.String(); got != test.want { + t.Fatalf("WriteAttr() = %q, want %q", got, test.want) + } + }) + } +} + +func TestWriteRCDATAAlwaysEscapesTrustedValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value any + want string + }{ + {name: "plain", value: ``, want: `</title><script>alert("x")</script>`}, + {name: "trusted HTML", value: sando.TrustHTML(``), want: `</textarea><script>alert(1)</script>`}, + {name: "trusted URL", value: sando.TrustURL(`javascript:`), want: `javascript:</title>`}, + {name: "trusted JavaScript", value: sando.TrustJS(``), want: `</title><script>alert(1)</script>`}, + {name: "trusted CSS", value: sando.TrustCSS(``), want: `</textarea><style>*{display:none}</style>`}, + {name: "unicode and NUL", value: "姫\x00さん", want: "姫\uFFFDさん"}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + var output bytes.Buffer + if err := sando.WriteRCDATA(&output, test.value); err != nil { + t.Fatalf("WriteRCDATA() error = %v", err) + } + if got := output.String(); got != test.want { + t.Fatalf("WriteRCDATA() = %q, want %q", got, test.want) + } + }) + } +} + +func TestWriteRCDATAAlwaysEscapesTrustedHTML(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + value := sando.TrustHTML(``) + if err := sando.WriteRCDATA(&output, value); err != nil { + t.Fatal(err) + } + if got, want := output.String(), `</textarea><script>alert(1)</script>`; got != want { + t.Fatalf("WriteRCDATA() = %q, want %q", got, want) + } +} + +func TestWriteURLAllowsAndCanonicalizesOrdinaryValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value any + want string + }{ + {name: "root relative", value: "/items?q=tea&sort=name", want: "/items?q=tea&sort=name"}, + {name: "path relative", value: "../images/姫.png", want: "../images/姫.png"}, + {name: "fragment", value: "#section", want: "#section"}, + {name: "network path", value: "//static.example/assets", want: "//static.example/assets"}, + {name: "HTTP scheme case insensitive", value: "HTTP://example.test/a", want: "HTTP://example.test/a"}, + {name: "HTTPS", value: "https://example.test/", want: "https://example.test/"}, + {name: "mail", value: "mailto:hime@example.test", want: "mailto:hime@example.test"}, + {name: "telephone", value: "tel:+14165550123", want: "tel:+14165550123"}, + {name: "surrounding whitespace trimmed", value: " \n\thttps://example.test/path\r ", want: "https://example.test/path"}, + {name: "colon after query is relative", value: "/search?q=kind:value", want: "/search?q=kind:value"}, + {name: "empty", value: "", want: ""}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + var output bytes.Buffer + if err := sando.WriteURL(&output, test.value); err != nil { + t.Fatalf("WriteURL() error = %v", err) + } + if got := output.String(); got != test.want { + t.Fatalf("WriteURL() = %q, want %q", got, test.want) + } + }) + } +} + +func TestWriteURLRejectsDangerousAndAmbiguousValuesBeforeWriting(t *testing.T) { + t.Parallel() + + values := []string{ + "javascript:alert(1)", + "JaVaScRiPt:alert(1)", + "data:text/html,", + "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(`Hime`)) + }, want: `Hime`}, + {name: "URL bypasses scheme but not attribute escaping", write: func(w io.Writer) error { return sando.WriteURL(w, sando.TrustURL(`custom:"<&`)) }, want: `custom:"<&`}, + {name: "JavaScript", write: func(w io.Writer) error { return sando.WriteJS(w, sando.TrustJS(`window.hime = "<3";`)) }, want: `window.hime = "<3";`}, + {name: "CSS", write: func(w io.Writer) error { return sando.WriteCSS(w, sando.TrustCSS(`.hime::after { content: "<3"; }`)) }, want: `.hime::after { content: "<3"; }`}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + var output bytes.Buffer + if err := test.write(&output); err != nil { + t.Fatalf("trusted write error = %v", err) + } + if got := output.String(); got != test.want { + t.Fatalf("trusted write = %q, want %q", got, test.want) + } + }) + } +} + +func TestWriteHelpersPropagateWriterFailures(t *testing.T) { + t.Parallel() + + want := errors.New("disk full") + tests := []struct { + name string + write func(io.Writer) error + }{ + {name: "static literal", write: func(w io.Writer) error { return sando.WriteString(w, "hello") }}, + {name: "text", write: func(w io.Writer) error { return sando.WriteText(w, "hello") }}, + {name: "RCDATA", write: func(w io.Writer) error { return sando.WriteRCDATA(w, "hello") }}, + {name: "attribute", write: func(w io.Writer) error { return sando.WriteAttr(w, "hello") }}, + {name: "URL", write: func(w io.Writer) error { return sando.WriteURL(w, "/hello") }}, + {name: "HTML", write: func(w io.Writer) error { return sando.WriteHTML(w, sando.TrustHTML("hello")) }}, + {name: "JavaScript", write: func(w io.Writer) error { return sando.WriteJS(w, sando.TrustJS("hello")) }}, + {name: "CSS", write: func(w io.Writer) error { return sando.WriteCSS(w, sando.TrustCSS("hello")) }}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := test.write(errorWriter{err: want}); !errors.Is(got, want) { + t.Fatalf("write error = %v, want %v", got, want) + } + if got := test.write(shortWriter{}); !errors.Is(got, io.ErrShortWrite) { + t.Fatalf("short write error = %v, want io.ErrShortWrite", got) + } + if got := test.write(nil); !errors.Is(got, sando.ErrNilWriter) { + t.Fatalf("nil writer error = %v, want ErrNilWriter", got) + } + }) + } +} + +func TestURLSafetyErrorDoesNotEchoSensitiveValue(t *testing.T) { + t.Parallel() + + const secret = "user:password@example.test" + err := sando.WriteURL(io.Discard, "custom:"+secret) + if err == nil { + t.Fatal("WriteURL() unexpectedly accepted custom scheme") + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("error leaks URL contents: %q", err) + } + var safetyError *sando.URLSafetyError + if !errors.As(err, &safetyError) { + t.Fatalf("error type = %T, want *sando.URLSafetyError", err) + } +} + +type errorWriter struct{ err error } + +func (w errorWriter) Write([]byte) (int, error) { return 0, w.err } + +type shortWriter struct{} + +func (shortWriter) Write(value []byte) (int, error) { + if len(value) == 0 { + return 0, nil + } + return len(value) - 1, nil +} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..05c5d57 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,23 @@ + + +# 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. diff --git a/scripts/check-licenses.sh b/scripts/check-licenses.sh new file mode 100755 index 0000000..fa69ad9 --- /dev/null +++ b/scripts/check-licenses.sh @@ -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' diff --git a/scripts/check-site.sh b/scripts/check-site.sh new file mode 100755 index 0000000..d81a811 --- /dev/null +++ b/scripts/check-site.sh @@ -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 '' "$page" || fail "$page needs a document language" + grep -Fq '' "$page" || fail "$page needs the skip-link target" + grep -Fq '

' "$page" || grep -Fq '

' \ + site/index.html || fail 'compiler vanity-import metadata is missing or changed' +grep -Fq '' \ + site/sando/index.html || fail 'nested runtime vanity-import metadata is missing or changed' + +if find site -type f -name '*.html' -exec grep -Ein '])' {} + | 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 "" "$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 '' 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' diff --git a/scripts/release-check.sh b/scripts/release-check.sh new file mode 100755 index 0000000..1b04636 --- /dev/null +++ b/scripts/release-check.sh @@ -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" diff --git a/scripts/verify-public-install.sh b/scripts/verify-public-install.sh new file mode 100755 index 0000000..ea9591e --- /dev/null +++ b/scripts/verify-public-install.sh @@ -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='' +runtime_meta='' + +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' + +

version probe

+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" diff --git a/scripts/verify.ps1 b/scripts/verify.ps1 new file mode 100644 index 0000000..309d0f1 --- /dev/null +++ b/scripts/verify.ps1 @@ -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 + } +} diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..664e7be --- /dev/null +++ b/scripts/verify.sh @@ -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" diff --git a/site/README.md b/site/README.md new file mode 100644 index 0000000..96a1676 --- /dev/null +++ b/site/README.md @@ -0,0 +1,74 @@ + + +# 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 + +``` + +The fourth field maps the vanity path to the repository’s `sando` subdirectory. It is supported by the project’s minimum Go line, Go 1.25. Runtime versions must use tags such as `sando/v1.0.0`. + +## Hosting setup + +1. Keep the public DNS and TLS authority for `gamertan.com` under founder control. Use an `A`/`AAAA` record or a narrowly scoped `CNAME` appropriate to the chosen static host; do not delegate the whole zone to a project contributor. +2. Configure the exact browser routes plus the query-scoped `go-get=1` + fallbacks above. Preserve ordinary 404 behavior and do not use a client-side + redirect for metadata requests. +3. Return `Content-Type: text/html; charset=utf-8` for HTML and `text/css; charset=utf-8` for CSS. +4. Add server headers at least equivalent to `Content-Security-Policy: default-src 'none'; style-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`, `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`, and a conservative `Permissions-Policy`. +5. Keep deployment credentials outside this repository. A future deploy workflow needs a separately reviewed, least-privilege credential and protected environment approval. +6. Verify from an uncached public network before announcing installs: + + ```sh + curl -fsS 'https://gamertan.com/sandwich-hime?go-get=1' + curl -fsS 'https://gamertan.com/sandwich-hime/cmd/himesan?go-get=1' + curl -fsS 'https://gamertan.com/sandwich-hime/sando?go-get=1' + ./scripts/verify-public-install.sh --version v1.0.0 + ``` + +## Launch blockers + +Do not deploy or remove the pre-release warning until all of these are evidenced: + +- canonical Gitea is public, the security contact works, and protected release-key controls are active; +- root `v1.0.0` and nested `sando/v1.0.0` are signed and accompanied by checksums, SBOMs, release binaries, and reproducibility notes; +- Go 1.25 and Go 1.26 pass deterministic generation and tests on Linux, macOS, and Windows, including the canonical manual cross-platform workflow; +- vulnerability, race, fuzz, adversarial, license, accessibility, and CSP checks pass; +- EQL Wiki completes its differential pilot and 14-day production soak without Hime render, security, or accessibility regressions; +- the DCO contribution process, license map, governance, output permission, and trademark policy have final human review; +- `scripts/release-check.sh --version v1.0.0 --public` passes against a human-reviewed evidence bundle; +- the exact public `go install` and `go get` commands pass + `scripts/verify-public-install.sh` from clean direct-fetch and public-proxy + caches without repository credentials. + +At launch, replace each page’s `himesan-release-status` value with the exact compiler release (for example, `v1.0.0`), replace the human-facing pre-release copy with verified install information, and run `scripts/check-site.sh --public v1.0.0`. The public release preflight enforces that transition so a green evidence bundle cannot accidentally publish a page that still says the runtime does not exist. + +Gitea is the sole public forge. Cross-platform evidence must come from reviewed local or Gitea-runner execution; no secondary mirror or hosted workflow is part of the release plan. diff --git a/site/assets/site.css b/site/assets/site.css new file mode 100644 index 0000000..a17f1c2 --- /dev/null +++ b/site/assets/site.css @@ -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; + } +} diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..b004028 --- /dev/null +++ b/site/index.html @@ -0,0 +1,166 @@ + + + + + + + + + + + + + Sandwich Hime — typed Go templates with a hand-built-web heart + + + + + +
+
+ + Public pre-1.0 source preview — unsupported, no v1 release + +
+
+

Gamertan dot com presents

+ +

A tiny compiler for people who still want to touch the web.

+

+ Sandwich Hime turns one .sando component into ordinary, + reviewable Go. It aims for the immediacy of classic mixed markup, + while keeping typed data, contextual safety, and boring deployments. +

+ +

+ Source and contributions live only on canonical Gitea. +

+
+
+ +
+
+
+ +

A taste of .sando

+

+ Markup is the main language. Go appears exactly where the page needs + it, and every rendered value is escaped for its known HTML context. +

+
+
<?sando go
+package views
+
+func Profile(page ProfileView)
+?>
+<section class="profile">
+  <h1><?= page.Name ?></h1>
+  <? if page.IsAdmin { ?>
+    <?~ AdminBadge() ?>
+  <? } ?>
+</section>
+
+ +
+

Compiler at the workbench. Small runtime in production.

+
+
+

himesan

+

+ A globally installed development tool that checks, generates, and + supervises a local server. It does not own your router or app. +

+
+
+

page.sando.go

+

+ Deterministic generated Go, committed beside its source so code + review and production builds do not need the compiler. +

+
+
+

sando

+

+ A deliberately small Apache-2.0 runtime: components, error-aware + rendering, and stable escaping helpers—no production server. +

+
+
+
+ +
+ +
+

From a 2004 PSO game-club page, with love

+

+ 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. +

+

+ The checkerboard, bevels, and tiny status lights are intentional. + The security model is also intentional. +

+
+
+ +
+

Proof first. Release second.

+

+ 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. +

+
    +
  • Cross-platform deterministic generation on Go 1.25 and 1.26
  • +
  • Adversarial, fuzz, race, vulnerability, and accessibility gates
  • +
  • EQL differential pilot and a clean 14-day production soak
  • +
  • Working security contact and human-reviewed project policies
  • +
  • Signed tags, checksums, SBOMs, binaries, and vanity paths
  • +
+

+ 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. +

+
+ +
+

Please keep the name tags on the right guests

+
+
Project
Sandwich Hime / Hime-san
+
Command
himesan
+
Template
page.sando
+
Runtime
sando
+
San language
.san—reserved, separate, and never a Hime-san template
+
+

+ “Hime” on its own already belongs to an unrelated Go framework. The + compound Sandwich Hime and Hime-san identity avoids stepping on it. +

+
+
+ +
+

+ + Hand-built for the web by Gamertan + +

+

+ Unsupported pre-1.0 preview. Compiler sources are AGPL-3.0-only; the + nested runtime is Apache-2.0; copyable examples are 0BSD. +

+
+ + diff --git a/site/sando/index.html b/site/sando/index.html new file mode 100644 index 0000000..1e850c4 --- /dev/null +++ b/site/sando/index.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + sando runtime — Sandwich Hime + + + + +
+
Public source-preview metadata — no public runtime tag yet
+
+

Sandwich Hime / Hime-san

+

sando is the small production runtime.

+

+ This page carries Go 1.25+ vanity-import metadata for the nested module + at gamertan.com/sandwich-hime/sando. +

+
+
+
+
+

Not released yet

+

+ The runtime will be tagged independently as sando/vX.Y.Z + and licensed Apache-2.0. Do not add this vanity path to production until + a signed release exists and the install check is published. +

+

+ Return to the Sandwich Hime project page or inspect + the canonical runtime source on Gitea. +

+
+
+ + +