feat: add workspace skeleton and server configuration validation
This commit is contained in:
commit
6a3a198975
20 changed files with 1891 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
result
|
||||
result-*
|
||||
52
AGENTS.md
Normal file
52
AGENTS.md
Normal file
|
|
@ -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
|
||||
10
AI-DECLARATION.md
Normal file
10
AI-DECLARATION.md
Normal file
|
|
@ -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
|
||||
|
||||
-
|
||||
399
Cargo.lock
generated
Normal file
399
Cargo.lock
generated
Normal file
|
|
@ -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"
|
||||
12
Cargo.toml
Normal file
12
Cargo.toml
Normal file
|
|
@ -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
|
||||
201
LICENSE
Normal file
201
LICENSE
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 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.
|
||||
65
README.md
Normal file
65
README.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# 🕸 itsybitsy
|
||||
|
||||
[](LICENSE) [](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
|
||||
```
|
||||
23
bin/Cargo.toml
Normal file
23
bin/Cargo.toml
Normal file
|
|
@ -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"
|
||||
17
bin/src/cli.rs
Normal file
17
bin/src/cli.rs
Normal file
|
|
@ -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,
|
||||
}
|
||||
84
bin/src/lib.rs
Normal file
84
bin/src/lib.rs
Normal file
|
|
@ -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::<Vec<_>>().join(", ")
|
||||
);
|
||||
}
|
||||
16
bin/src/main.rs
Normal file
16
bin/src/main.rs
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
10
bin/src/registry.rs
Normal file
10
bin/src/registry.rs
Normal file
|
|
@ -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()
|
||||
}
|
||||
64
bin/tests/check.rs
Normal file
64
bin/tests/check.rs
Normal file
|
|
@ -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}");
|
||||
}
|
||||
18
core/Cargo.toml
Normal file
18
core/Cargo.toml
Normal file
|
|
@ -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"
|
||||
583
core/src/config.rs
Normal file
583
core/src/config.rs
Normal file
|
|
@ -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<String, SiteSpec>,
|
||||
#[serde(default)]
|
||||
pub listener: BTreeMap<String, ListenerSpec>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Gemini only; unused until that listener exists.
|
||||
#[serde(default)]
|
||||
pub tls: Option<TlsSpec>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// The one site served, for a protocol that carries no hostname.
|
||||
#[serde(default)]
|
||||
pub site: Option<String>,
|
||||
/// Fallback site when the request names a host this server does not know.
|
||||
#[serde(default)]
|
||||
pub default_site: Option<String>,
|
||||
/// 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<u16>,
|
||||
#[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<String, String>,
|
||||
/// Site name → canonicalised content root.
|
||||
pub roots: BTreeMap<String, PathBuf>,
|
||||
/// Groups of site names sharing one canonical root. They will share one
|
||||
/// `Site`, and so one render cache.
|
||||
pub shared_roots: Vec<Vec<String>>,
|
||||
/// Every format some enabled listener can serve, which is the set each page
|
||||
/// is rendered into.
|
||||
pub formats: BTreeSet<String>,
|
||||
}
|
||||
|
||||
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<Self, Error> {
|
||||
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<Checked, Error> {
|
||||
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.<name>] is required"));
|
||||
}
|
||||
if self.listener.is_empty() {
|
||||
return Err(Error::config(
|
||||
"no listeners defined; at least one [listener.<name>] 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<BTreeMap<String, PathBuf>, 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<String, PathBuf>,
|
||||
) -> 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<BTreeMap<String, String>, Error> {
|
||||
let mut hosts: BTreeMap<String, String> = 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<BTreeSet<String>, 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<String, PathBuf>) -> Vec<Vec<String>> {
|
||||
let mut by_root: BTreeMap<&PathBuf, Vec<String>> = 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<String> {
|
||||
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<Checked, Error> {
|
||||
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::<Vec<_>>(),
|
||||
["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);
|
||||
}
|
||||
}
|
||||
64
core/src/error.rs
Normal file
64
core/src/error.rs
Normal file
|
|
@ -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<String> },
|
||||
}
|
||||
|
||||
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<String>) -> Self {
|
||||
Error::Config { message: message.into() }
|
||||
}
|
||||
}
|
||||
10
core/src/lib.rs
Normal file
10
core/src/lib.rs
Normal file
|
|
@ -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;
|
||||
22
devbox.json
Normal file
22
devbox.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
235
devbox.lock
Normal file
235
devbox.lock
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
rustfmt.toml
Normal file
3
rustfmt.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Matches the width the rest of the tree is written to.
|
||||
max_width = 100
|
||||
use_small_heuristics = "Max"
|
||||
Loading…
Add table
Add a link
Reference in a new issue