rescriptum
Development
an answer written for this machine
Working on rescriptum
Working on rescriptum
rescriptum is a small, focused thing: it works out which install config each machine should get, composes it from layers, and serves it. Around 4,000 lines of Rust, 308 tests, and a short list of constraints that are not up for casual revision.
This space is the why. The Guide is the what.
Get it running
git clone https://github.com/z29k/rescriptum && cd rescriptum
cargo test # 308 tests
cargo run -- --help
Try a change against the worked examples rather than only against tests — they are the only place all the formats are shown composing together:
RESCRIPTUM_ANSWERS_DIR=examples cargo run -- check
RESCRIPTUM_ANSWERS_DIR=examples cargo run -- render --query "path=/rhel/ks&serial=7ABC123"
Before opening a PR:
cargo fmt --all
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo build --release --no-default-features # the smallest build must keep working
Those four are exactly what CI runs.
The repository
| Path | Holds |
|---|---|
src/main.rs | runtime setup, accept loop, connection serving, routing, and the blocking half of a request |
src/lib.rs | the crate. main.rs is a thin binary over it, so behaviour is testable directly |
src/select.rs | normalization, matching, layering — the behaviour that matters |
src/facts.rs | what a request says about the machine |
src/format/ | one interface per document format; xml.rs holds the XML tree |
src/merge.rs | the TOML merge, used by format |
src/store/ | where documents come from: file.rs, sqlite.rs, behind a thin trait |
src/admin.rs | the write API, and the guarantee that a write cannot break the fleet |
src/config.rs | environment configuration |
src/envfile.rs | the optional file of defaults RESCRIPTUM_ENV_FILE names — never discovered, only named |
src/capture.rs | recording what machines actually send |
src/cli.rs | the render, check, import and export subcommands |
src/log.rs | one line per event, UTC timestamps without a date crate, and the two knobs over both |
tests/ | the real binary over a socket (integration, admin, guards), its command line (cli), and the two-store conformance suite (stores) |
examples/ | a worked example of every supported format |
docs/ | this site |
Never re-declare a module in main.rs. It compiles a second copy, runs every unit
test twice, and lets the two copies drift.
Where to start reading
- The constraints — first. They explain most of the code’s shape, and several of them look like things worth “improving” until you know why they are there.
- Architecture — the module map and what flows between them.
- The request lifecycle — a request from accept to response.
- Selection — the part with the most behaviour per line.
- Traps already hit — a list of things that cost time once. Reading it is cheaper than rediscovering them.
Conventions
- English for code, comments, commit messages, and the source of the documentation.
The docs are additionally published in French (
*.fr.mdsiblings) — see the documentation site. - Behaviour belongs in
tests/stores.rs, which runs every case against both stores and requires the identical outcome. A test covering one store proves half of what it claims. See testing. - Arrays replace, they do not append, in every format.
- Fail loudly. A missing group, an unfillable template, a document that will not parse — all are errors with a reason. Serving a half-built answer installs a machine wrongly, and nobody finds out until it is running.
- Adding a dependency needs a reason in the commit message. This binary runs as root
on other people’s hardware, and CI’s
auditjob is the other half of that rule: a reason to add one is not a reason to keep it. - Conventional commits with a scope —
feat(http): …,fix(select): ….
Also worth reading
CLAUDE.md at the repository
root is the architecture document written for coding agents. It overlaps this space
heavily and is the file to update when a constraint changes.
Architecture
Architecture
One process, one crate, no framework. main.rs is a thin binary over lib.rs, so every
behaviour can be tested directly rather than only through a socket.
The shape of it
flowchart TB
subgraph net["Network"]
I["Installer<br/>POST /answer · GET /rhel/ks"]
A["Admin client"]
end
I --> M["main.rs<br/>accept · timeouts · routing"]
A --> AD["admin.rs<br/>own listener · auth · guarded writes"]
M --> F["facts.rs<br/>query · JSON leaves · haystack"]
F --> S["select.rs<br/>match · layer · fill"]
AD --> S
S --> FM["format/<br/>parse · merge · render"]
FM --> MG["merge.rs<br/>TOML deep merge"]
FM --> X["format/xml.rs<br/>XML tree"]
S --> ST["store/ (trait)"]
AD --> ST
ST --> FS["file.rs<br/>a directory"]
ST --> SQ["sqlite.rs<br/>a database"]
CLI["cli.rs<br/>render · check · import · export"] --> S
What each piece owns
| Module | Owns |
|---|---|
main.rs | the tokio runtime, the accept loop, the connection semaphore, both timeouts, routing, the answer-token check, and the spawn_blocking call that does the lookup |
facts.rs | turning a request into labelled values — query parameters, a flattened JSON body, path segments, and the normalized haystack |
select.rs | the behaviour that matters: normalization, scoring, the group chain, the merge order, template filling, and the cached listing |
format/ | one interface per document format. Doc parses, merges, renders, and reports its control keys |
merge.rs | the TOML deep merge, used by format |
store/ | where documents come from, behind a two-method read trait |
admin.rs | its own listener, bearer auth, the failure guard, and the rollback that keeps a write from breaking the answer set |
config.rs | the environment, and the validation that turns a dangerous configuration into a startup error |
envfile.rs | the file RESCRIPTUM_ENV_FILE names: parsed, never discovered, and fatal when it cannot be read |
cli.rs | render, check, import, export |
capture.rs | recording request bodies |
log.rs | one line per event, UTC timestamps computed without a date crate, and the two knobs over both: what is kept, and where it goes |
The one boundary worth defending
The store is deliberately thin. It hands back raw document text and a cheap version token, and decides nothing:
pub trait Store: Send + Sync {
fn version(&self) -> Version; // cheap enough to call per request
fn snapshot(&self) -> io::Result<Snapshot>; // only when version moved
fn describe(&self) -> String;
}
Every decision — matching, extends chains, merging, rendering, check — lives above
it, in select.rs and merge.rs, and is shared by both backends. The moment a backend
starts deciding behaviour, the two drift.
tests/stores.rs is what makes that a guarantee rather than an intention: every
behavioural case runs twice, once per store, and asserts the identical outcome.
The write half is a separate trait, because serving answers never needs it:
pub trait StoreWrite: Store {
fn put_machine(&self, id: &str, format: &str, body: &str) -> io::Result<()>;
fn delete_machine(&self, id: &str, format: &str) -> io::Result<bool>;
// …groups, default
}
Note that every operation names a format. A document is keyed by what it is for — a machine and an operating system — not by identifier alone.
The caching layer
Answers wraps a store and holds a parsed, merged Listing behind a mutex:
struct Cached { version: Version, loaded_at: Instant, listing: Arc<Listing> }
A request reuses the cache only when all three hold:
store.version()is unchanged — for files, the directory’s mtime; for SQLite, an in-process atomic;- that version is
Some— an unreadable version is never treated as “unchanged”; - less than
RELOAD_BACKSTOP(1 s) has passed.
The backstop is not redundant with the version check. Editing a group file’s contents moves no directory mtime, and a change made by another process moves no in-process atomic. Without the backstop, either edit would be invisible until something else happened to the directory.
A poisoned mutex — some other request panicked mid-refresh — is recovered into rather than propagated. The cached data is still structurally fine, and failing an install over another request’s panic would be the wrong trade.
Why there is no framework
Routing here is one if on method and path. A framework buys nothing for that, and
axum specifically gives no way to set a header-read timeout — which is precisely the
slowloris guard that motivated going async in the first place. So: hyper directly.
Dependencies
64 crates, 2.4 MB static on ARMv7 (1.3 MB without SQLite). Direct:
| Crate | For |
|---|---|
tokio | the runtime, timers, signals |
hyper + hyper-util + http-body-util | HTTP/1, with a header-read timeout |
toml_edit | TOML, preserving formatting |
serde_json | JSON documents, and flattening a request body |
serde_yaml_ng | YAML documents |
quick-xml | XML documents |
rusqlite (optional, bundled) | the SQLite store |
No serde derive anywhere. The original rule was “never parse the request body as
JSON”; it has since been relaxed deliberately, and the honest statement of where it stands
is: the body is parsed into an untyped serde_json::Value when it happens to be
JSON, purely to harvest facts. Nothing is deserialized into a struct, so no assumption
about Proxmox’s schema is baked into a type. A body that is not JSON is not an error — it
contributes the haystack and nothing more. See selection.
Adding a dependency needs a reason in the commit message. This binary runs as root on other people’s hardware.
The constraints
The constraints
These are decisions, not oversights. Several of them look like obvious improvements from
the outside. Do not change one without asking — and if you do change one, change this
page and CLAUDE.md with it.
Async, on tokio and hyper
The original specification asked for zero dependencies and a thread per connection. Both were overridden deliberately, once the requirement became “absorb a professional provisioning burst”. A 2,000-machine rollout is 2,000 near-simultaneous connections, and a thread each is 2,000 stacks on a box with 512 MB.
What survived from the spec: no serde derive, no framework, and a very short direct
dependency list. See architecture.
hyper directly, not axum
axum gives no way to set a header-read timeout, which is precisely the slowloris guard
that motivated going async. Routing here is one if on method and path, so a framework
buys nothing and costs the one thing that mattered.
Bounded concurrency, even though tasks are cheap
A connection costs kilobytes rather than a thread — that is the whole point of the async rewrite. But cheap is not free, and unbounded accept still turns a burst into an out-of-memory.
A Semaphore of RESCRIPTUM_MAX_CONNECTIONS caps in-flight connections. Over the cap the
server writes a prompt 503 and closes rather than queueing: a client told to retry is
better off than one parked in a queue that will not drain.
Filesystem work goes through spawn_blocking
read_dir and read are blocking calls, and blocking an async worker thread stalls every
other connection that thread is driving. On a NAS with a sleeping disk that is not
theoretical — a spin-up is seconds, not milliseconds.
resolve() holds both the parse and the IO, and is only ever called inside
spawn_blocking. A panic there returns a 500; it cannot take the server down.
Never panic on malformed input
Any parse failure becomes an error response plus a log line. Write the code as if there were no safety net.
There is one, deliberately: the release profile does not set panic = "abort". With
unwinding, a panic is contained to the connection that caused it instead of killing a
server mid-install. Measured cost on ARMv7: +2416 bytes, +0.8%. Do not optimize it
back.
If the design ever moves to a thread pool, add catch_unwind at the worker boundary — a
pool thread that dies silently is worse than either.
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
# panic = "abort" is deliberately ABSENT
The store decides nothing
It hands back raw document text and a cheap version token. Matching, extends chains,
merging, rendering and check all live above it and are shared.
Keep it that way. The moment a backend starts deciding behaviour, the two drift — and
tests/stores.rs stops being able to prove they have not.
Storage layout is not the URL
Directories and database rows are a lookup space and must stay free to be reorganised. A URL is a public contract baked into an ISO and must not move because someone renamed a folder. An earlier design made the directory name be the URL segment and was discarded for exactly that reason.
The consequence is that a document’s key is (identifier, format), which is what the SQLite schema is built around.
Never build a filesystem path from request data
This is the path-traversal guard, and it is structural rather than a check: only direct
entries of the answers directory are ever read. Identifiers arriving at the admin API
are separately validated, at the API boundary and in both stores, because export turns
them back into filenames.
Answers must be valid documents
Before merging, an answer file was served as opaque bytes, so a malformed one reached the
installer. Now it is a 500 with the parse error in the log.
That is the better failure — an installer receiving half-valid TOML fails in a much more confusing way — but it is a behaviour change, and fixtures written as YAML-ish text stopped working when it landed.
Fail loudly
A missing group, an unfillable template, a document that will not parse: all are errors with a reason, never a best-effort answer.
The reasoning is always the same. A half-built answer installs a machine wrongly, and nobody finds out until it is running. A failed install is noticed in minutes.
Deliberate asymmetries
Two places where the obvious symmetry is wrong on purpose:
| The answer token is never rate-limited; the admin token is | a rack can sit behind one address, so shutting it out turns a bad token into a failed rollout. No installer talks to the admin API |
| A short answer token warns; a short admin token refuses to start | refusing to start would leave a fleet unable to install. Refusing to start the admin API costs nobody an install |
What the spec asked for and did not get
plans/rescriptum-spec.md (gitignored, so a contributor will not have it) is the record
of what was first asked for, not a description of what exists. The project outgrew it
in every direction: multi-OS, selectors, templating, an admin API, a database store.
Three specific departures, all listed above: async rather than a thread per connection,
panic = "abort" omitted, and the request body parsed as untyped JSON to harvest facts.
Where the spec and this page disagree, this page is right.
The request lifecycle
The request lifecycle
sequenceDiagram
participant C as Installer
participant L as accept loop
participant T as tokio task
participant B as blocking pool
C->>L: TCP connect
L->>L: try_acquire_owned()
alt no permit
L-->>C: 503, close
else
L->>T: spawn(connection)
Note over T: whole-connection timeout starts
C->>T: request headers
Note over T: header_read_timeout
T->>T: /health? token? method? Content-Length?
C->>T: body (capped at 1 MB)
T->>B: spawn_blocking(Facts + resolve)
B->>B: version() → cached listing or snapshot()
B->>B: match · layer · merge · fill · strip
B-->>T: Resolution | None | Err
T-->>C: 200 + document · 404 · 500
T->>T: log one line, capture if enabled
end
1. Accept
serve() loops on listener.accept() inside a tokio::select! with the shutdown signal
(SIGTERM, which DSM’s task scheduler sends, or Ctrl-C).
An accept failure — file-descriptor exhaustion, say — logs and continues. Ending the loop there would turn a transient resource problem into an outage.
A permit is taken from the semaphore before spawning. Without one, shed() writes a
503 and closes — answering honestly rather than dropping silently,
so the client knows to retry rather than guessing.
2. The connection
Two timeouts, and neither is redundant:
| Guard | Covers |
|---|---|
http1::Builder::header_read_timeout | a client that opens a connection and dribbles headers |
tokio::time::timeout around the whole connection | everything after the headers |
hyper has no body-read timeout. Without the second guard, a client that promises a body in
its Content-Length and then sends nothing would park a connection indefinitely — inside
a permit, so it costs a slot as well as memory.
hyper panics if a timeout is set without a timer.
header_read_timeoutrequires.timer(TokioTimer::new()). Omit it and every connection panics at runtime — it does not fail to compile. See traps.
3. Routing
One if on method and path, in this order:
GET /health→200 OK. Before authentication, before anything, so monitoring never goes dark.- The answer token, when
RESCRIPTUM_ANSWER_TOKENis set. Compared without an early return, so a wrong token cannot be recovered a byte at a time by whoever is timing the responses. Logged, never rate-limited. - Method — anything but
GETorPOSTis405. Content-Length— an aberrant declared size is refused from the header, rather than by lettingLimitedtrip after buffering a megabyte.- The body, through
Limited::new(…, MAX_BODY). A length-limit error becomes413, anything else400.
There is no path routing beyond that: POST and GET are answered on any path,
because the URL is baked into an ISO. The path is not ignored — it becomes
facts — it just does not decide whether to answer.
4. Resolution, off the async worker
let picked = tokio::task::spawn_blocking(move || {
let facts = Facts::from_request(Some(&request_path), query.as_deref(), &body);
answers.resolve(&facts)
}).await;
Both halves belong off the async worker: building facts is CPU work on an arbitrary-sized payload, and the lookup is blocking IO. Doing either on a runtime thread stalls every other connection that thread is driving.
Inside, resolve():
- asks the store for its
version()— onestatfor files, an atomic load for SQLite; - reuses the cached
Listing, or takes a freshsnapshot()and rebuilds it; - picks the best machine document and the best group (scoring);
- resolves
extends, within one format; - merges group chain → machine document;
- fills
{{ placeholders }}; - strips the control keys;
- renders.
5. Response
| Outcome | Response |
|---|---|
Ok(Ok(Some(resolution))) | 200, the document, Content-Type from its format, Connection: close |
Ok(Ok(None)) | 404 no answer file applies |
Ok(Err(e)) | 500, with the reason on the log line |
Err(join_error) | 500 answer lookup panicked — it cannot take the server with it, but it must not pass silently either |
Then exactly one log line, and a capture if one is configured. The body was cloned before resolution took it, and only when capturing is on.
The admin listener
A separate TcpListener, a separate serve() task, spawned only when
RESCRIPTUM_ADMIN_ADDR is set — and only after Config::validate has confirmed the store
is SQLite and the token is long enough. Its own pipeline is in
the admin API internals.
Shutdown
SIGTERM or Ctrl-C ends the accept loop and returns from serve(). In-flight connections
are not drained: there is no state to lose, the client retries, and a provisioning server
that refuses to stop is worse than one that drops a request.
Selection internals
Selection internals
src/select.rs and src/facts.rs hold the behaviour that matters. Both are pure logic
over data handed to them, and both are heavily unit-tested — 27 and 22 tests
respectively.
Normalization
pub fn normalize(input: &[u8]) -> String // lowercase ASCII alphanumerics, everything else dropped
It takes bytes, not &str, on purpose: a request body is arbitrary bytes and need not
be valid UTF-8. Filtering to ASCII alphanumerics sidesteps the question entirely — no
validation, no lossy conversion, no failure mode.
This is what makes matching indifferent to separator style and to how Proxmox structures its JSON this version. It is a substring test over bytes, not a schema.
normalize_patternis the other one. Ordinary normalization strips*and?along with the rest of the punctuation, which turns every glob into a literal — quietly. Selector patterns must go throughnormalize_pattern, which keeps them.
Facts
Facts is a map of label → values, plus the haystack. Three sources, layered from most to
least structured:
Query parameters — hand-rolled parsing with percent-decoding, rather than pulling in a
URL crate for twenty lines of work. Values also go into the haystack, so a document
named after a MAC resolves whether that MAC arrived in a POST body or a query string.
Without that, a GET — which has no body at all — could never match by name.
The path contributes three synthesized labels:
| Label | From |
|---|---|
path | the whole path, trimmed of slashes |
file | its last segment |
segment | every segment, as separate values |
file is not decoration: cloud-init’s NoCloud datasource fetches user-data and
meta-data from one URL and skips the datasource entirely if either is missing, so the
same server has to answer them differently. Path segments feed the haystack too, because
NoCloud can expand __dmi.chassis-serial-number__ into the URL.
The JSON body, flattened by flatten() into both full dotted paths and bare leaf
names. Array indices become part of the path but not of the leaf name, so
network_interfaces.0.mac is also reachable as plain mac.
The departure from “do not parse the JSON”
The original rule was that the request body is never parsed as JSON. That has been relaxed, deliberately and narrowly:
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) {
flatten(&value, &mut String::new(), &mut facts);
}
Untyped, opportunistic, and non-fatal — a body that is not JSON simply contributes the haystack and nothing more. No struct is derived, so no assumption about Proxmox’s schema is baked into a type.
The leaf-name form is why this exists. Proxmox’s own documentation warns that the
contents of dmi “might vary wildly, depending on the system”. A serial cannot be reached
any other way, because the URL baked into an ISO is the same for every machine. A selector
saying “a field called serial, wherever it lives” survives a reorganisation that a
fixed path would not.
Scoring
const IDENTITY_SCORE: u32 = 1_000;
fn score(control: &Control, identity: &[String], facts: &Facts) -> Option<u32> {
if identity.iter().any(|n| !n.is_empty() && facts.haystack().contains(n)) {
return Some(IDENTITY_SCORE); // naming a machine is as specific as it gets
}
if control.matchers.is_empty() { return None; }
control.matchers.iter()
.all(|(k, p)| facts.matches(k, p))
.then_some(control.matchers.len() as u32)
}
identityis the normalized stem for a machine document, and the normalizedmembersfor a group.- All matchers must hold; the score is how many there are.
IDENTITY_SCOREis 1000 rather thanu32::MAXso that “an identity match beats any selector” stays readable, and a selector with a thousand criteria remains a theoretical problem rather than a subtle one.
Ties break on sorted name, alphabetically first:
.max_by(|(a, ca), (b, cb)| a.cmp(b).then_with(|| cb.id.cmp(&ca.id)))
The reversed inner comparison is what makes max_by prefer the smaller name. matchbox,
the closest prior art, documents that its own resolution between competing groups “will
not be deterministic”. This one is, and a test pins it.
Format filtering
fn wanted(facts: &Facts) -> Option<&'static [&'static str]> // from the `segment` facts
fn acceptable(wanted: Option<&…>, format: &str) -> bool // None ⇒ anything answers
Filtering is on the extension, never the family. .ks and .preseed are both
Kind::Text; filtering by family would let a preseed answer /rhel/ks.
None — a URL naming no alias — constrains nothing, which is what keeps /answer working
for a deployment that only ever serves one format.
The listing cache
struct Cached { version: Version, loaded_at: Instant, listing: Arc<Listing> }
Reused only when the store’s version() is unchanged, is Some, and less than
RELOAD_BACKSTOP (1 s) has passed.
The literal reading of the specification — re-read the directory on every request — is a
readdir plus a sort plus a normalization pass per request. With one answer document per
machine, throughput collapses:
| Documents | Literal re-read | mtime-cached |
|---|---|---|
| 10 | 11,954 req/s | 12,922 req/s |
| 200 | 3,198 req/s | 12,890 req/s |
| 2,000 | 311 req/s | 12,520 req/s |
| 10,000 | — | 6,924 req/s |
One stat replaces the whole walk, and a new machine is still picked up with no restart —
which is the guarantee the specification actually wanted. Normalized identities are computed
once per store read, not once per request.
The backstop is not redundant, and it does more work than it used to. Editing a document’s contents moves no directory mtime; neither does adding one inside a machine’s own directory, since that is one level below the mtime being watched; and a change made by another process moves no in-process atomic. So with a directory per identity, the backstop is what picks up everything except an identity appearing or leaving. Tests cover each case.
The figures above were measured against the flat layout. The read itself is now a
readdirper identity on top of the file it already opened — 28 ms to 63 ms at 2,000 machines — which the cache amortises over a second’s worth of requests, and which did not move throughput measurably. It is still the reason a group beats a directory per machine.
The remaining cost at 10,000 documents is a linear scan of precomputed needles — pure CPU, no syscalls. Bucketing needles by length and sliding a window over the body would remove it, but a 10,000-machine rollout already completes in under two seconds. Measure before adding it.
Building a Listing
build(snapshot) does everything expensive once:
- parse every document, keeping the error rather than failing the load;
- normalize every stem and every
membersentry; - resolve
extendschains, detecting cycles and missing parents — the broken group is dropped rather than half-applied, and the problem is recorded; - pre-merge each group’s chain, and pre-render it as a string when it carries no placeholders.
That last one is why grouping is the fast path: the common datacenter case parses nothing
per request. Group::has_placeholders is the flag that decides it.
problems is collected here, not at request time, which is what lets the admin API’s
rollback guard catch a broken
extends before anyone asks for it.
Resolution
resolve() is a match on (machine, machine_doc, group):
| Case | Behaviour |
|---|---|
| group only | serve the prepared string, or clone-fill-strip-render when templated |
| machine only | fill, strip, render |
| both | group chain, merge the machine on top, fill, strip, render |
| neither | fall back to default for the requested format, which may itself extends a group |
Template variables are the request’s facts plus machine and group, which the facts
cannot carry because they are only known once matching has happened.
machineis bound only when a machine document matched. A machine claimed by a group’smemberswith no document of its own resolves withmachine: None, so{{ machine }}in a group fails for exactly the members it was meant to serve. The templating guide says to use a request fact there instead.
Formats and merging
Formats and merging
src/format/mod.rs gives every document format one interface, so select.rs never has to
know which one it is holding.
enum Inner {
Toml(toml_edit::DocumentMut),
Yaml(serde_yaml_ng::Value),
Json(serde_json::Value),
Xml(xml::Document),
Text(String),
}
Doc wraps it and offers parse, merge, render, control, strip_control,
substitute and has_placeholders. Adding a format means adding a variant and filling in
those seven — nothing above this module changes.
Kind
Kind::for_extension is a deliberate allowlist. txt is not on it, so a stray notes
file next to the answers never becomes a candidate.
Kind is the family; the extension is kept separately, because they are not the same
thing:
Kinddecides how to parse, how to merge, and theContent-Type.- The extension decides whether an endpoint may be answered, and which validator
checkcalls.ksandpreseedare bothKind::Textbut do not share a validator — which is whyResolutioncarriesformat_namealongsideformat.
Filtering on the family instead of the extension would let a preseed answer /rhel/ks.
endpoint_formats
A small alias table mapping a URL segment to the extensions it accepts. Two traps live in it, both already paid for:
- Filter on the extension, not the
Kind— as above. - An alias must be specific enough that nobody reaches it by accident.
seedwas removed:s=http://server/seed/is an ordinary NoCloud seed URL, and it serves YAML.
A segment naming no alias constrains nothing, so /answer keeps working.
Merge rules
| Maps / objects | merge recursively |
| Any other value | replaced outright by the higher layer |
| Arrays | replace, they do not append |
Kind::Text | concatenation in layer order |
Arrays replace because appending would make a list impossible to shorten from a higher layer, and “this node has two disks, not four” has to be expressible. The rule is the same in every format so you never have to remember which one you are in.
merge.rs holds the TOML case, and uses as_table_like so [table] and
{ inline = "table" } merge with each other — a group can use one style and a machine the
other without surprises.
The text case is honest about being concatenation rather than pretending otherwise: whether that amounts to an override is the target format’s business (preseed’s last answer wins; kickstart’s does not always).
The XML tree
format/xml.rs is a small hand-built tree over quick-xml, because none of the
general-purpose crates preserve what an answer document needs preserved.
Pairing. Children are paired by element name plus a discriminating attribute:
const DISCRIMINATORS: [&str; 5] = ["name", "id", "key", "alias", "pass"];
That is what makes <component name="Microsoft-Windows-Shell-Setup"> and
<settings pass="specialize"> mergeable: overriding one pass leaves the others alone.
Repeated siblings are not always a list. Treating them as one replaced every
<component>in an unattend.xml with the one the overlay happened to mention. If they carry a discriminating attribute they are a keyed collection. AutoYaST’sconfig:type="list"is honoured for the genuine list case.
Fidelity. Declarations, doctypes, namespaces and attributes survive a merge. Original indentation and comment placement do not — the output is re-rendered, not patched.
quick-xml emits entity references as their own events. Ignoring them welds the surrounding text fragments together:
1 < 2 & 3came back as123. Numeric entities are resolved; unknown ones are refused rather than silently dropped.
It understands no schema. check calls xmllint where it is installed, and that is the
extent of the guarantee.
Control keys
pub const CONTROL_KEYS: [&str; 3] = ["extends", "members", "match"];
pub const XML_CONTROL_ELEMENT: &str = "answer-meta";
pub const TEXT_DIRECTIVE: &str = "answer:";
They travel in whatever the format allows — native top-level keys in the structured
formats, an <answer-meta> element in XML, # answer: (or // answer:) directives in
text — and strip_control() removes all of them before the answer is sent.
Control is the parsed form: extends: Option<String>, members: Vec<String>,
matchers: BTreeMap<String, String>.
Templating
Two rules, both load-bearing:
Substitution happens on parsed string values, never on raw document text. The value
goes into the document’s own data model and the format’s serializer writes it out, so the
serializer does the escaping. A value containing a quote cannot break the TOML it lands
in; one containing < cannot break the XML. A test feeds a"b'c<d>e&f into all four
structured formats and reparses the output.
A missing fact is an error, never an empty string. Serving node-.example.com
installs a machine with a broken hostname and nobody notices until later. Control
characters are refused for the same class of reason — a newline in a kickstart value
injects a directive into a file the installer executes.
Group::has_placeholders is why a group with no template costs no parsing per request:
the string prepared at load is served as-is.
The worked examples are part of the design
examples/ carries a commented
example of all thirteen extensions the allowlist names, and
RESCRIPTUM_ANSWERS_DIR=examples cargo run -- check
exercises them all. Keep it that way. They are the only place the formats are shown
composing together, and two of them — suse-node.autoyast and windows-node.unattend —
are what caught the missing doctype and the unpaired pass.
The stores
The stores
Answers come from either a directory of documents (RESCRIPTUM_STORE=files, the default)
or a SQLite database (RESCRIPTUM_STORE=sqlite), chosen at runtime.
The trait is deliberately thin
pub trait Store: Send + Sync {
fn version(&self) -> Version; // Option<String>, cheap per request
fn snapshot(&self) -> io::Result<Snapshot>; // only when version moved
fn describe(&self) -> String;
}
A Snapshot is raw document text and nothing else: RawMachine, RawGroup,
RawDefault, each carrying an identifier, a format and a body.
Every decision lives above this. Matching, extends chains, merging, rendering,
check — all in select.rs and merge.rs, shared. Keep it that way: the moment a
backend starts deciding behaviour, the two drift and the conformance suite stops being
able to prove they have not.
The write half is separate, because serving answers never needs it:
pub trait StoreWrite: Store {
fn put_machine(&self, id: &str, format: &str, body: &str) -> io::Result<()>;
fn delete_machine(&self, id: &str, format: &str) -> io::Result<bool>;
fn put_group(&self, name: &str, format: &str, body: &str) -> io::Result<()>;
fn delete_group(&self, name: &str, format: &str) -> io::Result<bool>;
fn put_default(&self, format: &str, body: &str) -> io::Result<()>;
fn delete_default(&self, format: &str) -> io::Result<bool>;
}
Every operation names a format. A document is keyed by what it is for — a machine and an operating system.
An earlier
putdeleted the other formats of a stem, to avoid “two answers for one machine”. That was the wrong model: they are that machine’s answers for two operating systems, and both are meant to exist. See traps.
tests/stores.rs is the guarantee
Every behavioural case runs twice, once per store, and asserts the identical outcome. 35 cases at last count.
A new behaviour belongs there, not in a store-specific test. A test that covers one backend proves half of what it claims.
The file store
One directory per identity. A machine is a directory named after it, holding one
document per format; groups/ holds the same shape for groups, and default/ the
fallbacks. Both names are reserved, so a machine cannot claim them — valid_machine_id
refuses them in both stores, because a database that accepted one would export into a
directory that cannot hold it.
Inside a directory, the extension is the format and the stem is nothing at all. That is
the rule that makes two documents of one format in one directory a reported problem rather
than a resolved one: there is no tiebreak an operator could have predicted. Sorted order
decides which of the two answers, so the choice at least does not depend on readdir — and
the loser is named in problems().
A servable document left at the top of the answers directory — the layout that came before —
is reported and not served, with its destination spelled out. Half-reading an old layout
would mean a machine whose answer moved silently between two files. pending_moves() is the
same knowledge exposed for migrate, so the command and the reader cannot disagree about
where a document belongs.
version() is the directory’s mtime:
fs::metadata(&self.dir).ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_nanos().to_string())
One stat replaces a whole directory walk — see
the listing cache.
The directory’s mtime moves when an entry is added or removed in it, not when one is edited, and not when something changes one level down. So a machine’s whole directory appearing or leaving is seen at once, while a document added or edited inside one waits for the 1-second reload backstop — which is what already covered a file edited in place. A unit test pins each half.
What the layout costs on a read. A full reload is now a
readdirper identity on top of the file it already opened. Measured at 2,000 machines on an M1 Pro: 28 ms flat, 63 ms with a directory each. It is amortised over a second’s worth of requests, and end-to-end throughput did not move measurably — but it is a real 2.2× on the one operation the backstop guarantees will run every second, and it is the reason to reach for a group before a directory per machine.
Writes go through a temporary file plus rename, which is atomic within a directory
on POSIX, so a reader never meets a half-written answer. The temporary name carries the
process id, and is removed if the rename fails. A test asserts no .tmp file survives.
Reading is DirEntry::file_type(), not fs::metadata. The file type comes back free
with the readdir on Unix; only a symlink needs the stat to resolve. That alone was worth
65% at 2,000 files, before caching was added.
The SQLite store
rusqlite with the bundled feature — SQLite is compiled from source into the binary, so
there is nothing to install. It cross-compiles to armv7-musl under zigbuild; CI builds
that target on every push precisely because a C dependency breaks there first.
WAL mode, so the admin API never stalls an install in progress.
version() reads an in-process atomic, not the database:
Some(self.revision.load(Ordering::Relaxed).to_string())
It is called per request, and a query per request would defeat the point of caching. The consequence is that a change made by another process does not move it — the reload backstop is what catches that.
Schema versions live in PRAGMA user_version. There is one, and nothing has been
released under an older one, so migrate() has no steps: it refuses a database from the
future, creates the schema when the version is 0, and stamps it. The shapes this went
through while it was being written never left the repository, and carrying migrations from
them would be carrying code that cannot run.
What the version is for is the rollback direction:
database schema is version 2, this binary understands 1
Refused rather than guessed at, because a database written by a newer binary may hold columns this one would silently ignore — and silently ignoring part of an answer set is how a machine gets installed wrongly.
import / export
$ rescriptum import <dir> # directory → the configured store
$ rescriptum export <dir> # the configured store → a directory
Both go through Snapshot, so they share every rule. The round trip is byte-identical
— import a directory, export it again, diff -r reports nothing, paths included. A test
compares both sides at the same path for exactly that reason: export writing a document
somewhere import would not look for it is what would make the database unsafe to leave. That is what makes the
database safe to adopt and safe to leave, and it is worth keeping true.
Identifiers become directory names
pub fn valid_id(id: &str) -> bool // letters, digits, - _ . : and no separators
pub fn valid_machine_id(id: &str) -> bool // …and not `groups` or `default`
Enforced at the admin API boundary and in both stores. The store is the layer that turns an identifier into a path, so it is the layer that must not be fooled — checking only at the boundary would make the guard depend on every future caller remembering.
valid_format is the equivalent for extensions: a document in a format nobody can read
never reaches the store in the first place.
The sqlite cargo feature
On by default, and removable:
| Build | ARMv7 size |
|---|---|
| default | 2,103,456 bytes |
--no-default-features | 944,928 bytes |
Dropping it also drops the admin API, which needs the database. CI builds
--release --no-default-features on every push so the smallest build cannot rot
unnoticed.
Admin API internals
Admin API internals
src/admin.rs, enabled only by RESCRIPTUM_ADMIN_ADDR, and only over SQLite. Three
properties are load-bearing — a change that quietly drops any of them is a regression.
1. Its own listener
The answer endpoint is unauthenticated by necessity: the installer has no credentials to offer. This API sets the root password and SSH keys of every machine installed afterwards. It never shares that port.
Config::validate refuses to start — as an error, not a warning — without a token, with a
token under 16 characters, or over the file store. Those checks run before the listener
is bound, so a misconfiguration is never briefly live.
2. SQLite only
Over a directory of files there would be two ways to change the same configuration, by hand and over the wire, racing each other.
3. The write that cannot break the fleet
fn guarded(admin, kind, id, format, body) -> Response<Body> {
let before = admin.answers.problems()?; // snapshot the damage
let previous = admin.store.snapshot()?…; // what was there, so it can be restored
let existed = apply(…)?; // put or delete
let after = admin.answers.problems();
let introduced = after.filter(|p| !before.contains(p));
if !introduced.is_empty() { restore(previous); return 409 }
200 with `problems: before`
}
- Only newly introduced problems roll back. A store that was already broken stays editable — otherwise a bad state would be unfixable through the API that caused it.
- A successful write still reports the pre-existing problems, so a clean response never implies the whole set is healthy.
- This is why a machine’s
extendspointing at a missing group is detected at load time inselect.rsrather than only when that machine asks. The guard can only catch whatproblems()reports. Adding a new class of breakage means adding it there, or the guard silently stops covering it.
Malformed documents are refused at write time (400) rather than becoming a 500 the
next time a machine asks for one.
Authentication
Constant-time comparison. An ordinary == returns early on the first differing byte,
which leaks the token one byte at a time to anyone timing the responses — a few thousand
requests instead of an impossible number. Comparing every byte regardless removes the
signal. Over a network the timing is usually lost in jitter, so this is precautionary; it
costs five lines.
AuthGuard shuts out an address after repeated failures:
| Constant | Value |
|---|---|
MAX_FAILURES | 5 |
FAILURE_WINDOW | 60 s |
BASE_BLOCK | 60 s, doubling on repeats |
MAX_BLOCK | 900 s |
MAX_TRACKED | 4096 addresses |
Three details that are not accidents:
- The block applies to a correct token too. Otherwise guessing until you got it right would cost nothing.
MAX_TRACKEDis bounded, so the guard cannot itself be turned into a memory leak by an attacker cycling source addresses.GET /healthis checked before the guard and before auth, so monitoring does not go dark during an attack.
A block answers 429 with Retry-After.
Request handling
let segments: Vec<&str> = path.trim_matches('/').split('/').collect();
match (&method, segments.as_slice()) {
(&Method::GET, ["machines"]) => list(…),
(&Method::GET, ["resolve", id]) => resolve(…),
(&Method::PUT, ["groups", id]) => put(…).await,
…
_ => error(NOT_FOUND, "no such endpoint"),
}
?format= selects the document’s extension, defaulting to toml — which is what this
server started life serving.
Read the request body before rejecting a request. Answering and closing while the client is still writing earns a
ECONNRESETinstead of the response.put()drains first, then validates the identifier.
Every admin response must set
Connection: close. Without it, every test client waited out the connection timeout — the suite took 30 s instead of 0.4 s — and the eventual drop sometimes arrived as a reset rather than a clean EOF.
GET /resolve sets X-Answer-Source with the same description the log line uses.
GET /resolve/{id}ignores the path identifier when a query string is present — facts come from the query alone, so it can rehearse a real request. That makes?format=tomlon that endpoint actively wrong: it resolves nothing. Documented in the guide.
Identifiers
valid_id — letters, digits, - _ . :, no path separators — is enforced at the API
boundary and in both stores. export turns identifiers back into filenames, so
anything that could traverse a directory has to be rejected in the layer that builds the
path, not only in the layer that received it.
Known and accepted
- Per-address limiting does not stop an attacker with many addresses. The token’s length is what makes guessing hopeless — hence the 16-character floor at startup.
- It speaks plain HTTP. Put TLS in front if it leaves loopback.
- Binding beyond loopback logs a warning rather than refusing, because a management network is a legitimate choice.
Tests
tests/admin.rs (15 cases) covers routing, the rollback, identifier validation and the
status codes. tests/guards.rs (5) covers the lockout arithmetic and that /health stays
reachable through it.
Testing
Testing
619 tests. cargo test runs all of them in about twenty seconds — most of that is
tests/tftp.rs, which waits on real UDP timeouts because that is what it is testing.
cargo test does not run the harnesses that matter most: the boot rig, the DSM
package’s three, and the loader build. See The package is tested too
and the boot rig.
cargo test # everything
cargo test <name> # one, by substring
cargo test -- --nocapture # show stdout
cargo test --all-features # what CI runs
Where a test belongs
| Suite | Cases | For |
|---|---|---|
tests/integration.rs | 52 | the real binary over a real socket |
tests/cli.rs | 65 | render, check, import, export, config and the env file — against the real binary |
tests/media.rs | 45 | boot media against the real binary, with both listeners up |
src/config.rs | 56 | the environment, what refuses to start, and which of the file and the environment wins |
tests/stores.rs | 48 | every behaviour, against both stores |
tests/tftp.rs | 30 | TFTP over real UDP: the turn-taking, and what a failed bind must not cost |
src/select.rs | 29 | normalization, scoring, layering, template filling |
src/format/mod.rs | 28 | parsing, merging, control keys, endpoint aliases |
tests/admin.rs | 26 | the admin API end to end, formats included |
src/envfile.rs | 23 | the env-file parser and writer, and what each refuses |
src/facts.rs | 22 | query parsing, JSON flattening, globbing |
src/format/xml.rs | 18 | the XML tree — pairing, entities, fidelity |
src/merge.rs | 11 | the TOML deep merge |
tests/guards.rs | 7 | the answer token, and the lockout that deliberately is not there |
src/installed.rs | 6 | a machine reporting it installed, and what must never be disarmed |
src/log.rs | 15 | level parsing, and the timestamp arithmetic |
src/boot/*.rs | 128 | the ISO reader, probing, the catalogue, image sources, patch plans, the menu, the loader table, DHCP snippets, cpio and SHA-256 |
src/admin.rs, src/capture.rs, src/store/mod.rs | 21 | unit-level behaviour |
tests/common/mod.rs — the fixtures every suite shares
Answers are stored as a directory per identity, so a fixture cannot just be a filename
any more. seed() takes the name a test thinks in — 98fa9b50d810.toml,
groups/rack-a.toml, default.toml — and writes it through StoreWrite, so it lands
exactly where a write from the admin API would and cannot drift from the layout. A name the
store would refuse (an extension nobody serves) is written literally instead, because those
fixtures exist precisely to prove that a stray file answers nothing.
One copy, not one per suite — the same reasoning that makes loaders.rs a single table
read by both TFTP and the DHCP snippet. Four copies of a mapping are four chances for a
fixture to land somewhere the server does not look, and a test that seeds nothing passes
for the wrong reason.
tests/stores.rs — the conformance suite
Every behavioural case runs twice, once per store, and asserts the identical outcome. That suite is what keeps two backends from drifting.
A new behaviour belongs there, not in a store-specific test. A test that covers one backend proves half of what it claims — and the half it does not cover is exactly where a divergence hides.
tests/cli.rs — the commands people are told to run
check is what deploy.sh runs before it ships
anything, and what the documentation tells people to put in CI — so its exit code is a
contract, not a convenience. render’s stdout/stderr split is another: the document
goes to stdout so render … > answer.toml yields a usable file, and the provenance line
goes to stderr so it does not end up inside it.
Also pinned here: the import → export round trip is byte-identical, comments and
formatting included. That is what makes the database safe to adopt and safe to leave;
if it ever stops being exact, export is no longer a way back out.
tests/integration.rs — against the real binary
It starts the actual binary on an ephemeral port and talks HTTP to it. The binary prints the address it bound, so there is no port race and no sleep-and-hope.
This suite exists because some failures are invisible to unit tests. The clearest example:
hyper panics at runtime if header_read_timeout is set without .timer(…). It
compiles. Only a real connection finds it.
Explicitly covered:
- a truncated request, and one with no
Content-Length; - an aberrant
Content-Length— and a chunked body that outgrows the cap while streaming, which is the other way in and trips the limit mid-read; - an unknown method, an empty body, a 1 MB body, a body that is not valid UTF-8;
- the connection cap: over it, a prompt
503rather than a queue — and the permit coming back afterwards; - and, after each of those, that the server still answers. That last assertion is the one that matters — the abuse is only interesting if the server survives it.
cargo testdoes not rebuildtarget/debug/rescriptum. A manual check against a stale binary once “reproduced” a bug that had already been fixed. Rebuild before poking at the binary by hand.
tests/tftp.rs — a transfer is a conversation
Nothing here can be proved from inside a function. Blocks, acknowledgements, retransmission, the empty packet that ends a transfer — every bug worth catching lives in the turn-taking, and the first run found two of the “works by hand, never after a reboot” kind. A file whose length is an exact multiple of the block size must end with an empty data packet; leave it out and the client waits forever for a final block that never comes.
It also owns the one listener failure in this server that is not fatal. A TFTP port that
cannot be bound must not take answers and media down with it — measured on DSM, where the
capability is granted outside the package and an upgrade drops it — so the test holds the
port with a squatter, then asserts three things at once: the server came up, it warned and
said what still works, and boot check still exits non-zero.
That last one first passed for the wrong reason: three missing loaders were already failing the command. The fixture now writes every loader the table names, and a control run with TFTP off proves the directory is otherwise clean.
tests/media.rs — boot media against the real binary
Both listeners up, and every abuse case ends by proving the server still answers. One case proves the property the separate socket exists for: answers keep succeeding while four image transfers are in flight.
There is deliberately no binary ISO fixture in this repository. boot::iso::build
writes images in memory, behind the test-support feature so it never reaches a release
binary.
The boot chain belongs in the rig
packaging/boot-rig/run.sh is not Rust and cargo test does not run it. It boots a
claimed and an unclaimed machine in QEMU under TCG, on a private bridge with no uplink,
and asserts four markers: the DHCP handoff answered from our own generated snippet, a
loader fetched over TFTP, the unclaimed machine fell through to its local disk, and the
claimed machine reached its own answer. CI runs the same thing plus a deliberate break.
A QEMU guest bridged into a container has a MAC of its own, and Docker Desktop’s virtual switch does not forward frames from a MAC it did not assign — measured, which is why the primary rig is one container rather than four on a Docker network.
Check that a test can fail
A test that passes for the wrong reason is worse than no test: it reports coverage that does not exist. Before trusting a new one, break the thing it guards and watch it go red.
One in this suite did not survive that check. It claimed to protect the version.is_some()
clause in the listing cache; removing the clause left it green, because with either store a
version is unreadable only when the store is also empty, so the clause cannot currently
fire at all. The test proves something real — a directory that appears after startup is
served on the very next request — and now says so instead.
Assertions worth copying
- Assert on parsed values, not on formatting. Replacing a table with a scalar leaves
the key’s original decor, so the output can read
value= 3— valid TOML, different text. A string comparison there fails for the wrong reason, or passes for one. - Cache-invalidation tests must share one
Answersinstance. A test that constructs a fresh one per call bypasses the cache entirely and silently proves nothing. Config::from_lookuptakes a closure, so configuration tests never touch the process environment — and therefore never race each other under a parallel test runner.- Assert the old text was found before writing. Two
python/sedpatches in this project’s history silently matched nothing and were only caught by checking test counts afterwards.
The example answers are a test too
RESCRIPTUM_ANSWERS_DIR=examples cargo run -- check
examples/ holds a worked example
of every format, and it is the only place they are shown composing together. Two of them
caught real bugs — a missing doctype and an unpaired pass attribute. Keep them working.
The package is tested too, in three places
cargo test does not touch the DSM package, because none of it is Rust. Three harnesses
do, and each proves something the others cannot.
| Proves | Cost | |
|---|---|---|
packaging/dsm/check-spk.sh | the archive is structurally what DSM expects — uncompressed outer tar, six INFO fields, an all-numeric version, os_min_ver at least 7.1, 64×64 and 256×256 icons, executable scripts with no CRLF, the packaged binary’s own --version, and the desktop application: dsmappname naming a class its ui/config actually declares, a JavaScript filename that carries the version, and a backend that still checks the DSM session and administrators | seconds, on every push |
packaging/dsm/lifecycle-test.sh | everything the package’s scripts decide, against a fake /var/packages tree: the env file written once and only once, the wizard’s values and their absence, the service surviving its own start script and answering /health, the exit codes Package Center reads, an upgrade that must not touch a hand-edited configuration, an uninstall that must not touch the answers — and the desktop application’s backend, driven with a stubbed authenticator: refusing no session, refusing a non-administrator, refusing a write with no intent header, refusing one that would stop the server starting, and never handing a token to the browser | seconds, on every push |
packaging/dsm/vm/on-dsm.sh | DSM’s own machinery — the data-share worker and its ACL, the port-config worker, the generated systemd unit, logrotate against a live descriptor, whether Package Center accepts the archive — and that a machine asking for its configuration gets one: a POST with hardware in the body, answered by that machine’s file merged over the group claiming it. It also owns the only route to port 69 and whether this NAS can reach a vendor’s image index: that 69/udp survives into the acquired firewall entry, that the package still answers without the capability, and that setcap cap_net_bind_service=+ep plus a restart binds udp/69 as the unprivileged package process | minutes, on a DSM 7 VM — and then on the DS416j |
packaging/dsm/lifecycle-test.sh # the first .spk in dist/ that runs here
docker compose -f packaging/dsm/vm/docker-compose.yml up -d # a DSM 7.2 machine
packaging/dsm/vm/on-dsm.sh admin@<host> -p 2222 # against it
packaging/dsm/vm/on-dsm.sh admin@nas # the verdict
The VM is vdsm/virtual-dsm, which installs Synology’s own Virtual DSM release — no loader
image to find. KVM makes it fast rather than possible: without /dev/kvm it emulates, about
ten times slower, which is what docker-compose.emulated.yml is for. It does want 14 GiB
free for the storage, hardcoded in the image.
The last one is destructive on purpose — it upgrades over a hand-edited env file and a
canary in the shared folder, then uninstalls, then checks both survived. Those two guards
are the most expensive things in the package to get wrong, and the first published .spk
is the one whose uninstall scripts will run during everybody’s first upgrade.
packaging/dsm/vm/README.md
is the rig: what it is evidence about, and what it is not.
The same rule as everywhere else applies to these: break the thing they guard and watch
them go red. Reverting the postinst upgrade guard, making postuninst delete the
share, returning 1 for a stopped package and refusing prestart turns 33 green checks
into 25 green and 8 red — which is how we know the harness is testing anything at all.
Today it is 85 checks in lifecycle-test.sh, 28 in check-spk.sh and 52 on
the machine; the three most recently added were each watched red the same way — by putting
RESCRIPTUM_TFTP_ADDR=off back, by deleting the panel’s report of the TFTP state, and by
making it claim to be serving with nothing bound.
CI
.github/workflows/ci.yml, on every push to main and develop and on every pull
request:
| Job | Runs |
|---|---|
| gates | cargo fmt --all --check, cargo clippy --all-targets --all-features -D warnings, cargo test --all-features, cargo build --release --no-default-features |
| docs | builds the public site and runs notabene lint |
| audit | cargo audit --deny warnings over the dependency tree |
| cross | a full ARMv7 build against the glibc floor DSM has, asserting it needs nothing newer, then assembles both .spks, checks them structurally and drives the package lifecycle |
The cross job is not redundant. SQLite is compiled from source, and armv7-musl is the
least forgiving target shipped — it is where a C dependency breaks first. Catching that
on a push beats catching it while cutting a release.
The audit job is the other half of the rule that adding a dependency needs a reason: a
reason to add one is not a reason to keep it. --deny warnings fails on an unmaintained or
yanked crate too, not only on a vulnerability. When something appears with no fix, add
--ignore RUSTSEC-… with a line saying why rather than dropping the flag.
Every action used is an official actions/* one, and both Zig and cargo-audit are
installed directly rather than through a third-party action. That is deliberate: this
toolchain vets and links a binary people run as root.
The docs site has its own gate — see the docs site.
Building
Building
./build.sh # this machine, and print the size
./build.sh --all # every target a release ships
./build.sh --no-sqlite # the smallest binary
./build.sh armv7-unknown-linux-gnueabihf
./build.sh --help
build.sh adds a missing Rust target for you and warns if a musl build came out
dynamically linked — which DSM would refuse to run, at exec time on the NAS rather than
at build time on your laptop.
Plain cargo build works too; build.sh exists for the size report and that warning.
The release targets
| Target | For | Cross |
|---|---|---|
armv7-unknown-linux-gnueabihf | the DS416j, the reason this project exists — glibc, not musl, see below | zigbuild, floor 2.17 |
aarch64-unknown-linux-musl | modern ARM NAS, Raspberry Pi | zigbuild |
x86_64-unknown-linux-musl | most other Linux hosts | zigbuild |
aarch64-apple-darwin | local development | native |
x86_64-apple-darwin | local development | native |
Cross-compiling
cargo-zigbuild uses Zig as the linker,
which avoids a full cross toolchain per target:
cargo install cargo-zigbuild
cargo zigbuild --release --target armv7-unknown-linux-gnueabihf.2.17
Why armv7 is the one target that is not musl
Every other target is static musl. ARMv7 is glibc, and it is not a preference — it is the only way the machine this project exists for runs the binary at all.
Synology’s ARMv7 kernels are 3.10, and they answer the time64 syscalls with EINVAL
rather than ENOSYS. musl 1.2 made time_t 64-bit on 32-bit architectures and tries
clock_gettime64 (and clock_nanosleep, and the timed futex) first, falling back to the
32-bit syscall only on ENOSYS. On a kernel that says EINVAL the fallback never
happens, so every call for the time fails. Measured on a DS416j running DSM 7.1, kernel
3.10.108:
$ ./probe
libc clock_gettime(CLOCK_REALTIME) -> -1 errno=22 (Invalid argument)
syscall 263 (time32) -> 0 ok
syscall 403 (time64) -> -1 errno=22 (Invalid argument)
The symptom is a binary that answers --version and then panics the moment it wants a
timestamp — time.rs:131, Os { code: 22, kind: InvalidInput }. It is not an ABI problem
and not a kernel-too-old-for-the-instructions problem, which is what it looks like.
glibc on 32-bit uses the time32 syscalls, and DSM ships its own (2.20 on armada38x). So
the armv7 build targets a glibc floor of 2.17 — low enough for DSM, and since glibc is
backward compatible, the same binary runs on newer ARMv7 Linux as well.
What to verify, then, is not that it is static — it is that it needs no glibc newer than the floor. Anything newer fails at exec time on the NAS, naming a symbol version and nothing else:
$ readelf --dyn-syms target/armv7-unknown-linux-gnueabihf/release/rescriptum \
| grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1
GLIBC_2.17
CI asserts exactly that on every push. The musl targets are still checked for being static, because for them that is the promise.
Installing Zig on the maintainer’s machine
Zig is not a Homebrew install here: brew install aborts on that machine over
untrusted third-party taps unrelated to Zig. It lives in ~/.local/zig, symlinked at
~/.local/bin/zig. To upgrade, replace that directory — brew upgrade zig does
nothing.
Verified toolchain: Rust 1.93, cargo-zigbuild 0.23.0, Zig 0.16.0, with targets
aarch64-apple-darwin and armv7-unknown-linux-gnueabihf installed.
The release profile
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort" is deliberately absent — see
constraints. Measured cost of keeping
unwinding on ARMv7: +2416 bytes, +0.8%.
Size
| Build | ARMv7 |
|---|---|
| default | 2,103,456 bytes |
--no-default-features (no SQLite, no admin API) | 944,928 bytes |
Most of the difference is bundled SQLite, compiled from source. CI builds
--release --no-default-features on every push so the small build cannot rot unnoticed.
Features
| Feature | Default | Gives |
|---|---|---|
sqlite | on | the SQLite store and the admin API |
cargo build --no-default-features # smallest
cargo test --all-features # what CI runs
The Synology package
A .spk is a release format, not a build: the binary is finished before packaging
begins, there is no DSM-specific build, and nothing in src/ knows Synology exists.
./build.sh --spk x86_64-unknown-linux-musl # build, then wrap it
packaging/dsm/make-spk.sh armv7 # wrap a build that already exists
packaging/dsm/check-spk.sh # structural check over dist/*.spk
The package carries the loaders, so build them first or it will not pass its own
check. make-spk.sh takes them from packaging/ipxe/out (override with
RESCRIPTUM_LOADERS), and check-spk.sh fails a package that has none — a TFTP server
with nothing to hand out boots nothing. Building iPXE needs a Linux C toolchain, which on
a Mac means a container:
docker run --rm --platform linux/amd64 -v "$PWD:/w" -w /w debian:bookworm-slim sh -c '
apt-get update -qq &&
apt-get install -y --no-install-recommends build-essential liblzma-dev mtools \
xorriso isolinux gcc-aarch64-linux-gnu git ca-certificates perl &&
packaging/ipxe/build.sh --out /w/packaging/ipxe/out'
Once, not per package: the loaders are the same bytes in every ABI’s .spk, because they
run on the machines being booted, not on the NAS. packaging/ipxe/out is gitignored —
no binaries in git, ever.
| ABI | arch in INFO | From |
|---|---|---|
x86_64 | x86_64 — the family name, so it covers every Intel platform | x86_64-unknown-linux-musl |
armv7 | armada38x — the family shorthand does not reach the Marvell platforms | armv7-unknown-linux-gnueabihf |
aarch64 | armv8 | aarch64-unknown-linux-musl, once the binary has been run on one |
The rule for widening that: claim an ABI once the binary has run on the oldest-kernel member of it, never because a platform is plausible.
make-spk.sh is deterministic — fixed mtimes, ownership 0:0, ustar, gzip -n, a
pre-sorted file list — so the same inputs give a byte-identical .spk, which is what makes
the published checksum worth something.
check-spk.sh runs in CI on every push. It asserts the outer archive is an uncompressed
tar, that INFO has its six required fields and an all-numeric version, that the icons are
exactly 64×64 and 256×256, that the lifecycle scripts parse and are executable, and that
the packaged binary’s own --version matches INFO — the x86_64 build runs on the
runner, so that last one is a real assertion rather than a re-read of the same string.
lifecycle-test.sh then drives the package’s own scripts against a fake /var/packages
tree — install, start, /health, the exit codes, an upgrade over a hand-edited
configuration, an uninstall over a canary in the share — and also runs on every push.
packaging/dsm/lifecycle-test.sh
What none of that can prove is that DSM will accept the package; only installing it can.
That is the rig in
packaging/dsm/vm/:
a QEMU launcher, and one script that runs the on-machine checks against the VM while you
iterate and against the DS416j for the verdict. See
testing.
Deploying a build
./deploy.sh admin@nas
./deploy.sh admin@nas /volume1/netboot
Builds, checks the answers and refuses to ship if they do not come back clean, copies
under a temporary name, restarts, and confirms /health. See
deployment.
| Environment | Default |
|---|---|
TARGET | armv7-unknown-linux-gnueabihf |
ANSWERS | <remote-dir>/answers |
PORT | 8000 |
Branching and releases
Branching and releases
The model mirrors the sibling project notabene deliberately — same maintainer, same
expectations.
Branches
| Branch | Rule |
|---|---|
main | stable. Only release commits and vX.Y.Z tags land here. Never push feature work directly |
develop | integration. Kept at the in-progress next version |
feature/<name>, fix/<name> | branch from develop, PR back into develop |
main ──●────────────────────────●─(tag vX.Y.Z)──▶ releases
\ /
develop ●───●───●───●───●────● ────────────────▶ CI gates only, publishes nothing
\ / \ /
feature/… ● fix/… ● (PRs into develop)
develop publishes nothing. It runs the gates — build, tests, clippy, fmt — and stops
there. No prereleases, no artifacts. Binaries are produced only by a vX.Y.Z tag on
main.
That is the one thing that does not carry over from notabene, which is an npm package
and publishes prereleases to a @dev dist-tag. This project ships a compiled binary, so
the release artifact is a GitHub Release with cross-compiled binaries attached, built by a
CI matrix.
Commits
Conventional commits with a scope:
feat(http): answer GET as well as POST
fix(select): normalize member strings before comparing
chore: release v0.2.0
Keep PRs focused. Adding a dependency needs a reason in the commit message — this binary runs as root on other people’s hardware.
Cutting a release
# on develop, with everything green
$EDITOR Cargo.toml # bump version
cargo build # refresh Cargo.lock
git commit -am "chore: release vX.Y.Z"
git checkout main && git merge --no-ff develop
git tag -a vX.Y.Z -m "rescriptum vX.Y.Z"
git push origin main --follow-tags
.github/workflows/release.yml then:
- Refuses the tag if it disagrees with
Cargo.toml. A release whose binary reports a different version than its tag is a support problem that outlives the release. - Cross-compiles the five published targets.
- Packages each as
rescriptum-<version>-<target>.tar.gz, withREADME.mdandLICENSEalongside the binary, plus a SHA-256 sum — whoever runs this as root should be able to check what they downloaded. - Builds the branded iPXE loaders from the pinned commit and attaches them as
rescriptum-boot-assets-<version>.tar.gz, after askingboot checkwhether the directory satisfies the loader table the server hands out from. Without this the release is incomplete and quietly so: a deployment gets a TFTP server with nothing to hand out, and every machine the generated DHCP snippet sends there asks for a file, gets nothing, and stops. They are their own download, never part of a binary archive or an.spk— they are iPXE, GPLv2, and separate files served alongside is mere aggregation, withpackaging/ipxe/as the written offer. - Wraps the Linux musl builds as Synology packages,
rescriptum-<version>-<build>-<abi>.spk, and checks each structurally before it can be published. - Cuts the GitHub Release with
ghand--generate-notes, or uploads into it if it already exists.
It is re-runnable by hand through workflow_dispatch with a tag, for when a job fails
after the tag is already pushed.
A packaging-only fix needs no tag. SPK versions are all-numeric segments and the last
one is a package build number, so v0.1.0 produces 0.1.0-1; dispatching by hand with
spk_build: 2 attaches rescriptum-0.1.0-2-<abi>.spk to the same Release. A prerelease
does not produce an .spk at all — the archives are the prerelease channel.
A tag must not be the first time an .spk is installed on a DSM machine. The
structural check catches a broken archive; only Package Center catches a broken package,
and the first published one is the one whose uninstall scripts will run during everybody’s
first upgrade. The checklist is in
packaging/dsm/README.md.
Every action used is an official actions/* one, and gh is already on the runner. That
is deliberate for the same reason as everything else in this file.
Versioning
SemVer. The tag is vX.Y.Z and must match Cargo.toml exactly.
Answer documents are data, not state: nothing migrates, and a new binary reads the same
directory. The exception is the SQLite schema, which carries a user_version — see
stores. There is one version so far. Adding a second means
writing the migration step and a minor bump at least, and the release notes have to say
so, because an older binary will refuse the upgraded database rather than half-read it.
Documentation
The documentation site is published from main, so a docs change
ships with the next release — or by running the docs workflow by hand
(workflow_dispatch) when it should not wait.
Traps already hit
Traps already hit
Each of these cost real time. None is obvious from the code alone.
Runtime, not compile time
hyper panics if a timeout is set without a timer. http1::Builder::header_read_timeout
requires .timer(TokioTimer::new()). Omit it and every connection panics at runtime —
it does not fail to compile. The integration tests caught this; unit tests could not have.
header_read_timeout stops at the end of the headers. hyper has no body-read timeout,
so a client that promises a body and sends nothing would park a connection indefinitely.
The whole-connection tokio::time::timeout in connection() is what covers that. Both
are needed; neither is redundant.
hyper emits header names lowercased. That is correct — they are case-insensitive — so
assert on a lowercased copy. See has_header in the integration tests.
Performance
fs::metadata per directory entry is a stat syscall each. DirEntry::file_type()
comes back free with the readdir on Unix; only a symlink needs the stat to resolve. That
alone was worth 65% at 2,000 files, before caching was added.
Editing a group file’s contents changes no directory mtime. Only RELOAD_BACKSTOP
(1 s) picks that up, which is why the backstop is not redundant with the mtime check. An
integration test covers it.
An aberrant Content-Length must be refused from the header, not by letting Limited
trip after buffering a megabyte.
Closing on a peer that is still writing discards the response you just wrote. The
kernel sends a reset, and the reset throws away the unread bytes — so the client sees a
dropped connection, not your answer. shed() had exactly this: it wrote its 503 and
closed immediately, so the installer it was trying to tell “retry” got a connection
reset instead. It now drains briefly first, the way the admin API’s put() already did.
A test at the connection cap pins it.
- macOS lets an unprivileged process bind UDP port 69; Linux does not. So a test that
reaches the default TFTP address takes a different branch on each platform —
boot checkcalls an obtainable-but-silent port a note and an unbindable one a problem, which is the right rule and exactly what makes the test platform-dependent. It passed locally and failed in CI for a reason that had nothing to do with the change. Any test that setsRESCRIPTUM_BOOT_DIRmust also setRESCRIPTUM_TFTP_ADDR=offunless the probe is the subject;tests/tftp.rscovers the unbindable port on a high one. - A branch developed entirely offline has never met the CI. This one accumulated 57 commits before its first push, and the first run failed on two things no local run could see: a clippy five versions newer than the pinned local toolchain, and a Linux-only port permission. Push early enough to find out, or expect to.
Selection and formats
A Mac editing the answers directory over SMB can hijack a machine’s answer. macOS writes
an AppleDouble ._<name> beside a file whose extended attributes the filesystem will not
take — ._proxmox.toml has an extension that is on the allowlist. With a directory per
identity it is worse than it was when answers were flat: it is a second .toml in a
directory that may hold only one, and it sorts before the real one, so a rule that took
the first would hand every request a binary body. The machine being configured then receives
a parse error instead of its answer. .DS_Store is harmless only by luck (its extension is
not on the list). The file store skips every entry whose name starts with .; found on a
real NAS, not by reading anything.
Normalizing a selector pattern strips * and ? unless you use normalize_pattern —
which turns every glob into a literal, quietly.
In a text format, a placeholder inside a comment is still a placeholder. Kind::Text
is an opaque string, so substitution runs over the whole document — a {{ mac }} written
in a # comment to explain templating still has to resolve, and fails check exactly
like a real one. Found while adding the .ipxe and .cfg worked examples.
A GET has no body, so the haystack is empty. Query values and path segments must feed it too, or a document named after a MAC can never answer a preseed or kickstart fetch.
quick-xml emits entity references as their own events. Ignoring them welds the
surrounding text fragments together: 1 < 2 & 3 came back as 123.
Repeated XML siblings are not always a list. If they carry a discriminating attribute
they are a keyed collection; treating them as a list replaced every <component> in an
unattend.xml with the one the overlay happened to mention.
Two documents with the same stem are not duplicates. An earlier put deleted the other
formats of a stem to avoid “two answers for one machine”. That was the wrong model: they
are that machine’s answers for two operating systems.
Filter endpoints on the extension, not the Kind. .ks and .preseed are both
Kind::Text; filtering by family would let a preseed answer /rhel/ks.
An alias must be specific enough that nobody reaches it by accident. seed was removed
as an endpoint alias: s=http://server/seed/ is an ordinary NoCloud seed URL, and it
serves YAML.
The admin API
Read the request body before rejecting a request. Answering and closing while the
client is still writing earns an ECONNRESET instead of the response. put() drains
first, then validates the identifier.
Admin responses must set Connection: close. Without it every test client waited out
the connection timeout — the suite took 30 s instead of 0.4 s — and the eventual drop
sometimes arrived as a reset rather than a clean EOF.
Identifiers become filenames. export and the file store build paths from machine ids
and group names, so valid_id is enforced at the API boundary and in both stores.
Testing
cargo test does not rebuild target/debug/rescriptum. A manual check against a stale
binary once “reproduced” a bug that had already been fixed. Rebuild before poking at the
binary by hand.
Cache-invalidation tests must share one Answers instance. A test that constructs a
fresh one per call bypasses the cache entirely and silently proves nothing.
Assert on parsed values, not on formatting. Replacing a table with a scalar leaves the
key’s original decor, so the output can read value= 3 — valid TOML, different text.
A python/sed patch that “succeeds” may have matched nothing. Two edits in this
project’s history silently no-opped and were only caught by checking test counts
afterwards. Assert the old text was found before writing.
Packaging for DSM
A shell script that works on macOS is not a shell script that works on CI. Two found by
running the harnesses in a Linux container rather than trusting them: stat -f '%Lp' is the
format flag on BSD and filesystem status on GNU — where it succeeds, printing overlayfs
trivia into a variable that was supposed to hold a file mode, so the fallback never fires.
Ask GNU first (stat -c '%a' || stat -f '%Lp'), which fails cleanly on macOS. And shasum
is a Perl script that a minimal Debian does not have: sha256sum is coreutils and is
everywhere on Linux. Ubuntu runners carry both, which is exactly how a script like that ships
broken to everyone else.
musl 1.2 cannot run on Synology’s ARMv7 kernels, and the symptom names nothing. Those
kernels are 3.10 and answer the time64 syscalls with EINVAL; musl falls back to the
32-bit ones only on ENOSYS, so clock_gettime, clock_nanosleep and the timed futex all
fail. The binary installs, answers --version, and panics at time.rs:131 with
Os { code: 22, kind: InvalidInput } the moment it wants a timestamp — which looks like an
ABI or a too-old-kernel problem and is neither. The armv7 target is glibc with a 2.17 floor
for this reason; 64-bit targets have no time32/time64 split and are unaffected. Proven with
a ten-line C probe on the machine, not by reading anything.
SYNOPKG_PKGDEST is /volume1/@appstore/<package>, not /var/packages/<package>/target.
The second is a symlink to the first, so dirname "$SYNOPKG_PKGDEST" is /volume1/@appstore
and everything hung off it — etc/, var/, shares/ — lands where nothing reads it. The
package root is a fixed path. This one costs a service that installs perfectly and never
starts, and a fake-tree harness cannot catch it: in a tree you built yourself, dirname is
right by construction.
$SYNOPKG_TEMP_UPGRADE_FOLDER outlives the upgrade that created it. A fresh install
that reads it finds the configuration of an installation the user removed, and silently
restores it — tokens and all. Restoring from it has to require SYNOPKG_PKG_STATUS = UPGRADE.
etc/ and var/ survive an uninstall. They are symlinks into /volume1/@appconf/<pkg>
and /volume1/@appdata/<pkg>, which DSM keeps. So the env file, tokens included, stays on
the volume after the package is gone — which the documentation has to say, and which makes
a rig that does not clear them fail on the next run for reasons belonging to the last one.
A DSM account named after the package user is destroyed with it. conf/privilege’s
username creates a system user at install; an administrator of the same name is shadowed
by it and removed on uninstall.
The firewall directory is /usr/local/etc/services.d/ — plural. The developer guide says
service.d, which does not exist. The port-config worker acquires after postinst, so
the wizard’s port does reach the firewall entry on a fresh install.
port-config and usr-local-linker acquire when the package is enabled, not when
postinst runs: checked any earlier they are always absent.
The generated unit has no Restart= — Type=oneshot, RemainAfterExit=yes,
TimeoutStartSec=3600. DSM does not restart the process if it dies.
postinst runs on an upgrade too, and it runs before postupgrade. So “the env
file is absent” is not the same question as “this is a fresh install”: on an upgrade where
etc/ did not survive, writing defaults there destroys the user’s port and tokens before
the restore ever runs. postinst checks $SYNOPKG_TEMP_UPGRADE_FOLDER before it decides.
Found by simulating that exact case, not by reading the documented sequence.
The old version’s preuninst/postuninst run during an upgrade. Anything destructive
in them therefore runs every time somebody upgrades — and the first published .spk is
the one whose uninstall scripts will run during everybody’s first upgrade. They cannot be
fixed later.
status returning 1 means “crashed, stale pidfile”, not “stopped”. A cleanly stopped
package is 3. Returning 1 tells Package Center the service died.
prestart runs at boot, and DSM calls it whether or not you wrote it —
precheckstartstop defaults to "yes". A case that exits non-zero on an unrecognised
verb stops the package from ever starting after a reboot, with a symptom (“works by hand,
never after a reboot”) that looks like anything but a missing case arm.
The lifecycle scripts are not root. run-as: package governs them, not only the
service — so a chown outside the package tree, or synopkghelper, fails, possibly
silently.
data-share runs at package start, not at install, so nothing in postinst may
assume the shared folder exists. And a username that does not match its permission list
creates the share and grants it to nobody, without a word.
A logrotate stanza without copytruncate silently ends logging: log::init opens the
file once and never reopens it, so a rotation moves the inode out from under a server that
carries on writing to a file with no name.
A .spk whose outer tar is gzipped is rejected with “invalid file format” and no
further detail. So is one carrying macOS ._ members. check-spk.sh asserts both.
There is exactly one route to port 69 on DSM 7, and it is setcap. All four were
tried on a 7.2.2 machine on 2026-08-27, because the claim “DSM 7 does not let an unsigned
package run as root” had sat in CLAUDE.md for a while with no measurement behind it —
true, but by luck.
| Route | Result |
|---|---|
"defaults": {"run-as": "root"} in conf/privilege | refused — synopkg error 319, invalid package privilege content, stage: install_failed |
"ctrl-script": [{"action":"start","run-as":"root"}] — the shape Synology’s own packages use (FileStation, QuickConnect and StorageManager all do) | refused, same error 319 |
cap_net_bind_service embedded as a security.capability xattr in package.tgz | installs fine — the pax inner format is accepted — but Package Center strips the xattr, and getcap comes back empty |
setcap cap_net_bind_service=+ep on the installed binary, as root, after install | works; the package then binds udp/69 as its own unprivileged user alongside 8000 and 8001 |
net.ipv4.ip_unprivileged_port_start does not exist on that kernel, so that route is
closed too. /volume1 is btrfs with nodev but not nosuid, so file capabilities do
work there, and /usr/bin/setcap exists at mode 0700.
Root on DSM 7 is gated on being a Synology package, and libsynopkg.so.1 says so in
so many words. Reading its strings on a 7.2.2 machine turns the measurement above into
an explanation. A package that does not pass the signature check (verifyPackageSignature
lives in the same library) is refused all of this:
Failed to pass privilege check, ctrl-script and executable section should not exist
Failed to pass privilege check, defaults should be provided and defaults.run-as should be package
Failed to pass privilege check, join-groupname should not contains admin group
Failed to pass privilege check, tool capabilities should not exist
Failed to pass privilege check, tool user should be package
Failed to pass privilege check, non-synology package should not use privilege migration
Which is why FileStation, StorageManager, QuickConnect and SecureSignIn all carry
"ctrl-script": [{"action": "start", "run-as": "root"}] in their own conf/privilege and
we cannot: the shape is legal, the signature is what makes it legal for them.
The line that matters most is tool capabilities should not exist. DSM’s privilege
format has a native capabilities field — documented as
"capabilities": "cap_chown,cap_net_raw" on a tool entry since 7.0-40656, and
SYNOPackageTool::Privilege::ChangeCapabilities is right there in the library. A signed
package declares cap_net_bind_service and never needs setcap at all. The mechanism we
want exists, is documented, and is closed to us.
Synology’s developer guide states the rule outright: “If you are developing a package with root privilege, you are not able to install that package unless it is signed by synology.” So it is their signature, not any trusted publisher’s — which answers what the library string left open. SynoCommunity hit the same wall (spksrc#4170, #4215).
There is one documented bypass and it is not a distribution path: a development
token. Generate debug.dat from Support Center → Support Services, send it to Synology,
receive a signed token, drop it at /var/packages/syno_dev_token. It is valid only on
the NAS that generated the debug.dat, so shipping this way would mean every single user
doing a round trip with Synology before they could install. setcap is one local command
and strictly better for them.
Conclusion, and it is settled rather than provisional: the manual setcap is the price
of not being signed by Synology, and no packaging change removes it. If the package is
ever signed, the manual step and the boot-up task are both replaced by three lines in
conf/privilege.
setcap works on a DS416j too, and that was not a given. The four routes to port 69
were measured on a 7.2.2 VM, which is x86_64 with /volume1 on btrfs mounted nodev but
not nosuid — and a volume mounted nosuid makes the kernel ignore file capabilities
entirely, which would have closed the last open route on the one machine this project
exists for. Measured on the DS416j (ARMv7, armada38x): the capability holds, the package
binds udp/69 as its unprivileged user, and boot check reports
0.0.0.0:69 handed over ipxe-arm64.efi — a real read request answered with real data.
The capability belongs to the file, so an upgrade drops it. A new version replaces the binary and the capability goes with the old one — which is why the package documents a Task Scheduler boot-up task rather than a one-off command, and why a failed TFTP bind is not fatal: when it was, that upgrade took the answer endpoint down too.
Binding is not a health check, and it proves the opposite of what it looks like. A
bind that succeeds on the TFTP port means nothing is listening — the degraded state, not
the healthy one — and a bind that fails cannot tell this server apart from another daemon
squatting the port, because both are AddrInUse. boot check therefore sends a real read
request and reports what a machine would get. The first version of it reported “already in
use — that is this server, if it is running” and a test with a squatter on the port
immediately showed that to be a guess.
A new setting never reaches an installation that already exists, unless something
puts it there. The live env file is written only when absent — correct, because an upgrade
must never replace somebody’s port and tokens with defaults — but on its own that makes a
new feature invisible to every install that predates it. Boot media shipped with the
folders created, the loaders seeded and 69/udp registered with the firewall, and
RESCRIPTUM_BOOT_DIR never arriving, so boot check answered “boot assets are off” on a
DS416j that had everything else in place. etc/ surviving an uninstall means even removing
and reinstalling does not fix it. The .env.example was no help, because nothing makes
anybody read it.
postinst now appends keys the live file has never heard of, touching nothing that is
present. A commented-out key counts as present, and that is the safety property: it is
how an operator says “I know about this one and I do not want it”. Deleting a line means
“never heard of it” and gets it back; commenting it out means no, and is respected.
The DSM desktop application
Eight things, measured on a DSM 7.2.2 virtual machine and on a DS416j running 7.1.1, and none of them in the developer guide.
A default computed at runtime has to be computed in settings() too. The panel renders
a variable’s default as the field’s value, so a default that exists only where the server
consumes it shows as an empty box — while the server runs on an address it derived and
never displayed. RESCRIPTUM_PUBLIC_HOST shipped that way; the operator had no way to see
which address their machines would be sent to short of reading the startup log. Two entries
in KNOWN are like this, and both are special-cased in settings(): the worker count and
the public host. A third would need the same treatment, and nothing in the type system says
so.
A CGI under /webman/3rdparty/<pkg>/ runs as the owner of the script. Not as http,
and not as root — as whoever owns the file. DSM chowns a package’s tree to the package
user, so the application’s backend runs as rescriptum and can read the 0600 env file it
owns, which is the entire reason the configuration can be edited while the server is
stopped. Proven by chowning the same script two ways and watching id change. A script
left owned by root does run as root there, so do not leave one lying about.
That path is not authenticated by DSM. An unauthenticated request reaches the script
and is answered 200. Whatever guards a package’s CGI, the package wrote it — here that is
authenticate.cgi plus an administrators check, and losing either would be silent.
su in a CGI hangs the request. Without </dev/null it inherits the CGI’s stdin — a
pipe from the web server that nothing will close — reads from it, and never returns. The
status page simply stopped mid-answer. Then, once that was fixed, it failed anyway with
“Permission denied”, because a non-root process cannot become anybody. Both were wasted
effort: the script already is the user in question, so a plain test -r was the answer
all along.
The framework a package can use is the machine’s choice, not Synology’s guide’s. DSM
7.2 ships a Vue UI framework and the current guide documents only that one. The DS416j is
capped at DSM 7.1.1, where Vue is undefined — so an application built on it installs and
gives that machine an icon that opens nothing. ExtJS is on both (7.1.1 and 7.2.2 measured),
which is why there is one application rather than two.
The guide’s own ExtJS example does not run. It declares classes with Ext.define and
chains with callParent; against SYNO.SDS.AppInstance that throws Cannot read properties of null (reading 'apply') before the window ever appears. This is ExtJS 3.4.1 with an
Ext.define shim over it: use Ext.define for the declaration — DSM’s launcher finds the
class that way and it does set superclass — and then call
MyClass.superclass.constructor.call(this, config) rather than callParent.
DSM’s taskbar calls getWindowTitle() on the window. Without a title it throws from
inside DSM’s own taskbar bundle, and the application then fails to open at all — with a
stack trace that names Synology’s code and not yours.
Do not name a method show. Ext.Window.prototype.show() is what DSM calls to display
the window, so a show(which) added for switching tabs silently overrode it: the window was
built, laid out, and rendered a correct thumbnail in the taskbar preview — and never
appeared. Nothing threw, on either DSM version, which is what made it expensive: it was
found by bisecting from the guide’s minimal example upwards. Everything added to that
prototype shares a namespace with every method of Ext.Window, and that is a large
namespace.
fieldLabel is drawn by the form layout, not by the field. A syno_displayfield in a
plain Ext.Panel renders its value and silently drops its label, which turned the status
page into a bare column of values with nothing saying what they were. SYNO.ux.FormPanel,
or layout: 'form'.
Reproducible builds and browser caches disagree, and the browser wins. make-spk.sh
gives every packaged file a fixed mtime so the same inputs produce a byte-identical .spk.
nginx turns that into Last-Modified: 2019 with no Cache-Control, and a browser’s
heuristic freshness is a tenth of the file’s apparent age — years. An upgraded package went
on running the old JavaScript against the new backend, through a reinstall and a hard
reload. The application’s file is therefore named after the version and everything it
fetches itself carries ?v=; check-spk.sh asserts the name still moves.
Behaviour changes worth remembering
Answer documents must now be valid. Before merging they were served as opaque bytes, so
a malformed one reached the installer; now it is a 500 with the parse error in the log.
That is the better failure, but it is a behaviour change — fixtures written as YAML-ish
text stopped working when it landed.
{{ machine }} is bound only when a machine document matched. A machine claimed by a
group’s members, with no document of its own, resolves with machine: None — so
{{ machine }} in a group fails for exactly the members it was meant to cover. Use a
request fact such as {{ mac }} there.
The documentation site
The documentation site
This site is docs/ in the repository, rendered by
notabene and published to GitHub Pages. The Rust
binary knows nothing about any of it; the docs toolchain is a package.json and one
config file, and removing it would leave docs/ as perfectly readable Markdown.
Why a site and not a longer README
The README had grown to 28 KB and was three documents wearing one coat: a pitch, a user manual, and an architecture note. A reader looking for the DSM firewall step had to scroll past the merge semantics. So:
docs/guide/— using rescriptum: install, write answers, run it in production.docs/development/— building rescriptum: the constraints, the internals, the release.README.md— what it is, a 30-second demonstration, and links into the site.
The two spaces have different audiences and no reason to interleave.
Two languages
The site is bilingual: English is the source, French is a translation of it. The
suffix i18n strategy means the English files keep their paths and URLs and the French
ones are *.fr.md siblings:
docs/guide/answers/grouping.md → /guide/answers/grouping
docs/guide/answers/grouping.fr.md → /fr/guide/answers/grouping
That layout was chosen over a folder per locale because it can be added to an existing doc without moving anything — the English URLs and their comment threads survive.
Rules that follow from it:
- Write English first, then translate. A change to an English page that is not mirrored leaves the French page stale rather than broken; the reader falls back with a banner.
- Links keep the base name. From a French page, write
./selection.md, not./selection.fr.md— notabene resolves the locale. But anchors must be the French heading’s slug:./templating.md#machine-exige-un-document-machine. - Comments are per language. A comment left on the French page is its own thread and maps to the French source file.
- The site chrome, search and
llms.txtare per locale too.
README.md and README.fr.md follow the same rule and link to each other.
Working on the docs
npm install # once
npm run docs # → http://localhost:3009
That opens the site with the review loop enabled: select any text on the rendered page and leave a comment, exactly where the problem is. The anchored comment is the instruction — no quoting a passage into a chat box and hoping the agent re-finds it.
Then tell your agent “address the doc comments”. It reads docs/.notabene/, edits the
source, marks each comment handled, and appends a journal entry saying what changed and
why.
| Script | Does |
|---|---|
npm run docs | the review server, live-reloading |
npm run docs:build | the public static site into ./_site |
npm run docs:preview | serve what was built |
npm run docs:lint | validate every internal link against the routes the last build emitted |
npm run docs:status / docs:stop | manage a detached dev server |
Using Claude Code? /plugin marketplace add z29k/notabene then
/plugin install notabene@z29k, and say “set up notabene”. The plugin runs its own
pinned renderer, so it does not conflict with the one in package.json.
Review mode is approve
notabene.config.mjs sets review: "approve", so the agent proposes rather than
resolves: each edit is validated against its real git diff at /review before the
comment is closed. Documentation that describes root passwords and boot-time configuration
is worth reading before it ships. Change it to "auto" if that ceremony is not earning
its keep.
Writing a page
Every page is CommonMark with optional YAML frontmatter:
---
title: Groups and merging
description: One sentence — it becomes the meta description and the search snippet.
sidebar:
label: Grouping # sidebar text, if the title is too long for it
order: 3 # position among siblings, ascending
---
Everything has a default: a page with no frontmatter renders fine, ordered
alphabetically. A folder is named and positioned by its index.md.
Conventions in this repository:
- Relative links between pages, with the
.mdextension —./selection.md,../reference/configuration.md. They become routes on the site and stay clickable on GitHub. - Absolute GitHub URLs for repository files —
answers/,CLAUDE.md, a workflow. They are outsidedocs/and have no route. - English, like everything else written to disk here.
- Mermaid diagrams are rendered natively, in a
```mermaidfence — see architecture and the request lifecycle. - Every page needs its
*.fr.mdsibling, with the frontmatter translated too — thetitle,descriptionandsidebar.labelare all reader-facing.
Configuration
notabene.config.mjs at the repository root. The parts that matter:
roots: [
{ key: "guide", label: "Guide", path: "docs/guide" },
{ key: "development", label: { en: "Development", fr: "Développement" }, path: "docs/development" },
],
store: "docs/.notabene",
home: { en: "docs/home.md", fr: "docs/home.fr.md" },
i18n: { locales: ["en", "fr"], defaultLocale: "en", strategy: "suffix" },
branding: {
logo: "assets/rescriptum-logo.jpg",
favicon: "assets/rescriptum-logo.jpg",
socialImage: "assets/rescriptum-logo.jpg",
},
editPattern: "https://github.com/z29k/rescriptum/edit/develop/{path}",
review: "approve",
publish: { site: "https://z29k.github.io", base: "/rescriptum" },
Every reader-facing string in the config takes a per-locale map — a space’s label and
description, the home page, every nav link label, the sidebar block title, the footer.
Unset for a locale, it falls back to the default one.
The logo is assets/rescriptum-logo.jpg: a sealed rescript on a floppy disk — a written
answer, delivered by a machine. It serves as the topbar logo, the favicon and the social
card, and it is the image the README uses too.
editPattern points at develop, not main: docs are merged there like everything
else, and main only receives release commits.
docs/.notabene/ is the comment and journal store — plain JSON, committed, diffable in a
PR. Commit it.
The CI gate
.github/workflows/ci.yml has a docs job: npm ci, build the public site, then
notabene lint, which checks every internal link against the routes the build actually
emitted and suggests near-misses. A dead link in published documentation is cheap to
prevent and embarrassing to ship.
It runs on the same pushes as the Rust gates.
Publishing
.github/workflows/docs.yml builds --public and deploys to GitHub Pages on every push
to main that touches docs/, the config, or the workflow — plus
workflow_dispatch, for publishing a docs fix without waiting for a release.
Because main only receives release commits, documentation normally ships with a
release. Run the workflow by hand when it should not wait.
The artifact is the read-only public build: no review UI, no store data, plus llms.txt,
a Markdown twin per page, a sitemap and OpenGraph metadata. pagefind is a dev dependency,
so npm ci gives the site full-text search with no further configuration.
One-time repository setup: Settings → Pages → Source = GitHub Actions.
The dependency situation
npm audit reports advisories in Astro, esbuild and sharp, transitively under notabene,
with no fixes currently available upstream. They are development-only: nothing from
node_modules is executed by the published site or reaches the Rust binary, and the CI
job builds static HTML from Markdown this repository owns.
Worth re-checking when notabene updates, not worth blocking on.