# Privacy and security What this plugin fetches, what it writes, and what it refuses to do. ## Privacy - The plugin makes **no request at all** until you give it a key and open the panel. - Everything transport-related goes to `api.opentransportdata.swiss`. - `ipapi.co` is contacted **at most once, ever**, to pick a starting town. The answer is cached, and `detectLocation: false` stops it before it ever runs. - `wmts.geo.admin.ch` and `api3.geo.admin.ch` — both swisstopo — supply the map sheets and the canton a place is in. The map is no longer optional in the panel, so **this is a request the plugin now makes by default**, which the earlier opt-in backdrop did not. `basemap: false` in `shell.json` stops both outright. A canton is asked once per place and stored with it, so switching swisstopo off later keeps the labels already resolved. - Your search terms travel in the request *body*, never in a URL. The canton lookup is the one request carrying a coordinate in its URL, and that coordinate is a published stop position — the stop's, not yours. - Nothing is written anywhere except your key, a small state file, and the tile cache. ## Every input is bounded, including the ones that are not requests Each network response carries a byte ceiling — `curl --max-filesize` — so a slow or hostile peer can neither stall the shell nor grow its heap. The state file used to be the exception, and it was the wrong exception to make. Quickshell's `FileView` materialises a whole file as a QML string with no size limit, and it does so **as soon as `path` is set**. That is worth stating plainly because the property names suggest otherwise: neither `blockAllReads` nor turning `watchChanges` off prevents it. Measured on a 200 MB file, with a bare shell at 257 MB resident: | configuration | resident | |---|---| | no `FileView` at all | 257 MB | | `FileView`, `watchChanges: false`, `blockAllReads: true` | 884 MB | | `FileView`, plain | 886 MB | So a state file that had grown absurd — a runaway writer, a truncated restore, a synced home gone wrong — would have been held whole, for the session, before a single field of it had been validated. `Model.parseState` bounds what it *keeps*, but that is after the allocation, not before it. There is therefore no `FileView` anywhere near the state file. It is read through a bounded reader — `head -c` at first, `dd` once the section below found the other two hazards — which hands over at most 64 KB and offers no path by which more arrives, and written through `install -m 600 /dev/stdin` followed by a rename — the same pattern the API key already uses. Sixty-four kilobytes is an order of magnitude more than the file needs: twenty-four favourites, eight recent stops and a dozen scalars come to a few kilobytes. A file past the ceiling arrives truncated, so its JSON does not parse and the defaults apply, which is the right reading of a state file too big to be a state file. Writing stays atomic by construction: the new contents go to a neighbouring file and are renamed into place, so the state file is either the old one or the new one and never a half-written one. It also gains `0600`, where the previous writer left it at the umask default. Measured after the change: a fresh shell started against a 255 MB state file settles at its ordinary 497 MB, reads 64 KB in about a millisecond, falls back to defaults and rewrites the file. ### The state path is not trusted to be a file A path is a name, not a promise. Whatever sits at the state path is whatever was last put there, so both ends now say what they will accept. **Reading** is `dd ... iflag=nofollow,nonblock,fullblock`. Three hazards, and the byte ceiling only answers the first: - *size* — `bs= count=1` hands over at most n bytes; - *a symlink* — `nofollow` opens O_NOFOLLOW, so a link left there fails to open rather than reading whatever it points at; - *a FIFO* — this is the one worth spelling out. `open()` on a FIFO with no writer blocks forever. Measured: `head -c` on one never returns, and that read would sit in a process that lives for the whole session while the panel waits for state that never arrives. `nonblock` returns immediately instead. `fullblock` is there so a legitimate file bigger than one `read()` still arrives whole rather than truncated by a short read. **Writing** stages into a file `mktemp` creates O_EXCL under a name nobody can predict, then renames it into place with `mv -fT`. A fixed neighbour such as `state.json.new` is a name that can be occupied in advance; GNU `install` does unlink its destination first, so a symlink there is replaced rather than written through — measured, for both a live and a dangling link — but that is a property of one implementation of one tool and not one to rest on. `-T` matters too: without it, a *directory* where the state file belongs swallows the staged file, `mv` reports success, and the state ends up one level down where nothing will read it again. A rename that fails leaves the staged file behind, and every attempt uses a fresh name, so a failure would litter one file per save. The failure path removes it. Exercised against all four, with the panel running: a normal file, a FIFO, a directory and a symlink where the state file belongs. The symlink's target is untouched, nothing is swallowed, no staged file is left in any case, and the file always lands `0600`. Two things this costs, both worth saying: - The file is no longer watched, so a second shell instance editing it will not be noticed until the next start. The plugin is the only writer in ordinary use. - A state file that fails to parse is replaced by defaults at the next save. That is the existing self-healing behaviour, and it means a corrupt file loses the favourites it contained. And one thing it is not: a privilege boundary. The file lives in the user's own home, in a directory this plugin creates `0700`, and anyone able to write a huge file there could replace the plugin's QML instead — plugins run unsandboxed. It is a robustness bound, and it belongs here because every other input already had one. ### The tile cache is a write target and a listing, and both were unbounded The basemap cache is the plugin's other file on disk, and it had the same two problems the state file had — a write to a name that could be occupied in advance, and a read with no ceiling. **Where a tile is downloaded.** A tile's cache name is entirely derivable: layer, zoom, column and row all follow from where the map is looking, so the name of a tile *not fetched yet* can be planted in advance. curl's `--output` opens with plain `O_CREAT|O_TRUNC` and follows a symlink. Measured at the previous release, with the panel running and the link planted after startup so the cache listing had not seen the name: ``` VICTIM size: 33377 b'\xff\xd8\xff\xe0\x00\x10JFIF...' colour-15-17027-11644.jpeg islink=True ``` The victim file was replaced by JPEG bytes and the link survived to catch the next tile. So the download now goes to a `mktemp` file — created O_EXCL under a name nobody can predict — and is renamed into place with `mv -fT`, the same staging discipline as the state write, for the same reason. Repeating the test against the fix: the planted links are *replaced* by real tiles, the victim stays at its 17 bytes, and no staged file is left behind. `-T` earns its place here as well; a directory sitting at a tile's name used to make the download fail outright, and is now simply replaced. That is three processes per tile — stage, download, rename — where there was one. The two extra are `mktemp` and `mv`, about a millisecond together against a download measured in tens. **Reading the cache back.** The listing was `ls -1t` collected whole by a `StdioCollector`, then split into one JavaScript string per entry, and only then reduced to the 400 names worth keeping. Neither `ls` nor the collector has a ceiling. Measured against a directory of 600 000 entries, in the shell that lives for the whole session: | | peak over an idle shell | entries evicted | |---|---|---| | previous release | +546 MB | 0 | | bounded listing | +142 MB transient, settling at +20 MB | 398 780 → 403 in 121 s | The zero in that first row is the second half of the finding, and the worse half: with the whole directory in one array, eviction built a single `rm` of 573 000 arguments — about 15 MB of argv, which `execve` refuses with `E2BIG`. Nothing was deleted. Past roughly `ARG_MAX` the four-hundred-tile cap silently stopped being enforced at all, which is exactly the size at which it was worth having, and nothing in the interface would ever have said so. The listing is now read one name at a time through a `SplitParser`, which retains nothing, under two ceilings: 20 000 entries and 1 MB. Reaching either stops the reader *and* the producer, and anything still in the pipe afterwards is dropped rather than kept, so the bound holds whether or not the kill lands first. Eviction removes at most 5 000 paths per pass — around 350 KB of argv — and asks for another pass if it stopped short, so a pathological directory drains over bounded passes rather than in one command that cannot run. Neither ceiling is a memory figure; names are read and discarded, and what a pass retains is the few thousand paths it decided to keep or remove. The +142 MB above is the garbage of eighty consecutive passes, not a working set: it falls back to +20 MB — an open panel with its map — and stays there. A pass that recognises none of its own files prunes nothing and asks for no further pass, so a directory full of somebody else's files cannot spin the loop. Two smaller things fell out of it. A staged download that never got renamed is recognised by its `tile.` prefix and swept by the next listing, but only when no download is in flight. And a listing or a prune is never asked for while one is already running, which would have skipped the counter reset and fired the ceiling early. ## Security notes The security-relevant decisions live in `lib/Net.js`, `lib/Xml.js` and `lib/Ojp.js`, and `tools/test.js` tests them adversarially. The short version: - **Constant URLs.** The planner endpoint is a literal. Nothing you type ever becomes part of a URL, which deletes the SSRF and URL-injection classes rather than filtering for them. - **The key never enters `argv`.** A Bearer token on a command line is readable from `/proc//cmdline` by anything running as you. It goes in a `0600` config file that `curl` reads. The key's character set is validated before that file is written — a key containing a newline could otherwise append a second `curl` option and turn a paste into a file write. - **The request body never enters `argv` either.** It is piped in on stdin, so what you typed reaches neither the process table nor the disk. - **`argv`, never a shell.** Every command is an array handed to Quickshell's `Process`; there is no `sh -c`, so there is no quoting to get wrong. - **The XML reader cannot be made to read a file.** It is written by hand precisely so that XXE and entity expansion are impossible by construction rather than by configuration: it resolves the five predefined entities from a table and nothing else, refuses any document carrying a `DOCTYPE`, and has no I/O of any kind. Nesting and node count are bounded. - **Every request is bounded** — connect timeout, total timeout, byte ceiling — and follows no redirects, so a slow or hostile peer can neither stall the shell nor grow its heap. - **Fail closed.** Host checks re-derive the host from the finished URL and refuse anything off the allowlist, even for URLs built from constants, so a future concatenation bug is caught rather than shipped.