refactor(backups): drop node-7z and 7zip-bin by moving backups to gzipped tar - #274
refactor(backups): drop node-7z and 7zip-bin by moving backups to gzipped tar#274Pixnop wants to merge 8 commits into
Conversation
The backup writer was the only thing left in the launcher that needed a 7-Zip process. It now goes through the tar package the game archives are already read with, so a backup is written in process rather than by spawning a binary and parsing what it prints. The compressionLevel the config carries keeps its meaning: zlib's gzip takes the same 0 to 9 scale, so the number reaches the writer unchanged and level 0 still stores rather than deflates. Progress reporting keeps the shape the worker protocol expects. The safety walk over the source tree now also totals the bytes it sees, and each entry written moves the figure, capped a point short of the single terminal 100 the caller emits at the end. The tests drive real archives instead of a stand-in for the 7-Zip call, which is what makes the compression level and the archive's own shape assertable rather than taken on trust.
Every backup made up to now is a zip, and those have to keep restoring for as long as players still hold them. yauzl already reads mod archives and already reads a zip's table of contents before extraction, so the restore gets a yauzl unpacking path and the zip writer is what goes away. Two formats reach the launcher now and no others. validateArchive routes gzipped tar to the tar reader and zip to yauzl, and refuses anything else by name rather than handing it to a reader that would have to guess. The hand-written parse of 7-Zip's -slt listing text, which nothing could reach any more, goes with it. The single-wrapping-folder flattening moves from being inferred from the file extension to being asked for. It was only ever meant for the Linux game archives, and now that backups are gzipped tar too, inferring it would flatten the restore of any installation whose only entry happens to be a folder. Two committed fixtures back this: a zip backup shaped like the ones the launcher used to write, restored and compared byte for byte, and one whose entry names climb out with "..", name a drive, and give an absolute path. Nothing lands outside the destination for any of them.
Nothing spawns 7-Zip any more, so the three packages and the six 7za binaries they ship go. That takes the bundled binaries out of app.asar.unpacked, takes the asarUnpack entry that put them there out of the builder config, and takes the executable-bit repair out of the postinstall script. The script itself stays: its other half downloads the Electron binary that a plain npm ci no longer fetches, which has nothing to do with 7-Zip.
None of them turned out to be a Windows bug in the launcher. The twelve EXECUTE_GAME failures all came from one fixture assumption: every test in gameHandlers.test.ts writes a game binary called "Vintagestory", and buildGameLaunchPlan only ever looks for "Vintagestory.exe" on Windows. So the folder held no game, the handler answered no-executable, and every outcome those tests were written for went unreached, adoption included. Renaming the file by platform in one place restores all twelve, and the two that looked like they diverged into session adoption with mismatched uids were the same cascade one level further down: with no launch there is no session write, so nothing was there to adopt. Reproduced on Linux by pointing the same helper at a name the launcher does not know, which fails all twelve with the exact Windows messages, mismatched uids and all. Three tests in the same file cannot work on Windows whatever the binary is called. Two make a write fail by taking write permission off the installation folder and one makes a folder unlistable with chmod 0o000; NTFS has no such mode bits, so the write lands and the folder lists. They skip there with the reason on them. The three CHANGE_PERMS failures share one cause too: the handler returns false on anything that is not Linux before it looks at its arguments, which is right, since POSIX mode bits are the only thing it has to apply. So the two validation tests get no throw to catch and the worker test waits for a worker that is never started. Also reproduced on Linux, by making that early return fire here. Of the two in extraction.test.ts, one asked the filesystem whether "vintagestory" exists to prove the wrapping folder was flattened away, which on a case-insensitive filesystem answers about the "Vintagestory" file sitting next to it. The full listing on the line above already says it, and says it better, since an extra folder could not hide from it either; breaking the flattening still fails the test with that line gone. The other spends two 7-Zip processes on a 2000 file archive and runs past the five second default on a Windows runner. Nothing in the coalescing it covers is platform-specific, and #274 replaces the test with a yauzl one that spawns nothing, so it skips on Windows for now.
Zaldaryon
left a comment
There was a problem hiding this comment.
Requesting changes on one point. The refactor itself is clean: the flatten flip is behaviour preserving, the removed 7z -slt branch really was unreachable, the dependency and config removals are complete, and the two validation gates plus the fresh temp dir hold up against zip slip on both readers. Local gates pass here too (typecheck, lint:ci at 0 errors and the same 15 pre-existing warnings, format:check, test:coverage at 92.44 statements / 89.79 branches / 92.07 functions / 93.89 lines, all over the floors).
Blocking
A .tar.gz restore that fails partway through reports success, and the restore then deletes the user's only other copy.
src/ipc/workers/extraction.ts extractTarGz builds tar.extract with no strict and no warn handler. In node-tar, [ONERROR] (unpack.js) emits 'error' only for CwdError. Every per-entry write failure from [FILE] (ENOSPC, EACCES, EMFILE, an OS-rejected name) goes through this.warn('TAR_ENTRY_ERROR', ...), which, without strict, emits 'warn', then entry.resume() and continues. The extractTarGz promise only listens for unpacker.on("error") (CwdError) and unpacker.on("close", () => finish(unsafeEntry)), where unsafeEntry is set only by the type filter. So a truncated extraction resolves as { ok: true }.
runExtraction then runs validateTree (which only sees what landed) and copyTree, and src/domain/installations/restore.ts moves the original to replacedPath, moves the truncated tree into place, and calls discard(ports, events, replacedPath).
Failure scenario: a user restores a multi-GB backup. runExtraction unpacks into mkdtempSync(join(tmpdir(), "riftlauncher-extract-")). On a /tmp tmpfs sized at half of RAM, it fills partway through. The remaining entries fail with ENOSPC and are warned and skipped. copyTree writes the truncated tree into stagingPath (a sibling of the installation, on a different filesystem, so it succeeds), the swap completes, and the pre-restore installation is deleted.
This is unchanged from base, but base only ran game installs through the tar path, where a truncated result is caught downstream and is re-downloadable. Backup restores went through the zip reader (and before that a 7-Zip child), both of which fail closed. This PR moves every new-format backup restore onto the tar reader, into the one flow that deletes the only other copy.
Fix: pass strict: true to tar.extract, or add unpacker.on("warn", (code) => { if (code === "TAR_ENTRY_ERROR" && !unsafeEntry) unsafeEntry = new Error("Extraction failed") }) so close rejects. Test: extract into a destination made unwritable (or a filter that throws on the Nth entry) and assert runExtraction rejects and no swap happens.
Worth fixing in the same pass, not blocking
-
extractZipnever tears down its streams on failure.src/ipc/workers/extraction.ts:finish()closeszipFileonly. On areadStreamorwriteStream"error"it settles the promise but destroys neither, andrunExtraction'sfinallythen runsfse.removeSync(temporaryRoot)over the open write fd.extractTarGz, eleven lines up, handles exactly this withreader.unpipe(); reader.destroy(); unpacker.abort(error)and a comment saying why. On Windows a corrupt deflate entry in a legacy backup givesEBUSYfromremoveSync, which replaces the real error and leaks the temp folder. HoistreadStream/writeStreaminto the closure and destroy them infinish. -
A failed
tar.createleaves a partial.tar.gzon disk.src/ipc/workers/compression.ts:tar.create({ file: archivePath, ... })opens the file immediately; thecatchrethrows without removing it.makeInstallationBackupreturnsrefuse("compress-failed")with no record, so the truncated archive sits in the backups folder invisible topruneOldestBackups(it walksinstallation.backups) and accumulates across retries.try { await tar.create(...) } catch { fse.removeSync(archivePath); throw ... }. -
assertSafeCompressionTreenow returns the byte total but nothing checks it againstMAX_ARCHIVE_TOTAL_BYTES. An installation over 2 GiB produces a backup thatvalidateTarGzArchivewill always refuse on restore, surfaced only as the generic restore error. Base had the same gap, but the total is now in hand, so refusing inrunCompressionwith a message the backup UI can render is a one-liner. -
Legacy zip restores drop unix mode bits on macOS.
extractZipuses plaincreateWriteStream(target)and readsexternalFileAttributesonly for the symlink check. Linux is covered by thechangePerms([outputPath], 0o755)call after every extraction; macOS returns early from that handler. Bounded, since a macOS game version cannot be launched yet, but new.tar.gzbackups keep their modes and legacy zips silently do not. Either chmod from the archived mode after the write, or say in the comment that legacy-zip modes are deliberately not preserved.
Minor
- Progress fidelity.
compression.tsonWriteEntrycounts a file's full size when its header is emitted, before the body streams, so an installation dominated by one large file jumps to 99 immediately and sits there. The old 7-Zip$progresswas byte accurate. Monotonicity, dedup and the single terminal 100 all still hold. tests/fixtures/build-fixtures.tscomment forhostile-backup.zipis wrong. It says theC:/escaped-drive.txtentry "is the one yauzl itself lets through". yauzl'svalidateFileNamerefuses/^[a-zA-Z]:/the same as a leading/or a..segment, andtests/ipc/extraction.test.tscorrectly asserts/could not be read/for this fixture. All three entries are stopped by yauzl, sovalidateZipArchive's ownisSafeArchiveEntrygate is never exercised by a real archive. Fix the comment; a fixture that actually reaches that gate needs a name yauzl accepts but the launcher refuses.- Stale 7-Zip references the sweep missed:
src/ipc/handlers/pathsHandlers.ts(thearchiveConcurrencyrationale is still written around "concurrent 7-Zip processes"; the bound now guards in-process CPU and zlib),src/ipc/pathPolicy.ts("Every archive the launcher makes is a single.zipfile"), anddocs/decisions/0001-shell-and-codebase.md(still lists7zip-binin the production tree and the six7zabinaries).docs/vintage-story-quirks.mdwas updated; these were not.
Not checked here
Windows and macOS behaviour (CI runs test on ubuntu only, per #267, so findings 1 and 4 above and the extractZip teardown are all unexercised there), a real timing on a large backup (single-threaded zlib replacing mt=on 7-Zip deflate), and whether any macOS game build is ever published as something other than .tar.gz/.tgz/.zip, which base handled by falling back to 7-Zip and this now hard-refuses.
node-tar reports a per-entry write failure as a warning, skips the entry and closes the stream cleanly, so a restore that filled the disk halfway through came back looking like a whole one. runExtraction then validated only what had landed and the restore swapped the truncated tree in and deleted the copy it replaced. strict makes those failures errors, so the extraction rejects and the restore stops before it moves anything. It also covers a corrupt entry header, dropped just as quietly until now. The two other warnings it turns fatal are already refused earlier, by the entry type filter and by the table-of-contents pass. The zip reader gets the teardown the tar reader already had: a failure now unpipes and destroys the streams the entry was moving through, so the temporary folder is not removed out from under an open write handle.
tar opens the archive as soon as it starts, so a write that failed partway through left a truncated .tar.gz in the backups folder. No backup record names it, and pruning only walks the records, so it stayed there for good and every retry added another one beside it.
|
You are right about the restore path, and thank you for tracing it all the way to the The blocking oneI took Everything else strict makes fatal is already refused before it can happen, which is the part I wanted to be sure of before picking it. The test builds an archive whose second entry cannot be written. Taking The two smaller ones
A failed Items 3 and 4 and the minor list are untouched here. Gates
|
The reader holds an archive to a 2 GiB total and refuses anything past it, so an installation over that cap compressed happily into a backup that could never be put back. The walk already had the total in hand. Refusing costs the player a failed backup. Not refusing cost them a backup they would only discover was useless on the day they needed it, plus a prune slot, since pruning runs before the archive is written and an older restorable backup had already been deleted to make room.
hostile-backup.zip never reached isSafeArchiveEntry. yauzl's own validateFileName refuses a drive letter exactly as it refuses a leading slash or a ".." segment, so all three of its names stop the read before the launcher's gate has a say, and the comment claiming otherwise was wrong. A NUL byte in the middle of a name is one yauzl has nothing to say about, so unsafe-name-backup.zip is the archive that gets there. Without it, deleting that check from validateZipArchive failed no test in the suite.
The concurrency limiter and the worker timeout in pathsHandlers.ts were written around 7-Zip subprocesses, pathPolicy.ts still said every archive the launcher makes is a zip, isTarGzName explained itself in terms of what 7-Zip could not read, and the default compression level was described as 7-Zip's. ADR 0001 keeps its measurements. They were taken on a stated day to support a decision that is still pending, so it gets a dated note saying the 7-Zip lines no longer hold rather than an edit that would put today's tree into yesterday's argument. Also says outright, where the zip reader is, that a legacy backup's unix modes are deliberately not restored.
|
Second pass, working through the rest of the review. Three of the remaining points are fixed, two are answered rather than changed, and one is fixed differently from the way you suggested. Reasoning for each below. 3. The size the reader will not acceptFixed, and I think this one was closer to the blocking finding than its position in the list suggests. Refusing an oversized source in What makes it worse than "a restore that fails with a generic error" is the ordering in Two things I did not do. The prune-before-write ordering itself is untouched: pruning first is what keeps the folder from holding one more archive than the limit at its peak, and changing it is a backup-semantics decision that wants its own change rather than a line in this one. And the message does not reach the UI, because it cannot: The test uses a sparse file: 3 GiB by every stat, no blocks on disk, so it costs nothing and skips on Windows where truncate is not sparse. Removing the check fails it, and takes four seconds doing it, which is the run the guard now avoids. 4. Unix modes on a legacy zip restoreTaken as the second option you offered, documented rather than implemented, and I want to say why rather than just point at the comment. On Linux nothing preserves modes today, including the tar path: Against that, restoring a mode read out of an archive written by a tool the launcher no longer ships is not free. A zip made on Windows carries 0 in the upper half of Progress fidelityNot changed, and I think it should stay as it is. The accurate fix is byte accurate, and the only clean way to get there is a counting stream interposed in the pack pipeline, since The fixture commentYou were right, and the fixture was hiding more than a wrong sentence. yauzl's I also took your suggestion for a name yauzl accepts but the launcher refuses. A NUL byte in the middle of a name is one: The rebuild also produced a one byte change in The stale sweepDone, plus three the list did not name: the worker timeout comment in ADR 0001 I handled differently, and tell me if you disagree. It is dated, its status is proposed, it says outright that every number in it was measured on 2026-08-16, and Option A's cost paragraph argues partly from the Gates
|
Summary
The launcher's own backups were the last thing that needed a 7-Zip process, so this moves them off zip and takes the whole 7-Zip dependency chain out with them. New backups are written as gzipped tar through the
tarpackage the game archives are already read with, which means the writing happens in process instead of by spawning a binary and reading what it prints back. ThecompressionLevelin the config keeps its meaning exactly: zlib's gzip takes the same 0 to 9 scale, the number reaches the writer untouched, and level 0 still stores rather than deflates. Progress reporting keeps the shape the worker protocol expects, a leading 0 from the handler, deduplicated and monotonic figures from the writer capped a point short of the end, and exactly one terminal 100.Every backup a player already has is a zip, and those have to keep restoring forever, so the zip reader stays while the zip writer goes. yauzl already reads mod archives and already reads a zip's table of contents before extraction, so the restore path gets a yauzl unpacking branch alongside the existing tar one. Two formats now reach the launcher and no others, and
validateArchiverefuses anything else by name rather than handing it to a reader that would have to guess. One thing had to change along with the format: the single-wrapping-folder flattening used to be inferred from the file extension, which only worked while backups were the zips and game builds were the tar.gz files. Now that both are gzipped tar, the flattening is asked for by the caller instead. The game version install asks for it, the backup restore does not, and a backup whose only entry happens to be a folder is no longer at risk of being unpacked one level too shallow.Deleted
Three dependencies (
node-7z,7zip-bin,@types/node-7z), which took 76 lines out of the lockfile and six7zabinaries out of the package. TheasarUnpackentry that shipped them, the executable-bit repair inscripts/fix-native-deps.js(21 lines plus the paragraph explaining it: the script itself stays, because its other half downloads the Electron binary that a plainnpm cino longer fetches, which has nothing to do with 7-Zip), the 7-Zip spawn in the extraction worker (19 lines), and the hand-written parse of7z l -sltlisting text inarchiveValidation.ts(74 lines). The audit's claim that the-sltparse was unreachable holds up:EXTRACT_ON_PATHhas exactly two callers, the game version install, which passes a.tar.gzon Linux and macOS and routes Windows to the installer reader instead, and the backup restore, which passes a.zip. Neither could ever reach the third branch.Size
A Linux
--dirbuild goes from 367,178,968 bytes to 357,180,720, so 9,998,248 bytes smaller, around 9.5 MiB or 2.7 percent. Most of that is the 9,191,070 bytes of7zip-binthat used to sit inapp.asar.unpacked; the rest isnode-7zand its own dependencies inside the asar. Both numbers come fromnpm run build:unpackon the same machine, measured withdu -sb.Testing
Everything below ran on Linux (Manjaro, kernel 6.18.45, Node 22). CI runs the same suite on ubuntu only, per #267, so nothing here has been exercised on Windows or macOS. The parts most likely to behave differently there are the path comparisons in the zip writer's destination check, which is why that check is unit tested against Windows separators and drive letters directly rather than only through a real archive.
npm ciclean.npm run typecheck,npm run lint:ci(0 errors, 15 warnings, all pre-existing) andnpm run format:checkall pass.npm run test:coverageis 137 files, 1643 passing and 2 skipped, at 92.46 percent statements, 89.83 branches, 92.07 functions and 93.89 lines, every floor invitest.config.tsclear.npm run build:unpacksucceeds and the output contains no 7-Zip binary, checked by searching the packaged tree for anything named after it.The round trip is covered both ways. A fixture installation is compressed and restored, and the restored tree is compared file by file against the original, including a zero byte file and a nested folder. A committed zip fixture shaped like the backups the launcher used to write is restored and compared the same way, so the legacy path is held by an archive rather than by a mock. An empty installation round trips to an empty folder rather than to a failed backup.
The path safety on restore is pinned at both gates. A second committed fixture carries three entry names that point outside the folder they would be unpacked into: one climbing with
.., one absolute, and one naming a Windows drive. The archive is refused before the output folder is even created, and the test asserts that none of the three landed anywhere. The writer's own resolved-path check is unit tested directly, since yauzl's own name validation refuses these archives first and would otherwise hide it. The tar path gets the same treatment with a hostile archive built in the test.Pruning, the
isRestoringandisDeletingguards, and the backup adapter's verdicts are untouched; only the file extension in their fixtures changed.Four deliberate breakages, each confirmed to fail tests: removing the legacy zip branch from
validateArchivefails 8 tests, removing the escape check from the zip writer fails its unit test, removing the terminal 100 fails 2 compression tests and 2 extraction tests, and flipping the unwrap default to true fails the test that says a backup is never flattened.Related issues
Addresses the cut proposed in #222, taking the option the issue itself recommended: new backups switch to gzipped tar, old zips stay readable.