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 |