Turn Unicode normalization preferences into user configuration settings
Marco Ricci

Marco Ricci commited on 2024-12-19 15:04:11
Zeige 2 geänderte Dateien mit 247 Einfügungen und 31 Löschungen.


Instead of storing Unicode normalization preferences in the vault
configuration file, store these settings in the user configuration file:

  * For a service `SERVICE`, consult the configuration key
    `vault.SERVICE.unicode-normalization-form`.
  * If that is unset, or if there is no service, use
    `vault.default-unicode-normalization-form`.
  * If that too is unset, default to `NFC`.
... ...
@@ -1247,25 +1247,69 @@ def _prompt_for_passphrase() -> str:
1247 1247
     )
1248 1248
 
1249 1249
 
1250
+def _toml_key(*parts: str) -> str:
1251
+    """Return a formatted TOML key, given its parts."""
1252
+    def escape(string: str) -> str:
1253
+        translated = string.translate({
1254
+            0: r'\u0000',
1255
+            1: r'\u0001',
1256
+            2: r'\u0002',
1257
+            3: r'\u0003',
1258
+            4: r'\u0004',
1259
+            5: r'\u0005',
1260
+            6: r'\u0006',
1261
+            7: r'\u0007',
1262
+            8: r'\b',
1263
+            9: r'\t',
1264
+            10: r'\n',
1265
+            11: r'\u000B',
1266
+            12: r'\f',
1267
+            13: r'\r',
1268
+            14: r'\u000E',
1269
+            15: r'\u000F',
1270
+            ord('"'): r'\"',
1271
+            ord('\\'): r'\\',
1272
+            127: r'\u007F',
1273
+        })
1274
+        return f'"{translated}"' if translated != string else string
1275
+    return '.'.join(map(escape, parts))
1276
+
1277
+
1250 1278
 class _ORIGIN(enum.Enum):
1251
-    INTERACTIVE: str = 'interactive'
1279
+    INTERACTIVE: str = 'interactive input'
1252 1280
 
1253 1281
 
1254 1282
 def _check_for_misleading_passphrase(
1255 1283
     key: tuple[str, ...] | _ORIGIN,
1256 1284
     value: dict[str, Any],
1257 1285
     *,
1258
-    form: Literal['NFC', 'NFD', 'NFKC', 'NFKD'] = 'NFC',
1286
+    main_config: dict[str, Any],
1259 1287
 ) -> None:
1288
+    form_key = 'unicode-normalization-form'
1289
+    default_form: str = main_config.get('vault', {}).get(
1290
+        f'default-{form_key}', 'NFC'
1291
+    )
1292
+    form_dict: dict[str, dict] = main_config.get('vault', {}).get(form_key, {})
1293
+    form: Any = (
1294
+        default_form
1295
+        if isinstance(key, _ORIGIN) or key == ('global',)
1296
+        else form_dict.get(key[1], default_form)
1297
+    )
1298
+    config_key = (
1299
+        _toml_key('vault', key[1], form_key)
1300
+        if isinstance(key, tuple) and len(key) > 1 and key[1] in form_dict
1301
+        else f'vault.default-{form_key}'
1302
+    )
1303
+    if form not in {'NFC', 'NFD', 'NFKC', 'NFKD'}:
1304
+        msg = f'Invalid value {form!r} for config key {config_key}'
1305
+        raise AssertionError(msg)
1260 1306
     logger = logging.getLogger(PROG_NAME)
1307
+    formatted_key = (
1308
+        key.value if isinstance(key, _ORIGIN) else _types.json_path(key)
1309
+    )
1261 1310
     if 'phrase' in value:
1262 1311
         phrase = value['phrase']
1263 1312
         if not unicodedata.is_normalized(form, phrase):
1264
-            formatted_key = (
1265
-                key.value
1266
-                if isinstance(key, _ORIGIN)
1267
-                else _types.json_path(key)
1268
-            )
1269 1313
             logger.warning(
1270 1314
                 (
1271 1315
                     'the %s passphrase is not %s-normalized.  '
... ...
@@ -1274,6 +1318,7 @@ def _check_for_misleading_passphrase(
1274 1318
                 ),
1275 1319
                 formatted_key,
1276 1320
                 form,
1321
+                stacklevel=2,
1277 1322
             )
1278 1323
 
1279 1324
 
... ...
@@ -1952,24 +1997,20 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1952 1997
                 ),
1953 1998
                 PROG_NAME,
1954 1999
             )
