Skip to content

Refuse symlinked and unowned legacy user data folders - #675

Open
Zaldaryon wants to merge 3 commits into
devfrom
fix/issue-583-symlinked-data-folder
Open

Zaldaryon wants to merge 3 commits into
devfrom
fix/issue-583-symlinked-data-folder

Conversation

@Zaldaryon

@Zaldaryon Zaldaryon commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Carry migration refusal details into the startup log and document how to retry after fixing an unsupported legacy folder. Check the resolved source folder's owner in portable mode and apply the same ownership rule to an existing default profile. Legacy folders and Icons links remain refused; source data stays untouched.

Config copies now use a bounded, nonblocking read of a regular file, up to 16 MiB. A dangling config link is skipped so Icons can still migrate. Added regressions for FIFO and device targets, oversized configs, direct Icons directory links, and default-profile ownership.

Type

  • Bug fix
  • Feature
  • Performance
  • Refactor or cleanup
  • Tests only
  • Docs or build

Checklist

  • Targets dev, not main.
  • npm run typecheck passes.
  • npm run lint:ci passes.
  • npm run format:check passes.
  • npm run test:coverage passes, coverage at or above the floor in vitest.config.ts.
  • npm run build:unpack passes.

Testing

Linux, Node 26.10.0, head 711438dc: all five repository gates passed. Lint reports 0 errors and 12 existing warnings in untouched files. Coverage suite: 281 files, 5,120 passed, 5 skipped; 94.43% statements, 90.58% branches, 95.41% functions, 96.37% lines. Migration/profile targeted tests: 59 passed, 2 skipped.

The packaged headless launcher started with a linked VS Launcher folder, logged the specific link/junction refusal, used an empty RiftLauncher profile, and left the source config unchanged. Windows was not run locally; final-head CI covers the Windows matrix.

Related issues

Fixes #583

@Zaldaryon
Zaldaryon marked this pull request as ready for review October 6, 2026 00:04
@Zaldaryon
Zaldaryon requested a review from Pixnop October 6, 2026 00:04

@Pixnop Pixnop left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran both paths against real folder layouts on Linux, dev against this branch, each time in a throwaway appData, and put the new checks through a mutation pass.

The config.json part is a real fix. On dev, a VS Launcher config.json that is an absolute link gets copied as a link, and the first settings save follows it: writeJsonAtomic goes through write-file-atomic, which resolves the link, so RiftLauncher rewrites the file VS Launcher reads. I saved a config after migrating on both: on dev the file behind the link ended up holding RiftLauncher's JSON, on this branch it was untouched and the new profile had a plain copy. A relative link (config.json -> config.real.json) was worse on dev, since the copied link dangles in the new folder and the player starts with no config at all. Here the content comes across.

Two things need to change before it goes in.

Blocking

1. The default path refuses without saying why, and never tries again

Both paths now refuse the same layouts, but they still answer differently, which is what #583 was about. The portable path stops with "RiftLauncher could not start" and gives the reason ("Legacy profile is not a folder: …", "Legacy profile belongs to another user: …"). The default path starts on an empty folder, and the reason is swallowed by the bare catch in setUpUserDataFolder. The log line is the generic one, "Could not copy the VS Launcher user data folder. Starting on an empty RiftLauncher folder." #583 asked for the same reason in the log on both paths.

Default profile, first launch:

VS Launcher folder dev this branch
a link to a folder the player owns config.json and Icons copied empty profile
a link to a root-owned folder Icons copied empty profile
a real folder owned by another uid config.json and Icons copied empty profile
Icons is a link, or holds a link to a file or a folder both copied, links kept as links empty profile

Nothing is lost: the VS Launcher folder and every link target came out unchanged, and no RiftLauncher.migrating was left behind. The trouble is afterwards. The empty RiftLauncher folder makes planUserDataMigration return use-existing on the next launch, so the copy is never tried again, whatever the comment in that catch says ("still there to try again from on the next run"). The player gets a launcher with none of their installations, nothing on screen, and a log line that doesn't help. The installation pages under docs/get-started/installation/ (the README, "Where RiftLauncher keeps its data" in linux.md, "Portable data folder" in windows.md) still promise the copy with no conditions.

Windows players get this as well. lstat reports a junction as a link, and the branch's own junction test ("refuses a symlinked VS Launcher folder and starts on an empty folder") runs and passes on the Windows CI leg. So a VS Launcher folder moved to another drive with mklink /J now leaves RiftLauncher empty, or keeps it from starting in portable mode.

What I'd like: the error message carried in UserDataSetup and printed by describeUserDataSetup, the comment fixed, and a sentence in the docs saying which VS Launcher folders are not copied and how to get the copy once that is fixed (move the new RiftLauncher folder aside and start again).

Before doing that, have another look at #583's other option for this one folder. It is only ever read, it sits in the player's own appData where only they (or an administrator) can write, and copyLegacyUserDataEntries already keeps links out of the new profile. Accepting the folder itself, linked or not and whoever owns it, while still refusing links inside it would remove both the empty start and the portable boot failure for the first three rows. I'm fine either way, as long as the default path says why it started empty.

2. The owner check on the portable source profile reads the link, not the folder it copies

setUpPortableUserDataFolder takes the owner from fse.lstatSync(currentProfilePath) and then copies from realpathSync.native(currentProfilePath). A linked RiftLauncher folder is supported on purpose (the "copies a linked source profile without writing through its symlink" test), and when it is one, the check reads the owner of the link, which is the player who made it, while the copy reads the target. Linked to a root-owned folder, the target was copied into the portable profile, as on dev. Linked to a folder owned by another uid holding a config.json and an account-secrets.json, both files were copied, as on dev.

The check also opens a new split between the two paths. A real RiftLauncher folder owned by another uid now stops the portable path with "Profile folder belongs to another user", while the default path keeps using that same folder (use-existing, here and on dev).

Taking the owner from fse.statSync instead (or from the realpathSync.native result) checks the folder that is actually copied, and the suite stays green with that change. For the default path, either give an existing RiftLauncher folder the same check or leave this one out of the PR, but the two paths should agree.

Not blocking

  • A config.json link target is read without checking what it is. A link to a FIFO blocks the migration for good (I killed it after 20 s, and the next launch blocked again). A link to /dev/zero is read until the process hits a 1 GiB memory cap. A 600 MiB file is read whole (680 MB peak) and written into the new profile, and a 3 GiB file only stops at Node's 2 GiB limit. None of this is new for the launcher as a whole, because on dev the link was copied as a link and the first readJSONSync in index.ts blocked or blew up the same way. But "safely as regular files" is not what the code checks. An fse.statSync(source).isFile() before the read turns the FIFO and /dev/zero cases into a plain refusal, and a size cap would cover the big files.
  • A dangling config.json link now fails the whole default migration, Icons included. Dev skipped the dangling link and copied Icons.
  • The two helpers were inserted between setUpUserDataFolder's doc comment and the function, so that comment, @param appDataPath and all, now documents assertTrustedLegacyProfile, and setUpUserDataFolder has none.
  • "Legacy profile is not a folder" is what a player reads for a link or junction pointing at a folder. "is a link or junction" would say what was refused.
  • The test "refuses a symlinked Icons folder in the default VS Launcher migration" plants a link inside Icons. Icons being a link itself is refused too (I checked), but no test covers it.

