-
-
Notifications
You must be signed in to change notification settings - Fork 6
fjs/web: file an issue for the missing-index message #1714
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
174a9bb
bc5cba9
4ef85a9
836db52
0b0349d
1d19fee
2c3a5f0
d3f0978
f306a7c
8de8048
08e106c
14e9fdf
84f3386
3496001
2b01b11
ea10d89
6529b19
1dc4fe8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,197 @@ | ||
| ## missing-index-message. A directory with no index reads as a bad URL | ||
|
|
||
| **Priority:** P3 | ||
| **Status:** open | ||
|
|
||
| ### Problem | ||
|
|
||
| `fjs web` answers `/` with `not found` when the served root has no | ||
| `index.html`, which describes the file the server looked for and not the | ||
| request the client made. The URL was right, the root was right, and the one | ||
| thing missing is a file the operator generates — but the sentence names none of | ||
| that, so the first reading is that the address is wrong. | ||
|
|
||
| It is reachable from a clean checkout. `index.html` is a build artifact and is | ||
| gitignored, so `fjs web` in a fresh tree answers `not found` at `/` until | ||
| `npm run index-html` has run. Nothing in the response points at that. | ||
|
|
||
| The same sentence covers every other refusal: | ||
|
|
||
| | request | today | | ||
| |---|---| | ||
| | `/` with no `index.html` in the root | `404 not found` | | ||
| | `/fjs/` — a directory that exists, with no index | `404 not found` | | ||
| | `/no-such-dir/` | `404 not found` | | ||
| | `/fjs/web/nope.md` | `404 not found` | | ||
| | `/.git/` | `404 not found` | | ||
| | `/README.md/` — a trailing slash onto a regular file | `500 io error: ENOTDIR` on POSIX, `404 not found` on Windows | | ||
|
|
||
| Byte-identical, the first five. That is not an oversight for `/.git/`: refusing | ||
| a dot-prefixed path as *absent* is what keeps its existence undisclosed, per | ||
| `resolve`'s note and "Deliberately absent" in [`../README.md`](../README.md). | ||
| So the fix is not "say more when a file is missing" — it is to say more about | ||
| **what was asked for** without saying more about what is on disk. | ||
|
|
||
| The last row is the exception, and it sits in exactly the shape this issue | ||
| triggers on: a directory-form request whose status already tells an existing | ||
| regular file from nothing. It is filed separately as | ||
| [notdir-status](./notdir-status.md), because the message is not what is wrong | ||
| with it. | ||
|
|
||
| ### Proposal | ||
|
|
||
| Give a directory-form request its own sentence, decided by the shape of the | ||
| URL rather than by the file system. Such a request is one the server answered | ||
| by appending `index.html`, and saying so discloses nothing: the client sent | ||
| that path and already knows its shape. | ||
|
|
||
| ``` | ||
| no index.html in /fjs/ | ||
| ``` | ||
|
|
||
| **Directory-form is not "ends in a slash".** `resolve` asks | ||
| `segments.length === 0 || decoded.endsWith('/')`, so a path that *parses* to | ||
| nothing qualifies too — `/.`, `/%2E`, `/a/..` and `/fjs/..` all get | ||
| `index.html` appended and none ends in a slash. Verified: each answers `200` | ||
| against a root that has one. A trailing slash is sufficient, not necessary, and | ||
| an implementation keying off it alone would leave these on the old sentence. | ||
|
|
||
| **A hidden path keeps `not found`, though it is directory-form too.** | ||
| `resolve` refuses a dot-prefixed segment *before* computing `isDirectory`, so | ||
| `/.git/` never reaches the branch that appends `index.html` — and that | ||
| ordering is right rather than an obstacle to route around. The new sentence is | ||
| a claim about having looked; for a hidden path the server refused without | ||
| touching the disk, so `no index.html in /.git/` would describe work it did not | ||
| do. Non-disclosure is unaffected either way, which is worth stating plainly: | ||
| the hidden refusal never consults the disk, so `/.git/` and `/.nonexistent/` | ||
| answer identically whichever sentence they carry — verified. What separates | ||
| them from `/foo/` is that the client wrote a dot, which the client already | ||
| knows. So the question is which message is true, and only one of them is. | ||
|
|
||
| Three constraints on the wording: | ||
|
|
||
| - **Name the URL path, not the resolved path.** `fileResponse` already | ||
| prefers `errorSummary` to `errorMessage` so a client is not handed the | ||
| server's filesystem layout; a message reading `no index.html in | ||
| /home/…/fjs/` would give back exactly that. | ||
| - **Do not distinguish an existing directory from a missing one.** Telling | ||
| `/fjs/` apart from `/no-such-dir/` requires a second `stat` and turns the | ||
| response into a directory-existence oracle — the enumeration the identical | ||
| `404`s are written to deny, per the dot-prefixed-existence note in `resolve` | ||
| and "Deliberately absent" in [`../README.md`](../README.md). (Not the | ||
| loopback and `Host` checks: `respond` answers `403 host not served` before | ||
| `resolve` runs, so a rebound origin never reaches a `404` at all. Those | ||
| defend the socket; this defends what an answer says.) Both should keep | ||
| answering the same sentence. | ||
| - **Echo the percent-encoded spelling, never the decoded one.** The candidates | ||
| disagree — the raw target, the decoded path and the re-joined segments | ||
| differ for `/fjs%2F` (decodes to `/fjs/`) and `/fjs/./` (re-joins to `fjs`) | ||
| — but the choice is not only cosmetic. Nothing on the way in excludes a | ||
| control character: `percentDecode` rejects a malformed escape and invalid | ||
| UTF-8, `resolve` rejects NUL separately, and `/%1B%5B31m/` and `/a%0Ab/` are | ||
| none of those — they decode to real control characters. A body is not only | ||
| read by browsers: `curl` and `wget` write | ||
| it to a terminal, where an ANSI or OSC sequence is acted on rather than | ||
| displayed. `text/plain` and `nosniff` bound what a *browser* does with the | ||
| bytes and say nothing about that, so calling them harmless was wrong. | ||
|
|
||
| So the message percent-encodes what it echoes, rather than trusting what it | ||
| was handed. Over HTTP a raw control byte does not reach this module — Node's | ||
| parser answers `400 Bad Request` on the request line before the listener | ||
| runs, verified — but that is a property of one caller, not of `respond`. | ||
| `respond` is exported, `IncomingMessage.url` is an unrestricted `string`, | ||
| and `proof.f.mjs` already calls it directly through the virtual runner with | ||
| whatever URL a case names; a non-Node runner is the same story. Relying on | ||
| the parser would put the invariant outside the function that depends on it, | ||
| which is the arrangement that quietly stops holding. | ||
|
|
||
| The pass has to be spelled out, because the obvious one is wrong: a | ||
| general-purpose URI encoder escapes the `%` too, turning `/%1B%5B31m/` into | ||
| `/%251B%255B31m/` — unreadable, and not what the proof below asks for. And | ||
| preserving every `%` is not a rule either, since a lone one is not an | ||
| escape. So: | ||
|
|
||
| - a `%XX` triplet — `isEscape`, the predicate `percentDecode` already uses | ||
| — passes through verbatim; | ||
| - unreserved characters and `/` pass through; | ||
| - every other byte is percent-encoded, which is what catches a raw control | ||
| character. | ||
|
|
||
| Preserving triplets is total rather than a special case: by the time this | ||
| message is built `resolve` has accepted the path, and `percentDecode` | ||
| rejects a malformed escape, so every `%` present already begins a valid | ||
| triplet. A direct call to `respond` cannot smuggle a lone `%` past that — | ||
| it takes the same route — while it *can* carry a raw control byte, which | ||
| the third rule encodes. | ||
|
|
||
| So `%1B` survives as `%1B` whether it arrived encoded or raw, and the | ||
| raw-versus-normalized choice stays free of safety consequences, since either | ||
| is encoded on the way out. What must not happen is quoting `percentDecode`'s | ||
| output — or the target's — into the body unprocessed. | ||
|
|
||
| Nothing echoes anything today — the current answer is the constant `not | ||
| found` — so this is a property to build in, not a bug to fix. And nothing | ||
| needs plumbing for it: `respond` already binds `parseTarget(url)` for the | ||
| host check, so `target.path` is in scope where the message would be built. | ||
| Only the `isDirectory` fact below has to travel. | ||
|
|
||
| The obstacle is that the distinction is gone by the time it is needed. | ||
| `resolve` computes `isDirectory` and then returns `Result<string, Refusal>` — | ||
| a path and nothing else — so `respond` cannot tell an appended `index.html` | ||
| from a requested one, and `fileResponse` sees only a `stat` failure. Options: | ||
|
|
||
| 1. Widen what `resolve` returns, so the routing fact travels with the path. | ||
| Keeps every routing decision in the one pure function that owns them, at | ||
| the cost of a change to `Resolve` and its proofs. | ||
|
Comment on lines
+143
to
+145
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The preferred flow only carries the routing fact alongside a successfully resolved path, but AGENTS.md reference: AGENTS.md:L47-L49 Useful? React with 👍 / 👎. |
||
| 2. Re-derive it in `respond` from the URL. No type change, but it puts a | ||
| second copy of "what counts as a directory request" outside `resolve`, | ||
| which is the split the module header exists to state. | ||
| 3. Key off the resolved path ending in `index.html`. Cheapest and wrong: it | ||
| cannot tell `/index.html` — a request naming the file — from `/`. | ||
|
|
||
| (1) is the one that matches how the module is factored. | ||
|
|
||
| Two spellings are open. A literal `/index.html` should perhaps get the new | ||
| sentence too — the message would be true, and treating it as a directory | ||
| request would not be. And `/fjs/..` is directory-form without looking it, so | ||
| `no index.html in /fjs/..` names a path that reads as a file's neighbour; | ||
| echoing the parsed path instead would name a directory the client never wrote. | ||
| Either answer satisfies the encoding constraint above — a normalized path is | ||
| re-encoded on the way out — so that choice stays open on its own merits. | ||
|
|
||
| ### Tasks | ||
|
|
||
| - [ ] Decide how the directory-form fact reaches `respond` — (1) above unless | ||
| something argues otherwise. | ||
| - [ ] Answer a directory-form `404` with a sentence naming `index.html` and | ||
| the URL path, leaving every other `404` as it is. Key it off `resolve`'s | ||
| own predicate, not off a trailing slash — and off the branch that | ||
| appends `index.html`, so a hidden path keeps `not found`. | ||
| - [ ] Prove `/.git/` still answers `not found`, and answers it identically to | ||
| `/.nonexistent/`, so the refusal stays ahead of the new sentence. | ||
| - [ ] Prove `/%1B%5B31m/` echoes `%1B%5B31m` and not the escape it names, so | ||
| no answer this server writes can drive a terminal — and prove it for a | ||
| *raw* control character too, by calling `respond` directly the way | ||
| `proof.f.mjs` already does, since that path has no HTTP parser in front | ||
| of it. | ||
| - [ ] Prove that `/fjs/` and `/no-such-dir/` still answer identically — and | ||
| `/README.md/` with them, which needs | ||
| [notdir-status](./notdir-status.md) first. Without it the proof passes | ||
| while the directory-form shape still leaks. It will pass anyway for | ||
| `/locked/` and `/loop1/`, which that issue scopes out on purpose, so | ||
| state what the proof covers rather than letting it read as "no | ||
| directory-form request discloses". | ||
| - [ ] Update the response table in `module.f.mjs` and the prose in | ||
| [`../README.md`](../README.md). | ||
|
|
||
| ### Related | ||
|
|
||
| - [`fjs/web`](../README.md) — "Deliberately absent", where the missing | ||
| directory listing and the `/docs` vs `/docs/` split are settled. | ||
| - [notdir-status](./notdir-status.md) — a directory-form request whose status | ||
| already discloses, which this issue's proof depends on. Not the only one: it | ||
| scopes itself to `ENOTDIR` and leaves `EACCES` and `ELOOP` at `500` | ||
| deliberately, so directory-form requests still do not answer uniformly on a | ||
| POSIX host. | ||
| - [`fjs/website`](../../website/) — writes the `index.html` whose absence this | ||
| is about. | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Resolveresult before implementationThe preferred option changes the exported
ResolveAPI, but the issue never specifies the new success payload or howrespondconsumes it; the task even leaves the transport decision open. Becausetypes.tscurrently promisesResult<string, Refusal>and callers unwrap a string, an implementer must invent a public API rather than follow a concrete design. Record the exact result shape and its handling before this issue is ready for implementation.AGENTS.md reference: AGENTS.md:L47-L49
Useful? React with 👍 / 👎.