diff --git a/DOGFOODING.md b/DOGFOODING.md index 9ac0e77..5194022 100644 --- a/DOGFOODING.md +++ b/DOGFOODING.md @@ -1065,7 +1065,7 @@ ls .scaffold/basecamp/profiles - `basecamp docs` prints the canonical project-compatibility rules, including per-profile `env_file`, `runtime_dir`, `log_file`, custom profile names, and per-platform `[repos.basecamp.attr]`. - First `basecamp setup` clones the pinned basecamp repo into a pin-isolated cache path, builds `basecamp` and `lgpm` via Nix, seeds `.scaffold/basecamp/profiles/alice/` and `.scaffold/basecamp/profiles/bob/`, and reports completion. - If `[repos.basecamp.attr]` is a per-platform map, setup uses the current host's attr and preserves the map plus scalar fallback on serialize. -- `basecamp doctor` reports the basecamp + lgpm binaries as present and both profiles as seeded; `--json` returns parseable JSON with the same checks. +- `basecamp doctor` reports the basecamp + lgpm binaries as present and both profiles as seeded; `--json` returns parseable JSON with the same checks. Immediately after a green first `setup` (before `basecamp modules`) that is four PASS rows — `basecamp binary`, `lgpm binary`, `basecamp profile alice`, `basecamp profile bob`. A doctor that summarizes `0 PASS` there is the regression: it leaves the user with no confirmation that `setup` actually landed. - Second `basecamp setup` is idempotent: pin unchanged → no rebuild reported, exit 0. - All commands run only inside the project; running them from outside the project must fail with the existing scaffold "not a logos-scaffold project" message. @@ -1150,9 +1150,11 @@ If your project does not auto-discover correctly, capture explicit sources: - An unresolvable dep fails fast with a targeted error naming the dep and the two user-side fixes (capture as a project source, or add `[modules.]` with `role = "dependency"`); no silent drop. - `basecamp modules --show` prints the captured set without mutating state. - `basecamp install` builds each project source (sibling `--override-input` rewrites apply for `path:../` inputs in multi-flake projects) and shells out to `lgpm` to install into both `alice` and `bob`. By default it logs to `.scaffold/logs/-install.log` and prints a one-line status; `--print-output` (or `LOGOS_SCAFFOLD_PRINT_OUTPUT=1`) streams nix output directly. -- `basecamp doctor` reports each profile's installed modules matching the captured set; drift between `[modules]` and on-disk profile state is flagged, not hidden. +- `basecamp doctor` reports each profile's installed modules matching the captured set; drift between `[modules]` and on-disk profile state is flagged, not hidden. Drift is compared on the *normalized* flake ref: `basecamp modules` persists in-project sources relatively (`path:.#lgx`) while discovery yields `path:/abs/root#lgx`, so a doctor that reports `basecamp drift: uncaptured` for a source already present in `[modules]` is comparing raw strings and is a false positive. - `basecamp paths --json` is pure path resolution: it emits parseable JSON for XDG config/data/cache, runtime dir, module/plugin dirs, launch state, log file, and env file without building or mutating anything. - Custom profile names launch like default profiles when they are a single safe path component; `env_file` is sourced before global/profile inline env, `runtime_dir` is exported as both `TMPDIR` and `XDG_RUNTIME_DIR`, and `--log-file` overrides the configured `log_file`. +- `launch` prepares the runtime dir before it scrubs or reinstalls anything, and refuses to use one that is a symlink, is not a directory, or is owned by another user; it creates it `0700` and tightens loose permissions on an existing one. The default sits in world-writable `/tmp` under a name derived from the project path, so a local attacker can claim it first — and whatever lands there holds the modules' `logos_token_*` sockets. A launch that follows a pre-planted symlink, or that scrubs the profile before discovering the runtime dir is unusable, is the regression. +- With no configured `runtime_dir`, every profile still gets one: `basecamp paths --json` reports `tmpdir` == `xdg_runtime_dir` == `/tmp/lgs--` (the hash scopes it to the project root, so two checkouts never share a temp root). This path is deliberately **outside** the project tree — `launch` leaves live `logos_token_*` Unix sockets in it, and `nix build path:#lgx` refuses to copy a socket (`file ... has an unsupported type`). An in-project temp root therefore broke every `basecamp install` / `basecamp launch` after the first launch, and made concurrent `alice` / `bob` launches fail against each other's live sockets. A `tmpdir` that resolves under `/.scaffold/` by default is that regression; so is any socket found by `find .scaffold -type s` after a launch. - `basecamp launch alice` kills any prior `logos_host` / `logos-basecamp` descendants for that profile, scrubs the profile's XDG dirs under `.scaffold/basecamp/profiles/alice/`, reinstalls each captured source for that profile, sets `XDG_{CONFIG,DATA,CACHE}_HOME` plus `LOGOS_PROFILE=alice`, and `exec`s basecamp. ### Failure Signals / Common Pitfalls @@ -1284,7 +1286,8 @@ test -e .scaffold/basecamp/profiles/alice/.scaffold-xdg-data/scratch/marker.txt - The `marker.txt` file surviving `launch alice` is a regression: clean-slate is the v1 contract. - A `launch` scrubbing a path outside the profile's XDG dirs is a severe safety regression — capture the offending path and stop. - An empty `[modules]` plus a `launch` that wipes the profile and leaves it empty is a real regression; the empty-modules bail must fire first. -- A custom `runtime_dir` on macOS that makes `/logos_token__` exceed the 104-byte Unix socket path budget is a dogfooding finding; keep custom values short, preferably under `/tmp`. +- A custom `runtime_dir` on macOS that makes `/logos_token__` exceed the 104-byte Unix socket path budget is a dogfooding finding; keep custom values short, preferably under `/tmp`. Note that a custom `runtime_dir` is resolved relative to the project root, so pointing it back inside the project re-creates the socket-in-the-flake-tree failure described in B2 — prefer an absolute path under `/tmp`. +- `launch` scrubs `xdg-data`, `xdg-cache`, and the legacy in-profile `xdg-tmp`. The last one matters for profiles first launched by an older scaffold, which left sockets under `/xdg-tmp`; without that scrub such a project can never build its own root flake again. ### Evidence to Capture diff --git a/src/commands/basecamp.rs b/src/commands/basecamp.rs index 56746e9..bceee34 100644 --- a/src/commands/basecamp.rs +++ b/src/commands/basecamp.rs @@ -448,6 +448,14 @@ fn cmd_basecamp_launch( bail!("no modules captured — run `logos-scaffold basecamp modules` before launching."); } + // Same fail-fast reasoning as the bail above: prepare the runtime dir + // before the scrub + reinstall, not after. A hostile or unusable one is + // then reported in milliseconds instead of after a multi-minute nix + // build, and without having already wiped the profile. + let runtime_dir = + resolve_profile_runtime_dir(&project.root, &profile, project.config.basecamp.as_ref()); + ensure_private_runtime_dir(&runtime_dir)?; + // Pre-seed in case a prior crash between scrub and re-seed left the profile // without its xdg subdirs; scrub assumes both exist. seed_profiles is // idempotent and cheap. @@ -491,12 +499,7 @@ fn cmd_basecamp_launch( } } - let runtime_dir = - resolve_profile_runtime_dir(&project.root, &profile, project.config.basecamp.as_ref()); - if let Some(rt) = &runtime_dir { - fs::create_dir_all(rt).with_context(|| format!("create runtime dir {}", rt.display()))?; - } - let mut env = launch_env(&profile_dir, &profile, runtime_dir.as_deref()); + let mut env = launch_env(&profile_dir, &profile, &runtime_dir); // Layer scaffold.toml-declared launch env on top of the scaffold-owned // base (#163): [basecamp.env_append] path joins, then the per-profile // env_file, then [basecamp.env] globals, then @@ -650,10 +653,11 @@ struct BasecampProfilePaths { xdg_config_home: String, xdg_data_home: String, xdg_cache_home: String, - /// `TMPDIR` launch would export (runtime_dir if resolved, else `xdg-tmp`). + /// `TMPDIR` launch would export. Same value as `xdg_runtime_dir`. tmpdir: String, - /// `XDG_RUNTIME_DIR` launch would export; `None` when no runtime_dir resolves. - xdg_runtime_dir: Option, + /// `XDG_RUNTIME_DIR` launch would export. Always resolves; see + /// [`resolve_profile_runtime_dir`]. + xdg_runtime_dir: String, modules_dir: String, plugins_dir: String, launch_state: String, @@ -676,9 +680,7 @@ fn cmd_basecamp_paths(project: Project, profile: String, json: bool) -> DynResul let (modules_dir, plugins_dir) = profile_modules_and_plugins(&profiles_root, &profile, basecamp_repo); let runtime_dir = resolve_profile_runtime_dir(&project.root, &profile, bc); - let tmpdir = runtime_dir - .clone() - .unwrap_or_else(|| profile_dir.join("xdg-tmp")); + let tmpdir = runtime_dir.clone(); let profile_cfg = bc.and_then(|c| c.profiles.get(&profile)); let log_file = profile_cfg .and_then(|p| p.log_file.as_deref()) @@ -695,7 +697,7 @@ fn cmd_basecamp_paths(project: Project, profile: String, json: bool) -> DynResul xdg_data_home: profile_dir.join("xdg-data").display().to_string(), xdg_cache_home: profile_dir.join("xdg-cache").display().to_string(), tmpdir: tmpdir.display().to_string(), - xdg_runtime_dir: runtime_dir.as_ref().map(|p| p.display().to_string()), + xdg_runtime_dir: runtime_dir.display().to_string(), modules_dir: modules_dir.display().to_string(), plugins_dir: plugins_dir.display().to_string(), launch_state: profile_dir.join("launch.state").display().to_string(), @@ -712,10 +714,7 @@ fn cmd_basecamp_paths(project: Project, profile: String, json: bool) -> DynResul println!(" xdg_data_home: {}", paths.xdg_data_home); println!(" xdg_cache_home: {}", paths.xdg_cache_home); println!(" tmpdir: {}", paths.tmpdir); - println!( - " xdg_runtime_dir: {}", - paths.xdg_runtime_dir.as_deref().unwrap_or("(unset)") - ); + println!(" xdg_runtime_dir: {}", paths.xdg_runtime_dir); println!(" modules_dir: {}", paths.modules_dir); println!(" plugins_dir: {}", paths.plugins_dir); println!(" launch_state: {}", paths.launch_state); @@ -729,13 +728,12 @@ fn cmd_basecamp_paths(project: Project, profile: String, json: bool) -> DynResul } /// Env map exported to the basecamp child on launch. Scaffold-owned names only; -/// module port-override vars are not yet registered. `runtime_dir`, when set, -/// becomes both `TMPDIR` and `XDG_RUNTIME_DIR`; otherwise `TMPDIR` falls back to -/// the in-profile `xdg-tmp`. +/// module port-override vars are not yet registered. `runtime_dir` becomes both +/// `TMPDIR` and `XDG_RUNTIME_DIR`. fn launch_env( profile_dir: &Path, profile_name: &str, - runtime_dir: Option<&Path>, + runtime_dir: &Path, ) -> BTreeMap { let mut env = BTreeMap::new(); env.insert( @@ -762,18 +760,8 @@ fn launch_env( // A short `runtime_dir` (e.g. `/tmp/lgs-`) additionally dodges the // macOS `sun_path == 104` Unix-socket path limit that the long in-profile // `xdg-tmp` path can exceed; it also sets `XDG_RUNTIME_DIR`. - match runtime_dir { - Some(rt) => { - env.insert("TMPDIR".into(), rt.as_os_str().to_owned()); - env.insert("XDG_RUNTIME_DIR".into(), rt.as_os_str().to_owned()); - } - None => { - env.insert( - "TMPDIR".into(), - profile_dir.join("xdg-tmp").into_os_string(), - ); - } - } + env.insert("TMPDIR".into(), runtime_dir.as_os_str().to_owned()); + env.insert("XDG_RUNTIME_DIR".into(), runtime_dir.as_os_str().to_owned()); env.insert("LOGOS_PROFILE".into(), profile_name.into()); env } @@ -881,23 +869,132 @@ fn absolutize(base: &Path, path: &Path) -> PathBuf { /// Resolve the per-profile runtime dir (`TMPDIR` / `XDG_RUNTIME_DIR` root). /// Precedence: configured `[basecamp.profiles.].runtime_dir` -/// (project-relative or absolute) > macOS default `/tmp/lgs-` > `None` -/// (Linux keeps the in-profile `xdg-tmp`). +/// (project-relative or absolute) > `/tmp/lgs--`. +/// +/// Always resolves — there is deliberately no `None` case. A profile without a +/// temp root of its own would fall back to a shared `/tmp`, which is the +/// cross-profile `logos_token_` collision #89 guards against; a +/// fallback *inside* the profile dir reintroduces the socket-in-the-flake-tree +/// build failure this default exists to avoid. Returning `PathBuf` keeps both +/// out of reach by construction. fn resolve_profile_runtime_dir( project_root: &Path, profile: &str, basecamp: Option<&BasecampConfig>, -) -> Option { +) -> PathBuf { if let Some(rt) = basecamp .and_then(|bc| bc.profiles.get(profile)) .and_then(|p| p.runtime_dir.as_deref()) { - return Some(project_root.join(rt)); + return project_root.join(rt); } - if cfg!(target_os = "macos") { - return Some(PathBuf::from(format!("/tmp/lgs-{profile}"))); + // Default: a short, project-scoped temp root OUTSIDE the project tree. + // + // It must live outside the project because a module project's flake is + // usually the project root itself, and `nix build path:#lgx` copies + // that whole tree into the store. `launch` leaves a live `logos_token_*` + // Unix socket in the profile's temp dir, and nix refuses to copy a socket + // ("file ... has an unsupported type"), so an in-project temp root made + // every `basecamp install` / `basecamp launch` after the first launch fail + // — and made concurrent alice/bob launches (B3) fail against each other's + // live sockets, which no amount of scrubbing can fix. + // + // The project-root hash keeps two checkouts from sharing a temp root, and + // the whole path stays far below the macOS `sun_path == 104` budget that + // the long in-profile path could exceed. + PathBuf::from(format!( + "/tmp/lgs-{}-{profile}", + project_root_tag(project_root) + )) +} + +/// Create the per-profile runtime dir as a private (0700) directory we own. +/// +/// The default runtime dir lives under `/tmp`, which is world-writable, and +/// its name is derived from the project path rather than a secret — so another +/// local user can pre-create it and wait. The sticky bit stops them removing a +/// dir we already own, but not claiming the name first. Whatever ends up there +/// holds the modules' `logos_token_*` sockets, so a hostile or merely +/// world-readable dir means another user can reach a running module. +/// +/// Three cases: +/// - Missing: create it with 0700 already set, so there is no window in which +/// it exists with umask-derived permissions. +/// - Ours: tighten to 0700 if the mode is loose. +/// - Someone else's: `set_permissions` fails with `EPERM`, which is the +/// ownership check — no `libc::getuid` needed. +/// +/// A symlink is rejected outright rather than followed, so a pre-planted link +/// cannot redirect the sockets somewhere else. +fn ensure_private_runtime_dir(dir: &Path) -> DynResult<()> { + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + + match fs::symlink_metadata(dir) { + Ok(meta) => { + if meta.file_type().is_symlink() { + bail!( + "refusing to use runtime dir {} — it is a symlink. Remove it, or point \ + `[basecamp.profiles.].runtime_dir` somewhere you control.", + dir.display() + ); + } + if !meta.is_dir() { + bail!( + "refusing to use runtime dir {} — it exists but is not a directory.", + dir.display() + ); + } + if meta.permissions().mode() & 0o077 != 0 { + fs::set_permissions(dir, fs::Permissions::from_mode(0o700)).with_context(|| { + format!( + "cannot restrict permissions on runtime dir {} — it is owned by another \ + user. Remove it, or set `[basecamp.profiles.].runtime_dir`.", + dir.display() + ) + })?; + } + } + Err(_) => { + if let Some(parent) = dir.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create {}", parent.display()))?; + } + fs::DirBuilder::new() + .mode(0o700) + .create(dir) + .with_context(|| format!("create runtime dir {}", dir.display()))?; + } } - None + + // Re-check after the fact: a racing swap between the stat above and the + // chmod would otherwise go unnoticed. + let meta = + fs::symlink_metadata(dir).with_context(|| format!("stat runtime dir {}", dir.display()))?; + if meta.file_type().is_symlink() || !meta.is_dir() { + bail!( + "runtime dir {} changed type while being prepared — refusing to launch.", + dir.display() + ); + } + Ok(()) +} + +/// Short, stable tag for a project root — first 8 hex of the sha256 of its +/// canonical path. Used to scope shared-temp paths per project. +fn project_root_tag(project_root: &Path) -> String { + use sha2::{Digest, Sha256}; + let canon = project_root + .canonicalize() + .unwrap_or_else(|_| project_root.to_path_buf()); + let mut hasher = Sha256::new(); + hasher.update(canon.as_os_str().as_encoded_bytes()); + let digest = hasher.finalize(); + use std::fmt::Write as _; + let mut tag = String::new(); + for byte in &digest[..4] { + let _ = write!(tag, "{byte:02x}"); + } + tag } /// `lgs basecamp develop ` — enter a module's Nix dev shell. @@ -1144,7 +1241,13 @@ fn scrub_profile_data_and_cache(project_root: &Path, profile_dir: &Path) -> DynR canon_safe.display() ); } - for xdg in ["xdg-data", "xdg-cache"] { + for xdg in ["xdg-data", "xdg-cache", "xdg-tmp"] { + // `xdg-tmp` is the legacy in-profile temp root. Newly launched + // profiles get a temp root outside the project (see + // `resolve_profile_runtime_dir`), but a profile launched by an older + // scaffold still has stale `logos_token_*` sockets here — and any + // socket inside the project tree breaks `nix build path:#...`. + // Scrubbing it lets such a project heal itself on the next launch. let dir = profile_dir.join(xdg); if dir.exists() { fs::remove_dir_all(&dir).with_context(|| format!("scrub {}", dir.display()))?; @@ -2902,12 +3005,17 @@ pub(crate) fn compute_module_drift(project: &Project) -> DynResult = project .config .modules .values() .filter(|e| e.role == ModuleRole::Project) - .map(|e| e.flake.clone()) + .map(|e| normalize_flake_ref(&project.root, &e.flake)) .collect(); let mut discovered_not_captured: Vec = discovered_project @@ -2985,6 +3093,57 @@ fn push_basecamp_doctor_rows(project: &Project, rows: &mut Vec` sockets collide. - let alice = launch_env(Path::new("/p/alice"), "alice", None); - let bob = launch_env(Path::new("/p/bob"), "bob", None); + // or their `logos_token_` sockets collide. The distinctness + // now originates in `resolve_profile_runtime_dir` (launch_env just + // exports what it is handed), so assert it at the source and then + // confirm launch_env propagates it to both names. + let tmp = tempdir().expect("tempdir"); + let alice_rt = resolve_profile_runtime_dir(tmp.path(), "alice", None); + let bob_rt = resolve_profile_runtime_dir(tmp.path(), "bob", None); + assert_ne!(alice_rt, bob_rt); + + let alice = launch_env(Path::new("/p/alice"), "alice", &alice_rt); + let bob = launch_env(Path::new("/p/bob"), "bob", &bob_rt); assert_ne!(alice.get("TMPDIR"), bob.get("TMPDIR")); + assert_ne!(alice.get("XDG_RUNTIME_DIR"), bob.get("XDG_RUNTIME_DIR")); } #[test] fn launch_env_runtime_dir_sets_tmpdir_and_xdg_runtime_dir() { let rt = Path::new("/tmp/lgs-alice"); - let env = launch_env(Path::new("/p/alice"), "alice", Some(rt)); + let env = launch_env(Path::new("/p/alice"), "alice", rt); assert_eq!( env.get("TMPDIR").unwrap(), &OsString::from("/tmp/lgs-alice") @@ -4449,10 +4627,138 @@ mod tests { // Configured path wins (project-relative -> joined to root) on any OS. assert_eq!( resolve_profile_runtime_dir(Path::new("/proj"), "alice", Some(&cfg)), - Some(PathBuf::from("/proj/run/alice")) + PathBuf::from("/proj/run/alice") ); } + #[test] + fn captured_relative_flake_ref_normalizes_to_the_discovered_form() { + // Regression: `basecamp modules` persists in-project sources + // relatively (`path:.#lgx`) while discovery yields the absolute + // `path:/abs/root#lgx`. `compute_module_drift` compared the raw + // strings, so a captured project-root flake was reported as + // permanently "uncaptured" drift. + let tmp = tempdir().expect("tempdir"); + let root = tmp.path(); + let canon = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + let discovered = flake_ref(&BasecampSource::Flake(format!( + "path:{}#lgx", + canon.display() + ))); + assert_eq!(normalize_flake_ref(root, "path:.#lgx"), discovered); + } + + #[test] + fn ensure_private_runtime_dir_creates_it_0700() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempdir().expect("tempdir"); + let dir = tmp.path().join("rt"); + ensure_private_runtime_dir(&dir).expect("create"); + let mode = fs::metadata(&dir).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o700, "got {:o}", mode & 0o777); + } + + #[test] + fn ensure_private_runtime_dir_tightens_loose_permissions() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempdir().expect("tempdir"); + let dir = tmp.path().join("rt"); + fs::create_dir_all(&dir).unwrap(); + fs::set_permissions(&dir, fs::Permissions::from_mode(0o777)).unwrap(); + + ensure_private_runtime_dir(&dir).expect("tighten"); + + let mode = fs::metadata(&dir).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0, "group/other bits survived: {:o}", mode); + } + + #[test] + fn ensure_private_runtime_dir_rejects_a_symlink() { + // A pre-planted symlink in world-writable /tmp must not be followed — + // it would redirect the modules' `logos_token_*` sockets. + let tmp = tempdir().expect("tempdir"); + let target = tmp.path().join("elsewhere"); + fs::create_dir_all(&target).unwrap(); + let link = tmp.path().join("rt"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let err = ensure_private_runtime_dir(&link).expect_err("symlink must be rejected"); + assert!(err.to_string().contains("symlink"), "got: {err}"); + } + + #[test] + fn ensure_private_runtime_dir_rejects_a_non_directory() { + let tmp = tempdir().expect("tempdir"); + let file = tmp.path().join("rt"); + fs::write(&file, b"x").unwrap(); + + let err = ensure_private_runtime_dir(&file).expect_err("file must be rejected"); + assert!(err.to_string().contains("not a directory"), "got: {err}"); + } + + #[test] + fn ensure_private_runtime_dir_is_idempotent() { + let tmp = tempdir().expect("tempdir"); + let dir = tmp.path().join("rt"); + ensure_private_runtime_dir(&dir).expect("first"); + fs::write(dir.join("logos_token_x"), b"x").unwrap(); + ensure_private_runtime_dir(&dir).expect("second"); + assert!(dir.join("logos_token_x").exists(), "must not wipe contents"); + } + + #[test] + fn default_runtime_dir_is_outside_the_project_tree() { + // Regression: the Linux default used to be the in-profile `xdg-tmp`. + // `launch` leaves Unix sockets there, and `nix build path:#lgx` + // refuses to copy a socket, so every install/launch after the first + // one failed for a project-root flake. + let tmp = tempdir().expect("tempdir"); + let root = tmp.path(); + let rt = resolve_profile_runtime_dir(root, "alice", None); + let canon_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + assert!( + !rt.starts_with(&canon_root), + "runtime dir {} must not live inside the project tree {}", + rt.display(), + canon_root.display() + ); + assert!( + rt.to_string_lossy().ends_with("-alice"), + "got {}", + rt.display() + ); + // Short enough for the macOS sun_path == 104 budget with room for a + // `logos_token__` leaf. + assert!(rt.as_os_str().len() < 60, "got {}", rt.display()); + } + + #[test] + fn default_runtime_dir_is_project_scoped() { + let a = tempdir().expect("tempdir"); + let b = tempdir().expect("tempdir"); + assert_ne!( + resolve_profile_runtime_dir(a.path(), "alice", None), + resolve_profile_runtime_dir(b.path(), "alice", None), + "two project roots must not share a temp root" + ); + } + + #[test] + fn scrub_removes_legacy_in_profile_tmp_dir() { + // Projects launched by an older scaffold still carry sockets under + // `/xdg-tmp`; scrubbing lets them heal on the next launch. + let tmp = tempdir().expect("tempdir"); + let root = tmp.path(); + let profile_dir = root.join(".scaffold/basecamp/profiles/alice"); + let legacy = profile_dir.join("xdg-tmp"); + fs::create_dir_all(&legacy).unwrap(); + fs::write(legacy.join("logos_token_pkg"), b"x").unwrap(); + + scrub_profile_data_and_cache(root, &profile_dir).expect("scrub"); + + assert!(!legacy.exists(), "legacy xdg-tmp must be scrubbed"); + } + #[test] fn scrub_removes_xdg_data_and_cache_but_keeps_config() { let tmp = tempdir().expect("tempdir");