Stop the migration eating the client's own config directory

`migrate_from_moho_dirs` renamed `~/.config/moho` into the data directory
whenever the data directory did not exist yet. That path is not this daemon's
any more: it is Electron's userData directory for the client, and on this
machine right now it holds Cookies, Crashpad, blob storage and the client's
preferences. Moving it away takes the client's entire stored state with it,
silently, and the client then starts with defaults and nothing to explain
why.

Nothing but luck was preventing it. The code skips when the destination
exists, and `~/.config/nobilis` happens to exist here - so a `--data-dir`
pointing somewhere new, a cleared daemon directory, or a fresh daemon dir on
an install that still has the client's, each would have done it. It was hit
live while setting up an isolated daemon, and only missed because that
directory had been created by hand first.

Checking the source for a marker like accounts.toml was the obvious repair
and does not work: on the one machine that would still have old daemon state
at that path, the old daemon's files and Electron's are in the *same
directory*, because the old daemon wrote where Electron now writes. A rename
takes both or neither.

So the config half is dropped rather than gated. Anyone who still has daemon
state there still has it - nothing deletes it, and accounts.toml and
matrix-crypto/ can be carried across by hand. The cache half stays: Electron
keeps its cache inside userData, so `~/.cache/moho` really is the old
daemon's and nobody else's.

The list of what moves is now a function of its own, so the test can assert
the thing that matters and is otherwise untestable without renaming
directories in somebody's home: that no migration ever names a source under
.config.

Closes #208.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-12 20:51:57 -04:00
co-authored by Claude Opus 5
parent bd33f8890c
commit 9e885a40b1
+81 -15
View File
@@ -77,29 +77,41 @@ fn parse_args() -> Options {
Options { data_dir, socket_path }
}
/// One-time move of this daemon's state from the directories it used while it
/// was still called `chatd` and lived inside the moho repository. Everything
/// here is genuinely irreplaceable - saved accounts and their credentials,
/// persisted scrollback, and above all the Matrix crypto store, whose device
/// keys cannot be regenerated: losing it means re-verifying every session and
/// permanently losing access to any encrypted history not backed up to the
/// homeserver.
/// One-time move of this daemon's cache from the directory it used while it
/// was still called `chatd` and lived inside the moho repository.
///
/// Runs before the singleton lock is taken, so nothing is holding files open
/// in either directory yet. Deliberately conservative: it only ever moves a
/// directory into a destination that does not exist, so a second run, a
/// partially-completed move, or a fresh install with no old data all reduce to
/// no-ops rather than clobbering anything.
///
/// The config half of this used to be here and is gone, because the path it
/// read is not this daemon's any more: `~/.config/moho` is Electron's own
/// userData directory for the client - Cookies, Crashpad, blob storage, and
/// the client's preferences - live on any normal install. Moving it into the
/// data directory takes the client's entire stored state away silently, and
/// the client then starts with defaults and no explanation.
///
/// Checking the source for a marker like `accounts.toml` first was the
/// obvious repair and does not work, because on the one machine that would
/// still have old daemon state there, the daemon's files and Electron's are
/// *in the same directory* - the old daemon wrote to the path Electron now
/// uses. A rename cannot take one and leave the other.
///
/// So it is dropped rather than gated. Anyone who still has daemon state at
/// `~/.config/moho` some years on still has it: nothing here deletes it, and
/// `accounts.toml` and `matrix-crypto/` can be moved across by hand. That is a
/// worse outcome for nobody in practice and a far better one than eating a
/// live directory, which is silent, and whose payload - saved credentials and
/// a Matrix crypto store - is exactly what this function was written to
/// protect.
fn migrate_from_moho_dirs(data_dir: &std::path::Path) {
let _ = data_dir;
let Some(home) = dirs::home_dir() else { return };
let cache_root = dirs::cache_dir().unwrap_or_else(|| home.join(".cache"));
let migrations = [
("config", home.join(".config").join("moho"), data_dir.to_path_buf()),
("cache", cache_root.join("moho"), cache_root.join("nobilis")),
];
for (label, old, new) in migrations {
for (label, old, new) in moho_dir_migrations(&cache_root) {
if !old.is_dir() || new.exists() {
continue;
}
@@ -110,8 +122,8 @@ fn migrate_from_moho_dirs(data_dir: &std::path::Path) {
}
}
// A plain rename: both paths are under the same root in every normal
// install, and unlike a copy it cannot leave two diverging copies of
// an account store behind if it fails halfway.
// install, and unlike a copy it cannot leave two diverging copies
// behind if it fails halfway.
match std::fs::rename(&old, &new) {
Ok(()) => tracing::info!("migrated {label} from {} to {}", old.display(), new.display()),
Err(e) => tracing::warn!(
@@ -124,6 +136,19 @@ fn migrate_from_moho_dirs(data_dir: &std::path::Path) {
}
}
/// Which directories this will move, and to where.
///
/// Split out from the move itself so the list can be asserted about without a
/// test that renames anything in somebody's home directory - and the thing
/// worth asserting is what is *not* in it.
fn moho_dir_migrations(
cache_root: &std::path::Path,
) -> Vec<(&'static str, std::path::PathBuf, std::path::PathBuf)> {
// Caches only. This one is safe to move where the config one was not:
// Electron keeps its cache inside its userData directory, not here.
vec![("cache", cache_root.join("moho"), cache_root.join("nobilis"))]
}
/// flock()-based singleton lock, same purpose as
/// daemon/nobilis/nobilis.c's acquire_singleton_lock(): prevent two nobilis
/// processes racing the same socket/account-store. The lock file's fd is
@@ -413,3 +438,44 @@ async fn shutdown_signal() {
_ = terminate => {}
}
}
#[cfg(test)]
mod tests {
use super::moho_dir_migrations;
/// The config half of this migration once renamed `~/.config/moho` into
/// the data directory. That is Electron's own userData directory for the
/// client - its cookies, its crash reports, its preferences - so any run
/// with a data directory that did not exist yet, a `--data-dir` pointing
/// somewhere new among them, took the client's whole stored state away
/// without saying anything.
///
/// Asserted as an absence, because that is what the fix is: no migration
/// may ever name a directory under `.config` as a source.
#[test]
fn nothing_under_config_is_ever_moved() {
let cache_root = std::path::Path::new("/home/somebody/.cache");
let sources: Vec<_> = moho_dir_migrations(cache_root).into_iter().map(|(_, old, _)| old).collect();
assert!(!sources.is_empty(), "the cache migration is still wanted");
for source in sources {
assert!(
!source.components().any(|c| c.as_os_str() == ".config"),
"{} is somebody else's directory to move",
source.display()
);
}
}
/// And the half that remains still does its job: Electron keeps its cache
/// inside userData rather than here, so this path really is the old
/// daemon's and nobody else's.
#[test]
fn the_old_cache_still_moves_to_the_new_one() {
let cache_root = std::path::Path::new("/home/somebody/.cache");
let moves = moho_dir_migrations(cache_root);
assert_eq!(moves.len(), 1);
assert_eq!(moves[0].1, cache_root.join("moho"));
assert_eq!(moves[0].2, cache_root.join("nobilis"));
}
}