commit 6a3a198975b0444a49f438f38b8d84710274819f Author: randogoth Date: Sun Oct 4 18:55:33 2026 +0300 feat: add workspace skeleton and server configuration validation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27012f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +result +result-* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cb80a97 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# General Working Rules + +## Coding + +- **Simple and idiomatic.** Write readable, conventional code. Prefer composition, immutability, explicit dependencies, and a single source of truth. +- **Small and focused.** Keep functions and modules single-purpose. Separate concerns and avoid unnecessary nesting, abstraction, and global mutable state. +- **Reuse existing solutions.** Follow project conventions and prefer existing tools and dependencies. Introduce new ones only when justified. +- **Minimal changes.** Implement only what is requested. Prefer deletion over rewriting, and avoid unrelated refactoring, formatting, or speculative features. + +## Comments + +- **Self-contained.** Explain essential context inline without requiring external documents or conversations. References to nearby source code are acceptable. +- **Concise.** Usually one sentence. Put longer explanations in brief module headers or documentation. +- **Why, not what.** Comment only on non-obvious decisions, constraints, invariants, and surprising behavior. Do not restate obvious code. +- **No unnecessary history.** Include implementation history only when essential to understanding current behavior. Remove obsolete comments. + +## Documentation + +- Keep documentation concise, accurate, and synchronized with the implementation. +- Distinguish implemented behavior from proposals and historical plans. Remove outdated information and shorten resolved findings. +- Update specifications alongside changes to interfaces, formats, and contracts. Record important decisions where they are enforced. +- **Natural reflow.** Write paragraphs as continuous lines without hard wrapping. Use blank lines between paragraphs and explicit line breaks only where structurally necessary. + +## Working Style + +- **Inspect first.** Examine existing code, configuration, architecture, and conventions rather than assuming how things work. +- **Respect scope.** Follow the latest explicit instructions. Do not make unrelated changes or resume superseded tasks. +- **Work silently.** No progress narration or request recaps. Interrupt only for genuine blockers or discoveries that materially change the approach. +- **Resolve uncertainty.** Investigate missing information before asking. Never invent requirements or assume unverified behavior. +- **Respect user decisions.** Identify significant design problems and propose concrete alternatives with trade-offs, but follow the user's choice. +- **Respect authorization.** Observe approval requirements for changes, tests, commits, deployments, and destructive operations. +- **No subagents without permission.** Do not spawn or delegate work to subagents unless the user explicitly approves their use. +- **Commit messages.** Use concise, single-line, tag-prefixed messages (e.g., fix:, feat:, refactor:, docs:). Describe what changed using the imperative mood. +- **No credit notes.** Omit attribution, co-author trailers, AI-generated notices, and other credit statements from commits and generated documentation. + +## Verification + +- Test affected behavior using existing project tools. Add regression tests where appropriate. +- Preserve safeguards for security, external input, data integrity, and concurrency. +- Measure consequential assumptions before making architectural decisions. +- Perform repository-wide cleanup or extensive refactoring only when explicitly requested. +- Format and validate affected code without altering unrelated files. +- Report what passed, failed, or could not be verified. Never claim untested behavior works. + +## Completion + +Summarize changes, verification results, important decisions, and unresolved issues in a few sentences. Avoid lengthy reports unless requested. + +## Project Preferences + +- use `git` colocated `jj` for version management +- use `devbox` to manage dependencies and scripting \ No newline at end of file diff --git a/AI-DECLARATION.md b/AI-DECLARATION.md new file mode 100644 index 0000000..1aa6a1e --- /dev/null +++ b/AI-DECLARATION.md @@ -0,0 +1,10 @@ +--- +version: "0.1.2" +level: copilot +--- + +This format is based on [AI-DECLARATION.md](https://ai-declaration.md/en/0.1.2). + +## Notes + +- diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..eb8cbfa --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,399 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itsybitsy" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "itsybitsy-core", + "tempfile", +] + +[[package]] +name = "itsybitsy-core" +version = "0.1.0" +dependencies = [ + "serde", + "tempfile", + "toml", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..998e33f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[workspace] +# Format crates (gemtext, wap, text) join at the milestone that implements them. +members = ["core", "bin"] +default-members = ["bin"] +resolver = "2" + +# No `panic = "abort"`: a thread-per-connection server must survive one +# malformed document, and the handler boundary catches unwinds to log the cause. +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..55c8189 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 randogoth + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..76fbb11 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# 🕸 itsybitsy + +[![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![AI-DECLARATION: copilot](https://img.shields.io/badge/%E4%B7%BC%20AI--DECLARATION-copilot-fee2e2?labelColor=fee2e2)](https://ai-declaration.md) + +Serve folders of Markdown to different domains over HTTP, [Spartan](https://portal.mozz.us/gemini/spartan.mozz.us/) and [Nex](https://nightfall.city/nex/info/specification.txt), rendered on the fly with no build step. + +A Rust re-implementation of [smolweb](https://code.randogoth.com/randogoth/smolweb), which serves one folder from one process with its output formats hardcoded and its configuration in per-file frontmatter. itsybitsy changes all three: one process serves many domains, each output format is a separate crate behind a cargo feature, and configuration lives in a TOML file per content directory. + +## Status + +Early. The configuration layer is implemented and tested; nothing is served yet. + +| Area | State | +| --- | --- | +| Server configuration, virtual hosts, `--check` | implemented | +| Path resolution and the render cache | planned | +| gemtext and HTML output | planned | +| HTTP, Spartan and Nex listeners | planned | +| 80-column text and Nex output | planned | +| WML and XHTML-MP decks | planned | +| Gemini and Gopher | planned | + +## Configuration + +Two files. A server file names the virtual hosts and the listeners: + +```toml +version = 1 + +[site.smol] +root = "/srv/smol/content" +hosts = ["smol.place", "www.smol.place"] + +[listener.web] +protocol = "http" +bind = "0.0.0.0:8080" +formats = ["xhtmlmp", "html"] +default_site = "smol" +``` + +A `.itsybitsy.toml` in any content directory says how the Markdown in **that directory** renders. There is no inheritance: a subdirectory without its own file uses the built-in defaults rather than its parent's. Repetition in a deep tree is the price of never having to look elsewhere to know how a page renders. + +`--check` validates the configuration and reports the routing it resolved, without binding a port: + +```console +$ itsybitsy --config /etc/itsybitsy.toml --check +sites: + smol: /srv/smol/content +hosts: + smol.place -> smol + www.smol.place -> smol +listeners: + web: http on 0.0.0.0:8080 [xhtmlmp, html] by host, default smol +formats rendered per page: html, xhtmlmp +``` + +Everything the schema cannot express is checked here rather than on first request: a format no enabled feature provides, two sites claiming one hostname, a content root that does not exist, a server file sitting inside a content root where it would be served. + +## Development + +```bash +devbox run check # fmt --check, clippy -D warnings, test +devbox run test +devbox run build +``` diff --git a/bin/Cargo.toml b/bin/Cargo.toml new file mode 100644 index 0000000..5bd2381 --- /dev/null +++ b/bin/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "itsybitsy" +version = "0.1.0" +edition = "2024" +rust-version = "1.97" +description = "Serve folders of Markdown to several domains over HTTP, Spartan and Nex" +license = "Apache-2.0" +publish = false + +[[bin]] +name = "itsybitsy" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +clap = { version = "4.6", features = ["derive"] } +itsybitsy-core = { path = "../core" } + +[dev-dependencies] +tempfile = "3" + +[lints.rust] +unsafe_code = "forbid" diff --git a/bin/src/cli.rs b/bin/src/cli.rs new file mode 100644 index 0000000..ab8b789 --- /dev/null +++ b/bin/src/cli.rs @@ -0,0 +1,17 @@ +//! Command line surface. + +use std::path::PathBuf; + +use clap::Parser; + +#[derive(Debug, Parser)] +#[command(version, about = "Serve folders of Markdown to several domains")] +pub struct Cli { + /// Server configuration file declaring the sites and listeners. + #[arg(long, value_name = "FILE")] + pub config: PathBuf, + + /// Validate the configuration, report what it resolved to, and exit. + #[arg(long)] + pub check: bool, +} diff --git a/bin/src/lib.rs b/bin/src/lib.rs new file mode 100644 index 0000000..bcd59d9 --- /dev/null +++ b/bin/src/lib.rs @@ -0,0 +1,84 @@ +//! Wiring: the command line, the renderer registry, and the listeners. +//! +//! Everything testable without a socket lives in `itsybitsy-core` instead. This +//! crate is a library as well as a binary so integration tests can bind a +//! listener on port 0 in-process. + +pub mod cli; +pub mod registry; + +use std::path::Path; + +use anyhow::{Context, Result}; +use itsybitsy_core::config::{Checked, ServerConfig}; + +use crate::cli::Cli; + +pub fn run(cli: &Cli) -> Result<()> { + let formats = registry::format_ids(); + if cli.check { + return check(&cli.config, &formats); + } + let _ = load(&cli.config, &formats)?; + anyhow::bail!("serving is not implemented yet; run with --check to validate the configuration") +} + +/// Validate a configuration and report what it resolved to. +/// +/// Takes the format set rather than reading [`registry`] directly, so a test can +/// supply the ids a later milestone's registry will provide. +pub fn check(config_path: &Path, formats: &[&str]) -> Result<()> { + let (config, checked) = load(config_path, formats)?; + report(&config, &checked); + Ok(()) +} + +fn load(config_path: &Path, formats: &[&str]) -> Result<(ServerConfig, Checked)> { + // No context on load: its errors already name the file. + let config = ServerConfig::load(config_path)?; + let checked = config + .validate(config_path, formats) + .with_context(|| format!("validating {}", config_path.display()))?; + Ok((config, checked)) +} + +/// What `--check` prints: the resolved routing, so an operator can see which +/// name reaches which folder without sending a request. +fn report(config: &ServerConfig, checked: &Checked) { + println!("sites:"); + for (name, root) in &checked.roots { + println!(" {name}: {}", root.display()); + } + + println!("hosts:"); + if checked.hosts.is_empty() { + println!(" (none; every site is reachable only by name)"); + } + for (host, site) in &checked.hosts { + println!(" {host} -> {site}"); + } + + for group in &checked.shared_roots { + println!("note: sites {} share one root, and so one render cache", group.join(", ")); + } + + println!("listeners:"); + for (name, spec) in &config.listener { + let target = match (&spec.site, &spec.default_site) { + (Some(site), _) => format!("site {site}"), + (_, Some(site)) => format!("by host, default {site}"), + _ => "by host, no default".to_string(), + }; + println!( + " {name}: {} on {} [{}] {target}", + spec.protocol.as_str(), + spec.bind, + spec.formats.join(", ") + ); + } + + println!( + "formats rendered per page: {}", + checked.formats.iter().cloned().collect::>().join(", ") + ); +} diff --git a/bin/src/main.rs b/bin/src/main.rs new file mode 100644 index 0000000..b89e1f2 --- /dev/null +++ b/bin/src/main.rs @@ -0,0 +1,16 @@ +use std::process::ExitCode; + +use clap::Parser; + +use itsybitsy::cli::Cli; + +fn main() -> ExitCode { + let cli = Cli::parse(); + match itsybitsy::run(&cli) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("itsybitsy: {err:#}"); + ExitCode::FAILURE + } + } +} diff --git a/bin/src/registry.rs b/bin/src/registry.rs new file mode 100644 index 0000000..691b5c9 --- /dev/null +++ b/bin/src/registry.rs @@ -0,0 +1,10 @@ +//! The only file that names a format crate. +//! +//! Cargo features select which arms compile, so a build carries exactly the +//! formats it was asked for. The registry is built once at startup and shared +//! immutably across every connection. + +/// Ids of the formats this build provides. +pub fn format_ids() -> Vec<&'static str> { + Vec::new() +} diff --git a/bin/tests/check.rs b/bin/tests/check.rs new file mode 100644 index 0000000..bfc0f27 --- /dev/null +++ b/bin/tests/check.rs @@ -0,0 +1,64 @@ +//! `--check` end to end: a configuration on disk through to the resolved +//! routing, without a renderer registry or a socket. + +use std::fs; + +const FORMATS: &[&str] = &["gemtext", "html"]; + +/// Writes a two-site configuration beside a pair of content roots and returns +/// the config path. The config deliberately sits outside both roots. +fn fixture(dir: &std::path::Path) -> std::path::PathBuf { + let one = dir.join("one"); + let two = dir.join("two"); + fs::create_dir_all(&one).unwrap(); + fs::create_dir_all(&two).unwrap(); + let text = format!( + r#" +version = 1 + +[site.one] +root = "{one}" +hosts = ["one.test", "www.one.test"] + +[site.two] +root = "{two}" +hosts = ["two.test"] + +[listener.web] +protocol = "http" +bind = "0.0.0.0:8080" +formats = ["html"] +default_site = "one" + +[listener.feed] +protocol = "nex" +bind = "0.0.0.0:1900" +formats = ["gemtext"] +site = "two" +"#, + one = one.to_str().unwrap(), + two = two.to_str().unwrap(), + ); + let path = dir.join("itsybitsy.toml"); + fs::write(&path, text).unwrap(); + path +} + +#[test] +fn checks_a_two_site_configuration() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture(dir.path()); + itsybitsy::check(&path, FORMATS).unwrap(); +} + +#[test] +fn an_unprovided_format_names_itself_and_the_file() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture(dir.path()); + // The registry is empty until a format crate lands, which is exactly the + // "feature switched off" case an operator hits. + let err = itsybitsy::check(&path, &[]).unwrap_err(); + let rendered = format!("{err:#}"); + assert!(rendered.contains("itsybitsy.toml"), "{rendered}"); + assert!(rendered.contains("does not provide"), "{rendered}"); +} diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..9157c10 --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "itsybitsy-core" +version = "0.1.0" +edition = "2024" +rust-version = "1.97" +description = "Path resolution, configuration and the output-format trait behind itsybitsy" +license = "Apache-2.0" +publish = false + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +toml = "1.1" + +[dev-dependencies] +tempfile = "3" + +[lints.rust] +unsafe_code = "forbid" diff --git a/core/src/config.rs b/core/src/config.rs new file mode 100644 index 0000000..fa6d283 --- /dev/null +++ b/core/src/config.rs @@ -0,0 +1,583 @@ +//! The server configuration file. +//! +//! `itsybitsy.toml`, passed with `--config`, declares the virtual hosts and the +//! listeners. Everything the schema cannot express is checked by +//! [`ServerConfig::validate`] at startup, so a mistake is a refusal to boot +//! rather than a surprising response later. +//! +//! How the Markdown in a directory renders is configured separately, by a +//! `.itsybitsy.toml` in that directory, which does not inherit from anywhere. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use crate::error::Error; + +/// Schema version of the server file, bumped only on an incompatible change. +pub const VERSION: u32 = 1; + +/// Reserved file name of a per-directory config. It begins with a dot, so the +/// "reject any component starting with `.`" rule in path cleaning already keeps +/// it out of the URL space. +pub const DIR_CONFIG: &str = ".itsybitsy.toml"; + +const DEFAULT_MAX_CONNECTIONS: u32 = 256; +const DEFAULT_TIMEOUT_SECS: u64 = 10; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerConfig { + pub version: u32, + #[serde(default)] + pub site: BTreeMap, + #[serde(default)] + pub listener: BTreeMap, +} + +/// One virtual host: a content root and the names that reach it. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SiteSpec { + /// Content root. Every resolved path must canonicalise inside this. + pub root: PathBuf, + /// Names this site answers to, compared after [`normalize_host`]. A site + /// with none is reachable only by being named as a listener's `site` or + /// `default_site`. + #[serde(default)] + pub hosts: Vec, + /// Gemini only; unused until that listener exists. + #[serde(default)] + pub tls: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TlsSpec { + pub cert: PathBuf, + pub key: PathBuf, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ListenerSpec { + pub protocol: Protocol, + pub bind: String, + /// Formats this listener can serve, most preferred first. For HTTP a tie on + /// q breaks by this order and the last entry is the fallback; every other + /// protocol serves exactly one format. + pub formats: Vec, + /// The one site served, for a protocol that carries no hostname. + #[serde(default)] + pub site: Option, + /// Fallback site when the request names a host this server does not know. + #[serde(default)] + pub default_site: Option, + /// Columns to wrap to, overriding the renderer's own default. Clients that + /// wrap for themselves (Gemini, Spartan) want this unset. + #[serde(default)] + pub width: Option, + #[serde(default = "default_max_connections")] + pub max_connections: u32, + #[serde(default = "default_timeout_secs")] + pub timeout_secs: u64, +} + +fn default_max_connections() -> u32 { + DEFAULT_MAX_CONNECTIONS +} + +fn default_timeout_secs() -> u64 { + DEFAULT_TIMEOUT_SECS +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Protocol { + Http, + Spartan, + Nex, + Gemini, + Gopher, +} + +impl Protocol { + pub fn as_str(self) -> &'static str { + match self { + Protocol::Http => "http", + Protocol::Spartan => "spartan", + Protocol::Nex => "nex", + Protocol::Gemini => "gemini", + Protocol::Gopher => "gopher", + } + } + + /// Whether a request carries a hostname it can be routed by. Nex and Gopher + /// do not, so their listeners name one site outright. + pub fn is_host_addressed(self) -> bool { + matches!(self, Protocol::Http | Protocol::Spartan | Protocol::Gemini) + } + + /// Whether more than one format can be negotiated. Only HTTP has an + /// `Accept` header to negotiate with. + pub fn negotiates(self) -> bool { + matches!(self, Protocol::Http) + } +} + +/// What validation established, and what `--check` prints. +#[derive(Debug)] +pub struct Checked { + /// Normalised host → site name: the map every request is routed through. + pub hosts: BTreeMap, + /// Site name → canonicalised content root. + pub roots: BTreeMap, + /// Groups of site names sharing one canonical root. They will share one + /// `Site`, and so one render cache. + pub shared_roots: Vec>, + /// Every format some enabled listener can serve, which is the set each page + /// is rendered into. + pub formats: BTreeSet, +} + +impl ServerConfig { + /// Read and parse the server file. Schema violations are reported here; + /// cross-field rules are [`ServerConfig::validate`]'s job. + pub fn load(path: &Path) -> Result { + let text = fs::read_to_string(path) + .map_err(|cause| Error::Io { path: path.to_path_buf(), cause })?; + toml::from_str(&text).map_err(|cause| Error::Toml { path: path.to_path_buf(), cause }) + } + + /// Check every rule the schema cannot express, resolving roots and the host + /// map on the way. `available_formats` is the renderer registry's id set, so + /// a format switched off by a cargo feature fails here by name instead of + /// becoming a server error on first request. + pub fn validate( + &self, + config_path: &Path, + available_formats: &[&str], + ) -> Result { + if self.version != VERSION { + return Err(Error::config(format!( + "unsupported config version {}, expected {VERSION}", + self.version + ))); + } + if self.site.is_empty() { + return Err(Error::config("no sites defined; at least one [site.] is required")); + } + if self.listener.is_empty() { + return Err(Error::config( + "no listeners defined; at least one [listener.] is required", + )); + } + + let roots = self.resolve_roots()?; + self.reject_config_inside_root(config_path, &roots)?; + let hosts = self.build_host_map()?; + let formats = self.validate_listeners(available_formats)?; + + Ok(Checked { + hosts, + roots: roots.clone(), + shared_roots: group_shared_roots(&roots), + formats, + }) + } + + /// Canonicalise every content root, which also proves it exists and is a + /// directory before any request can reach it. + fn resolve_roots(&self) -> Result, Error> { + let mut roots = BTreeMap::new(); + for (name, spec) in &self.site { + let root = spec + .root + .canonicalize() + .map_err(|cause| Error::Io { path: spec.root.clone(), cause })?; + if !root.is_dir() { + return Err(Error::config(format!( + "site '{name}': root {} is not a directory", + root.display() + ))); + } + roots.insert(name.clone(), root); + } + Ok(roots) + } + + /// The server file must not sit inside a content root, where it would be a + /// served file listing every other site's paths. A per-directory config is + /// a dotfile and so already unreachable; this one is not. + fn reject_config_inside_root( + &self, + config_path: &Path, + roots: &BTreeMap, + ) -> Result<(), Error> { + // A path that does not canonicalise cannot be inside a root either, and + // `load` has already read it, so there is nothing to report here. + let Ok(config) = config_path.canonicalize() else { return Ok(()) }; + for (name, root) in roots { + if config.starts_with(root) { + return Err(Error::config(format!( + "config file {} is inside site '{name}' root {}, where it would be served", + config.display(), + root.display() + ))); + } + } + Ok(()) + } + + fn build_host_map(&self) -> Result, Error> { + let mut hosts: BTreeMap = BTreeMap::new(); + for (name, spec) in &self.site { + for raw in &spec.hosts { + let host = normalize_host(raw).ok_or_else(|| { + Error::config(format!("site '{name}': '{raw}' is not a usable hostname")) + })?; + if let Some(first) = hosts.get(&host) { + return Err(Error::DuplicateHost { + host, + first: first.clone(), + second: name.clone(), + }); + } + hosts.insert(host, name.clone()); + } + } + Ok(hosts) + } + + fn validate_listeners(&self, available: &[&str]) -> Result, Error> { + let mut formats = BTreeSet::new(); + for (name, spec) in &self.listener { + if spec.formats.is_empty() { + return Err(Error::config(format!("listener '{name}': formats must not be empty"))); + } + if spec.formats.len() > 1 && !spec.protocol.negotiates() { + return Err(Error::config(format!( + "listener '{name}': {} serves one format, but {} are listed; only http \ + negotiates", + spec.protocol.as_str(), + spec.formats.len() + ))); + } + for format in &spec.formats { + if !available.contains(&format.as_str()) { + return Err(Error::UnknownFormat { + listener: name.clone(), + format: format.clone(), + available: available.iter().map(|id| id.to_string()).collect(), + }); + } + formats.insert(format.clone()); + } + + self.validate_listener_site(name, spec)?; + } + Ok(formats) + } + + /// `site` and `default_site` are not interchangeable: a protocol carrying a + /// hostname routes by it and falls back, one that does not serves a single + /// site outright. Mixing them up would silently serve the wrong content. + fn validate_listener_site(&self, name: &str, spec: &ListenerSpec) -> Result<(), Error> { + for (key, value) in [("site", &spec.site), ("default_site", &spec.default_site)] { + if let Some(site) = value + && !self.site.contains_key(site) + { + return Err(Error::config(format!( + "listener '{name}': {key} = '{site}' names no defined site" + ))); + } + } + + if spec.protocol.is_host_addressed() { + if spec.site.is_some() { + return Err(Error::config(format!( + "listener '{name}': {} routes by hostname, so use default_site, not site", + spec.protocol.as_str() + ))); + } + return Ok(()); + } + + if spec.default_site.is_some() { + return Err(Error::config(format!( + "listener '{name}': {} carries no hostname, so use site, not default_site", + spec.protocol.as_str() + ))); + } + // With one site defined there is nothing to get wrong; with two or more, + // leaving it implicit would let adding a vhost silently change which + // site this listener serves. + if spec.site.is_none() && self.site.len() > 1 { + return Err(Error::config(format!( + "listener '{name}': {} carries no hostname, so site is required when more than \ + one site is defined", + spec.protocol.as_str() + ))); + } + Ok(()) + } +} + +/// Site names that canonicalise to the same root, so they can share one `Site`. +fn group_shared_roots(roots: &BTreeMap) -> Vec> { + let mut by_root: BTreeMap<&PathBuf, Vec> = BTreeMap::new(); + for (name, root) in roots { + by_root.entry(root).or_default().push(name.clone()); + } + by_root.into_values().filter(|names| names.len() > 1).collect() +} + +/// Normalise a hostname into a routing key: lowercased, trailing dots and a +/// `:port` suffix removed. +/// +/// Returns `None` when a character outside the allowlist survives, which is +/// what keeps unvalidated request bytes out of the host map and the log. +pub fn normalize_host(raw: &str) -> Option { + let trimmed = raw.trim(); + let without_port = if trimmed.starts_with('[') { + // Bracketed IPv6 literal: the port, if any, follows the bracket. + &trimmed[..=trimmed.find(']')?] + } else { + match trimmed.rsplit_once(':') { + // An unbracketed colon elsewhere means a bare IPv6 literal rather + // than a port, so leave it alone. + Some((host, port)) + if !host.contains(':') + && !port.is_empty() + && port.bytes().all(|b| b.is_ascii_digit()) => + { + host + } + _ => trimmed, + } + }; + + let host = without_port.trim_end_matches('.').to_ascii_lowercase(); + if host.is_empty() { + return None; + } + let allowed = host + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':' | b'[' | b']')); + allowed.then_some(host) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FORMATS: &[&str] = &["gemtext", "html", "xhtmlmp", "nex"]; + + fn write(dir: &Path, name: &str, body: &str) -> PathBuf { + let path = dir.join(name); + fs::write(&path, body).unwrap(); + path + } + + /// A config whose single site root is `root`, with the given extra tables. + fn config_text(root: &Path, extra: &str) -> String { + format!( + "version = 1\n\n[site.one]\nroot = {:?}\nhosts = [\"one.test\"]\n\n{extra}", + root.to_str().unwrap() + ) + } + + fn check(dir: &Path, extra: &str) -> Result { + let root = dir.join("content"); + fs::create_dir_all(&root).unwrap(); + // Outside the root, as the rule under test requires. + let path = write(dir, "itsybitsy.toml", &config_text(&root, extra)); + ServerConfig::load(&path)?.validate(&path, FORMATS) + } + + const HTTP: &str = "[listener.web]\nprotocol = \"http\"\nbind = \"0.0.0.0:8080\"\n\ + formats = [\"xhtmlmp\", \"html\"]\n"; + + #[test] + fn accepts_a_minimal_config_and_reports_what_it_resolved() { + let dir = tempfile::tempdir().unwrap(); + let checked = check(dir.path(), HTTP).unwrap(); + assert_eq!(checked.hosts.get("one.test").map(String::as_str), Some("one")); + assert_eq!( + checked.formats.iter().map(String::as_str).collect::>(), + ["html", "xhtmlmp"] + ); + assert!(checked.shared_roots.is_empty()); + assert_eq!(checked.roots["one"], dir.path().join("content").canonicalize().unwrap()); + } + + #[test] + fn listener_defaults_are_applied() { + let text = "protocol = \"nex\"\nbind = \":1900\"\nformats = [\"nex\"]\n"; + let spec: ListenerSpec = toml::from_str(text).unwrap(); + assert_eq!(spec.max_connections, DEFAULT_MAX_CONNECTIONS); + assert_eq!(spec.timeout_secs, DEFAULT_TIMEOUT_SECS); + assert_eq!(spec.width, None); + } + + #[test] + fn rejects_an_unknown_key() { + let dir = tempfile::tempdir().unwrap(); + let err = check(dir.path(), &format!("{HTTP}bidn = \"typo\"\n")).unwrap_err(); + assert!(matches!(err, Error::Toml { .. }), "{err}"); + } + + #[test] + fn rejects_a_format_this_build_does_not_provide() { + let dir = tempfile::tempdir().unwrap(); + let listener = "[listener.web]\nprotocol = \"http\"\nbind = \":8080\"\n\ + formats = [\"wml\"]\n"; + let err = check(dir.path(), listener).unwrap_err(); + let Error::UnknownFormat { format, available, .. } = &err else { + panic!("wrong variant: {err}") + }; + assert_eq!(format, "wml"); + assert!(available.contains(&"gemtext".to_string())); + } + + #[test] + fn rejects_several_formats_on_a_protocol_that_cannot_negotiate() { + let dir = tempfile::tempdir().unwrap(); + let listener = "[listener.n]\nprotocol = \"nex\"\nbind = \":1900\"\n\ + formats = [\"nex\", \"gemtext\"]\n"; + let err = check(dir.path(), listener).unwrap_err(); + assert!(err.to_string().contains("only http negotiates"), "{err}"); + } + + #[test] + fn rejects_empty_formats() { + let dir = tempfile::tempdir().unwrap(); + let listener = "[listener.n]\nprotocol = \"nex\"\nbind = \":1900\"\nformats = []\n"; + let err = check(dir.path(), listener).unwrap_err(); + assert!(err.to_string().contains("must not be empty"), "{err}"); + } + + #[test] + fn rejects_site_on_a_host_addressed_listener() { + let dir = tempfile::tempdir().unwrap(); + let err = check(dir.path(), &format!("{HTTP}site = \"one\"\n")).unwrap_err(); + assert!(err.to_string().contains("use default_site, not site"), "{err}"); + } + + #[test] + fn rejects_default_site_on_a_listener_carrying_no_hostname() { + let dir = tempfile::tempdir().unwrap(); + let listener = "[listener.n]\nprotocol = \"nex\"\nbind = \":1900\"\n\ + formats = [\"nex\"]\ndefault_site = \"one\"\n"; + let err = check(dir.path(), listener).unwrap_err(); + assert!(err.to_string().contains("use site, not default_site"), "{err}"); + } + + #[test] + fn nex_may_omit_site_with_one_site_but_not_with_two() { + let dir = tempfile::tempdir().unwrap(); + let nex = "[listener.n]\nprotocol = \"nex\"\nbind = \":1900\"\nformats = [\"nex\"]\n"; + assert!(check(dir.path(), nex).is_ok()); + + let second = dir.path().join("other"); + fs::create_dir_all(&second).unwrap(); + let two = format!( + "{nex}\n[site.two]\nroot = {:?}\nhosts = [\"two.test\"]\n", + second.to_str().unwrap() + ); + let err = check(dir.path(), &two).unwrap_err(); + assert!(err.to_string().contains("site is required"), "{err}"); + } + + #[test] + fn rejects_a_listener_naming_an_undefined_site() { + let dir = tempfile::tempdir().unwrap(); + let err = check(dir.path(), &format!("{HTTP}default_site = \"nope\"\n")).unwrap_err(); + assert!(err.to_string().contains("names no defined site"), "{err}"); + } + + #[test] + fn rejects_one_host_claimed_by_two_sites() { + let dir = tempfile::tempdir().unwrap(); + let second = dir.path().join("other"); + fs::create_dir_all(&second).unwrap(); + // Differing case and a trailing dot still collide once normalised. + let two = format!( + "{HTTP}\n[site.two]\nroot = {:?}\nhosts = [\"One.Test.\"]\n", + second.to_str().unwrap() + ); + let err = check(dir.path(), &two).unwrap_err(); + assert!(matches!(err, Error::DuplicateHost { .. }), "{err}"); + } + + #[test] + fn reports_sites_sharing_one_root() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("content"); + fs::create_dir_all(&root).unwrap(); + let two = format!( + "{HTTP}\n[site.two]\nroot = {:?}\nhosts = [\"two.test\"]\n", + root.to_str().unwrap() + ); + let checked = check(dir.path(), &two).unwrap(); + assert_eq!(checked.shared_roots, vec![vec!["one".to_string(), "two".to_string()]]); + } + + #[test] + fn rejects_a_config_file_inside_a_content_root() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("content"); + fs::create_dir_all(&root).unwrap(); + let path = write(&root, "itsybitsy.toml", &config_text(&root, HTTP)); + let err = ServerConfig::load(&path).unwrap().validate(&path, FORMATS).unwrap_err(); + assert!(err.to_string().contains("where it would be served"), "{err}"); + } + + #[test] + fn rejects_a_missing_root() { + let dir = tempfile::tempdir().unwrap(); + let text = format!( + "version = 1\n\n[site.one]\nroot = {:?}\n\n{HTTP}", + dir.path().join("absent").to_str().unwrap() + ); + let path = write(dir.path(), "itsybitsy.toml", &text); + let err = ServerConfig::load(&path).unwrap().validate(&path, FORMATS).unwrap_err(); + assert!(matches!(err, Error::Io { .. }), "{err}"); + } + + #[test] + fn rejects_an_unsupported_version() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("content"); + fs::create_dir_all(&root).unwrap(); + let text = config_text(&root, HTTP).replace("version = 1", "version = 2"); + let path = write(dir.path(), "itsybitsy.toml", &text); + let err = ServerConfig::load(&path).unwrap().validate(&path, FORMATS).unwrap_err(); + assert!(err.to_string().contains("unsupported config version 2"), "{err}"); + } + + #[test] + fn normalizes_hosts() { + assert_eq!(normalize_host("Example.COM"), Some("example.com".into())); + assert_eq!(normalize_host("example.com."), Some("example.com".into())); + assert_eq!(normalize_host("example.com:8080"), Some("example.com".into())); + assert_eq!(normalize_host(" example.com "), Some("example.com".into())); + assert_eq!(normalize_host("[::1]:1965"), Some("[::1]".into())); + assert_eq!(normalize_host("[::1]"), Some("[::1]".into())); + // A bare IPv6 literal: the last colon is not a port separator. + assert_eq!(normalize_host("::1"), Some("::1".into())); + } + + #[test] + fn rejects_unusable_hosts() { + assert_eq!(normalize_host(""), None); + assert_eq!(normalize_host("."), None); + assert_eq!(normalize_host("exam ple.com"), None); + assert_eq!(normalize_host("example.com/path"), None); + // A control byte must never reach the host map or a log line. + assert_eq!(normalize_host("example.com\r\nX: y"), None); + assert_eq!(normalize_host("[::1"), None); + } +} diff --git a/core/src/error.rs b/core/src/error.rs new file mode 100644 index 0000000..a99a4e7 --- /dev/null +++ b/core/src/error.rs @@ -0,0 +1,64 @@ +//! The library error type. +//! +//! Variants carry the data a caller needs to act or an operator needs to fix +//! the cause, rather than a pre-formatted sentence. + +use std::fmt; +use std::io; +use std::path::PathBuf; + +#[derive(Debug)] +pub enum Error { + /// Reading or canonicalising a path named by the configuration failed. + Io { path: PathBuf, cause: io::Error }, + + /// A configuration file is not valid TOML, or does not match the schema. + Toml { path: PathBuf, cause: toml::de::Error }, + + /// A rule the schema cannot express was violated. + Config { message: String }, + + /// Two sites claim one hostname, so a request naming it could not be routed. + DuplicateHost { host: String, first: String, second: String }, + + /// A listener names a format no enabled feature provides. `available` is + /// the registry's full id set, which is what tells an operator whether the + /// name is a typo or a missing cargo feature. + UnknownFormat { listener: String, format: String, available: Vec }, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + // The cause is reachable through `source`, so it is not repeated here. + Error::Io { path, .. } => write!(f, "cannot read {}", path.display()), + Error::Toml { path, .. } => write!(f, "invalid TOML in {}", path.display()), + Error::Config { message } => write!(f, "{message}"), + Error::DuplicateHost { host, first, second } => { + write!(f, "host '{host}' is claimed by both site '{first}' and site '{second}'") + } + Error::UnknownFormat { listener, format, available } => write!( + f, + "listener '{listener}' wants format '{format}', which this build does not \ + provide; available: {}", + if available.is_empty() { "none".to_string() } else { available.join(", ") } + ), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::Io { cause, .. } => Some(cause), + Error::Toml { cause, .. } => Some(cause), + _ => None, + } + } +} + +impl Error { + pub(crate) fn config(message: impl Into) -> Self { + Error::Config { message: message.into() } + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..5307724 --- /dev/null +++ b/core/src/lib.rs @@ -0,0 +1,10 @@ +//! Everything itsybitsy does that is not a socket. +//! +//! Resolution, configuration and rendering live here so they can be tested +//! without binding a port; the binary crate holds the protocol listeners and +//! is the only place that names a format crate. + +pub mod config; +pub mod error; + +pub use error::Error; diff --git a/devbox.json b/devbox.json new file mode 100644 index 0000000..d167c94 --- /dev/null +++ b/devbox.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.17.2/.schema/devbox.schema.json", + "packages": [ + "rustc@1.97.1", + "cargo@1.97.1", + "rustfmt@1.97.1", + "clippy@1.97.1", + "jujutsu@0.44.0" + ], + "shell": { + "scripts": { + "fmt": "cargo fmt --all", + "check": [ + "cargo fmt --all -- --check", + "cargo clippy --workspace --all-targets -- -D warnings", + "cargo test --workspace" + ], + "test": "cargo test --workspace", + "build": "cargo build --release" + } + } +} diff --git a/devbox.lock b/devbox.lock new file mode 100644 index 0000000..4d7a213 --- /dev/null +++ b/devbox.lock @@ -0,0 +1,235 @@ +{ + "lockfile_version": "1", + "packages": { + "cargo@1.97.1": { + "last_modified": "2026-08-27T07:16:00Z", + "resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#cargo", + "source": "devbox-search", + "version": "1.97.1", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/cvd2pzmgq4pr7kkzx2zmya47fqd70sqa-cargo-1.97.1", + "default": true + } + ], + "store_path": "/nix/store/cvd2pzmgq4pr7kkzx2zmya47fqd70sqa-cargo-1.97.1" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/z64fp7fjfy9zr3xn7prdidi4gww213d5-cargo-1.97.1", + "default": true + } + ], + "store_path": "/nix/store/z64fp7fjfy9zr3xn7prdidi4gww213d5-cargo-1.97.1" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/dv55qwamlq68hdiprg1v104ymwh1c27r-cargo-1.97.1", + "default": true + } + ], + "store_path": "/nix/store/dv55qwamlq68hdiprg1v104ymwh1c27r-cargo-1.97.1" + } + } + }, + "clippy@1.97.1": { + "last_modified": "2026-08-27T07:16:00Z", + "resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#clippy", + "source": "devbox-search", + "version": "1.97.1", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/v3i30k0c7qw3imdbq491328r4a6qry1m-clippy-1.97.1", + "default": true + } + ], + "store_path": "/nix/store/v3i30k0c7qw3imdbq491328r4a6qry1m-clippy-1.97.1" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/y6v5c5bb5drnvyf3jgm4qkkyihnfjwhj-clippy-1.97.1", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/3mbk6zr9jqgzv3zfz3x3mirkf9894b5k-clippy-1.97.1-debug" + } + ], + "store_path": "/nix/store/y6v5c5bb5drnvyf3jgm4qkkyihnfjwhj-clippy-1.97.1" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/n4c4rzywa0wsfy239bqkgsx8lfhsfmwx-clippy-1.97.1", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/nafib7p33g095kh5s8y790wdcrv7mp9l-clippy-1.97.1-debug" + } + ], + "store_path": "/nix/store/n4c4rzywa0wsfy239bqkgsx8lfhsfmwx-clippy-1.97.1" + } + } + }, + "github:NixOS/nixpkgs/nixpkgs-unstable": { + "last_modified": "2026-09-29T03:29:29Z", + "resolved": "github:NixOS/nixpkgs/b6c8664de9b6cc07fe5666a29f91884ba81197c4?lastModified=1790652569&narHash=sha256-641r7xrlSOHYoktL9%2FaArPIYNgZJ9eM5URzSEOtPJDY%3D" + }, + "jujutsu@0.44.0": { + "last_modified": "2026-08-27T07:16:00Z", + "resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#jujutsu", + "source": "devbox-search", + "version": "0.44.0", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/lssnj3zacjjglpsbxifgsap602h2yssq-jujutsu-0.44.0", + "default": true + } + ], + "store_path": "/nix/store/lssnj3zacjjglpsbxifgsap602h2yssq-jujutsu-0.44.0" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/4f3hrr1aik4hazqlhxadva3j36z75j8n-jujutsu-0.44.0", + "default": true + } + ], + "store_path": "/nix/store/4f3hrr1aik4hazqlhxadva3j36z75j8n-jujutsu-0.44.0" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/vzrnnii3369cn2a2bgdlkx9gh6m297fj-jujutsu-0.44.0", + "default": true + } + ], + "store_path": "/nix/store/vzrnnii3369cn2a2bgdlkx9gh6m297fj-jujutsu-0.44.0" + } + } + }, + "rustc@1.97.1": { + "last_modified": "2026-08-27T07:16:00Z", + "plugin_version": "0.0.1", + "resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#rustc", + "source": "devbox-search", + "version": "1.97.1", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/swrk83krgf2iaqd7a1bs0pg1sinlz5ds-rustc-wrapper-1.97.1", + "default": true + }, + { + "name": "man", + "path": "/nix/store/grw7ifdixkdr4ya8bw5x0psjyfr5azva-rustc-wrapper-1.97.1-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/8qkha38sic70l51g4l7dmjrxnzqjrr6w-rustc-wrapper-1.97.1-doc" + } + ], + "store_path": "/nix/store/swrk83krgf2iaqd7a1bs0pg1sinlz5ds-rustc-wrapper-1.97.1" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/d6bnbpk6jr95frzmmksda4r7l3v8d3m6-rustc-wrapper-1.97.1", + "default": true + }, + { + "name": "man", + "path": "/nix/store/hn340n5pi9p30j4bcxgzqgq8lmclg1by-rustc-wrapper-1.97.1-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/avbwjkw896rb3nl28kh8qv6kxyq5b9v2-rustc-wrapper-1.97.1-doc" + } + ], + "store_path": "/nix/store/d6bnbpk6jr95frzmmksda4r7l3v8d3m6-rustc-wrapper-1.97.1" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/yaa1sq5g4jar7rvlspy7kk2wd98nj6qh-rustc-wrapper-1.97.1", + "default": true + }, + { + "name": "man", + "path": "/nix/store/an2hvf5i7mq52mqdad9rad8mxkpksmq2-rustc-wrapper-1.97.1-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/8svfp79q0gx10gcrsyxf1pw04q8jf0lp-rustc-wrapper-1.97.1-doc" + } + ], + "store_path": "/nix/store/yaa1sq5g4jar7rvlspy7kk2wd98nj6qh-rustc-wrapper-1.97.1" + } + } + }, + "rustfmt@1.97.1": { + "last_modified": "2026-08-27T07:16:00Z", + "resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#rustfmt", + "source": "devbox-search", + "version": "1.97.1", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/l7xlkqk1bz4w75rwf5dh7pn2br04n59l-rustfmt-1.97.1", + "default": true + } + ], + "store_path": "/nix/store/l7xlkqk1bz4w75rwf5dh7pn2br04n59l-rustfmt-1.97.1" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/rl3j1za9dpdri1vr52vl69y1qczjb5zv-rustfmt-1.97.1", + "default": true + } + ], + "store_path": "/nix/store/rl3j1za9dpdri1vr52vl69y1qczjb5zv-rustfmt-1.97.1" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/47k26k98v32x56rxiqawdk5qk0r0b6w1-rustfmt-1.97.1", + "default": true + } + ], + "store_path": "/nix/store/47k26k98v32x56rxiqawdk5qk0r0b6w1-rustfmt-1.97.1" + } + } + } + } +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..88fbc5d --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +# Matches the width the rest of the tree is written to. +max_width = 100 +use_small_heuristics = "Max"