1955
-        form = cast(
1956
-            Literal['NFC', 'NFD', 'NFKC', 'NFKD'],
1957
-            maybe_config.get('global', {}).get(
1958
-                'unicode_normalization_form', 'NFC'
1959
-            ),
1960
-        )
1961
-        assert form in {'NFC', 'NFD', 'NFKC', 'NFKD'}
2000
+        try:
1962 2001
             _check_for_misleading_passphrase(
1963 2002
                 ('global',),
1964 2003
                 cast(dict[str, Any], maybe_config.get('global', {})),
1965
-            form=form,
2004
+                main_config=user_config,
1966 2005
             )
1967 2006
             for key, value in maybe_config['services'].items():
1968 2007
                 _check_for_misleading_passphrase(
1969 2008
                     ('services', key),
1970 2009
                     cast(dict[str, Any], value),
1971
-                form=form,
2010
+                    main_config=user_config,
1972 2011
                 )
2012
+        except AssertionError as e:
2013
+            err('The configuration file is invalid.  ' + str(e))
1973 2014
         if overwrite_config:
1974 2015
             put_config(maybe_config)
1975 2016
         else:
... ...
@@ -2081,10 +2122,14 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
2081 2122
             elif use_phrase:
2082 2123
                 view['phrase'] = phrase
2083 2124
                 settings_type = 'service' if service else 'global'
2125
+                try:
2084 2126
                     _check_for_misleading_passphrase(
2085 2127
                         ('services', service) if service else ('global',),
2086 2128
                         {'phrase': phrase},
2129
+                        main_config=user_config,
2087 2130
                     )
2131
+                except AssertionError as e:
2132
+                    err('The configuration file is invalid.  ' + str(e))
2088 2133
                 if 'key' in settings:
2089 2134
                     logger.warning(
2090 2135
                         (
... ...
@@ -2123,16 +2168,14 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
2123 2168
             }
2124 2169
 
2125 2170
             if use_phrase:
2126
-                form = cast(
2127
-                    Literal['NFC', 'NFD', 'NFKC', 'NFKD'],
2128
-                    configuration.get('global', {}).get(
2129
-                        'unicode_normalization_form', 'NFC'
2130
-                    ),
2131
-                )
2132
-                assert form in {'NFC', 'NFD', 'NFKC', 'NFKD'}
2171
+                try:
2133 2172
                     _check_for_misleading_passphrase(
2134
-                    _ORIGIN.INTERACTIVE, {'phrase': phrase}, form=form
2173
+                        _ORIGIN.INTERACTIVE,
2174
+                        {'phrase': phrase},
2175
+                        main_config=user_config,
2135 2176
                     )
2177
+                except AssertionError as e:
2178
+                    err('The configuration file is invalid.  ' + str(e))
2136 2179
 
2137 2180
             # If either --key or --phrase are given, use that setting.
2138 2181
             # Otherwise, if both key and phrase are set in the config,
... ...
@@ -12,6 +12,7 @@ import logging
12 12
 import os
13 13
 import shutil
14 14
 import socket
15
+import textwrap
15 16
 import warnings
16 17
 from typing import TYPE_CHECKING
17 18
 
... ...
@@ -1414,9 +1415,10 @@ contents go here
1414 1415
             ), 'expected error exit and known error message'
1415 1416
 
