Skip to content

Share the bounded read, the symlink walk and the file name rule in the host - #515

Open
Pixnop wants to merge 4 commits into
devfrom
refactor/486-host-shared-rules
Open

Pixnop wants to merge 4 commits into
devfrom
refactor/486-host-shared-rules

Conversation

@Pixnop

@Pixnop Pixnop commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Three guards in the host layer were written twice each. None of them is relaxed here: every check stays exactly where it ran before, including the defence in depth inside the workers. Only the second copy goes, so none of the three can be tightened on one side and left stale on the other.

What changes

assertSafeFileName (item 3). workers/download.ts carried its own copy of a rule validation.ts already states, on an import line that already reached that module. The shared function takes the caller's wording, so assertSafeFileName(fileName, "download file name") throws the same "Invalid download file name" the worker always threw. The guard stays inside the worker, behind the handler's own check in pathsHandlers.ts. Minus 12 lines in download.ts.

The symlink ancestor walk (item 2). The same walk lived in pathPolicy.ts for managed paths and in workers/extraction.ts for the eight worker call sites, both inherited from the fork seed rather than chosen. It is one function in validation.ts now, which both sides already import and which stays Electron-free. dirname against resolve(current, "..") and break against return at the filesystem root were the only differences, and the root always exists, so that branch was unreachable either way. The refusal takes the caller's wording, so the path policy keeps its "for managed paths" text. Minus 19 lines net across the three files; innoExtraction.ts now takes the walk from validation.ts instead of re-exporting it through extraction.ts.

