• Persist archive content listings in a per-area data/dirs/.conten

    From Rob Swindell@1:103/705 to GitLab issue in main/sbbs on Sunday, September 20, 2026 01:18:31
    open https://gitlab.synchro.net/main/sbbs/-/work_items/1247

    ## Problem

    `?view=` on the web file index and the `list` path of `exec/archive.js` both call `Archive(filename).list()` on every request. Nothing is ever retained, so the same archive is opened and enumerated once per view, forever.

    That has three costs:

    1. **Repeated work.** Measured on Vertrauen over six days, 97,280 `?view=`
    requests covered 70,199 distinct URLs, so 72% were the first-ever request
    for that URL. A conventional expiring cache would ride at about a 28% hit
    rate; the population is a breadth-first crawl of a large corpus, not a
    working set.
    2. **A crash vector.** Building the entry list in mozjs185 is what
    `d777bda5c5` (patch-1-sell, 2026-06-01) deflects crawlers away from. The
    deflection reduces exposure but the underlying extraction still happens on
    every legitimate view.
    3. **Guaranteed-useless work.** 178 of 600 sampled archive-extension files
    fail to list at all, and 171 of those are `.arc`, which libarchive does not
    support in any form. Every view of those re-attempts a doomed extraction and
    returns an error page.

    ## Proposal

    Persist the outcome of archive-content extraction in a per-area file, and have viewers read it instead of extracting.

    ### Storage

    One file per file area, alongside the base it derives from:

    ```
    data/dirs/<code>.contents
    ```

    `data/dirs/` already holds multiple per-area extensions (`shd` `sdt` `sid` for the base, `sha` `sda` for header and data allocation, `hash`, `ini`, plus the legacy `ixb` `dat` `dab` `exb`), so this is consistent with existing practice. `contents` collides with none of them.

    Rejected alternatives: one file per archive costs about 576 MB of 4 KB block waste to hold roughly 82 MB of content, plus 144k inodes, for areas that hold 27 records at the median. A single global file puts every writer on one hot file with no lifecycle tie to anything.

    ### Record shape

    Keyed by filename within the area. Each record carries:

    - the extracted listing, as compact entries of name, size, time
    - `type`, so the same file can later hold image, audio or video metadata
    without a format break
    - the validity key: the file's size and mtime at extraction time
    - a format version, and an **extractor identity/version**
    - for failures, the outcome and reason instead of a listing

    Two versions rather than one, because they protect different things. The format version protects the encoding. The extractor version protects the *verdict*: a negative record written today because libarchive cannot read ARC must be re-examined if ARC support is ever added, and nothing else would trigger that, since the file itself never changed.

    ### No size cap

    Measured across 500 archives sampled at most 4 per area: mean listing 712 bytes, median 266 bytes and 9 entries, p99 10 KB and 330 entries, 34.4 bytes per entry. With 83.3% of the 144,259 file records being archives, the whole base projects to **about 82 MB uncapped**. A cap saves nothing worth having.

    Note this is deliberately not a guard against the mozjs185 crash vector. That cost is paid while building the list in memory, before any of it reaches storage, so a stored-size limit cannot prevent it. If a guard is wanted it belongs in the extraction path and is a separate concern. The store helps with the crash vector in a different way: each pathological archive is extracted once ever rather than once per view.

    ### Read path

    Three tiers, in this order:

    1. Store hit and current (size and mtime match): serve it.
    2. Miss or stale: extract, serve, and store the result.
    3. Cannot extract: report unsupported, and store *that*, so the next view does
    not retry.

    Tier 3 matters more than it looks. It is what stops the 171-in-600 `.arc`
    files from re-attempting a doomed extraction on every single view.

    ### Shared reader

    `exec/archive.js` (`list` and `json`) and `exec/webfileindex.ssjs` (`view_archive()`) need byte-identical lookup, staleness comparison and fallback. That belongs in one `load()`-able library under `exec/load/`, with both callers thin. Two implementations of a staleness rule will drift, and the drift is invisible until someone is looking at a listing that does not match the file.

    This is an added fast path, not a rewrite. `archive.js` also has `create`, `extract`, `read`, `type` and `install`, which still need real extraction and are untouched. Only `list` is wired into configuration
    (`[viewer:17] cmd=?archive list %f`).

    ### archive.js takes a path, not an area

    `archive.js` accepts a path or filename and knows nothing about area codes, and its configured invocation passes only `%f`. Passing the code instead would require a new `cmdstr()` specifier, and there is none for it today (checked all of A-Z plus the symbol specifiers in `xtrn.cpp`), so that would turn a pure-JS change into a C++ one.

    Resolve it in JS instead, by reverse-mapping the path to an area:

    1. Normalize the given path to absolute, and split into directory plus
    filename.
    2. Compare the directory against `file_area.dir[*].path`, respecting platform
    case rules and the trailing separator that `dir.path` already carries.
    3. On a match, look up the filename in that area's `.contents`.
    4. On no match, there is no store. Extract live and do not persist, which is
    correct: a file outside every file area has nowhere to persist to.

    Cost is about 1,400 in-memory string comparisons per invocation against already-loaded configuration, memoizable per process. `archive.js` keeps its current interface exactly, and `[viewer:17]` needs no change.

    Edge cases, all of which degrade to live extraction rather than misbehaving: paths reached via symlink or a different route will not match; two areas sharing a path simply get their own `.contents` with duplicate entries, which is harmless.

    ### Populator

    `exec/inventory_archives.js` is rewritten to target the store instead of `auxdata`. The area iteration and extraction logic survives; the write target and the skip logic change. Two existing bugs disappear with the rewrite rather than needing fixes:

    - It passes `readd_always: true` to `FileBase.update()`, which zeroes
    `when_imported` (`js_filebase.cpp`), and `smb_addmsg()` re-stamps a zeroed
    value to the current time (`smbadd.c:247`). A full run against every area
    would mark the entire file base as newly imported.
    - It skips any file that already has data rather than checking whether that
    data is still valid, so it has no staleness handling at all.

    With the three-tier read path in place, running the populator is a tuning decision (pre-empting the crawler, warming cold areas) rather than a prerequisite for correctness.

    ### ARC

    `.arc` was 28.5% of sampled archive-extension files and is entirely unviewable today. Two extractors handle it, both already present on Vertrauen:

    | tool | listing | notes |
    |---|---|---|
    | `lsar` / `unar` | `lsar -j` emits JSON | preserves filename case |
    | `nomarch` | `nomarch -l` | lowercases filenames |

    Both listed **80 of 80** sampled `.arc` files that libarchive rejects. `lsar -j`
    is preferable: it needs no output parsing and returns `XADFileName`, `XADFileSize` and `XADLastModificationDate`, which is exactly what the viewers render.

    The store makes this a populator concern rather than a viewer concern. Once a listing is stored, no viewer cares which tool produced it, and no per-format `[viewer:N]` entry is needed for ARC or anything else. Format knowledge collapses into one place.

    ## No C++ changes required

    Everything needed exists in the JS API already: `File`, `file_size()`, `file_date()`, `system.data_dir`, `FileBase`, `Archive().list()`, and `system.popen()` for shelling out to `lsar`.

    One caveat on `system.popen()`: its own documentation says "only functional on UNIX systems". It compiles everywhere, since `genwrap.h:315` maps `popen` to `_popen` on Windows, but `_popen` requires a console and fails inside a Windows service. `js_popen()` returns early on a NULL `FILE*` without setting a return value, so the caller sees `undefined`. The fallback must read that as "no capability" and drop to tier 3, rather than treating it as an empty listing and poisoning the store with bogus empty records. Net effect on Windows: ARC stays unsupported exactly as it is today, with no regression.

    Making `Archive()` itself fall back to an external extractor would fix every consumer at once but is not proposed: it puts a process spawn inside a core object and inherits the same Windows limitation.

    ## Prior art, and why this differs

    Archive contents were stored in the SMB file "tail" in `c5c17a98ad` (memo-43-dive, 2021-05-02) and removed ten days later in `2c5b131848` (city-14-relative, 2021-05-12) for two stated reasons: it badly slowed `upgrade_to_v319`, and there was no consumer of the data. `6b13e59979` (sync-3-shark, 2021-05-12) recorded the intended direction as JS and JSON rather than C and .ini, and `2066c74aa1` (layout-11-geek, 2023-12-25) left `exec/inventory_archives.js` as a proof of concept.

    Both reasons have changed. `?view=` arrived in `50d04b24aa` (trio-35-tall, 2025-01-22) and is now the consumer, under crawler load. And the cost that
    sank it was a property of the storage location, not of the idea: writing `auxdata` takes the remove-and-re-add path rather than the in-place `smb_putfile()` (`js_filebase.cpp`), under `smb_locksmbhdr()` on the area's `.shd` with a 30-second retry timeout, inflating `.sdt` in the process. A separate file has none of those properties, and the listing is derived data that is reconstructible at any time, so it does not belong in the authoritative record regardless.

    A survey found the slate is clean: across all 1,404 configured areas, exactly one area (`fidonet_mbse_bbs`) holds any stored contents, 2 records, in the 2021 .ini format. There is nothing to migrate.

    ## Open questions

    - Whether `?view=` should remain crawlable at all. The design does not depend
    on the answer, but it decides whether the crawler warms the store as a side
    effect or whether warming is the populator's job alone.
    - Whether tier 2 should be allowed on the web path. Permitting it lets crawler
    traffic populate the store for free; forbidding it keeps process spawning off
    anonymous requests entirely. Given `?view=` traffic is almost entirely
    crawler, this is a real trade rather than an obvious call.
    - Orphan sweeping. 1,794 `.sid` files exist against 1,404 configured areas, so
    about 390 orphaned bases are already sitting in `data/dirs/`. The store will
    accumulate the same way, which argues for folding it into whatever ought to
    clean those up rather than inventing a separate mechanism.

    -- *Authored by Claude (Claude Code), on behalf of @rswindell*
    --- SBBSecho 3.37-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Rob Swindell@1:103/705 to GitLab note in main/sbbs on Sunday, September 20, 2026 01:22:52
    https://gitlab.synchro.net/main/sbbs/-/work_items/1247#note_10393

    ## Resolutions and one new requirement

    ### `?view=` stays crawlable (open question 1)

    Settled. This also settles most of open question 2 in favour of allowing tier 2 on the web path: if crawler traffic is staying, letting it populate the store is the cheapest possible warming mechanism. Six days of logs covered 70,199 distinct archives, roughly half the base, at no cost to anyone. The populator then becomes a way to pre-empt cold areas rather than the primary means of filling the store.

    ### Use `system.exec()` with redirection, not `system.popen()`

    The Windows caveat in the description is withdrawn. Redirecting the external tool's output to a file and parsing the file works on both platforms: `js_sys_exec()` calls C `system()`, which runs the command through `/bin/sh -c` on Unix and `cmd.exe /c` on Windows, so shell redirection is available in both cases. `system.popen()` and its console-dependent `_popen()` behaviour are not needed at all.

    So ARC support via `lsar` works on Windows too, provided the tool is installed, and the "no improvement on Windows" note no longer applies.

    Two consequences of going through a shell, both of which need handling.

    ### Filenames reach a shell, and the file base permits shell metacharacters

    `ILLEGAL_FILENAME_CHARS` (`sbbsdefs.h:55`) is:

    ```
    \ / | < > : " ; , % ? *
    ```

    Legal in a file-base filename, and significant to a shell:

    ```
    $ ` & ' ( ) ! # [ ] { } plus space and newline
    ```

    Double-quoting the path handles most of those, and conveniently `"` and `\` are already illegal so the quoting cannot be broken out of that way. But `$` and backtick are still expanded by `sh` **inside** double quotes, so a file named like `readme$(id).zip` or one containing a backtick would execute a command.
    On Windows the analogous character is `%`, which is already illegal.

    Requirement: quote the path, and additionally refuse to invoke the external tool for any filename containing `$` or a backtick, falling to tier 3 for those. That is a two-character blocklist on top of quoting, and the affected names are pathological rather than plausible.

    The exposure is narrow either way, since this applies only to the external-tool fallback. `Archive()` handles the common formats natively with no shell involved.

    ### The temp file needs a unique name

    `system.temp_dir` is shared, and the web server runs many concurrent session threads, so a fixed output filename would have two requests overwriting each other's results and serving the wrong listing. The name must be unique per invocation, and the file removed after parsing, including on the error paths.

    -- *Authored by Claude (Claude Code), on behalf of @rswindell*
    --- SBBSecho 3.37-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Rob Swindell@1:103/705 to GitLab note in main/sbbs on Sunday, September 20, 2026 01:40:13
    https://gitlab.synchro.net/main/sbbs/-/work_items/1247#note_10394

    ## Correction: the wasted ARC extraction was terminal-side, not web-side

    The Problem section above says every view of a `.arc` file "re-attempts a doomed
    extraction and returns an error page". That is true of the terminal viewer and false of the web.

    `webfileindex.ssjs` gates viewing on its own hardcoded list, which never included the format:

    ```js
    return ['zip', '7z', 'tgz', 'rar', 'lha', 'lzh', 'iso', 'cab'].indexOf(ext.toLowerCase()) >= 0;
    ```

    So on the web those files were never offered a view link and a direct
    `?view=` on one returned "Non-viewable file type" without reaching extraction at all. The repeated doomed extraction happened through `[viewer:17]`, whose `extension=*` catch-all hands every unmatched type to `archive.js`.

    The waste was real, just on a different path than stated, and the fix has to touch both: the web needs the format added to that list before the store's fallback can ever be reached.

    ## Implementation status

    Landed locally, not yet pushed:

    - `exec/load/filecontents_lib.js` -- the store, path-to-area reverse mapping,
    `file_mutex` locking with a `js.on_exit` release, staleness, extraction with
    the external-tool fallback, and the three-tier read path.
    - `exec/tests/filecontents_test.js` -- 30 checks.
    - `exec/archive.js` -- `list` serves from the store; verbose still reads the
    archive, since CRC and compression format are not stored.
    - `exec/webfileindex.ssjs` -- `view_archive()` serves from the store, entry
    names are HTML-encoded, and `arc` is added to the viewable list.

    Verified on Vertrauen, on both consumers:

    | path | result |
    |---|---|
    | terminal, live session | ARC files that previously said "Unsupported archive" now list, with correct color and alignment |
    | web, `?view=` on a ZIP | renders, stored as `x: "libarchive"` |
    | web, `?view=` on an ARC | renders, stored as `x: "lsar"`, written by the web server process |
    | repeat requests | served from the store |
    | concurrency | 4 processes, 100 locked writes to one area, store intact, no stranded locks |

    The web result also settles the open question about allowing tier 2 there: the web server successfully ran the external tool as the `sbbs` user and wrote the store, so crawler traffic populates it as a side effect. Denying tier 2 on the web was never really viable anyway, since it would have left `?view=` showing nothing for any file the populator had not already reached.

    Still open: `exec/inventory_archives.js` has not been rewritten and still carries the `readd_always` import-date bug, so it should not be run.

    -- *Authored by Claude (Claude Code), on behalf of @rswindell*
    --- SBBSecho 3.37-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)