Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions src/wallet/wallet.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4229,10 +4229,20 @@ util::Result<MigrationResult> MigrateLegacyToDescriptor(std::shared_ptr<CWallet>
return util::Error{_("Error: This wallet is already a descriptor wallet")};
}

// Make a backup of the DB
fs::path this_wallet_dir = fs::absolute(fs::PathFromString(local_wallet->GetDatabase().Filename())).parent_path();
fs::path backup_filename = fs::PathFromString(strprintf("%s_%d.legacy.bak", (wallet_name.empty() ? "default_wallet" : wallet_name), GetTime()));

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.

In commit "wallet: Fix migration of wallets with pathnames." (70f1c99)

Note: This line of code was broken before this commit when wallet_name contained slashes. It would either cause the BackupWallet call below to fail, or to write backup files to unexpected places, instead of writing them to -walletdir as intended after the previous commit.

fs::path backup_path = this_wallet_dir / backup_filename;
// Make a backup of the DB in the wallet's directory with a unique filename
// using the wallet name and current timestamp. The backup filename is based
// on the name of the parent directory containing the wallet data in most
// cases, but in the case where the wallet name is a path to a data file,

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.

In commit "wallet: Fix migration of wallets with pathnames." (70f1c99)

IMO, it's confusing for this comment to distinguish between the case when the wallet is a directory and the case when it's a file, when there is no distinction in the code below. The code below is always returning the last path component no matter what kind of path is provided.

