Align behavior with vault concerning falsy values in config
Marco Ricci

Marco Ricci commited on 2024-10-08 09:32:00
Zeige 5 geänderte Dateien mit 719 Einfügungen und 236 Löschungen.


The original vault(1) sometimes checks only for falsy values (in the
JavaScript sense) for its configuration settings.  `derivepassphrase`
however uses strict type and value checks, and rejects falsy values of
the wrong type.  This behavior is a visible deviation from vault(1), and
shall thus be removed.

A new function, `_types.clean_up_falsy_vault_config_values`, normalizes
falsy values in a vault configuration to their correct types, in-place.
Running this on a potential vault configuration and then calling
`_types.is_vault_config` should return the same validity results as
vault(1) does.

The new handling of falsy values invalidates most of the tests for
validation errors, as `None`/`null` was a common way to generate an
invalid setting.  Instead, keep a master list of vault configurations
that is used (perhaps filtered first) for all validation tests, and test
the handling of falsy values by generating vault configurations with
falsy value replacements from the master list (a custom `hypothesis`
strategy).

On that note, the existing `_types.validate_vault_config` has proved
rather difficult to keep at 100% coverage with the new example vault
configurations, because some of the error conditions are triggered
elsewhere.  Accordingly, instead of treating global and service-specific
settings separately and quasi-duplicating all validation checks, unify
them into a queue of settings dicts to check, only mildly adjusting for
the very few differing keys between them.

GitHub: Closes #17.
... ...
@@ -7,6 +7,7 @@
7 7
 from __future__ import annotations
8 8
 
9 9
 import enum
10
+import math
10 11
 from typing import TYPE_CHECKING
11 12
 
