Merge bitcoin/bitcoin#34176: wallet: crash fix, handle non-writable db directories

08925d5ee7 test: add coverage for loading a wallet in a non-writable directory (furszy)
0218966c0d test: add coverage for wallet creation in non-writable directory (furszy)
bc0090f1d6 wallet: handle non-writable db directories (furszy)

Pull request description:

  Make wallet creation and load fail with a clear error when the db directory isn’t writable.

  #### 1) For Wallet Creation

  Before: creating a wallet would return a generic error:
  "SQLiteDatabase: Failed to open database: unable to open database file"

  After: creating a wallet returns:
  "SQLiteDatabase: Failed to open database in directory <dir_path>: directory is not writable"

  #### 2) For Wallet Loading

  We currently allow loading wallets located on non-writable directories. This is problematic
  because the node crashes on any subsequent write; generating a block is enough to trigger it.
  Can be verified just by running the following test on master: 85fa4e2910

  Also, to check directory writability, this creates a tmp file rather than relying on the
  `permissions()` functions, since perms bits alone may not reliably reflect actual writability
  in some systems.

  Testing Note:
  Pushed the tests in separate commits so they can be cherry-picked on master for comparison.

ACKs for top commit:
  rkrux:
    re-ACK 08925d5ee7
  achow101:
    ACK 08925d5ee7
  seduless:
    Tested ACK 08925d5ee7

Tree-SHA512: e480eab329a1d595fe0b191e83c97956e3ff1d1e335ada8ac6fe72bc4b2bb9b13b0d49db0254d34ad75f816db06d9cd0c21d3063d7d8ee6687a7ea2324c36288
This commit is contained in:
Ava Chow
2026-04-29 15:53:01 -07:00
6 changed files with 97 additions and 1 deletions

View File

@@ -749,3 +749,13 @@ def wallet_importprivkey(wallet_rpc, privkey, timestamp, *, label=""):
}]
import_res = wallet_rpc.importdescriptors(req)
assert_equal(import_res[0]["success"], True)
def is_dir_writable(dir_path: pathlib.Path) -> bool:
"""Return True if we can create a file in the directory, False otherwise"""
try:
tmp = dir_path / f".tmp_{random.randrange(1 << 32)}"
tmp.touch()
tmp.unlink()
return True
except OSError:
return False

View File

@@ -4,12 +4,15 @@
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Test createwallet arguments.
"""
import os
import stat
from test_framework.descriptors import descsum_create
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_equal,
assert_raises_rpc_error,
is_dir_writable,
wallet_importprivkey,
)
from test_framework.wallet_util import generate_keypair, WalletUnlock
@@ -25,9 +28,27 @@ class CreateWalletTest(BitcoinTestFramework):
def skip_test_if_missing_module(self):
self.skip_if_no_wallet()
def test_bad_dir_permissions(self, node):
self.log.info("Test wallet creation failure due to non-writable directory")
wallet_name = "bad_permissions"
dir_path = node.wallets_path / wallet_name
dir_path.mkdir(parents=True)
original_dir_perms = dir_path.stat().st_mode
os.chmod(dir_path, original_dir_perms & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH))
if is_dir_writable(dir_path):
self.log.warning("Skipping non-writable directory test: unable to enforce read-only permissions")
else:
# Run actual test
assert_raises_rpc_error(-4, f"SQLiteDatabase: Failed to open database in directory '{str(dir_path)}': directory is not writable", node.createwallet, wallet_name=wallet_name)
# Reset directory permissions for cleanup
dir_path.chmod(original_dir_perms)
def run_test(self):
node = self.nodes[0]
self.test_bad_dir_permissions(node)
self.log.info("Run createwallet with invalid parameters.")
# Run createwallet with invalid parameters. This must not prevent a new wallet with the same name from being created with the correct parameters.
assert_raises_rpc_error(-4, "Passphrase provided but private keys are disabled. A passphrase is only used to encrypt private keys, so cannot be used for wallets with private keys disabled.",

View File

@@ -6,12 +6,17 @@
Verify that a bitcoind node can maintain list of wallets loading on startup
"""
import os
import shutil
import stat
import uuid
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_equal,
assert_raises_rpc_error,
is_dir_writable,
)
@@ -38,6 +43,27 @@ class WalletStartupTest(BitcoinTestFramework):
shutil.move(self.nodes[0].wallets_path / wallet_name / "wallet.dat", self.nodes[0].wallets_path / "wallet.dat")
(self.nodes[0].wallets_path / wallet_name).rmdir()
def test_load_unwritable_wallet(self, node):
self.log.info("Test wallet load failure due to non-writable directory")
wallet_name = "bad_permissions"
node.createwallet(wallet_name)
node.unloadwallet(wallet_name)
dir_path = node.wallets_path / wallet_name
original_dir_perms = dir_path.stat().st_mode
os.chmod(dir_path, original_dir_perms & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH))
if is_dir_writable(dir_path):
self.log.warning("Skipping load non-writable directory test: unable to enforce read-only permissions")
else:
# Ensure we don't load a wallet located in a non-writable directory.
# The node will crash later on if we cannot write to disk.
assert_raises_rpc_error(-4, f"SQLiteDatabase: Failed to open database in directory '{str(dir_path)}': directory is not writable", node.loadwallet, wallet_name)
# Reset directory permissions for cleanup
dir_path.chmod(original_dir_perms)
def run_test(self):
self.log.info('Should start without any wallets')
assert_equal(self.nodes[0].listwallets(), [])
@@ -67,5 +93,7 @@ class WalletStartupTest(BitcoinTestFramework):
self.restart_node(0)
assert_equal(set(self.nodes[0].listwallets()), set(('w2', 'w3')))
self.test_load_unwritable_wallet(self.nodes[0])
if __name__ == '__main__':
WalletStartupTest(__file__).main()