The bounded read (item 1). network.ts wrote the Content-Length pre-check, the status check and the streamed byte-cap loop once per transport. Two transports is deliberate (#76); two reading loops was not, and they had drifted: only the Electron copy threw BoundedResponseError with the statusCode and headers that #442 added for releaseNotesFailureReason. collectBounded now reads for both, with the transport passing its own cancel (request.abort for Electron, request.destroy for Node). Minus 27 lines of code in network.ts; the helper's own doc comment puts the file at minus 7 overall.

The status parse (item 1, second half). loginFailureReason.ts parsed the status digits back out of the message text with an anchored regex, behind a comment arguing why that was safe. Now that both transports throw the typed error, it reads statusCode as a number, the same way codeOf reads code. The regex and its justification go; the module stays Electron-free because the property is read structurally rather than with instanceof.

What stays

Nothing was skipped. Every finding in the issue applied as written.

The guards themselves are untouched: the IPC validation at the trust boundary, the path policy, the mutation-tested guards, the defence in depth inside the workers, the hexagonal split. The two-transport split in network.ts stays two transports. tests/security-boundaries.test.ts, tests/log-provenance.test.ts, tests/text-contrast.test.ts and tests/i18n/i18n-parity.test.ts are unchanged and still pass.

The line savings are smaller than the issue estimated, 34 net rather than 65, because the two shared functions carry the doc comments the copies did not have to duplicate. The code they replace is gone as estimated.

Behaviour

Nothing changes for a caller. The refusal texts are byte for byte what they were, which matters in two places the issue named: netHandlers.fetchModDbListingArchive matches /redirect/ on the message to recognise ModDB's counted 302, and loginFailureReason maps three literal network messages by exact string. Both still match.

Two error classes change, and no caller anywhere branches on either. The symlink walk is a TypeError on both sides now (it was a plain Error in the workers); grep -rn "instanceof TypeError" src tests finds nothing, and all four pinned tests match by substring on /Symbolic links are not allowed/. A non-2xx from the Node transport is a BoundedResponseError now (it was a plain Error); its message is unchanged, network.test.ts matches the Node path by message substring, and BoundedResponseError extends Error.

Callers checked by grep before each change: assertSafeFileName (three call sites plus the two e2e scripts, all already on the shared one), assertNoSymlinkComponents (nine call sites across extraction.ts, innoExtraction.ts and pathPolicy.ts), runDownload (downloadWorker.ts and optimumManifest.ts), requestBoundedBuffer and requestBoundedTextViaNode.

Tests changed, all of them fixtures standing in for what the transport throws, none of them relaxed:

  • tests/ipc/download.test.ts: the describe("assertSafeFileName") block is deleted, 10 cases. It re-tested the rules tests/ipc-validation.test.ts already pins for the shared function, including the two extensions that pin the .zip suffix regression (vs_client_linux-x64_1.22.6.tar.gz and vs_install_win-x64_1.22.6.exe). The runDownload case asserting /Invalid download file name/ stays and is what pins the wording passing through.
  • tests/ipc/loginFailureReason.test.ts and tests/ipc/accountHandlers.test.ts: the hand-built new Error("Network request failed with status 503") fixtures now carry the status as a number, which is what the transport actually throws. Same expected tokens for every case, including a response with no status line, which still lands on http-other. The "matches the status message whole" case is kept and renamed: with nothing parsed out of the message, an error that only reads like a status refusal still falls through to the class name and still leaks no part of its message.

Gate, against origin/dev in the same worktree:

dev branch
typecheck pass pass
lint:ci 0 errors, 14 warnings 0 errors, 14 warnings (same 14)
format:check pass pass
test files 242 passed 242 passed
tests 4461 passed, 2 skipped 4451 passed, 2 skipped (minus the 10 deleted above)
statements 94.58% 94.62%
branches 91.01% 91.06%
functions 95.22% 95.32%
lines 96.26% 96.28%

git merge-tree against all six open PR branches reports no conflict. The only file overlap is pathPolicy.ts with fix/issue-465-worlds, which touches the grant lists and not the lines removed here.

Check

The login's HTTP classification had no end-to-end pin. Moving the chain off the message text and onto statusCode left the transport half unpinned: tests/ipc/network.test.ts matched only /404/, which a plain Error satisfies, and the two fixture files build the refusal by hand, so they assert the classifier against their own assumption rather than against the transport. Wrapping the Node transport's settle to replace the typed error with a plain one of the same text kept all 242 files green, while the real transport driven through the real classifier answered unclassified for a 503 instead of http-unavailable, which loginFailureFamily maps to unknown and accountHandlers.ts:155 turns into the generic "Login failed", the #481 regression.

The 404 case now asserts the property as well as the message: tests/ipc/network.test.ts:113-122. It keeps assert.match(error.message, /404/) and adds assert.equal(error.statusCode, 404). Against the mutation above it fails with undefined !== 404; against the branch as pushed it passes. No source file changed, so behaviour is untouched and no test was deleted.

Gate after the fix: typecheck pass, lint:ci 0 errors and the same 14 warnings, format:check pass, 242 files, 4451 passed, 2 skipped, statements 94.62%, branches 91.06%, functions 95.32%, lines 96.28%. Identical to the table above, as expected from a test-only change.

Closes #486. Part of #492.

The worker carried its own copy of a rule validation.ts already states,
on an import line that already reached that module. The shared function
takes the caller's wording, so the refusal text is unchanged.

The local describe block went with it: tests/ipc-validation.test.ts
already pins every rule for the shared function, including the two
extensions the .zip suffix regression cost us.
The same walk was written twice, once in pathPolicy.ts for managed paths
and once in workers/extraction.ts for the eight worker call sites. Both
came from the fork seed, so neither was a chosen split, and a guard held
in two copies is a guard that can be tightened on one side only.

It lives in validation.ts now, which both sides already import and which
stays Electron-free. The refusal takes the caller's wording, so the path
policy keeps its "for managed paths" text, and it is a TypeError on both
sides now; nothing anywhere branches on the class.
The Electron and the Node transport each carried their own Content-Length
pre-check, status check and streamed byte-cap loop. Two transports is
deliberate (#76); two reading loops was not, and they had drifted: only
the Electron copy threw the typed BoundedResponseError that #442 added
for the release-notes classifier.

collectBounded now reads for both. The refusal messages are byte for byte
what they were, which netHandlers.fetchModDbListingArchive and the login
classifier both match on, and a non-2xx from either transport is now the
typed error.

loginFailureReason reads the status off that error instead of parsing the
digits back out of the message with an anchored regex. Same tokens for
every case the transport can produce, including a response with no status
line, which still lands on http-other. The module stays Electron-free:
the property is read structurally, the way codeOf already reads code.
The tests that stood in for the transport build the typed shape now.
@Pixnop
Pixnop requested a review from Zaldaryon September 20, 2026 10:50
loginFailureReason reads statusCode now instead of parsing the message,
so the message match alone no longer pins the login chain end to end: a
transport that answered a plain Error with the same text would send a
503 outage to unclassified and put the generic "Login failed" on screen
where the player should read that the service is down. The 404 case now
asserts the property as well as the message.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant