feat(install): stamp username into account_name.txt after install

Some games ship an `account_name.txt` file somewhere under the unpacked
`local/` tree (location varies per game). After install or update, write
the configured username into the first such file we find so the game
launches under the user's account instead of whatever default the
archive contains.

The search is a deterministic alphabetical DFS rooted at the install
staging dir (`.local.installing/`, which becomes `local/` on rename),
stopping at the first regular-file match. Symlinks named
`account_name.txt` are skipped (`is_file()` is false for symlinks on
Linux), so a hostile archive can't redirect the write outside the game
tree. If no `account_name.txt` exists anywhere in the install, the step
is a no-op. If the write fails, the existing install rollback (cleanup
of staging on fresh installs, restore from backup on updates) handles
it — no partial state is left behind.

The username flows from the Tauri layer, where it is already sanitized
by `sanitize_username`, down through `PeerCommand` variants
(`InstallGame`, `DownloadGameFiles`, `DownloadGameFilesWithOptions`)
into `install`/`update`, which now take an `Option<&str> account_name`.
For the "install game that isn't downloaded yet" path the username has
to bridge the async gap between the `GetGame` / `FetchLatestFromPeers`
request and the eventual `GotGameFiles` event; we park it in a
per-game-id map on `LanSpreadState` and pop it when forwarding the
download command. The map is also cleared defensively on
`cancel_download`, `DownloadGameFilesFailed`, and
`DownloadGameFilesAllPeersGone` so a stale entry can't bleed into a
subsequent install with a different username.

`PeerCommand` is the in-process command channel, not the wire protocol;
no on-wire types changed, so the "one wire version" policy is
preserved. The peer-cli harness keeps passing `account_name: None`
since it tests peer interop, not user-facing settings.

# Test Plan

Unit tests in `crates/lanspread-peer/src/install/transaction.rs`:
- `install_overwrites_first_account_name_file` — unpacker creates
  `a/account_name.txt` and `z/account_name.txt`; after install with
  username "Alice", `a/` is overwritten and `z/` is left untouched,
  pinning the sorted-DFS "first match wins" behavior.
- `install_account_name_missing_file_is_noop` — install with a
  username but no `account_name.txt` anywhere in the archive
  succeeds and creates no spurious file.

Manual GUI check: in Settings, set a username; install a game whose
archive contains `account_name.txt`; open `local/` and confirm the
file now holds the configured username. Repeat for the update flow
(install, change username, click update).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 20:56:42 +02:00
parent 2e7a0cff2f
commit 574acfca45
6 changed files with 266 additions and 34 deletions
@@ -1,5 +1,6 @@
use std::{
collections::HashSet,
ffi::OsStr,
io::ErrorKind,
path::{Path, PathBuf},
sync::Arc,
@@ -19,6 +20,7 @@ const BACKUP_DIR: &str = ".local.backup";
const OWNED_MARKER: &str = ".lanspread_owned";
const VERSION_TMP_FILE: &str = ".version.ini.tmp";
const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded";
const ACCOUNT_NAME_FILE: &str = "account_name.txt";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FsEntryState {
@@ -38,6 +40,7 @@ pub async fn install(
state_dir: &Path,
id: &str,
unpacker: Arc<dyn Unpacker>,
account_name: Option<&str>,
) -> eyre::Result<()> {
let eti_version = read_downloaded_version(game_root).await;
write_intent(
@@ -47,7 +50,7 @@ pub async fn install(
)
.await?;
let result = install_inner(game_root, id, unpacker).await;
let result = install_inner(game_root, id, unpacker, account_name).await;
match result {
Ok(()) => {
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
@@ -71,6 +74,7 @@ pub async fn update(
state_dir: &Path,
id: &str,
unpacker: Arc<dyn Unpacker>,
account_name: Option<&str>,
) -> eyre::Result<()> {
let eti_version = read_downloaded_version(game_root).await;
write_intent(
@@ -80,7 +84,7 @@ pub async fn update(
)
.await?;
let result = update_inner(game_root, id, unpacker).await;
let result = update_inner(game_root, id, unpacker, account_name).await;
match result {
Ok(()) => {
write_intent(state_dir, id, &InstallIntent::none(id, eti_version)).await?;
@@ -188,6 +192,7 @@ async fn install_inner(
game_root: &Path,
id: &str,
unpacker: Arc<dyn Unpacker>,
account_name: Option<&str>,
) -> eyre::Result<()> {
let local = local_dir(game_root);
if path_is_dir(&local).await {
@@ -197,13 +202,19 @@ async fn install_inner(
let staging = installing_dir(game_root);
prepare_owned_empty_dir(&staging).await?;
unpack_archives(game_root, &staging, unpacker).await?;
write_account_name_if_present(&staging, account_name).await?;
tokio::fs::rename(&staging, &local)
.await
.wrap_err_with(|| format!("failed to promote install for {id}"))?;
Ok(())
}
async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -> eyre::Result<()> {
async fn update_inner(
game_root: &Path,
id: &str,
unpacker: Arc<dyn Unpacker>,
account_name: Option<&str>,
) -> eyre::Result<()> {
let local = local_dir(game_root);
let backup = backup_dir(game_root);
let staging = installing_dir(game_root);
@@ -219,6 +230,7 @@ async fn update_inner(game_root: &Path, id: &str, unpacker: Arc<dyn Unpacker>) -
prepare_owned_empty_dir(&staging).await?;
unpack_archives(game_root, &staging, unpacker).await?;
write_account_name_if_present(&staging, account_name).await?;
tokio::fs::rename(&staging, &local)
.await
.wrap_err_with(|| format!("failed to promote update for {id}"))?;
@@ -272,6 +284,48 @@ async fn root_eti_archives(game_root: &Path) -> eyre::Result<Vec<PathBuf>> {
Ok(archives)
}
async fn write_account_name_if_present(
install_root: &Path,
account_name: Option<&str>,
) -> eyre::Result<()> {
let Some(account_name) = account_name else {
return Ok(());
};
let Some(path) = find_account_name_file(install_root).await? else {
return Ok(());
};
tokio::fs::write(&path, account_name)
.await
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
Ok(())
}
async fn find_account_name_file(root: &Path) -> eyre::Result<Option<PathBuf>> {
let mut pending_dirs = vec![root.to_path_buf()];
while let Some(dir) = pending_dirs.pop() {
let mut entries = tokio::fs::read_dir(&dir).await?;
let mut child_dirs = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let file_type = entry.file_type().await?;
let path = entry.path();
if entry.file_name() == OsStr::new(ACCOUNT_NAME_FILE) && file_type.is_file() {
return Ok(Some(path));
}
if file_type.is_dir() {
child_dirs.push(path);
}
}
child_dirs.sort();
child_dirs.reverse();
pending_dirs.extend(child_dirs);
}
Ok(None)
}
async fn recover_none_intent(game_root: &Path) -> eyre::Result<()> {
sweep_owned_orphan(&installing_dir(game_root)).await?;
sweep_owned_orphan(&backup_dir(game_root)).await?;
@@ -576,7 +630,7 @@ mod tests {
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
install(&root, state.path(), "game", successful_unpacker())
install(&root, state.path(), "game", successful_unpacker(), None)
.await
.expect("install should succeed");
@@ -586,6 +640,28 @@ mod tests {
assert_eq!(intent.state, InstallIntentState::None);
}
#[tokio::test]
async fn install_account_name_missing_file_is_noop() {
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
install(
&root,
state.path(),
"game",
successful_unpacker(),
Some("Alice"),
)
.await
.expect("install should succeed without account file");
assert!(root.join("local").join("payload.txt").is_file());
assert!(!root.join("local").join(ACCOUNT_NAME_FILE).exists());
}
#[tokio::test]
async fn install_unpacks_multiple_root_eti_archives_in_sorted_order() {
let temp = TempDir::new("lanspread-install");
@@ -596,7 +672,7 @@ mod tests {
write_file(&root.join("version.ini"), b"20250101");
let unpacker = Arc::new(FakeUnpacker::default());
install(&root, state.path(), "game", unpacker.clone())
install(&root, state.path(), "game", unpacker.clone(), None)
.await
.expect("install should succeed");
@@ -610,6 +686,50 @@ mod tests {
assert_eq!(archives, vec!["a.eti", "b.eti"]);
}
#[tokio::test]
async fn install_overwrites_first_account_name_file() {
struct AccountNameUnpacker;
impl Unpacker for AccountNameUnpacker {
fn unpack<'a>(&'a self, _archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
Box::pin(async move {
tokio::fs::create_dir_all(dest.join("a")).await?;
tokio::fs::create_dir_all(dest.join("z")).await?;
tokio::fs::write(dest.join("a").join(ACCOUNT_NAME_FILE), b"old-a").await?;
tokio::fs::write(dest.join("z").join(ACCOUNT_NAME_FILE), b"old-z").await?;
Ok(())
})
}
}
let temp = TempDir::new("lanspread-install");
let state = test_state();
let root = temp.game_root();
write_file(&root.join("game.eti"), b"archive");
write_file(&root.join("version.ini"), b"20250101");
install(
&root,
state.path(),
"game",
Arc::new(AccountNameUnpacker),
Some("Alice"),
)
.await
.expect("install should succeed");
assert_eq!(
std::fs::read_to_string(root.join("local").join("a").join(ACCOUNT_NAME_FILE))
.expect("first account file should be readable"),
"Alice"
);
assert_eq!(
std::fs::read_to_string(root.join("local").join("z").join(ACCOUNT_NAME_FILE))
.expect("second account file should be readable"),
"old-z"
);
}
#[tokio::test]
async fn update_failure_restores_previous_local() {
let temp = TempDir::new("lanspread-install");
@@ -624,6 +744,7 @@ mod tests {
state.path(),
"game",
Arc::new(FakeUnpacker::failing()),
None,
)
.await
.expect_err("update should fail");
@@ -650,6 +771,7 @@ mod tests {
state.path(),
"game",
Arc::new(FakeUnpacker::commit_conflict()),
None,
)
.await
.expect_err("update should fail at commit rename");
@@ -679,7 +801,7 @@ mod tests {
write_file(&root.join("version.ini"), b"20250101");
write_file(&root.join("local").join("old.txt"), b"old");
update(&root, state.path(), "game", successful_unpacker())
update(&root, state.path(), "game", successful_unpacker(), None)
.await
.expect("update should succeed");