1416 1417
     @pytest.mark.parametrize(
1417
-        ['command_line', 'input', 'warning_message'],
1418
+        ['main_config', 'command_line', 'input', 'warning_message'],
1418 1419
         [
1419 1420
             pytest.param(
1421
+                '',
1420 1422
                 ['--import', '-'],
1421 1423
                 json.dumps({
1422 1424
                     'global': {'phrase': 'Du\u0308sseldorf'},
... ...
@@ -1426,6 +1428,7 @@ contents go here
1426 1428
                 id='global-NFC',
1427 1429
             ),
1428 1430
             pytest.param(
1431
+                '',
1429 1432
                 ['--import', '-'],
1430 1433
                 json.dumps({
1431 1434
                     'services': {
... ...
@@ -1440,6 +1443,7 @@ contents go here
1440 1443
                 id='service-weird-name-NFC',
1441 1444
             ),
1442 1445
             pytest.param(
1446
+                '',
1443 1447
                 ['--config', '-p', '--', DUMMY_SERVICE],
1444 1448
                 'Du\u0308sseldorf',
1445 1449
                 (
... ...
@@ -1449,16 +1453,20 @@ contents go here
1449 1453
                 id='config-NFC',
1450 1454
             ),
1451 1455
             pytest.param(
1456
+                '',
1452 1457
                 ['-p', '--', DUMMY_SERVICE],
1453 1458
                 'Du\u0308sseldorf',
1454
-                'the interactive passphrase is not NFC-normalized',
1459
+                'the interactive input passphrase is not NFC-normalized',
1455 1460
                 id='direct-input-NFC',
1456 1461
             ),
1457 1462
             pytest.param(
1463
+                textwrap.dedent(r"""
1464
+                [vault]
1465
+                default-unicode-normalization-form = 'NFD'
1466
+                """),
1458 1467
                 ['--import', '-'],
1459 1468
                 json.dumps({
1460 1469
                     'global': {
1461
-                        'unicode_normalization_form': 'NFD',
1462 1470
                         'phrase': 'D\u00fcsseldorf',
1463 1471
                     },
1464 1472
                     'services': {},
... ...
@@ -1467,11 +1475,12 @@ contents go here
1467 1475
                 id='global-NFD',
1468 1476
             ),
1469 1477
             pytest.param(
1478
+                textwrap.dedent(r"""
1479
+                [vault]
1480
+                default-unicode-normalization-form = 'NFD'
1481
+                """),
1470 1482
                 ['--import', '-'],
1471 1483
                 json.dumps({
1472
-                    'global': {
1473
-                        'unicode_normalization_form': 'NFD',
1474
-                    },
1475 1484
                     'services': {
1476 1485
                         DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1477 1486
                         'weird entry name': {'phrase': 'D\u00fcsseldorf'},
... ...
@@ -1483,12 +1492,32 @@ contents go here
1483 1492
                 ),
1484 1493
                 id='service-weird-name-NFD',
1485 1494
             ),
1495
+            pytest.param(
1496
+                textwrap.dedent(r"""
1497
+                [vault.unicode-normalization-form]
1498
+                'weird entry name 2' = 'NFKD'
1499
+                """),
1500
+                ['--import', '-'],
1501
+                json.dumps({
1502
+                    'services': {
1503
+                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1504
+                        'weird entry name 1': {'phrase': 'D\u00fcsseldorf'},
1505
+                        'weird entry name 2': {'phrase': 'D\u00fcsseldorf'},
1506
+                    },
1507
+                }),
1508
+                (
1509
+                    'the $.services["weird entry name 2"] passphrase '
1510
+                    'is not NFKD-normalized'
1511
+                ),
1512
+                id='service-weird-name-2-NFKD',
1513
+            ),
1486 1514
         ],
1487 1515
     )
1488 1516
     def test_300_unicode_normalization_form_warning(
1489 1517
         self,
1490 1518
         monkeypatch: pytest.MonkeyPatch,
1491 1519
         caplog: pytest.LogCaptureFixture,
1520
+        main_config: str,
1492 1521
         command_line: list[str],
1493 1522
         input: str | None,
1494 1523
         warning_message: str,
... ...
@@ -1500,10 +1529,11 @@ contents go here
1500 1529
             vault_config={
1501 1530
                 'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}
1502 1531
             },
1532
+            main_config_str=main_config,
1503 1533
         ):
1504 1534
             _result = runner.invoke(
1505 1535
                 cli.derivepassphrase_vault,
1506
-                command_line,
1536
+                ['--debug', *command_line],
1507 1537
                 catch_exceptions=False,
1508 1538
                 input=input,
1509 1539
             )
... ...
@@ -1513,6 +1543,149 @@ contents go here
1513 1543
             warning_message, caplog.record_tuples
1514 1544
         ), 'expected known warning message in stderr'
1515 1545
 
1546
+    @pytest.mark.parametrize(
1547
+        ['main_config', 'command_line', 'input', 'error_message'],
1548
+        [
1549
+            pytest.param(
1550
+                textwrap.dedent(r"""
1551
+                [vault]
1552
+                default-unicode-normalization-form = 'XXX'
1553
+                """),
1554
+                ['--import', '-'],
1555
+                json.dumps({
1556
+                    'services': {
1557
+                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1558
+                        'with_normalization': {'phrase': 'D\u00fcsseldorf'},
1559
+                    },
1560
+                }),
1561
+                (
1562
+                    "Invalid value 'XXX' for config key "
1563
+                    "vault.default-unicode-normalization-form"
1564
+                ),
1565
+                id='global',
1566
+            ),
1567
+            pytest.param(
1568
+                textwrap.dedent(r"""
1569
+                [vault.unicode-normalization-form]
1570
+                with_normalization = 'XXX'
1571
+                """),
1572
+                ['--import', '-'],
1573
+                json.dumps({
1574
+                    'services': {
1575
+                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1576
+                        'with_normalization': {'phrase': 'D\u00fcsseldorf'},
1577
+                    },
1578
+                }),
1579
+                (
1580
+                    "Invalid value 'XXX' for config key "
1581
+                    "vault.with_normalization.unicode-normalization-form"
1582
+                ),
1583
+                id='service',
1584
+            ),
1585
+        ],
1586
+    )
1587
+    def test_301_unicode_normalization_form_error(
1588
+        self,
1589
+        monkeypatch: pytest.MonkeyPatch,
1590
+        main_config: str,
1591
+        command_line: list[str],
1592
+        input: str | None,
1593
+        error_message: str,
1594
+    ) -> None:
1595
+        runner = click.testing.CliRunner(mix_stderr=False)
1596
+        with tests.isolated_vault_config(
1597
+            monkeypatch=monkeypatch,
1598
+            runner=runner,
1599
+            vault_config={
1600
+                'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}
1601
+            },
1602
+            main_config_str=main_config,
1603
+        ):
1604
+            _result = runner.invoke(
1605
+                cli.derivepassphrase_vault,
1606
+                command_line,
1607
+                catch_exceptions=False,
1608
+                input=input,
1609
+            )
1610
+        result = tests.ReadableResult.parse(_result)
1611
+        assert result.error_exit(
1612
+            error='The configuration file is invalid.'
1613
+        ), 'expected error exit and known error message'
1614
+        assert result.error_exit(
1615
+            error=error_message
1616
+        ), 'expected error exit and known error message'
1617
+
1618
+    @pytest.mark.parametrize(
1619
+        'command_line',
1620
+        [
1621
+            pytest.param(
1622
+                ['--config', '--phrase'],
1623
+                id='configure global passphrase',
1624
+            ),
1625
+            pytest.param(
1626
+                ['--phrase', '--', DUMMY_SERVICE],
1627
+                id='interactive passphrase',
1628
+            ),
1629
+        ],
1630
+    )
1631
+    def test_301a_unicode_normalization_form_error_from_stored_config(
1632
+        self,
1633
+        monkeypatch: pytest.MonkeyPatch,
1634
+        command_line: list[str],
1635
+    ) -> None:
1636
+        runner = click.testing.CliRunner(mix_stderr=False)
1637
+        with tests.isolated_vault_config(
1638
+            monkeypatch=monkeypatch,
1639
+            runner=runner,
1640
+            vault_config={
1641
+                'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}
1642
+            },
1643
+            main_config_str=textwrap.dedent("""
1644
+            [vault]
1645
+            default-unicode-normalization-form = 'XXX'
1646
+            """),
1647
+        ):
1648
+            _result = runner.invoke(
1649
+                cli.derivepassphrase_vault,
1650
+                command_line,
1651
+                input=DUMMY_PASSPHRASE,
1652
+                catch_exceptions=False,
1653
+            )
1654
+            result = tests.ReadableResult.parse(_result)
1655
+            assert result.error_exit(
1656
+                error='The configuration file is invalid.'
1657
+            ), 'expected error exit and known error message'
1658
+            assert result.error_exit(
1659
+                error=(
1660
+                    "Invalid value 'XXX' for config key "
1661
+                    "vault.default-unicode-normalization-form"
1662
+                ),
1663
+            ), 'expected error exit and known error message'
1664
+
1665
+    def test_310_bad_user_config_file(
1666
+        self,
1667
+        monkeypatch: pytest.MonkeyPatch,
1668
+    ) -> None:
1669
+        runner = click.testing.CliRunner(mix_stderr=False)
1670
+        with tests.isolated_vault_config(
1671
+            monkeypatch=monkeypatch,
1672
+            runner=runner,
1673
+            vault_config={'services': {}},
1674
+            main_config_str=textwrap.dedent("""
1675
+            This file is not valid TOML.
1676
+            """),
1677
+        ):
1678
+            _result = runner.invoke(
1679
+                cli.derivepassphrase_vault,
1680
+                ['--phrase', '--', DUMMY_SERVICE],
1681
+                input=DUMMY_PASSPHRASE,
1682
+                catch_exceptions=False,
1683
+            )
1684
+            result = tests.ReadableResult.parse(_result)
1685
+            assert result.error_exit(
1686
+                error='Cannot load user config:'
1687
+            ), 'expected error exit and known error message'
1688
+
1516 1689
     def test_400_missing_af_unix_support(
1517 1690
         self,
1518 1691
         monkeypatch: pytest.MonkeyPatch,
1519 1692