diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-19 11:48:30 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-19 11:48:30 +0200 |
| commit | 6762ce46af3cb12bc6ae37cda762c5d95add7903 (patch) | |
| tree | bc1d8c86050d0149ff961a9a4ff838f9c474ac2a /test/cli.t | |
| parent | 897c274fd28402159ca6d45eedc1257b1ce98696 (diff) | |
| parent | 390bc6ac5196a946c473d0dbe7760fa41837c428 (diff) | |
| download | colitur-6762ce46af3cb12bc6ae37cda762c5d95add7903.tar.gz colitur-6762ce46af3cb12bc6ae37cda762c5d95add7903.zip | |
feat: output, rendering and publishing
Gives colitur a publishable exit. Until now its only output was terminal
rows; it can now print an ordo booklet and a wall calendar, publish an
iCalendar feed people subscribe to, and serve a static JSON/XML API.
lib/render escaping (six flavours + RFC 5545 folding), a deliberately
logic-less template engine, the view model, and five
emitters (CSV, JSON, XML, iCalendar, S-expression)
CLI emit, table, render, publish -- all accepting --overlay
templates ordo booklet in six flavours, wall grid in three
schema day-v1.json and colitur-v1.xsd, the published contract
man colitur-templates.5, plus colitur.1 updates
The view model is why the engine can stay logic-less: a month grid needs
leading blank cells, week bucketing and an in-month test, and a
logic-less template can compute none of it. Shaping the data in OCaml
keeps the engine safe for untrusted templates and makes the grid
trivial.
Formats split by whether correctness is mechanical. Presentation goes
through templates; iCalendar and XML get dedicated emitters, because
folding, exclusive DTEND, stable UIDs and schema fidelity are rules a
template cannot enforce and each fails silently in a subscriber's
client rather than loudly at generation.
publish is deterministic and non-destructive: two runs produce a
byte-identical tree, and --prune removes only files a previous run
created, refusing any manifest entry that escapes the output directory.
No new dependencies. The kernel and rite modules are untouched, and
colitur day and colitur readings remain byte-identical.
Diffstat (limited to 'test/cli.t')
| -rw-r--r-- | test/cli.t | 680 |
1 files changed, 677 insertions, 3 deletions
@@ -17,7 +17,7 @@ A year outside the supported domain is rejected (exit 2): No/garbage arguments give a usage error (exit 2): $ colitur - colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur check FILE | colitur new-overlay (try: colitur --help) + colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur check FILE | colitur new-overlay (try: colitur --help) [2] The EF temporal cycle for a year, one line per day: @@ -327,14 +327,14 @@ A flag needing a value, given none: $ colitur day 2026 --overlay colitur: --overlay needs a file path - colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur check FILE | colitur new-overlay (try: colitur --help) + colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur check FILE | colitur new-overlay (try: colitur --help) [2] An unknown option is rejected rather than treated as a positional word: $ colitur day 2026 --diocese colitur: unknown option --diocese - colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur check FILE | colitur new-overlay (try: colitur --help) + colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur check FILE | colitur new-overlay (try: colitur --help) [2] The shipped example overlay is runnable documentation, and it must actually @@ -415,3 +415,677 @@ displaced silently. $ colitur day 2026 --overlay ben.sexp | grep '^2026-03-21' 2026-03-21 saturday lent 4 transitus-of-our-holy-father-benedict class-1 white +ef-lent-4-saturday + +CSV emits a header and one row per day: + + $ colitur emit --format csv --from 2027 --to 2027 | head -2 + date,rite,season,week,slug,rank,colour,subject,name_la,name_en,first,gospel,comms + 2027-01-01,ef,christmastide,,ef-circumcision,class-1,white,temporal,,,Titus 2:11-15,Luke 2:21, + + $ colitur emit --format csv --from 2027 --to 2027 | wc -l + 366 + +JSON is one object, ICS one VCALENDAR: + + $ colitur emit --format json --from 2027 --to 2027 | cut -c1-20 + {"rite":"ef","year": + + $ colitur emit --format ics --from 2027 --to 2027 | head -1 | cat -A | head -1 + BEGIN:VCALENDAR^M$ + +Two runs are byte-identical (no clock read anywhere): + + $ colitur emit --format ics --from 2027 --to 2027 > /tmp/a.ics + $ colitur emit --format ics --from 2027 --to 2027 > /tmp/b.ics + $ cmp /tmp/a.ics /tmp/b.ics && echo identical + identical + +A multi-year range concatenates years in order, one header for the whole +CSV run rather than one per year: + + $ colitur emit --format csv --from 2027 --to 2028 | grep -c '^2028-' + 366 + + $ colitur emit --format csv --from 2027 --to 2028 | wc -l + 732 + +sexp and xml are also available: + + $ colitur emit --format sexp --from 2027 --to 2027 | wc -l + 8472 + + $ colitur emit --format xml --from 2027 --to 2027 | head -2 + <?xml version="1.0" encoding="UTF-8"?> + <calendar rite="ef" year="2027"> + +An unknown format is a usage error on stderr, exit 2: + + $ colitur emit --format yaml --from 2027 --to 2027 + colitur: unknown format "yaml" (want csv, json, sexp, xml or ics) + [2] + +--dtstamp is the only user string that reaches ICS output unescaped and +unvalidated -- it must be exactly RFC 5545's UTC DATE-TIME form (8 digits, +"T", 6 digits, "Z") or refused outright, rather than either silently +emitting a malformed DTSTAMP or, worse, letting an embedded CRLF inject +extra lines into every VEVENT: + + $ colitur emit --format ics --from 2027 --to 2027 --dtstamp hello + colitur: --dtstamp "hello" is not RFC 5545 UTC form (want 8 digits, 'T', 6 digits, 'Z', e.g. 20270101T000000Z) + [2] + + $ colitur emit --format ics --from 2027 --to 2027 --dtstamp "$(printf 'X\r\nBEGIN:VEVENT\r\nUID:evil')" + colitur: --dtstamp "X\r\nBEGIN:VEVENT\r\nUID:evil" is not RFC 5545 UTC form (want 8 digits, 'T', 6 digits, 'Z', e.g. 20270101T000000Z) + [2] + +A well-formed value is threaded through unchanged. (The events themselves +end in CRLF per RFC 5545 -- match the substring, not a `$`-anchored full +line, or a shell that does not mangle the trailing "\r" is doing the +grep-anchor's job for it by accident.) + + $ colitur emit --format ics --from 2027 --to 2027 --dtstamp 20270101T000000Z | grep -c 'DTSTAMP:20270101T000000Z' + 365 + +emit refuses a reversed range rather than emitting nothing: + + $ colitur emit --format csv --from 2028 --to 2027 + colitur: --from 2028 is after --to 2027 + [2] + +emit's own flags have no effect on the other commands, refused rather than +silently ignored, the same discipline --overlay already gets: + + $ colitur day 2027 --format csv + colitur: --format/--from/--to/--dtstamp have no effect on `day`; refusing rather than ignoring them + [2] + +day and readings are untouched: + + $ colitur day 2027 | head -1 + 2027-01-01 friday christmastide - ef-circumcision class-1 white + + $ colitur readings 2027 | head -1 + 2027-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21 + +A minimal inline template renders -- table computes and renders in one +process (2 January 2027 is a Saturday, not a Sunday, so Holy Name Sunday +falls on the 3rd, not the 2nd, that year): + + $ printf '{{#days}}{{iso}} {{slug}}\n{{/days}}' > /tmp/t.txt + $ colitur table --year 2027 --template /tmp/t.txt | head -2 + 2027-01-01 ef-circumcision + 2027-01-02 ef-christmas-1-saturday + +render is the same operation under the name the design used: + + $ colitur render --template /tmp/t.txt --year 2027 | head -2 + 2027-01-01 ef-circumcision + 2027-01-02 ef-christmas-1-saturday + +Flavour is inferred from the extension and escapes data -- Sts. Peter & +Paul (29 June) and its vigil are the only two 2035 entries whose English +name needs LaTeX escaping: + + $ printf '{{#days}}{{name.en}}\n{{/days}}' > /tmp/t.tex + $ colitur table --year 2035 --template /tmp/t.tex | grep -c 'Peter \\& Paul' + 2 + +An unknown extension with no --flavour is an error, not a silent fallback: + + $ printf 'x' > /tmp/t.wat + $ colitur table --year 2027 --template /tmp/t.wat + colitur: cannot infer a flavour from ".wat"; pass --flavour latex|groff|html|xml|ics|none + [2] + + $ colitur table --year 2027 --template /tmp/t.wat --flavour none + x + +An unrecognised --flavour value is also an error naming the six valid ones: + + $ colitur table --year 2027 --template /tmp/t.txt --flavour bogus + colitur: unknown flavour "bogus" (want latex, groff, html, xml, ics or none) + [2] + +A malformed template is a clear error, not a crash: + + $ printf '{{#days}}oops' > /tmp/bad.txt + $ colitur table --year 2027 --template /tmp/bad.txt + colitur: template /tmp/bad.txt: unclosed section {{#days}} + [2] + +A missing template file is an error: + + $ colitur table --year 2027 --template /tmp/nope.txt + colitur: cannot read template /tmp/nope.txt + [2] + +Pointing --template at a directory is an error, not a crash: the read +itself is guarded, not only the open (F1, fix round 1). "." is used rather +than a fixed /tmp path so this does not depend on anything outside the +cram sandbox itself: + + $ colitur table --year 2027 --template . --flavour none + colitur: cannot read template .: Sys_error("Value too large for defined data type") + [2] + +table and render both require --year and --template: + + $ colitur table --year 2027 + colitur: table requires --year YEAR and --template FILE + [2] + + $ colitur render --template /tmp/t.txt + colitur: render requires --year YEAR and --template FILE + [2] + +table/render's own flags have no effect on the other commands, refused +rather than silently ignored: + + $ colitur emit --format csv --from 2027 --to 2027 --year 2028 + colitur: --year/--template/--flavour have no effect on `emit`; refusing rather than ignoring them + [2] + +publish writes the documented tree (Task 12): the manifest itself +(.colitur-manifest) is a real file `find` sees too, since it lives in the +same directory as everything else it tracks. Every /tmp/pub* path below is +cleared first, so this section is self-contained across repeat runs: + + $ rm -rf /tmp/pub /tmp/pub1 /tmp/pub2 /tmp/pub3 + + $ colitur publish --from 2027 --to 2027 --out /tmp/pub >/dev/null + $ find /tmp/pub -type f | sed 's|/tmp/pub/||' | sort + .colitur-manifest + ef/2027.csv + ef/2027.ics + ef/2027.json + ef/2027.xml + ef/2027/01/01.json + ef/2027/01/02.json + ef/2027/01/03.json + ef/2027/01/04.json + ef/2027/01/05.json + ef/2027/01/06.json + ef/2027/01/07.json + ef/2027/01/08.json + ef/2027/01/09.json + ef/2027/01/10.json + ef/2027/01/11.json + ef/2027/01/12.json + ef/2027/01/13.json + ef/2027/01/14.json + ef/2027/01/15.json + ef/2027/01/16.json + ef/2027/01/17.json + ef/2027/01/18.json + ef/2027/01/19.json + ef/2027/01/20.json + ef/2027/01/21.json + ef/2027/01/22.json + ef/2027/01/23.json + ef/2027/01/24.json + ef/2027/01/25.json + ef/2027/01/26.json + ef/2027/01/27.json + ef/2027/01/28.json + ef/2027/01/29.json + ef/2027/01/30.json + ef/2027/01/31.json + ef/2027/02/01.json + ef/2027/02/02.json + ef/2027/02/03.json + ef/2027/02/04.json + ef/2027/02/05.json + ef/2027/02/06.json + ef/2027/02/07.json + ef/2027/02/08.json + ef/2027/02/09.json + ef/2027/02/10.json + ef/2027/02/11.json + ef/2027/02/12.json + ef/2027/02/13.json + ef/2027/02/14.json + ef/2027/02/15.json + ef/2027/02/16.json + ef/2027/02/17.json + ef/2027/02/18.json + ef/2027/02/19.json + ef/2027/02/20.json + ef/2027/02/21.json + ef/2027/02/22.json + ef/2027/02/23.json + ef/2027/02/24.json + ef/2027/02/25.json + ef/2027/02/26.json + ef/2027/02/27.json + ef/2027/02/28.json + ef/2027/03/01.json + ef/2027/03/02.json + ef/2027/03/03.json + ef/2027/03/04.json + ef/2027/03/05.json + ef/2027/03/06.json + ef/2027/03/07.json + ef/2027/03/08.json + ef/2027/03/09.json + ef/2027/03/10.json + ef/2027/03/11.json + ef/2027/03/12.json + ef/2027/03/13.json + ef/2027/03/14.json + ef/2027/03/15.json + ef/2027/03/16.json + ef/2027/03/17.json + ef/2027/03/18.json + ef/2027/03/19.json + ef/2027/03/20.json + ef/2027/03/21.json + ef/2027/03/22.json + ef/2027/03/23.json + ef/2027/03/24.json + ef/2027/03/25.json + ef/2027/03/26.json + ef/2027/03/27.json + ef/2027/03/28.json + ef/2027/03/29.json + ef/2027/03/30.json + ef/2027/03/31.json + ef/2027/04/01.json + ef/2027/04/02.json + ef/2027/04/03.json + ef/2027/04/04.json + ef/2027/04/05.json + ef/2027/04/06.json + ef/2027/04/07.json + ef/2027/04/08.json + ef/2027/04/09.json + ef/2027/04/10.json + ef/2027/04/11.json + ef/2027/04/12.json + ef/2027/04/13.json + ef/2027/04/14.json + ef/2027/04/15.json + ef/2027/04/16.json + ef/2027/04/17.json + ef/2027/04/18.json + ef/2027/04/19.json + ef/2027/04/20.json + ef/2027/04/21.json + ef/2027/04/22.json + ef/2027/04/23.json + ef/2027/04/24.json + ef/2027/04/25.json + ef/2027/04/26.json + ef/2027/04/27.json + ef/2027/04/28.json + ef/2027/04/29.json + ef/2027/04/30.json + ef/2027/05/01.json + ef/2027/05/02.json + ef/2027/05/03.json + ef/2027/05/04.json + ef/2027/05/05.json + ef/2027/05/06.json + ef/2027/05/07.json + ef/2027/05/08.json + ef/2027/05/09.json + ef/2027/05/10.json + ef/2027/05/11.json + ef/2027/05/12.json + ef/2027/05/13.json + ef/2027/05/14.json + ef/2027/05/15.json + ef/2027/05/16.json + ef/2027/05/17.json + ef/2027/05/18.json + ef/2027/05/19.json + ef/2027/05/20.json + ef/2027/05/21.json + ef/2027/05/22.json + ef/2027/05/23.json + ef/2027/05/24.json + ef/2027/05/25.json + ef/2027/05/26.json + ef/2027/05/27.json + ef/2027/05/28.json + ef/2027/05/29.json + ef/2027/05/30.json + ef/2027/05/31.json + ef/2027/06/01.json + ef/2027/06/02.json + ef/2027/06/03.json + ef/2027/06/04.json + ef/2027/06/05.json + ef/2027/06/06.json + ef/2027/06/07.json + ef/2027/06/08.json + ef/2027/06/09.json + ef/2027/06/10.json + ef/2027/06/11.json + ef/2027/06/12.json + ef/2027/06/13.json + ef/2027/06/14.json + ef/2027/06/15.json + ef/2027/06/16.json + ef/2027/06/17.json + ef/2027/06/18.json + ef/2027/06/19.json + ef/2027/06/20.json + ef/2027/06/21.json + ef/2027/06/22.json + ef/2027/06/23.json + ef/2027/06/24.json + ef/2027/06/25.json + ef/2027/06/26.json + ef/2027/06/27.json + ef/2027/06/28.json + ef/2027/06/29.json + ef/2027/06/30.json + ef/2027/07/01.json + ef/2027/07/02.json + ef/2027/07/03.json + ef/2027/07/04.json + ef/2027/07/05.json + ef/2027/07/06.json + ef/2027/07/07.json + ef/2027/07/08.json + ef/2027/07/09.json + ef/2027/07/10.json + ef/2027/07/11.json + ef/2027/07/12.json + ef/2027/07/13.json + ef/2027/07/14.json + ef/2027/07/15.json + ef/2027/07/16.json + ef/2027/07/17.json + ef/2027/07/18.json + ef/2027/07/19.json + ef/2027/07/20.json + ef/2027/07/21.json + ef/2027/07/22.json + ef/2027/07/23.json + ef/2027/07/24.json + ef/2027/07/25.json + ef/2027/07/26.json + ef/2027/07/27.json + ef/2027/07/28.json + ef/2027/07/29.json + ef/2027/07/30.json + ef/2027/07/31.json + ef/2027/08/01.json + ef/2027/08/02.json + ef/2027/08/03.json + ef/2027/08/04.json + ef/2027/08/05.json + ef/2027/08/06.json + ef/2027/08/07.json + ef/2027/08/08.json + ef/2027/08/09.json + ef/2027/08/10.json + ef/2027/08/11.json + ef/2027/08/12.json + ef/2027/08/13.json + ef/2027/08/14.json + ef/2027/08/15.json + ef/2027/08/16.json + ef/2027/08/17.json + ef/2027/08/18.json + ef/2027/08/19.json + ef/2027/08/20.json + ef/2027/08/21.json + ef/2027/08/22.json + ef/2027/08/23.json + ef/2027/08/24.json + ef/2027/08/25.json + ef/2027/08/26.json + ef/2027/08/27.json + ef/2027/08/28.json + ef/2027/08/29.json + ef/2027/08/30.json + ef/2027/08/31.json + ef/2027/09/01.json + ef/2027/09/02.json + ef/2027/09/03.json + ef/2027/09/04.json + ef/2027/09/05.json + ef/2027/09/06.json + ef/2027/09/07.json + ef/2027/09/08.json + ef/2027/09/09.json + ef/2027/09/10.json + ef/2027/09/11.json + ef/2027/09/12.json + ef/2027/09/13.json + ef/2027/09/14.json + ef/2027/09/15.json + ef/2027/09/16.json + ef/2027/09/17.json + ef/2027/09/18.json + ef/2027/09/19.json + ef/2027/09/20.json + ef/2027/09/21.json + ef/2027/09/22.json + ef/2027/09/23.json + ef/2027/09/24.json + ef/2027/09/25.json + ef/2027/09/26.json + ef/2027/09/27.json + ef/2027/09/28.json + ef/2027/09/29.json + ef/2027/09/30.json + ef/2027/10/01.json + ef/2027/10/02.json + ef/2027/10/03.json + ef/2027/10/04.json + ef/2027/10/05.json + ef/2027/10/06.json + ef/2027/10/07.json + ef/2027/10/08.json + ef/2027/10/09.json + ef/2027/10/10.json + ef/2027/10/11.json + ef/2027/10/12.json + ef/2027/10/13.json + ef/2027/10/14.json + ef/2027/10/15.json + ef/2027/10/16.json + ef/2027/10/17.json + ef/2027/10/18.json + ef/2027/10/19.json + ef/2027/10/20.json + ef/2027/10/21.json + ef/2027/10/22.json + ef/2027/10/23.json + ef/2027/10/24.json + ef/2027/10/25.json + ef/2027/10/26.json + ef/2027/10/27.json + ef/2027/10/28.json + ef/2027/10/29.json + ef/2027/10/30.json + ef/2027/10/31.json + ef/2027/11/01.json + ef/2027/11/02.json + ef/2027/11/03.json + ef/2027/11/04.json + ef/2027/11/05.json + ef/2027/11/06.json + ef/2027/11/07.json + ef/2027/11/08.json + ef/2027/11/09.json + ef/2027/11/10.json + ef/2027/11/11.json + ef/2027/11/12.json + ef/2027/11/13.json + ef/2027/11/14.json + ef/2027/11/15.json + ef/2027/11/16.json + ef/2027/11/17.json + ef/2027/11/18.json + ef/2027/11/19.json + ef/2027/11/20.json + ef/2027/11/21.json + ef/2027/11/22.json + ef/2027/11/23.json + ef/2027/11/24.json + ef/2027/11/25.json + ef/2027/11/26.json + ef/2027/11/27.json + ef/2027/11/28.json + ef/2027/11/29.json + ef/2027/11/30.json + ef/2027/12/01.json + ef/2027/12/02.json + ef/2027/12/03.json + ef/2027/12/04.json + ef/2027/12/05.json + ef/2027/12/06.json + ef/2027/12/07.json + ef/2027/12/08.json + ef/2027/12/09.json + ef/2027/12/10.json + ef/2027/12/11.json + ef/2027/12/12.json + ef/2027/12/13.json + ef/2027/12/14.json + ef/2027/12/15.json + ef/2027/12/16.json + ef/2027/12/17.json + ef/2027/12/18.json + ef/2027/12/19.json + ef/2027/12/20.json + ef/2027/12/21.json + ef/2027/12/22.json + ef/2027/12/23.json + ef/2027/12/24.json + ef/2027/12/25.json + ef/2027/12/26.json + ef/2027/12/27.json + ef/2027/12/28.json + ef/2027/12/29.json + ef/2027/12/30.json + ef/2027/12/31.json + index.html + schema/day-v1.json + + $ ls /tmp/pub/ef/2027/01/*.json | wc -l + 31 + + $ test -f /tmp/pub/schema/day-v1.json && echo schema-present + schema-present + + $ test -f /tmp/pub/index.html && echo index-present + index-present + +Publishing twice is byte-identical -- safe to publish into a git repo: + + $ colitur publish --from 2027 --to 2027 --out /tmp/pub1 >/dev/null + $ colitur publish --from 2027 --to 2027 --out /tmp/pub2 >/dev/null + $ diff -r /tmp/pub1 /tmp/pub2 && echo identical + identical + +The published .ics is byte-identical to `emit --format ics` for the same +year -- both walk through the identical Emit_ics.year: + + $ colitur emit --format ics --from 2027 --to 2027 > /tmp/emit-2027.ics + $ diff /tmp/pub1/ef/2027.ics /tmp/emit-2027.ics && echo ics-identical + ics-identical + +publish never deletes a file it does not own: + + $ touch /tmp/pub1/MY-NOTES.txt + $ colitur publish --from 2027 --to 2027 --out /tmp/pub1 >/dev/null + $ test -f /tmp/pub1/MY-NOTES.txt && echo kept + kept + +--prune removes only files a previous run created: + + $ colitur publish --from 2027 --to 2027 --out /tmp/pub1 --prune >/dev/null + $ test -f /tmp/pub1/MY-NOTES.txt && echo still-kept + still-kept + + $ colitur publish --from 2028 --to 2028 --out /tmp/pub1 --prune >/dev/null + $ test -d /tmp/pub1/ef/2027 || echo pruned-2027 + pruned-2027 + $ test -f /tmp/pub1/MY-NOTES.txt && echo notes-survived-prune + notes-survived-prune + +--out is required: + + $ colitur publish --from 2027 --to 2027 + colitur: publish requires --out DIR + [2] + +publish writes many files across a whole year range (mkdir_p/write_file), +same as the template read guarded in commit 6bd741b -- --out is user input +too, and an unwritable parent used to surface as an uncaught +Unix.Unix_error instead of the project's one-line form. The read-only +directory below lives in this test's own cram sandbox, not /tmp: a failed +`rm -rf` of an unwritable directory would otherwise leave it behind in a +shared location, so it is restored to writable before the test ends either +way: + + $ mkdir ro-parent && chmod 555 ro-parent + $ colitur publish --from 2027 --to 2027 --out ro-parent/sub + colitur: mkdir: ro-parent/sub: Permission denied + [2] + $ chmod 755 ro-parent + +publish's own flags have no effect on the other commands, and other +commands' flags have no effect on publish -- refused rather than silently +ignored, the same discipline as everywhere else: + + $ colitur publish --from 2027 --to 2027 --out /tmp/pub3 --format json + colitur: --format has no effect on `publish`; refusing rather than ignoring it + [2] + + $ colitur day 2027 --out /tmp/pub3 --prune + colitur: --out/--prune have no effect on `day`; refusing rather than ignoring them + [2] + +--prune's manifest-driven deletion is hardened against a manifest entry it +did not itself write (fix round 1, F1, CRITICAL): the manifest lives INSIDE +the tree publish writes into, so a bad merge or a hand-edit can put an +arbitrary path in it -- no attacker required. This is the exact CANARY +reproduction the finding was raised with: a ".." entry appended to the +manifest must never let --prune delete outside --out. + + $ rm -rf /tmp/pub-sec /tmp/pub-sec-outside + $ mkdir -p /tmp/pub-sec-outside + $ touch /tmp/pub-sec-outside/CANARY.txt + $ colitur publish --from 2027 --to 2027 --out /tmp/pub-sec >/dev/null + $ echo '../pub-sec-outside/CANARY.txt' >> /tmp/pub-sec/.colitur-manifest + $ colitur publish --from 2028 --to 2028 --out /tmp/pub-sec --prune >/dev/null + colitur: refusing to prune manifest entry "../pub-sec-outside/CANARY.txt" (absolute path or .. component) + $ test -f /tmp/pub-sec-outside/CANARY.txt && echo canary-survives + canary-survives + +The same run's own legitimate stale entries (2027's files, superseded by +2028) still prune normally -- the hardening does not disable pruning, only +unsafe entries: + + $ test -d /tmp/pub-sec/ef/2027 || echo 2027-pruned-normally + 2027-pruned-normally + +An absolute-path entry is refused the same way, not only a ".." one: + + $ echo '/tmp/pub-sec-outside/CANARY.txt' >> /tmp/pub-sec/.colitur-manifest + $ colitur publish --from 2028 --to 2028 --out /tmp/pub-sec --prune >/dev/null + colitur: refusing to prune manifest entry "/tmp/pub-sec-outside/CANARY.txt" (absolute path or .. component) + $ test -f /tmp/pub-sec-outside/CANARY.txt && echo canary-still-survives + canary-still-survives + +A legitimate filename that merely CONTAINS two dots -- but has no ".." path +COMPONENT -- is not caught by the same check, proving it is not +over-broad: it still prunes normally when stale. + + $ touch /tmp/pub-sec/ef/2027..old.json + $ echo 'ef/2027..old.json' >> /tmp/pub-sec/.colitur-manifest + $ colitur publish --from 2029 --to 2029 --out /tmp/pub-sec --prune >/dev/null + $ test -f /tmp/pub-sec/ef/2027..old.json || echo dotted-name-pruned + dotted-name-pruned + +...and that same run is an ordinary --prune cycle in every other respect -- +2028's own files, now stale relative to 2029, are gone too: + + $ test -d /tmp/pub-sec/ef/2028 || echo pruned-2028 + pruned-2028 |