// the name of the data file is used, and in the case where the wallet name
// is blank, "default_wallet" is used.
const std::string backup_prefix = wallet_name.empty() ? "default_wallet" : [&] {
// fs::weakly_canonical resolves relative specifiers and remove trailing slashes.
const auto legacy_wallet_path = fs::weakly_canonical(GetWalletDir() / fs::PathFromString(wallet_name));

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.

In commit "wallet: Fix migration of wallets with pathnames." (70f1c99)

This looks good, but just for comparison here is an updated version of the suggestion from: #32273 (review)

diff

--- a/src/wallet/wallet.cpp
+++ b/src/wallet/wallet.cpp
@@ -4231,14 +4231,15 @@ util::Result<MigrationResult> MigrateLegacyToDescriptor(std::shared_ptr<CWallet>
 
     // Make a backup of the DB in the wallet's directory with a unique filename
     // using the wallet name and current timestamp. The backup filename is based
-    // on the name of the parent directory containing the wallet data in most
-    // cases, but in the case where the wallet name is a path to a data file,
-    // the name of the data file is used, and in the case where the wallet name
-    // is blank, "default_wallet" is used.
-    const std::string backup_prefix = wallet_name.empty() ? "default_wallet" : [&] {
-        // fs::weakly_canonical resolves relative specifiers and remove trailing slashes.
-        const auto legacy_wallet_path = fs::weakly_canonical(GetWalletDir() / fs::PathFromString(wallet_name));
-        return fs::PathToString(legacy_wallet_path.filename());
+    // on walletname but expanded to "default_wallet" if it is empty and only
+    // the final filename portion is used if contains slashes.
+    std::string backup_prefix = wallet_name.empty() ? "default_wallet" : [&] {
+        // Normalize any .. components
+        fs::path p{fs::PathFromString(wallet_name).lexically_normal()};
+        // Drop trailing slash if any.
+        if (p.filename().empty()) p = p.parent_path();
+        // Return last path component
+        return fs::PathToString(p.filename());
     }();
 
     fs::path backup_filename = fs::PathFromString(strprintf("%s_%d.legacy.bak", backup_prefix, GetTime()));

IMO, the suggestion is safer because it just uses string manipulation to choose a backup file prefix, avoiding the complexity of GetWalletDir() and unnecessary filesystem access done by weakly_canonical.

I also think the suggestion is better because it chooses the backup prefix based on the wallet name, not based on the last component of the symlink destination path if the wallet is a symlink.

But current code is fine and a clear improvement over the status quo.

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.

addressing this in #33122

@ryanofsky ryanofsky Aug 5, 2025

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.

re: #32273 (comment)

addressing this in #33122

Note this change is now dropped from #33122, but could be good to revisit at some point (I think it's still a good idea).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I can't recall if this was discussed elsewhere, but in some cases when the path ends in a relative specifier the parent directory name can't be recovered without filesystem access: e.g. the wallet name is ../, I think filesystem access might be necessary here.

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.

It was discussed, see #32273 (review):

I don't think this is true. If a wallet ascends more than it descends like ../../../../mywallet, the suggested change would use "mywallet" as the prefix. If the path contains . components or internal .., lexically_normal will strip them out. If the path contains trailing slashes, the suggested code strips them out to. If the path is literally just .. or ../.. or similar even that is fine, producing backups files like .._1234.legacy.bak

More generally, putting this specific suggestion aside, hopefully it is clear that turning a wallet name into a safe backup filename just requires string manipulation, and does not require accessing the filesystem, resolving symlinks, or calling GetWalletDir().

@davidgumberg davidgumberg Sep 26, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I agree, maybe I had too strict of an idea of what the expectation would be for the destination of the backup: if the only goal is that the backup filename be safe and likely to work, then lexically_normal() or even current_time_1234.legacy.bak in the datadir works perfectly fine, and satisfying those requirements is probably good enough for the migration backup, but I'm still not sure I understand what is wrong with using the filesystem here.

return fs::PathToString(legacy_wallet_path.filename());
}();

fs::path backup_filename = fs::PathFromString(strprintf("%s_%d.legacy.bak", backup_prefix, GetTime()));
fs::path backup_path = fsbridge::AbsPathJoin(GetWalletDir(), backup_filename);

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.

In commit "wallet: migration: Make backup in walletdir" (f6ee59b)

Notes about this change (mostly for myself to avoid getting confused later)

  • This commit changes location of wallet backups created during migrations when the wallets being migrated are directories. Instead of placing backups inside those directories, backups are placed in the top level -walletdir (<datadir>/wallets) so they are easier to find and don't need to copied around if migration fails. Currently two extra copies are necessary because if migration fails the wallet directories are deleted and recreated.

  • This commit does not change location of wallet backups created during migrations when the wallets being migrated are files, since they were always saved in -walletdir. But I think this commit might prevent errors if migration fails in that case, because backup_path and temp_backup_location would point to the same path in that case, and copying a file on top of itself could be an error.

  • Some reasons for wanting to store backups in the top-level directory wallets directory instead of inside individual wallet directories are mentioned in wallet: Fix relative path backup during migration. #32273 (comment). (Though at the time I wrote that comment I was under the mistaken impression that storing backups inside wallets was newly added behavior and did not realize it happened previously.)

if (!local_wallet->BackupWallet(fs::PathToString(backup_path))) {
return util::Error{_("Error: Unable to make a backup of your wallet")};
}
Expand Down Expand Up @@ -4314,11 +4324,6 @@ util::Result<MigrationResult> MigrateLegacyToDescriptor(std::shared_ptr<CWallet>
}
}
if (!success) {
// Migration failed, cleanup
// Before deleting the wallet's directory, copy the backup file to the top-level wallets dir
fs::path temp_backup_location = fsbridge::AbsPathJoin(GetWalletDir(), backup_filename);
fs::copy_file(backup_path, temp_backup_location, fs::copy_options::none);

// Make list of wallets to cleanup
std::vector<std::shared_ptr<CWallet>> created_wallets;
if (local_wallet) created_wallets.push_back(std::move(local_wallet));
Expand Down Expand Up @@ -4354,18 +4359,14 @@ util::Result<MigrationResult> MigrateLegacyToDescriptor(std::shared_ptr<CWallet>
// Restore the backup
// Convert the backup file to the wallet db file by renaming it and moving it into the wallet's directory.
bilingual_str restore_error;
const auto& ptr_wallet = RestoreWallet(context, temp_backup_location, wallet_name, /*load_on_start=*/std::nullopt, status, restore_error, warnings, /*load_after_restore=*/false);
const auto& ptr_wallet = RestoreWallet(context, backup_path, wallet_name, /*load_on_start=*/std::nullopt, status, restore_error, warnings, /*load_after_restore=*/false);
if (!restore_error.empty()) {
error += restore_error + _("\nUnable to restore backup of wallet.");
return util::Error{error};
}
// Verify that the legacy wallet is not loaded after restoring from the backup.
assert(!ptr_wallet);

// The wallet directory has been restored, but just in case, copy the previously created backup to the wallet dir
fs::copy_file(temp_backup_location, backup_path, fs::copy_options::none);
fs::remove(temp_backup_location);

return util::Error{error};
}
return res;
Expand Down
24 changes: 20 additions & 4 deletions test/functional/wallet_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,17 +118,30 @@ def migrate_and_get_rpc(self, wallet_name, **kwargs):
for w in wallets["wallets"]:
if w["name"] == wallet_name:
assert_equal(w["warnings"], ["This wallet is a legacy wallet and will need to be migrated with migratewallet before it can be loaded"])

# Mock time so that we can check the backup filename.
mocked_time = int(time.time())
self.master_node.setmocktime(mocked_time)
# Migrate, checking that rescan does not occur
with self.master_node.assert_debug_log(expected_msgs=[], unexpected_msgs=["Rescanning"]):
migrate_info = self.master_node.migratewallet(wallet_name=wallet_name, **kwargs)
self.master_node.setmocktime(0)
# Update wallet name in case the initial wallet was completely migrated to a watch-only wallet
# (in which case the wallet name would be suffixed by the 'watchonly' term)
wallet_name = migrate_info['wallet_name']
wallet = self.master_node.get_wallet_rpc(wallet_name)
migrated_wallet_name = migrate_info['wallet_name']
wallet = self.master_node.get_wallet_rpc(migrated_wallet_name)
assert_equal(wallet.getwalletinfo()["descriptors"], True)
self.assert_is_sqlite(wallet_name)
self.assert_is_sqlite(migrated_wallet_name)
# Always verify the backup path exist after migration
assert os.path.exists(migrate_info['backup_path'])
if wallet_name == "":
backup_prefix = "default_wallet"
else:
backup_prefix = os.path.basename(os.path.realpath(self.old_node.wallets_path / wallet_name))

expected_backup_path = self.master_node.wallets_path / f"{backup_prefix}_{mocked_time}.legacy.bak"
assert_equal(str(expected_backup_path), migrate_info['backup_path'])

return migrate_info, wallet

def test_basic(self):
Expand Down Expand Up @@ -584,7 +597,10 @@ def test_direct_file(self):
)
assert (self.master_node.wallets_path / "plainfile").is_file()

self.master_node.migratewallet("plainfile")
mocked_time = int(time.time())
self.master_node.setmocktime(mocked_time)
migrate_res = self.master_node.migratewallet("plainfile")
assert_equal(f"plainfile_{mocked_time}.legacy.bak", os.path.basename(migrate_res["backup_path"]))
wallet = self.master_node.get_wallet_rpc("plainfile")
info = wallet.getwalletinfo()
assert_equal(info["descriptors"], True)
Expand Down