← Back to site Loading…

rescriptum

Development

an answer written for this machine

Generated on August 30, 2026

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

PathHolds
src/main.rsruntime setup, accept loop, connection serving, routing, and the blocking half of a request
src/lib.rsthe crate. main.rs is a thin binary over it, so behaviour is testable directly
src/select.rsnormalization, matching, layering — the behaviour that matters
src/facts.rswhat a request says about the machine
src/format/one interface per document format; xml.rs holds the XML tree
src/merge.rsthe TOML merge, used by format
src/store/where documents come from: file.rs, sqlite.rs, behind a thin trait
src/admin.rsthe write API, and the guarantee that a write cannot break the fleet
src/config.rsenvironment configuration
src/envfile.rsthe optional file of defaults RESCRIPTUM_ENV_FILE names — never discovered, only named
src/capture.rsrecording what machines actually send
src/cli.rsthe render, check, import and export subcommands
src/log.rsone 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.md siblings) — 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 audit job is the other half of that rule: a reason to add one is not a reason to keep it.
  • Conventional commits with a scopefeat(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

ModuleOwns
main.rsthe 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.rsturning a request into labelled values — query parameters, a flattened JSON body, path segments, and the normalized haystack
select.rsthe 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.rsthe TOML deep merge, used by format
store/where documents come from, behind a two-method read trait
admin.rsits own listener, bearer auth, the failure guard, and the rollback that keeps a write from breaking the answer set
config.rsthe environment, and the validation that turns a dangerous configuration into a startup error
envfile.rsthe file RESCRIPTUM_ENV_FILE names: parsed, never discovered, and fatal when it cannot be read
cli.rsrender, check, import, export
capture.rsrecording request bodies
log.rsone 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:

  1. store.version() is unchanged — for files, the directory’s mtime; for SQLite, an in-process atomic;
  2. that version is Some — an unreadable version is never treated as “unchanged”;
  3. 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:

CrateFor
tokiothe runtime, timers, signals
hyper + hyper-util + http-body-utilHTTP/1, with a header-read timeout
toml_editTOML, preserving formatting
serde_jsonJSON documents, and flattening a request body
serde_yaml_ngYAML documents
quick-xmlXML 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 isa 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 startrefusing 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:

GuardCovers
http1::Builder::header_read_timeouta client that opens a connection and dribbles headers
tokio::time::timeout around the whole connectioneverything 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_timeout requires .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:

  1. GET /health200 OK. Before authentication, before anything, so monitoring never goes dark.
  2. The answer token, when RESCRIPTUM_ANSWER_TOKEN is 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.
  3. Method — anything but GET or POST is 405.
  4. Content-Length — an aberrant declared size is refused from the header, rather than by letting Limited trip after buffering a megabyte.
  5. The body, through Limited::new(…, MAX_BODY). A length-limit error becomes 413, anything else 400.

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():

  1. asks the store for its version() — one stat for files, an atomic load for SQLite;
  2. reuses the cached Listing, or takes a fresh snapshot() and rebuilds it;
  3. picks the best machine document and the best group (scoring);
  4. resolves extends, within one format;
  5. merges group chain → machine document;
  6. fills {{ placeholders }};
  7. strips the control keys;
  8. renders.

5. Response

OutcomeResponse
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_pattern is 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 through normalize_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:

LabelFrom
paththe whole path, trimmed of slashes
fileits last segment
segmentevery 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)
}
  • identity is the normalized stem for a machine document, and the normalized members for a group.
  • All matchers must hold; the score is how many there are.
  • IDENTITY_SCORE is 1000 rather than u32::MAX so 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:

DocumentsLiteral re-readmtime-cached
1011,954 req/s12,922 req/s
2003,198 req/s12,890 req/s
2,000311 req/s12,520 req/s
10,0006,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 readdir per 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 members entry;
  • resolve extends chains, 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):

CaseBehaviour
group onlyserve the prepared string, or clone-fill-strip-render when templated
machine onlyfill, strip, render
bothgroup chain, merge the machine on top, fill, strip, render
neitherfall 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.

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 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:

  • Kind decides how to parse, how to merge, and the Content-Type.
  • The extension decides whether an endpoint may be answered, and which validator check calls. ks and preseed are both Kind::Text but do not share a validator — which is why Resolution carries format_name alongside format.

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. seed was 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 / objectsmerge recursively
Any other valuereplaced outright by the higher layer
Arraysreplace, they do not append
Kind::Textconcatenation 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’s config: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 &lt; 2 &amp; 3 came back as 123. 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 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, 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 readdir per 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:

BuildARMv7 size
default2,103,456 bytes
--no-default-features944,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 extends pointing at a missing group is detected at load time in select.rs rather than only when that machine asks. The guard can only catch what problems() 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:

ConstantValue
MAX_FAILURES5
FAILURE_WINDOW60 s
BASE_BLOCK60 s, doubling on repeats
MAX_BLOCK900 s
MAX_TRACKED4096 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_TRACKED is bounded, so the guard cannot itself be turned into a memory leak by an attacker cycling source addresses.
  • GET /health is 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 ECONNRESET instead 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=toml on 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