Validation

  • 35 layouts, each run on dev and on this branch, through selectUserDataFolder with a throwaway appData, then the first config read index.ts does, then a second launch. Folders owned by another account were real ones (a different uid), not a mocked getuid. Apart from the two layouts where I swapped the folder on purpose, the VS Launcher folder and its link targets were unchanged in every run.
  • The check and the copy are separate path lookups. Swapping the VS Launcher folder for a link between them goes unnoticed and the copy reads the link target. Only someone who can already write the player's appData can do that, so I'm not asking for anything there.
  • Hard links come across as independent files (new inode, source left with its two links) on both branches. Hard-linking another account's file into the folder is refused by the kernel (EPERM, with protected_hardlinks on).
  • Mutation: of 11 hand mutants, the 10 that remove or weaken one of the new checks all fail the two touched test files on Linux. Three of them (dropping the legacy owner check, the source profile owner check, or the config.json copy as a file) are caught only by tests that skip on Windows, so only the Ubuntu leg guards them. Switching the source profile check from lstat to stat, the fix in point 2, survives: nothing pins either behaviour.
  • The 8 new tests fail against dev's source and pass here.
  • The 14 Windows skips in the two files are the 11 skipIf(win32) tests and the Linux-only unreadable-folder test in userDataMigration.test.ts, 4 of them new (the default config.json link test and the three owner tests), plus the two existing marker tests in profileChoice.test.ts. On Windows the owner checks do nothing (process.getuid is undefined), so what Windows players get from this PR is: a junction or directory link at %APPDATA%\VSLauncher or inside Icons now stops the copy, a linked config.json (which needs Developer Mode or admin rights to create) is copied as a file with no Windows test behind it, and ownership is unchanged.
  • On this head: npm ci, npm run typecheck, npm run lint:ci (0 errors, the same 12 warnings as dev), npm run format:check, npm run test:coverage (280 files, 5048 passed, 4 skipped; 94.32% statements, 90.4% branches, 95.36% functions, 96.18% lines). The first full run had one failure in tests/ipc/compression.test.ts, unrelated to this PR: it passed 3 times out of 3 on its own on dev and here, and in the full rerun.
  • CI is green on every leg that ran. dev has not moved since the base commit, so it merges without conflicts. No locale strings are touched.

Requesting changes for points 1 and 2.

Verify that legacy user data folders and current portable profile folders are regular directories owned by the current user before migrating them. Refuse symlinks in Icons, copy config.json symlinks safely as regular files, and start on an empty folder when migration fails.

Fixes #583
@Zaldaryon
Zaldaryon force-pushed the fix/issue-583-symlinked-data-folder branch from 5920556 to d298246 Compare October 6, 2026 11:06
@Zaldaryon

Copy link
Copy Markdown
Collaborator Author

Addressed the review feedback in commit \d298246f:

  1. Failure reason reporting & docs:
    • Added \ ailureReason?: string\ to \UserDataSetup\ and formatted it in \describeUserDataSetup\ when outcome is \migration-failed.
    • Updated the catch comment in \setUpUserDataFolder\ explaining that removing the empty \RiftLauncher\ folder allows retrying once the issue is resolved.
    • Updated installation documentation in \docs/get-started/installation/README.md, \linux.md, and \windows.md\ detailing which legacy folders are not copied (symlinks, junctions, mismatched ownership) and how to retry by moving the newly created empty folder aside.
  2. Owner check on portable source profile:
    • In \setUpPortableUserDataFolder, \currentStats\ now checks \ se.statSync(sourceProfilePath)\ (the resolved real path) instead of \lstatSync\ on the link itself, ensuring the actual folder being copied is verified.
    • Added a test ensuring a symlinked source RiftLauncher profile whose target belongs to another user is rejected.
  3. Doc comment position & message clarification:
    • Moved \setUpUserDataFolder's doc comment from above \�ssertTrustedLegacyProfile\ down to \setUpUserDataFolder.
    • Updated \�ssertTrustedLegacyProfile's error message to explicitly state \Legacy profile is not a folder (link or junction).
  4. Rebased cleanly onto latest \dev.

@Zaldaryon

Copy link
Copy Markdown
Collaborator Author

The remaining migration feedback is in signed commit 711438dc. Both default and portable paths now check the owner of the resolved existing profile. Config copies require a regular file and use a bounded, nonblocking read with a 16 MiB limit, so FIFO and device targets are refused without blocking startup. A dangling config link is skipped while Icons still copy. A direct Icons directory link now has regression coverage in both paths.

The branch already carries the refusal reason in the startup log, documents the retry after moving the empty profile aside, and checks a linked portable source by its resolved target. I kept the documented refusal of linked legacy folders. The packaged headless launcher logged the specific link/junction reason, started on an empty profile, and left the legacy source unchanged.

On Linux with Node 26.10.0, typecheck, lint, formatting, coverage and the packaged build passed. The full suite has 5,120 passed and 5 skipped; coverage is 94.43% statements, 90.58% branches, 95.41% functions and 96.37% lines. Lint has 0 errors and 12 existing warnings in untouched files. Targeted migration/profile tests passed 59 cases with 2 platform skips.

@Zaldaryon
Zaldaryon requested a review from Pixnop October 6, 2026 11:54
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.

2 participants