12 13
 from typing_extensions import (
... ...
@@ -162,8 +163,24 @@ def validate_vault_config(  # noqa: C901,PLR0912,PLR0915
162 163
 
163 164
     """
164 165
 
166
+    def maybe_quote(x: str) -> str:
167
+        chars = (
168
+            frozenset('abcdefghijklmnopqrstuvwxyz')
169
+            | frozenset('ABCDEFGHIJKLMNOPQRSTUVWXYZ')
170
+            | frozenset('0123456789')
171
+            | frozenset('_')
172
+        )
173
+        initial = (
174
+            frozenset('abcdefghijklmnopqrstuvwxyz')
175
+            | frozenset('ABCDEFGHIJKLMNOPQRSTUVWXYZ')
176
+            | frozenset('_')
177
+        )
178
+        return (
179
+            x if x and set(x).issubset(chars) and x[:1] in initial else repr(x)
180
+        )
181
+
165 182
     def as_json_path_string(json_path: Sequence[str], /) -> str:
166
-        return ''.join('.' + repr(x) for x in json_path)
183
+        return ''.join('.' + maybe_quote(x) for x in json_path)
167 184
 
168 185
     err_obj_not_a_dict = 'vault config is not a dict'
169 186
     err_non_str_service_name = (
... ...
@@ -212,24 +229,12 @@ def validate_vault_config(  # noqa: C901,PLR0912,PLR0915
212 229
 
213 230
     if not isinstance(obj, dict):
214 231
         raise TypeError(err_obj_not_a_dict)
232
+    queue_to_check: list[tuple[dict[str, Any], tuple[str, ...]]] = []
215 233
     if 'global' in obj:
216 234
         o_global = obj['global']
217 235
         if not isinstance(o_global, dict):
218 236
             raise TypeError(err_not_a_dict(['global']))
219
-        for key, value in o_global.items():
220
-            # Use match/case here once Python 3.9 becomes unsupported.
221
-            if key in {'key', 'phrase'}:
222
-                if not isinstance(value, str):
223
-                    raise TypeError(err_not_a_dict(['global', key]))
224
-            elif key == 'unicode_normalization_form':
225
-                if not isinstance(value, str):
226
-                    raise TypeError(err_not_a_dict(['global', key]))
227
-                if not allow_derivepassphrase_extensions:
228
-                    raise ValueError(
229
-                        err_derivepassphrase_extension(key, ('global',))
230
-                    )
231
-            elif not allow_unknown_settings:
232
-                raise ValueError(err_unknown_setting(key, ('global',)))
237
+        queue_to_check.append((o_global, ('global',)))
233 238
     if not isinstance(obj.get('services'), dict):
234 239
         raise TypeError(err_not_a_dict(['services']))
235 240
     for sv_name, service in obj['services'].items():
... ...
@@ -237,23 +242,27 @@ def validate_vault_config(  # noqa: C901,PLR0912,PLR0915
237 242
             raise TypeError(err_non_str_service_name.format(sv_name))
238 243
         if not isinstance(service, dict):
239 244
             raise TypeError(err_not_a_dict(['services', sv_name]))
240
-        for key, value in service.items():
245
+        queue_to_check.append((service, ('services', sv_name)))
246
+    for settings, path in queue_to_check:
247
+        for key, value in settings.items():
241 248
             # Use match/case here once Python 3.9 becomes unsupported.
242
-            if key in {'notes', 'phrase', 'key'}:
249
+            if key in {'key', 'phrase'}:
243 250
                 if not isinstance(value, str):
244
-                    raise TypeError(
245
-                        err_not_a_string(['services', sv_name, key])
246
-                    )
251
+                    raise TypeError(err_not_a_string((*path, key)))
252
+            elif key == 'unicode_normalization_form' and path == ('global',):
253
+                if not isinstance(value, str):
254
+                    raise TypeError(err_not_a_string((*path, key)))
255
+                if not allow_derivepassphrase_extensions:
256
+                    raise ValueError(err_derivepassphrase_extension(key, path))
257
+            elif key == 'notes' and path != ('global',):
258
+                if not isinstance(value, str):
259
+                    raise TypeError(err_not_a_string((*path, key)))
247 260
             elif key == 'length':
248 261
                 if not isinstance(value, int):
249
-                    raise TypeError(err_not_an_int(['services', sv_name, key]))
262
+                    raise TypeError(err_not_an_int((*path, key)))
250 263
                 if value < 1:
251 264
                     raise ValueError(
252
-                        err_bad_number(
253
-                            key,
254
-                            ['services', sv_name],
255
-                            strictly_positive=True,
256
-                        )
265
+                        err_bad_number(key, path, strictly_positive=True)
257 266
                     )
258 267
             elif key in {
259 268
                 'repeat',
... ...
@@ -265,19 +274,13 @@ def validate_vault_config(  # noqa: C901,PLR0912,PLR0915
265 274
                 'symbol',
266 275
             }:
267 276
                 if not isinstance(value, int):
268
-                    raise TypeError(err_not_an_int(['services', sv_name, key]))
277
+                    raise TypeError(err_not_an_int((*path, key)))
269 278
                 if value < 0:
270 279
                     raise ValueError(
271
-                        err_bad_number(
272
-                            key,
273
-                            ['services', sv_name],
274
-                            strictly_positive=False,
275
-                        )
280
+                        err_bad_number(key, path, strictly_positive=False)
276 281
                     )
277 282
             elif not allow_unknown_settings:
278
-                raise ValueError(
279
-                    err_unknown_setting(key, ['services', sv_name])
280
-                )
283
+                raise ValueError(err_unknown_setting(key, path))
281 284
 
282 285
 
283 286
 def is_vault_config(obj: Any) -> TypeIs[VaultConfig]:  # noqa: ANN401
... ...
@@ -303,6 +306,99 @@ def is_vault_config(obj: Any) -> TypeIs[VaultConfig]:  # noqa: ANN401
303 306
     return True
304 307
 
305 308
 
309
+def js_truthiness(value: Any, /) -> bool:  # noqa: ANN401
310
+    """Return the truthiness of the value, according to JavaScript/ECMAScript.
311
+
312
+    Like Python, ECMAScript considers certain values to be false in
313
+    a boolean context, and every other value to be true.  These
314
+    considerations do not agree: ECMAScript considers [`math.nan`][] to
315
+    be false too, and empty arrays and objects/dicts to be true,
316
+    contrary to Python.  Because of these discrepancies, we cannot defer
317
+    to [`bool`][] for ECMAScript truthiness checking, and need
318
+    a separate, explicit predicate.
319
+
320
+    (Some falsy values in ECMAScript aren't defined in Python:
321
+    `undefined`, and `document.all`.  We do not implement support for
322
+    those.)
323
+
324
+    !!! note
325
+
326
+        We cannot use a simple `value not in falsy_values` check,
327
+        because [`math.nan`][] behaves in annoying and obstructive ways.
328
+        In general, `float('NaN') == float('NaN')` is false, and
329
+        `float('NaN') != math.nan` and `math.nan != math.nan` are true.
330
+        CPython says `float('NaN') in [math.nan]` is false, PyPy3 says
331
+        it is true.  Seemingly the only reliable and portable way to
332
+        check for [`math.nan`][] is to use [`math.isnan`][] directly.
333
+
334
+    Args:
335
+        value: The value to test.
336
+
337
+    """  # noqa: RUF002
338
+    try:
339
+        if value in {None, False, 0, 0.0, ''}:
340
+            return False
341
+    except TypeError:
342
+        # All falsy values are hashable, so this can't be falsy.
343
+        return True
344
+    return not (isinstance(value, float) and math.isnan(value))
345
+
346
+
347
+def clean_up_falsy_vault_config_values(obj: Any) -> None:  # noqa: ANN401,C901,PLR0912
348
+    """Convert falsy values in a vault config to correct types, in-place.
349
+
350
+    Needed for compatibility with vault(1), which sometimes uses only
351
+    truthiness checks.
352
+
353
+    If vault(1) considered `obj` to be valid, then after clean up,
354
+    `obj` will be valid as per [`validate_vault_config`][].
355
+
356
+    Args:
357
+        obj:
358
+            A presumed valid vault configuration save for using falsy
359
+            values of the wrong type.
360
+
361
+    """
362
+    if (  # pragma: no cover
363
+        not isinstance(obj, dict)
364
+        or 'services' not in obj
365
+        or not isinstance(obj['services'], dict)
366
+    ):
367
+        # config is invalid
368
+        return
369
+    service_objects = list(obj['services'].values())
370
+    if not all(  # pragma: no cover
371
+        isinstance(service_obj, dict) for service_obj in service_objects
372
+    ):
373
+        # config is invalid
374
+        return
375
+    if 'global' in obj:
376
+        if isinstance(obj['global'], dict):
377
+            service_objects.append(obj['global'])
378
+        else:  # pragma: no cover
379
+            # config is invalid
380
+            return
381
+    for service_obj in service_objects:
382
+        for key, value in list(service_obj.items()):
383
+            # Use match/case here once Python 3.9 becomes unsupported.
384
+            if key == 'phrase':
385
+                if not js_truthiness(value):
386
+                    service_obj[key] = ''
387
+            elif key in {'notes', 'key', 'length', 'repeat'}:
388
+                if not js_truthiness(value):
389
+                    service_obj.pop(key)
390
+            elif key in {  # noqa: SIM102
391
+                'lower',
392
+                'upper',
393
+                'number',
394
+                'space',
395
+                'dash',
396
+                'symbol',
397
+            }:
398
+                if not js_truthiness(value) and value != 0:
399
+                    service_obj.pop(key)
400
+
401
+
306 402
 class KeyCommentPair(NamedTuple):
307 403
     """SSH key plus comment pair.  For typing purposes.
308 404
 
... ...
@@ -1410,6 +1410,7 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1410 1410
             err(f'Cannot load config: cannot decode JSON: {e}')
1411 1411
         except OSError as e:
1412 1412
             err(f'Cannot load config: {e.strerror}: {e.filename!r}')
1413
+        _types.clean_up_falsy_vault_config_values(maybe_config)
1413 1414
         if _types.is_vault_config(maybe_config):
1414 1415
             form = cast(
1415 1416
                 Literal['NFC', 'NFD', 'NFKC', 'NFKD'],
... ...
@@ -6,6 +6,7 @@ from __future__ import annotations
6 6
 
7 7
 import base64
8 8
 import contextlib
9
+import copy
9 10
 import enum
10 11
 import importlib.util
11 12
 import json
... ...
@@ -16,7 +17,9 @@ import tempfile
16 17
 import zipfile
17 18
 from typing import TYPE_CHECKING
18 19
 
20
+import hypothesis
19 21
 import pytest
22
+from hypothesis import strategies
20 23
 from typing_extensions import NamedTuple, Self, assert_never
21 24
 
22 25
 from derivepassphrase import _types, cli, ssh_agent, vault
... ...
@@ -38,6 +41,376 @@ if TYPE_CHECKING:
38 41
         derived_passphrase: bytes | str | None
39 42
 
40 43
 
44
+class ValidationSettings(NamedTuple):
45
+    allow_unknown_settings: bool
46
+    allow_derivepassphrase_extensions: bool
47
+
48
+
49
+class VaultTestConfig(NamedTuple):
50
+    config: Any
51
+    comment: str
52
+    validation_settings: ValidationSettings | None
53
+
54
+
55
+TEST_CONFIGS: list[VaultTestConfig] = [
56
+    VaultTestConfig(None, 'not a dict', None),
57
+    VaultTestConfig({}, 'missing required keys', None),
58
+    VaultTestConfig(
59
+        {'global': None, 'services': {}}, 'bad config value: global', None
60
+    ),
61
+    VaultTestConfig(
62
+        {'global': {'key': 123}, 'services': {}},
63
+        'bad config value: global.key',
64
+        None,
65
+    ),
66
+    VaultTestConfig(
67
+        {'global': {'phrase': 'abc', 'key': '...'}, 'services': {}},
68
+        '',
69
+        None,
70
+    ),
71
+    VaultTestConfig({'services': None}, 'bad config value: services', None),
72
+    VaultTestConfig(
73
+        {'services': {'1': {}, 2: {}}}, 'bad config value: services."2"', None
74
+    ),
75
+    VaultTestConfig(
76
+        {'services': {'1': {}, '2': 2}}, 'bad config value: services."2"', None
77
+    ),
78
+    VaultTestConfig(
79
+        {'services': {'sv': {'notes': ['sentinel', 'list']}}},
80
+        'bad config value: services.sv.notes',
81
+        None,
82
+    ),
83
+    VaultTestConfig(
84
+        {'services': {'sv': {'notes': 'blah blah blah'}}}, '', None
85
+    ),
86
+    VaultTestConfig(
87
+        {'services': {'sv': {'length': '200'}}},
88
+        'bad config value: services.sv.length',
89
+        None,
90
+    ),
91
+    VaultTestConfig(
92
+        {'services': {'sv': {'length': 0.5}}},
93
+        'bad config value: services.sv.length',
94
+        None,
95
+    ),
96
+    VaultTestConfig(
97
+        {'services': {'sv': {'length': ['sentinel', 'list']}}},
98
+        'bad config value: services.sv.length',
99
+        None,
100
+    ),
101
+    VaultTestConfig(
102
+        {'services': {'sv': {'length': -10}}},
103
+        'bad config value: services.sv.length',
104
+        None,
105
+    ),
106
+    VaultTestConfig(
107
+        {'services': {'sv': {'lower': '10'}}},
108
+        'bad config value: services.sv.lower',
109
+        None,
110
+    ),
111
+    VaultTestConfig(
112
+        {'services': {'sv': {'upper': -10}}},
113
+        'bad config value: services.sv.upper',
114
+        None,
115
+    ),
116
+    VaultTestConfig(
117
+        {'services': {'sv': {'number': ['sentinel', 'list']}}},
118
+        'bad config value: services.sv.number',
119
+        None,
120
+    ),
121
+    VaultTestConfig(
122
+        {
123
+            'global': {'phrase': 'my secret phrase'},
124
+            'services': {'sv': {'length': 10}},
125
+        },
126
+        '',
127
+        None,
128
+    ),
129
+    VaultTestConfig(
130
+        {'services': {'sv': {'length': 10, 'phrase': '...'}}}, '', None
131
+    ),
132
+    VaultTestConfig(
133
+        {'services': {'sv': {'length': 10, 'key': '...'}}}, '', None
134
+    ),
135
+    VaultTestConfig(
136
+        {'services': {'sv': {'upper': 10, 'key': '...'}}}, '', None
137
+    ),
138
+    VaultTestConfig(
139
+        {'services': {'sv': {'phrase': 'abc', 'key': '...'}}}, '', None
140
+    ),
141
+    VaultTestConfig(
142
+        {
143
+            'global': {'phrase': 'abc'},
144
+            'services': {'sv': {'phrase': 'abc', 'length': 10}},
145
+        },
146
+        '',
147
+        None,
148
+    ),
149
+    VaultTestConfig(
150
+        {
151
+            'global': {'key': '...'},
152
+            'services': {'sv': {'phrase': 'abc', 'length': 10}},
153
+        },
154
+        '',
155
+        None,
156
+    ),
157
+    VaultTestConfig(
158
+        {
159
+            'global': {'key': '...'},
160
+            'services': {
161
+                'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
162
+                'sv2': {'length': 10, 'repeat': 1, 'lower': 1},
163
+            },
164
+        },
165
+        '',
166
+        None,
167
+    ),
168
+    VaultTestConfig(
169
+        {
170
+            'global': {'key': '...', 'unicode_normalization_form': 'NFC'},
171
+            'services': {
172
+                'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
173
+                'sv2': {'length': 10, 'repeat': 1, 'lower': 1},
174
+            },
175
+        },
176
+        '',
177
+        None,
178
+    ),
179
+    VaultTestConfig(
180
+        {
181
+            'global': {'key': '...', 'unicode_normalization_form': True},
182
+            'services': {},
183
+        },
184
+        'bad config value: global.unicode_normalization_form',
185
+        None,
186
+    ),
187
+    VaultTestConfig(
188
+        {
189
+            'global': {'key': '...', 'unicode_normalization_form': 'NFC'},
190
+            'services': {
191
+                'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
192
+                'sv2': {'length': 10, 'repeat': 1, 'lower': 1},
193
+            },
194
+        },
195
+        '',
196
+        ValidationSettings(False, True),
197
+    ),
198
+    VaultTestConfig(
199
+        {
200
+            'global': {'key': '...', 'unicode_normalization_form': 'NFC'},
201
+            'services': {
202
+                'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
203
+                'sv2': {'length': 10, 'repeat': 1, 'lower': 1},
204
+            },
205
+        },
206
+        'extension key: .global.unicode_normalization_form',
207
+        ValidationSettings(False, False),
208
+    ),
209
+    VaultTestConfig(
210
+        {
211
+            'global': {'key': '...', 'unknown_key': True},
212
+            'services': {
213
+                'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
214
+                'sv2': {'length': 10, 'repeat': 1, 'lower': 1},
215
+            },
216
+        },
217
+        '',
218
+        ValidationSettings(True, False),
219
+    ),
220
+    VaultTestConfig(
221
+        {
222
+            'global': {'key': '...', 'unknown_key': True},
223
+            'services': {
224
+                'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
225
+                'sv2': {'length': 10, 'repeat': 1, 'lower': 1},
226
+            },
227
+        },
228
+        'unknown key: .global.unknown_key',
229
+        ValidationSettings(False, False),
230
+    ),
231
+    VaultTestConfig(
232
+        {
233
+            'global': {'key': '...'},
234
+            'services': {
235
+                'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
236
+                'sv2': {
237
+                    'length': 10,
238
+                    'repeat': 1,
239
+                    'lower': 1,
240
+                    'unknown_key': True,
241
+                },
242
+            },
243
+        },
244
+        'unknown_key: .services.sv2.unknown_key',
245
+        ValidationSettings(False, False),
246
+    ),
247
+    VaultTestConfig(
248
+        {
249
+            'global': {'key': '...', 'unicode_normalization_form': 'NFC'},
250
+            'services': {
251
+                'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
252
+                'sv2': {
253
+                    'length': 10,
254
+                    'repeat': 1,
255
+                    'lower': 1,
256
+                    'unknown_key': True,
257
+                },
258
+            },
259
+        },
260
+        '',
261
+        ValidationSettings(True, True),
262
+    ),
263
+    VaultTestConfig(
264
+        {
265
+            'global': {'key': '...', 'unicode_normalization_form': 'NFC'},
266
+            'services': {
267
+                'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
268
+                'sv2': {
269
+                    'length': 10,
270
+                    'repeat': 1,
271
+                    'lower': 1,
272
+                    'unknown_key': True,
273
+                },
274
+            },
275
+        },
276
+        (
277
+            'extension key (permitted): .global.unicode_normalization_form; '
278
+            'unknown key: .services.sv2.unknown_key'
279
+        ),
280
+        ValidationSettings(False, True),
281
+    ),
282
+    VaultTestConfig(
283
+        {
284
+            'global': {'key': '...', 'unicode_normalization_form': 'NFC'},
285
+            'services': {
286
+                'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
287
+                'sv2': {
288
+                    'length': 10,
289
+                    'repeat': 1,
290
+                    'lower': 1,
291
+                    'unknown_key': True,
292
+                },
293
+            },
294
+        },
295
+        (
296
+            'unknown key (permitted): .services.sv2.unknown_key; '
297
+            'extension key: .global.unicode_normalization_form'
298
+        ),
299
+        ValidationSettings(True, False),
300
+    ),
301
+]
302
+
303
+
304
+def is_valid_test_config(conf: VaultTestConfig, /) -> bool:
305
+    """Return true if the test config is valid.
306
+
307
+    Args:
308
+        conf: The test config to check.
309
+
310
+    """
311
+    return not conf.comment and conf.validation_settings in {
312
+        None,
313
+        (True, True),
314
+    }
315
+
316
+
317
+def _test_config_ids(val: VaultTestConfig) -> Any:  # pragma: no cover
318
+    """pytest id function for VaultTestConfig objects."""
319
+    assert isinstance(val, VaultTestConfig)
320
+    return val[1] or (val[0], val[1], val[2])
321
+
322
+
323
+def is_smudgable_vault_test_config(conf: VaultTestConfig) -> bool:
324
+    """Check whether this vault test config can be effectively smudged.
325
+
326
+    A "smudged" test config is one where falsy values (in the JavaScript
327
+    sense) can be replaced by other falsy values without changing the
328
+    meaning of the config.
329
+
330
+    Args:
331
+        conf: A test config to check.
332
+
333
+    Returns:
334
+        True if the test config can be smudged, False otherwise.
335
+
336
+    """
337
+    config = conf.config
338
+    return bool(
339
+        isinstance(config, dict)
340
+        and ('global' not in config or isinstance(config['global'], dict))
341
+        and ('services' in config and isinstance(config['services'], dict))
342
+        and all(isinstance(x, dict) for x in config['services'].values())
343
+        and (config['services'] or config.get('global'))
344
+    )
345
+
346
+
347
+@strategies.composite
348
+def smudged_vault_test_config(
349
+    draw: strategies.DrawFn,
350
+    config: Any = strategies.sampled_from(TEST_CONFIGS).filter(  # noqa: B008
351
+        is_smudgable_vault_test_config
352
+    ),
353
+) -> Any:
354
+    """Hypothesis strategy to replace falsy values with other falsy values.
355
+
356
+    Uses [`_types.js_truthiness`][] internally, which is tested
357
+    separately by
358
+    [`tests.test_derivepassphrase_types.test_100_js_truthiness`][].
359
+
360
+    Args:
361
+        draw:
362
+            The hypothesis draw function.
363
+        config:
364
+            A strategy which generates [`VaultTestConfig`][] objects.
365
+
366
+    Returns:
367
+        A new [`VaultTestConfig`][] where some falsy values have been
368
+        replaced or added.
369
+
370
+    """
371
+
372
+    falsy = (None, False, 0, 0.0, '', float('nan'))
373
+    falsy_no_str = (None, False, 0, 0.0, float('nan'))
374
+    falsy_no_zero = (None, False, '', float('nan'))
375
+    conf = draw(config)
376
+    hypothesis.assume(is_smudgable_vault_test_config(conf))
377
+    obj = copy.deepcopy(conf.config)
378
+    services: list[dict[str, Any]] = list(obj['services'].values())
379
+    if 'global' in obj:
380
+        services.append(obj['global'])
381
+    assert all(isinstance(x, dict) for x in services), (
382
+        'is_smudgable_vault_test_config guard failed to '
383
+        'ensure each setings dict is a dict'
384
+    )
385
+    for service in services:
386
+        for key in ('phrase',):
387
+            value = service.get(key)
388
+            if not _types.js_truthiness(value) and value != '':
389
+                service[key] = draw(strategies.sampled_from(falsy_no_str))
390
+        for key in (
391
+            'notes',
392
+            'key',
393
+            'length',
394
+            'repeat',
395
+        ):
396
+            value = service.get(key)
397
+            if not _types.js_truthiness(value):
398
+                service[key] = draw(strategies.sampled_from(falsy))
399
+        for key in (
400
+            'lower',
401
+            'upper',
402
+            'number',
403
+            'space',
404
+            'dash',
405
+            'symbol',
406
+        ):
407
+            value = service.get(key)
408
+            if not _types.js_truthiness(value) and value != 0:
409
+                service[key] = draw(strategies.sampled_from(falsy_no_zero))
410
+    hypothesis.assume(obj != conf.config)
411
+    return VaultTestConfig(obj, conf.comment, conf.validation_settings)
412
+
413
+
41 414
 class KnownSSHAgent(str, enum.Enum):
42 415
     UNKNOWN: str = '(unknown)'
43 416
     Pageant: str = 'Pageant'
... ...
@@ -5,6 +5,7 @@
5 5
 from __future__ import annotations
6 6
 
7 7
 import contextlib
8
+import copy
8 9
 import errno
9 10
 import json
10 11
 import os
... ...
@@ -13,7 +14,9 @@ import socket
13 14
 from typing import TYPE_CHECKING
14 15
 
15 16
 import click.testing
17
+import hypothesis
16 18
 import pytest
19
+from hypothesis import strategies
17 20
 from typing_extensions import NamedTuple
18 21
 
19 22
 import tests
... ...
@@ -39,6 +42,8 @@ DUMMY_KEY2_B64 = tests.DUMMY_KEY2_B64
39 42
 DUMMY_KEY3 = tests.DUMMY_KEY3
40 43
 DUMMY_KEY3_B64 = tests.DUMMY_KEY3_B64
41 44
 
45
+TEST_CONFIGS = tests.TEST_CONFIGS
46
+
42 47
 
43 48
 class IncompatibleConfiguration(NamedTuple):
44 49
     other_options: list[tuple[str, ...]]
... ...
@@ -573,7 +578,74 @@ class TestCLI:
573 578
             error='mutually exclusive with '
574 579
         ), 'expected error exit and known error message'
575 580
 
576
-    def test_213_import_bad_config_not_vault_config(
581
+    @pytest.mark.parametrize(
582
+        'config',
583
+        [
584
+            conf.config
585
+            for conf in tests.TEST_CONFIGS
586
+            if tests.is_valid_test_config(conf)
587
+        ],
588
+    )
589
+    def test_213_import_config_success(
590
+        self,
591
+        monkeypatch: pytest.MonkeyPatch,
592
+        config: Any,
593
+    ) -> None:
594
+        runner = click.testing.CliRunner(mix_stderr=False)
595
+        with tests.isolated_vault_config(
596
+            monkeypatch=monkeypatch,
597
+            runner=runner,
598
+            config={'services': {}},
599
+        ):
600
+            _result = runner.invoke(
601
+                cli.derivepassphrase_vault,
602
+                ['--import', '-'],
603
+                input=json.dumps(config),
604
+                catch_exceptions=False,
605
+            )
606
+            with open(
607
+                cli._config_filename(subsystem='vault'), encoding='UTF-8'
608
+            ) as infile:
609
+                config2 = json.load(infile)
610
+        result = tests.ReadableResult.parse(_result)
611
+        assert result.clean_exit(empty_stderr=True), 'expected clean exit'
612
+        assert config2 == config, 'config not imported correctly'
613
+
614
+    @hypothesis.given(
615
+        conf=tests.smudged_vault_test_config(
616
+            strategies.sampled_from(TEST_CONFIGS).filter(
617
+                tests.is_valid_test_config
618
+            )
619
+        )
620
+    )
621
+    def test_213a_import_config_success(
622
+        self,
623
+        conf: tests.VaultTestConfig,
624
+    ) -> None:
625
+        config = conf.config
626
+        config2 = copy.deepcopy(config)
627
+        _types.clean_up_falsy_vault_config_values(config2)
628
+        runner = click.testing.CliRunner(mix_stderr=False)
629
+        with tests.isolated_vault_config(
630
+            monkeypatch=pytest.MonkeyPatch(),
631
+            runner=runner,
632
+            config={'services': {}},
633
+        ):
634
+            _result = runner.invoke(
635
+                cli.derivepassphrase_vault,
636
+                ['--import', '-'],
637
+                input=json.dumps(config),
638
+                catch_exceptions=False,
639
+            )
640
+            with open(
641
+                cli._config_filename(subsystem='vault'), encoding='UTF-8'
642
+            ) as infile:
643
+                config3 = json.load(infile)
644
+        result = tests.ReadableResult.parse(_result)
645
+        assert result.clean_exit(empty_stderr=True), 'expected clean exit'
646
+        assert config3 == config2, 'config not imported correctly'
647
+
648
+    def test_213b_import_bad_config_not_vault_config(
577 649
         self,
578 650
         monkeypatch: pytest.MonkeyPatch,
579 651
     ) -> None:
... ...
@@ -590,7 +662,7 @@ class TestCLI:
590 662
             error='Invalid vault config'
591 663
         ), 'expected error exit and known error message'
592 664
 
593
-    def test_213a_import_bad_config_not_json_data(
665
+    def test_213c_import_bad_config_not_json_data(
594 666
         self,
595 667
         monkeypatch: pytest.MonkeyPatch,
596 668
     ) -> None:
... ...
@@ -607,7 +679,7 @@ class TestCLI:
607 679
             error='cannot decode JSON'
608 680
         ), 'expected error exit and known error message'
609 681
 
610
-    def test_213b_import_bad_config_not_a_file(
682
+    def test_213d_import_bad_config_not_a_file(
611 683
         self,
612 684
         monkeypatch: pytest.MonkeyPatch,
613 685
     ) -> None:
... ...
@@ -4,136 +4,103 @@
4 4
 
5 5
 from __future__ import annotations
6 6
 
7
+import copy
8
+
9
+import hypothesis
7 10
 import pytest
11
+from hypothesis import strategies
8 12
 from typing_extensions import Any
9 13
 
14
+import tests
10 15
 from derivepassphrase import _types
11 16
 
12 17
 
18
+@hypothesis.given(
19
+    value=strategies.one_of(
20
+        strategies.recursive(
21
+            strategies.one_of(
22
+                strategies.none(),
23
+                strategies.booleans(),
24
+                strategies.integers(),
25
+                strategies.floats(allow_nan=False, allow_infinity=False),
26
+                strategies.text(max_size=100),
27
+                strategies.binary(max_size=100),
28
+            ),
29
+            lambda s: strategies.one_of(
30
+                strategies.frozensets(s, max_size=100),
31
+                strategies.builds(
32
+                    tuple, strategies.frozensets(s, max_size=100)
33
+                ),
34
+            ),
35
+            max_leaves=8,
36
+        ),
37
+        strategies.recursive(
38
+            strategies.one_of(
39
+                strategies.none(),
40
+                strategies.booleans(),
41
+                strategies.integers(),
42
+                strategies.floats(allow_nan=False, allow_infinity=False),
43
+                strategies.text(max_size=100),
44
+                strategies.binary(max_size=100),
45
+            ),
46
+            lambda s: strategies.one_of(
47
+                strategies.lists(s, max_size=100),
48
+                strategies.dictionaries(strategies.text(max_size=100), s),
49
+            ),
50
+            max_leaves=25,
51
+        ),
52
+        strategies.builds(tuple),
53
+        strategies.builds(list),
54
+        strategies.builds(dict),
55
+        strategies.builds(set),
56
+        strategies.builds(frozenset),
57
+    ),
58
+)
59
+def test_100_js_truthiness(value: Any) -> None:
60
+    expected = (
61
+        value is not None  # noqa: PLR1714
62
+        and value != False  # noqa: E712
63
+        and value != 0
64
+        and value != 0.0
65
+        and value != ''
66
+    )
67
+    assert _types.js_truthiness(value) == expected
68
+
69
+
13 70
 @pytest.mark.parametrize(
14
-    ['obj', 'comment'],
71
+    'test_config',
15 72
     [
16
-        (None, 'not a dict'),
17
-        ({}, 'missing required keys'),
18
-        ({'global': None, 'services': {}}, 'bad config value: global'),
19
-        (
20
-            {'global': {'key': 123}, 'services': {}},
21
-            'bad config value: global.key',
22
-        ),
23
-        (
24
-            {'global': {'phrase': 'abc', 'key': '...'}, 'services': {}},
25
-            '',
26
-        ),
27
-        ({'services': None}, 'bad config value: services'),
28
-        ({'services': {2: {}}}, 'bad config value: services."2"'),
29
-        ({'services': {'2': 2}}, 'bad config value: services."2"'),
30
-        (
31
-            {'services': {'sv': {'notes': False}}},
32
-            'bad config value: services.sv.notes',
33
-        ),
34
-        ({'services': {'sv': {'notes': 'blah blah blah'}}}, ''),
35
-        (
36
-            {'services': {'sv': {'length': '200'}}},
37
-            'bad config value: services.sv.length',
38
-        ),
39
-        (
40
-            {'services': {'sv': {'length': 0.5}}},
41
-            'bad config value: services.sv.length',
42
-        ),
43
-        (
44
-            {'services': {'sv': {'length': -10}}},
45
-            'bad config value: services.sv.length',
46
-        ),
47
-        (
48
-            {'services': {'sv': {'lower': '10'}}},
49
-            'bad config value: services.sv.lower',
50
-        ),
51
-        (
52
-            {'services': {'sv': {'upper': -10}}},
53
-            'bad config value: services.sv.upper',
54
-        ),
55
-        (
56
-            {
57
-                'global': {'phrase': 'my secret phrase'},
58
-                'services': {'sv': {'length': 10}},
59
-            },
60
-            '',
61
-        ),
62
-        ({'services': {'sv': {'length': 10, 'phrase': '...'}}}, ''),
63
-        ({'services': {'sv': {'length': 10, 'key': '...'}}}, ''),
64
-        ({'services': {'sv': {'upper': 10, 'key': '...'}}}, ''),
65
-        ({'services': {'sv': {'phrase': 'abc', 'key': '...'}}}, ''),
66
-        (
67
-            {
68
-                'global': {'phrase': 'abc'},
69
-                'services': {'sv': {'phrase': 'abc', 'length': 10}},
70
-            },
71
-            '',
72
-        ),
73
-        (
74
-            {
75
-                'global': {'key': '...'},
76
-                'services': {'sv': {'phrase': 'abc', 'length': 10}},
77
-            },
78
-            '',
79
-        ),
80
-        (
81
-            {
82
-                'global': {'key': '...'},
83
-                'services': {
84
-                    'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
85
-                    'sv2': {'length': 10, 'repeat': 1, 'lower': 1},
86
-                },
87
-            },
88
-            '',
89
-        ),
90
-        (
91
-            {
92
-                'global': {'key': '...', 'unicode_normalization_form': 'NFC'},
93
-                'services': {
94
-                    'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
95
-                    'sv2': {'length': 10, 'repeat': 1, 'lower': 1},
96
-                },
97
-            },
98
-            '',
99
-        ),
100
-        (
101
-            {
102
-                'global': {'key': '...', 'unicode_normalization_form': None},
103
-                'services': {},
104
-            },
105
-            'bad config value: global.unicode_normalization_form',
106
-        ),
107
-        (
108
-            {
109
-                'global': {'key': '...', 'unknown_key': None},
110
-                'services': {
111
-                    'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
112
-                    'sv2': {'length': 10, 'repeat': 1, 'lower': 1},
113
-                },
114
-            },
115
-            '',
116
-        ),
117
-        (
118
-            {
119
-                'global': {'key': '...', 'unicode_normalization_form': 'NFC'},
120
-                'services': {
121
-                    'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
122
-                    'sv2': {
123
-                        'length': 10,
124
-                        'repeat': 1,
125
-                        'lower': 1,
126
-                        'unknown_key': None,
127
-                    },
128
-                },
129
-            },
130
-            '',
131
-        ),
73
+        conf
74
+        for conf in tests.TEST_CONFIGS
75
+        if conf.validation_settings in {None, (True, True)}
132 76
     ],
77
+    ids=tests._test_config_ids,
133 78
 )
134
-def test_200_is_vault_config(obj: Any, comment: str) -> None:
135
-    is_vault_config = _types.is_vault_config
136
-    assert is_vault_config(obj) == (not comment), (
79
+def test_200_is_vault_config(test_config: tests.VaultTestConfig) -> None:
80
+    obj, comment, _ = test_config
81
+    obj = copy.deepcopy(obj)
82
+    _types.clean_up_falsy_vault_config_values(obj)
83
+    assert _types.is_vault_config(obj) == (not comment), (
84
+        'failed to complain about: ' + comment
85
+        if comment
86
+        else 'failed on valid example'
87
+    )
88
+
89
+
90
+@hypothesis.given(
91
+    test_config=tests.smudged_vault_test_config(
92
+        config=strategies.sampled_from(tests.TEST_CONFIGS).filter(
93
+            tests.is_valid_test_config
94
+        )
95
+    )
96
+)
97
+def test_200a_is_vault_config_smudged(
98
+    test_config: tests.VaultTestConfig,
99
+) -> None:
100
+    obj, comment, _ = test_config
101
+    obj = copy.deepcopy(obj)
102
+    _types.clean_up_falsy_vault_config_values(obj)
103
+    assert _types.is_vault_config(obj) == (not comment), (
137 104
         'failed to complain about: ' + comment
138 105
         if comment
139 106
         else 'failed on valid example'
... ...
@@ -141,88 +108,62 @@ def test_200_is_vault_config(obj: Any, comment: str) -> None:
141 108
 
142 109
 
143 110
 @pytest.mark.parametrize(
144
-    ['obj', 'allow_unknown_settings', 'allow_derivepassphrase_extensions'],
145
-    [
146
-        (
147
-            {
148
-                'global': {'key': '...', 'unicode_normalization_form': 'NFC'},
149
-                'services': {
150
-                    'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
151
-                    'sv2': {'length': 10, 'repeat': 1, 'lower': 1},
152
-                },
153
-            },
154
-            False,
155
-            False,
156
-        ),
157
-        (
158
-            {
159
-                'global': {'key': '...', 'unknown_key': None},
160
-                'services': {
161
-                    'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
162
-                    'sv2': {'length': 10, 'repeat': 1, 'lower': 1},
163
-                },
164
-            },
165
-            False,
166
-            False,
167
-        ),
168
-        (
169
-            {
170
-                'global': {'key': '...', 'unicode_normalization_form': 'NFC'},
171
-                'services': {
172
-                    'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
173
-                    'sv2': {
174
-                        'length': 10,
175
-                        'repeat': 1,
176
-                        'lower': 1,
177
-                        'unknown_key': None,
178
-                    },
179
-                },
180
-            },
181
-            False,
182
-            False,
183
-        ),
184
-        (
185
-            {
186
-                'global': {'key': '...', 'unicode_normalization_form': 'NFC'},
187
-                'services': {
188
-                    'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
189
-                    'sv2': {
190
-                        'length': 10,
191
-                        'repeat': 1,
192
-                        'lower': 1,
193
-                        'unknown_key': None,
194
-                    },
195
-                },
196
-            },
197
-            False,
198
-            True,
199
-        ),
200
-        (
201
-            {
202
-                'global': {'key': '...', 'unicode_normalization_form': 'NFC'},
203
-                'services': {
204
-                    'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
205
-                    'sv2': {
206
-                        'length': 10,
207
-                        'repeat': 1,
208
-                        'lower': 1,
209
-                        'unknown_key': None,
210
-                    },
211
-                },
212
-            },
213
-            True,
214
-            False,
215
-        ),
216
-    ],
111
+    'test_config', tests.TEST_CONFIGS, ids=tests._test_config_ids
112
+)
113
+def test_400_validate_vault_config(test_config: tests.VaultTestConfig) -> None:
114
+    obj, comment, validation_settings = test_config
115
+    allow_unknown_settings, allow_derivepassphrase_extensions = (
116
+        validation_settings or (True, True)
117
+    )
118
+    obj = copy.deepcopy(obj)
119
+    _types.clean_up_falsy_vault_config_values(obj)
120
+    if comment:
121
+        with pytest.raises((TypeError, ValueError)):
122
+            _types.validate_vault_config(
123
+                obj,
124
+                allow_unknown_settings=allow_unknown_settings,
125
+                allow_derivepassphrase_extensions=allow_derivepassphrase_extensions,
126
+            )
127
+    else:
128
+        try:
129
+            _types.validate_vault_config(
130
+                obj,
131
+                allow_unknown_settings=allow_unknown_settings,
132
+                allow_derivepassphrase_extensions=allow_derivepassphrase_extensions,
133
+            )
134
+        except (TypeError, ValueError) as exc:  # pragma: no cover
135
+            assert not exc, 'failed to validate valid example'  # noqa: PT017
136
+
137
+
138
+@hypothesis.given(
139
+    test_config=tests.smudged_vault_test_config(
140
+        config=strategies.sampled_from(tests.TEST_CONFIGS).filter(
141
+            tests.is_smudgable_vault_test_config
142
+        )
217 143
     )
218
-def test_400_validate_vault_config(
219
-    obj: Any,
220
-    allow_unknown_settings: bool,
221
-    allow_derivepassphrase_extensions: bool,
144
+)
145
+def test_400a_validate_vault_config_smudged(
146
+    test_config: tests.VaultTestConfig,
222 147
 ) -> None:
223
-    with pytest.raises((TypeError, ValueError), match='vault config '):
148
+    obj, comment, validation_settings = test_config
149
+    allow_unknown_settings, allow_derivepassphrase_extensions = (
150
+        validation_settings or (True, True)
151
+    )
152
+    obj = copy.deepcopy(obj)
153
+    _types.clean_up_falsy_vault_config_values(obj)
154
+    if comment:
155
+        with pytest.raises((TypeError, ValueError)):
156
+            _types.validate_vault_config(
157
+                obj,
158
+                allow_unknown_settings=allow_unknown_settings,
159
+                allow_derivepassphrase_extensions=allow_derivepassphrase_extensions,
160
+            )
161
+    else:
162
+        try:
224 163
             _types.validate_vault_config(
225 164
                 obj,
226 165
                 allow_unknown_settings=allow_unknown_settings,
227 166
                 allow_derivepassphrase_extensions=allow_derivepassphrase_extensions,
228 167
             )
168
+        except (TypeError, ValueError) as exc:  # pragma: no cover
169
+            assert not exc, 'failed to validate valid example'  # noqa: PT017
229 170