SuiteCasesFor
tests/integration.rs52the real binary over a real socket
tests/cli.rs65render, check, import, export, config and the env file — against the real binary
tests/media.rs45boot media against the real binary, with both listeners up
src/config.rs56the environment, what refuses to start, and which of the file and the environment wins
tests/stores.rs48every behaviour, against both stores
tests/tftp.rs30TFTP over real UDP: the turn-taking, and what a failed bind must not cost
src/select.rs29normalization, scoring, layering, template filling
src/format/mod.rs28parsing, merging, control keys, endpoint aliases
tests/admin.rs26the admin API end to end, formats included
src/envfile.rs23the env-file parser and writer, and what each refuses
src/facts.rs22query parsing, JSON flattening, globbing
src/format/xml.rs18the XML tree — pairing, entities, fidelity
src/merge.rs11the TOML deep merge
tests/guards.rs7the answer token, and the lockout that deliberately is not there
src/installed.rs6a machine reporting it installed, and what must never be disarmed
src/log.rs15level parsing, and the timestamp arithmetic
src/boot/*.rs128the 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.rs21unit-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 importexport 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-Lengthand 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 503 rather 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 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.

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 Answers instance. A test that constructs a fresh one per call bypasses the cache entirely and silently proves nothing.
  • Config::from_lookup takes 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/sed patches 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.

ProvesCost
packaging/dsm/check-spk.shthe 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 administratorsseconds, on every push
packaging/dsm/lifecycle-test.sheverything 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 browserseconds, on every push
packaging/dsm/vm/on-dsm.shDSM’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 processminutes, 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:

JobRuns
gatescargo fmt --all --check, cargo clippy --all-targets --all-features -D warnings, cargo test --all-features, cargo build --release --no-default-features
docsbuilds the public site and runs notabene lint
auditcargo audit --deny warnings over the dependency tree
crossa 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

TargetForCross
armv7-unknown-linux-gnueabihfthe DS416j, the reason this project exists — glibc, not musl, see belowzigbuild, floor 2.17
aarch64-unknown-linux-muslmodern ARM NAS, Raspberry Pizigbuild
x86_64-unknown-linux-muslmost other Linux hostszigbuild
aarch64-apple-darwinlocal developmentnative
x86_64-apple-darwinlocal developmentnative

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 directorybrew 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

BuildARMv7
default2,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

FeatureDefaultGives
sqliteonthe 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.

ABIarch in INFOFrom
x86_64x86_64 — the family name, so it covers every Intel platformx86_64-unknown-linux-musl
armv7armada38x — the family shorthand does not reach the Marvell platformsarmv7-unknown-linux-gnueabihf
aarch64armv8aarch64-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.

EnvironmentDefault
TARGETarmv7-unknown-linux-gnueabihf
ANSWERS<remote-dir>/answers
PORT8000

Branching and releases

Branching and releases

The model mirrors the sibling project notabene deliberately — same maintainer, same expectations.

Branches

BranchRule
mainstable. Only release commits and vX.Y.Z tags land here. Never push feature work directly
developintegration. 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:

  1. 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.
  2. Cross-compiles the five published targets.
  3. Packages each as rescriptum-<version>-<target>.tar.gz, with README.md and LICENSE alongside the binary, plus a SHA-256 sum — whoever runs this as root should be able to check what they downloaded.
  4. Builds the branded iPXE loaders from the pinned commit and attaches them as rescriptum-boot-assets-<version>.tar.gz, after asking boot check whether 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, with packaging/ipxe/ as the written offer.
  5. Wraps the Linux musl builds as Synology packages, rescriptum-<version>-<build>-<abi>.spk, and checks each structurally before it can be published.
  6. Cuts the GitHub Release with gh and --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 check calls 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 sets RESCRIPTUM_BOOT_DIR must also set RESCRIPTUM_TFTP_ADDR=off unless the probe is the subject; tests/tftp.rs covers 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 &lt; 2 &amp; 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.

RouteResult
"defaults": {"run-as": "root"} in conf/privilegerefusedsynopkg 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.tgzinstalls 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 installworks; 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.txt are 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.

ScriptDoes
npm run docsthe review server, live-reloading
npm run docs:buildthe public static site into ./_site
npm run docs:previewserve what was built
npm run docs:lintvalidate every internal link against the routes the last build emitted
npm run docs:status / docs:stopmanage 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 .md extension — ./selection.md, ../reference/configuration.md. They become routes on the site and stay clickable on GitHub.
  • Absolute GitHub URLs for repository filesanswers/, CLAUDE.md, a workflow. They are outside docs/ and have no route.
  • English, like everything else written to disk here.
  • Mermaid diagrams are rendered natively, in a ```mermaid fence — see architecture and the request lifecycle.
  • Every page needs its *.fr.md sibling, with the frontmatter translated too — the title, description and sidebar.label are 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.