Rename the configuration file to be subsystem-specific
Marco Ricci

Marco Ricci commited on 2024-09-11 22:13:56
Zeige 3 geänderte Dateien mit 365 Einfügungen und 29 Löschungen.


In preparation for multiple passphrase derivation schemes with separate
settings, use multiple configuration files for each part of the
application ("subsystem").  The only currently established subsystem is
"vault", using `vault.json`, and the existing configuration effectively
corresponds to a "settings" subsystem.

Until v1.0 is released, fall back to `settings.json` if `vault.json`
does not exist. (But include a deprecation warning, and attempt to
migrate to the new filename automatically.)
... ...
@@ -340,20 +340,48 @@ def derivepassphrase_export_vault(
340 340
 # =====
341 341
 
342 342
 
343
-def _config_filename() -> str | bytes | pathlib.Path:
344
-    """Return the filename of the configuration file.
343
+def _config_filename(
344
+    subsystem: str | None = 'settings',
345
+) -> str | bytes | pathlib.Path:
346
+    """Return the filename of the configuration file for the subsystem.
347
+
348
+    The (implicit default) file is currently named `settings.json`,
349
+    located within the configuration directory as determined by the
350
+    `DERIVEPASSPHRASE_PATH` environment variable, or by
351
+    [`click.get_app_dir`][] in POSIX mode.  Depending on the requested
352
+    subsystem, this will usually be a different file within that
353
+    directory.
345 354
 
346
-    The file is currently named `settings.json`, located within the
347
-    configuration directory as determined by the `DERIVEPASSPHRASE_PATH`
348
-    environment variable, or by [`click.get_app_dir`][] in POSIX
349
-    mode.
355
+    Args:
356
+        subsystem:
357
+            Name of the configuration subsystem whose configuration
358
+            filename to return.  If not given, return the old filename
359
+            from before the subcommand migration.  If `None`, return the
360
+            configuration directory instead.
361
+
362
+    Raises:
363
+        AssertionError:
364
+            An unknown subsystem was passed.
365
+
366
+    Deprecated:
367
+        Since v0.2.0: The implicit default subsystem and the old
368
+        configuration filename are deprecated, and will be removed in v1.0.
369
+        The subsystem will be mandatory to specify.
350 370
 
351 371
     """
352 372
     path: str | bytes | pathlib.Path
353 373
     path = os.getenv(PROG_NAME.upper() + '_PATH') or click.get_app_dir(
354 374
         PROG_NAME, force_posix=True
355 375
     )
356
-    return os.path.join(path, 'settings.json')
376
+    match subsystem:
377
+        case None:
378
+            return path
379
+        case 'vault' | 'settings':
380
+            filename = f'{subsystem}.json'
381
+        case _:  # pragma: no cover
382
+            msg = f'Unknown configuration subsystem: {subsystem!r}'
383
+            raise AssertionError(msg)
384
+    return os.path.join(path, filename)
357 385
 
358 386
 
359 387
 def _load_config() -> _types.VaultConfig:
... ...
@@ -375,7 +403,7 @@ def _load_config() -> _types.VaultConfig:
375 403
             config.
376 404
 
377 405
     """
378
-    filename = _config_filename()
406
+    filename = _config_filename(subsystem='vault')
379 407
     with open(filename, 'rb') as fileobj:
380 408
         data = json.load(fileobj)
381 409
     if not _types.is_vault_config(data):
... ...
@@ -383,6 +411,43 @@ def _load_config() -> _types.VaultConfig:
383 411
     return data
384 412
 
385 413
 
414
+def _migrate_and_load_old_config() -> (
415
+    tuple[_types.VaultConfig, OSError | None]
416
+):
417
+    """Load and migrate a vault(1)-compatible config.
418
+
419
+    The (old) filename is obtained via
420
+    [`derivepassphrase.cli._config_filename`][].  This must be an
421
+    unencrypted JSON file.  After loading, the file is migrated to the new
422
+    standard filename.
423
+
424
+    Returns:
425
+        The vault settings, and an optional exception encountered during
426
+        migration.  See [`derivepassphrase.types.VaultConfig`][] for
427
+        details on the former.
428
+
429
+    Raises:
430
+        OSError:
431
+            There was an OS error accessing the old file.
432
+        ValueError:
433
+            The data loaded from the file is not a vault(1)-compatible
434
+            config.
435
+
436
+    """
437
+    new_filename = _config_filename(subsystem='vault')
438
+    old_filename = _config_filename()
439
+    with open(old_filename, 'rb') as fileobj:
440
+        data = json.load(fileobj)
441
+    if not _types.is_vault_config(data):
442
+        raise ValueError(_INVALID_VAULT_CONFIG)
443
+    try:
444
+        os.replace(old_filename, new_filename)
445
+    except OSError as exc:
446
+        return data, exc
447
+    else:
448
+        return data, None
449
+
450
+
386 451
 def _save_config(config: _types.VaultConfig, /) -> None:
387 452
     """Save a vault(1)-compatible config to the application directory.
388 453
 
... ...
@@ -403,7 +468,7 @@ def _save_config(config: _types.VaultConfig, /) -> None:
403 468
     """
404 469
     if not _types.is_vault_config(config):
405 470
         raise ValueError(_INVALID_VAULT_CONFIG)
406
-    filename = _config_filename()
471
+    filename = _config_filename(subsystem='vault')
407 472
     filedir = os.path.dirname(os.path.abspath(filename))
408 473
     try:
409 474
         os.makedirs(filedir, exist_ok=False)
... ...
@@ -1203,8 +1268,36 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1203 1268
     def get_config() -> _types.VaultConfig:
1204 1269
         try:
1205 1270
             return _load_config()
1271
+        except FileNotFoundError:
1272
+            try:
1273
+                backup_config, exc = _migrate_and_load_old_config()
1206 1274
             except FileNotFoundError:
1207 1275
                 return {'services': {}}
1276
+            old_name = os.path.basename(_config_filename())
1277
+            new_name = os.path.basename(_config_filename(subsystem='vault'))
1278
+            click.echo(
1279
+                (
1280
+                    f'{PROG_NAME}: Using deprecated v0.1-style config file '
1281
+                    f'{old_name!r}, instead of v0.2-style {new_name!r}.  '
1282
+                    f'Support for v0.1-style config filenames will be '
1283
+                    f'removed in v1.0.'
1284
+                ),
1285
+                err=True,
1286
+            )
1287
+            if isinstance(exc, OSError):
1288
+                click.echo(
1289
+                    (
1290
+                        f'{PROG_NAME}: Warning: Failed to migrate to '
1291
+                        f'{new_name!r}: {exc.strerror}: {exc.filename!r}'
1292
+                    ),
1293
+                    err=True,
1294
+                )
1295
+            else:
1296
+                click.echo(
1297
+                    f'{PROG_NAME}: Successfully migrated to {new_name!r}.',
1298
+                    err=True,
1299
+                )
1300
+            return backup_config
1208 1301
         except OSError as e:
1209 1302
             err(f'Cannot load config: {e.strerror}: {e.filename!r}')
1210 1303
         except Exception as e:  # noqa: BLE001
... ...
@@ -552,7 +552,8 @@ def isolated_config(
552 552
         monkeypatch.setenv('HOME', os.getcwd())
553 553
         monkeypatch.setenv('USERPROFILE', os.getcwd())
554 554
         monkeypatch.delenv(env_name, raising=False)
555
-        os.makedirs(os.path.dirname(cli._config_filename()), exist_ok=True)
555
+        config_dir = cli._config_filename(subsystem=None)
556
+        os.makedirs(config_dir, exist_ok=True)
556 557
         yield
557 558
 
558 559
 
... ...
@@ -563,7 +564,8 @@ def isolated_vault_config(
563 564
     config: Any,
564 565
 ) -> Iterator[None]:
565 566
     with isolated_config(monkeypatch=monkeypatch, runner=runner):
566
-        with open(cli._config_filename(), 'w', encoding='UTF-8') as outfile:
567
+        config_filename = cli._config_filename(subsystem='vault')
568
+        with open(config_filename, 'w', encoding='UTF-8') as outfile:
567 569
             json.dump(config, outfile)
568 570
         yield
569 571
 
... ...
@@ -620,10 +620,10 @@ class TestCLI:
620 620
         # We also might as well use `isolated_config` instead.
621 621
         with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
622 622
             with open(
623
-                cli._config_filename(), 'w', encoding='UTF-8'
623
+                cli._config_filename(subsystem='vault'), 'w', encoding='UTF-8'
624 624
             ) as outfile:
625 625
                 print('This string is not valid JSON.', file=outfile)
626
-            dname = os.path.dirname(cli._config_filename())
626
+            dname = cli._config_filename(subsystem=None)
627 627
             _result = runner.invoke(
628 628
                 cli.derivepassphrase_vault,
629 629
                 ['--import', os.fsdecode(dname)],
... ...
@@ -641,7 +641,7 @@ class TestCLI:
641 641
         runner = click.testing.CliRunner(mix_stderr=False)
642 642
         with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
643 643
             with contextlib.suppress(FileNotFoundError):
644
-                os.remove(cli._config_filename())
644
+                os.remove(cli._config_filename(subsystem='vault'))
645 645
             _result = runner.invoke(
646 646
                 cli.derivepassphrase_vault,
647 647
                 ['--export', '-'],
... ...
@@ -676,8 +676,8 @@ class TestCLI:
676 676
         runner = click.testing.CliRunner(mix_stderr=False)
677 677
         with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
678 678
             with contextlib.suppress(FileNotFoundError):
679
-                os.remove(cli._config_filename())
680
-            os.makedirs(cli._config_filename())
679
+                os.remove(cli._config_filename(subsystem='vault'))
680
+            os.makedirs(cli._config_filename(subsystem='vault'))
681 681
             _result = runner.invoke(
682 682
                 cli.derivepassphrase_vault,
683 683
                 ['--export', '-'],
... ...
@@ -695,7 +695,7 @@ class TestCLI:
695 695
     ) -> None:
696 696
         runner = click.testing.CliRunner(mix_stderr=False)
697 697
         with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
698
-            dname = os.path.dirname(cli._config_filename())
698
+            dname = cli._config_filename(subsystem=None)
699 699
             _result = runner.invoke(
700 700
                 cli.derivepassphrase_vault,
701 701
                 ['--export', os.fsdecode(dname)],
... ...
@@ -750,7 +750,9 @@ contents go here
750 750
             )
751 751
             result = tests.ReadableResult.parse(_result)
752 752
             assert result.clean_exit(empty_stderr=True), 'expected clean exit'
753
-            with open(cli._config_filename(), encoding='UTF-8') as infile:
753
+            with open(
754
+                cli._config_filename(subsystem='vault'), encoding='UTF-8'
755
+            ) as infile:
754 756
                 config = json.load(infile)
755 757
             assert config == {
756 758
                 'global': {'phrase': 'abc'},
... ...
@@ -774,7 +776,9 @@ contents go here
774 776
             )
775 777
             result = tests.ReadableResult.parse(_result)
776 778
             assert result.clean_exit(empty_stderr=True), 'expected clean exit'
777
-            with open(cli._config_filename(), encoding='UTF-8') as infile:
779
+            with open(
780
+                cli._config_filename(subsystem='vault'), encoding='UTF-8'
781
+            ) as infile:
778 782
                 config = json.load(infile)
779 783
             assert config == {'global': {'phrase': 'abc'}, 'services': {}}
780 784
 
... ...
@@ -795,7 +799,9 @@ contents go here
795 799
             )
796 800
             result = tests.ReadableResult.parse(_result)
797 801
             assert result.clean_exit(empty_stderr=True), 'expected clean exit'
798
-            with open(cli._config_filename(), encoding='UTF-8') as infile:
802
+            with open(
803
+                cli._config_filename(subsystem='vault'), encoding='UTF-8'
804
+            ) as infile:
799 805
                 config = json.load(infile)
800 806
             assert config == {
801 807
                 'global': {'phrase': 'abc'},
... ...
@@ -821,7 +827,9 @@ contents go here
821 827
             assert result.error_exit(
822 828
                 error='user aborted request'
823 829
             ), 'expected known error message'
824
-            with open(cli._config_filename(), encoding='UTF-8') as infile:
830
+            with open(
831
+                cli._config_filename(subsystem='vault'), encoding='UTF-8'
832
+            ) as infile:
825 833
                 config = json.load(infile)
826 834
             assert config == {'global': {'phrase': 'abc'}, 'services': {}}
827 835
 
... ...
@@ -888,7 +896,9 @@ contents go here
888 896
             )
889 897
             result = tests.ReadableResult.parse(_result)
890 898
             assert result.clean_exit(), 'expected clean exit'
891
-            with open(cli._config_filename(), encoding='UTF-8') as infile:
899
+            with open(
900
+                cli._config_filename(subsystem='vault'), encoding='UTF-8'
901
+            ) as infile:
892 902
                 config = json.load(infile)
893 903
             assert (
894 904
                 config == result_config
... ...
@@ -1015,7 +1025,7 @@ contents go here
1015 1025
             config={'global': {'phrase': 'abc'}, 'services': {}},
1016 1026
         ):
1017 1027
             tests.make_file_readonly(
1018
-                cli._config_filename(),
1028
+                cli._config_filename(subsystem='vault'),
1019 1029
                 try_race_free_implementation=try_race_free_implementation,
1020 1030
             )
1021 1031
             _result = runner.invoke(
... ...
@@ -1118,7 +1128,9 @@ contents go here
1118 1128
                 result.stderr == 'Passphrase:'
1119 1129
             ), 'program unexpectedly failed?!'
1120 1130
             assert os_makedirs_called, 'os.makedirs has not been called?!'
1121
-            with open(cli._config_filename(), encoding='UTF-8') as infile:
1131
+            with open(
1132
+                cli._config_filename(subsystem='vault'), encoding='UTF-8'
1133
+            ) as infile:
1122 1134
                 config_readback = json.load(infile)
1123 1135
             assert config_readback == {
1124 1136
                 'global': {'phrase': 'abc'},
... ...
@@ -1283,7 +1295,38 @@ contents go here
1283 1295
 
1284 1296
 
1285 1297
 class TestCLIUtils:
1286
-    def test_100_save_bad_config(
1298
+    @pytest.mark.parametrize(
1299
+        'config',
1300
+        [
1301
+            {'global': {'phrase': 'my passphrase'}, 'services': {}},
1302
+            {'global': {'key': DUMMY_KEY1_B64}, 'services': {}},
1303
+            {
1304
+                'global': {'phrase': 'abc'},
1305
+                'services': {'sv': {'phrase': 'my passphrase'}},
1306
+            },
1307
+            {
1308
+                'global': {'phrase': 'abc'},
1309
+                'services': {'sv': {'key': DUMMY_KEY1_B64}},
1310
+            },
1311
+            {
1312
+                'global': {'phrase': 'abc'},
1313
+                'services': {'sv': {'key': DUMMY_KEY1_B64, 'length': 15}},
1314
+            },
1315
+        ],
1316
+    )
1317
+    def test_100_load_config(
1318
+        self, monkeypatch: pytest.MonkeyPatch, config: Any
1319
+    ) -> None:
1320
+        runner = click.testing.CliRunner()
1321
+        with tests.isolated_vault_config(
1322
+            monkeypatch=monkeypatch, runner=runner, config=config
1323
+        ):
1324
+            config_filename = cli._config_filename(subsystem='vault')
1325
+            with open(config_filename, encoding='UTF-8') as fileobj:
1326
+                assert json.load(fileobj) == config
1327
+            assert cli._load_config() == config
1328
+
1329
+    def test_110_save_bad_config(
1287 1330
         self, monkeypatch: pytest.MonkeyPatch
1288 1331
     ) -> None:
1289 1332
         runner = click.testing.CliRunner()
... ...
@@ -1295,7 +1338,7 @@ class TestCLIUtils:
1295 1338
         ):
1296 1339
             cli._save_config(None)  # type: ignore[arg-type]
1297 1340
 
1298
-    def test_101_prompt_for_selection_multiple(self) -> None:
1341
+    def test_111_prompt_for_selection_multiple(self) -> None:
1299 1342
         @click.command()
1300 1343
         @click.option('--heading', default='Our menu:')
1301 1344
         @click.argument('items', nargs=-1)
... ...
@@ -1370,7 +1413,7 @@ Your selection? (1-10, leave empty to abort):\x20
1370 1413
 """  # noqa: E501
1371 1414
         ), 'expected known output'
1372 1415
 
1373
-    def test_102_prompt_for_selection_single(self) -> None:
1416
+    def test_112_prompt_for_selection_single(self) -> None:
1374 1417
         @click.command()
1375 1418
         @click.option('--item', default='baked beans')
1376 1419
         @click.argument('prompt')
... ...
@@ -1415,7 +1458,7 @@ Boo.
1415 1458
 """
1416 1459
         ), 'expected known output'
1417 1460
 
1418
-    def test_103_prompt_for_passphrase(
1461
+    def test_113_prompt_for_passphrase(
1419 1462
         self, monkeypatch: pytest.MonkeyPatch
1420 1463
     ) -> None:
1421 1464
         monkeypatch.setattr(
... ...
@@ -1480,7 +1523,9 @@ Boo.
1480 1523
                 assert result.clean_exit(
1481 1524
                     empty_stderr=True
1482 1525
                 ), 'expected clean exit'
1483
-                with open(cli._config_filename(), encoding='UTF-8') as infile:
1526
+                with open(
1527
+                    cli._config_filename(subsystem='vault'), encoding='UTF-8'
1528
+                ) as infile:
1484 1529
                     config_readback = json.load(infile)
1485 1530
                 assert config_readback == result_config
1486 1531
 
... ...
@@ -1611,6 +1656,127 @@ class TestCLITransition:
1611 1656
             empty_stderr=True, output='Use NUMBER=0, e.g. "--symbol 0"'
1612 1657
         ), 'expected clean exit, and option group epilog in help text'
1613 1658
 
1659
+    @pytest.mark.parametrize(
1660
+        'config',
1661
+        [
1662
+            {'global': {'phrase': 'my passphrase'}, 'services': {}},
1663
+            {'global': {'key': DUMMY_KEY1_B64}, 'services': {}},
1664
+            {
1665
+                'global': {'phrase': 'abc'},
1666
+                'services': {'sv': {'phrase': 'my passphrase'}},
1667
+            },
1668
+            {
1669
+                'global': {'phrase': 'abc'},
1670
+                'services': {'sv': {'key': DUMMY_KEY1_B64}},
1671
+            },
1672
+            {
1673
+                'global': {'phrase': 'abc'},
1674
+                'services': {'sv': {'key': DUMMY_KEY1_B64, 'length': 15}},
1675
+            },
1676
+        ],
1677
+    )
1678
+    def test_110_load_config_backup(
1679
+        self, monkeypatch: pytest.MonkeyPatch, config: Any
1680
+    ) -> None:
1681
+        runner = click.testing.CliRunner()
1682
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
1683
+            config_filename = cli._config_filename()
1684
+            with open(config_filename, 'w', encoding='UTF-8') as fileobj:
1685
+                print(json.dumps(config, indent=2), file=fileobj)
1686
+            assert cli._migrate_and_load_old_config()[0] == config
1687
+
1688
+    @pytest.mark.parametrize(
1689
+        'config',
1690
+        [
1691
+            {'global': {'phrase': 'my passphrase'}, 'services': {}},
1692
+            {'global': {'key': DUMMY_KEY1_B64}, 'services': {}},
1693
+            {
1694
+                'global': {'phrase': 'abc'},
1695
+                'services': {'sv': {'phrase': 'my passphrase'}},
1696
+            },
1697
+            {
1698
+                'global': {'phrase': 'abc'},
1699
+                'services': {'sv': {'key': DUMMY_KEY1_B64}},
1700
+            },
1701
+            {
1702
+                'global': {'phrase': 'abc'},
1703
+                'services': {'sv': {'key': DUMMY_KEY1_B64, 'length': 15}},
1704
+            },
1705
+        ],
1706
+    )
1707
+    def test_111_migrate_config(
1708
+        self, monkeypatch: pytest.MonkeyPatch, config: Any
1709
+    ) -> None:
1710
+        runner = click.testing.CliRunner()
1711
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
1712
+            config_filename = cli._config_filename()
1713
+            with open(config_filename, 'w', encoding='UTF-8') as fileobj:
1714
+                print(json.dumps(config, indent=2), file=fileobj)
1715
+            assert cli._migrate_and_load_old_config() == (config, None)
1716
+
1717
+    @pytest.mark.parametrize(
1718
+        'config',
1719
+        [
1720
+            {'global': {'phrase': 'my passphrase'}, 'services': {}},
1721
+            {'global': {'key': DUMMY_KEY1_B64}, 'services': {}},
1722
+            {
1723
+                'global': {'phrase': 'abc'},
1724
+                'services': {'sv': {'phrase': 'my passphrase'}},
1725
+            },
1726
+            {
1727
+                'global': {'phrase': 'abc'},
1728
+                'services': {'sv': {'key': DUMMY_KEY1_B64}},
1729
+            },
1730
+            {
1731
+                'global': {'phrase': 'abc'},
1732
+                'services': {'sv': {'key': DUMMY_KEY1_B64, 'length': 15}},
1733
+            },
1734
+        ],
1735
+    )
1736
+    def test_112_migrate_config_error(
1737
+        self, monkeypatch: pytest.MonkeyPatch, config: Any
1738
+    ) -> None:
1739
+        runner = click.testing.CliRunner()
1740
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
1741
+            config_filename = cli._config_filename()
1742
+            with open(config_filename, 'w', encoding='UTF-8') as fileobj:
1743
+                print(json.dumps(config, indent=2), file=fileobj)
1744
+            os.mkdir(cli._config_filename(subsystem='vault'))
1745
+            config2, err = cli._migrate_and_load_old_config()
1746
+            assert config2 == config
1747
+            assert isinstance(err, OSError)
1748
+            assert err.errno == errno.EISDIR
1749
+
1750
+    @pytest.mark.parametrize(
1751
+        'config',
1752
+        [
1753
+            {'global': '', 'services': {}},
1754
+            {'global': 0, 'services': {}},
1755
+            {
1756
+                'global': {'phrase': 'abc'},
1757
+                'services': False,
1758
+            },
1759
+            {
1760
+                'global': {'phrase': 'abc'},
1761
+                'services': True,
1762
+            },
1763
+            {
1764
+                'global': {'phrase': 'abc'},
1765
+                'services': None,
1766
+            },
1767
+        ],
1768
+    )
1769
+    def test_113_migrate_config_error_bad_config_value(
1770
+        self, monkeypatch: pytest.MonkeyPatch, config: Any
1771
+    ) -> None:
1772
+        runner = click.testing.CliRunner()
1773
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner):
1774
+            config_filename = cli._config_filename()
1775
+            with open(config_filename, 'w', encoding='UTF-8') as fileobj:
1776
+                print(json.dumps(config, indent=2), file=fileobj)
1777
+            with pytest.raises(ValueError, match=cli._INVALID_VAULT_CONFIG):
1778
+                cli._migrate_and_load_old_config()
1779
+
1614 1780
     def test_200_forward_export_vault_path_parameter(
1615 1781
         self, monkeypatch: pytest.MonkeyPatch
1616 1782
     ) -> None:
... ...
@@ -1671,3 +1837,78 @@ class TestCLITransition:
1671 1837
             assert (
1672 1838
                 c not in result.output
1673 1839
             ), f'derived password contains forbidden character {c!r}'
1840
+
1841
+    def test_300_export_using_old_config_file(
1842
+        self,
1843
+        monkeypatch: pytest.MonkeyPatch,
1844
+    ) -> None:
1845
+        runner = click.testing.CliRunner(mix_stderr=False)
1846
+        with tests.isolated_config(
1847
+            monkeypatch=monkeypatch,
1848
+            runner=runner,
1849
+        ):
1850
+            with open(
1851
+                cli._config_filename(), 'w', encoding='UTF-8'
1852
+            ) as fileobj:
1853
+                print(
1854
+                    json.dumps(
1855
+                        {'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}},
1856
+                        indent=2,
1857
+                    ),
1858
+                    file=fileobj,
1859
+                )
1860
+            _result = runner.invoke(
1861
+                cli.derivepassphrase_vault,
1862
+                ['--export', '-'],
1863
+                catch_exceptions=False,
1864
+            )
1865
+        result = tests.ReadableResult.parse(_result)
1866
+        assert result.clean_exit(), 'expected clean exit'
1867
+        assert (
1868
+            'v0.1-style config file' in result.stderr
1869
+        ), 'expected known warning message in stderr'
1870
+        assert (
1871
+            'Successfully migrated to ' in result.stderr
1872
+        ), 'expected known warning message in stderr'
1873
+
1874
+    def test_300a_export_using_old_config_file_migration_error(
1875
+        self,
1876
+        monkeypatch: pytest.MonkeyPatch,
1877
+    ) -> None:
1878
+        runner = click.testing.CliRunner(mix_stderr=False)
1879
+        with tests.isolated_config(
1880
+            monkeypatch=monkeypatch,
1881
+            runner=runner,
1882
+        ):
1883
+            with open(
1884
+                cli._config_filename(), 'w', encoding='UTF-8'
1885
+            ) as fileobj:
1886
+                print(
1887
+                    json.dumps(
1888
+                        {'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}},
1889
+                        indent=2,
1890
+                    ),
1891
+                    file=fileobj,
1892
+                )
1893
+
1894
+            def raiser(*_args: Any, **_kwargs: Any) -> None:
1895
+                raise OSError(
1896
+                    errno.EACCES,
1897
+                    os.strerror(errno.EACCES),
1898
+                    cli._config_filename(subsystem='vault'),
1899
+                )
1900
+
1901
+            monkeypatch.setattr(os, 'replace', raiser)
1902
+            _result = runner.invoke(
1903
+                cli.derivepassphrase_vault,
1904
+                ['--export', '-'],
1905
+                catch_exceptions=False,
1906
+            )
1907
+        result = tests.ReadableResult.parse(_result)
1908
+        assert result.clean_exit(), 'expected clean exit'
1909
+        assert (
1910
+            'v0.1-style config file' in result.stderr
1911
+        ), 'expected known warning message in stderr'
1912
+        assert (
1913
+            'Warning: Failed to migrate to ' in result.stderr
1914
+        ), 'expected known warning message in stderr'
1674 1915