Move vault config exporter functions to the top
Marco Ricci

Marco Ricci commited on 2025-01-19 21:10:38
Zeige 2 geänderte Dateien mit 211 Einfügungen und 211 Löschungen.


For both the `storeroom` and the `vault_native` modules, move the
exporter function to the top of the module.
... ...
@@ -86,6 +86,174 @@ __all__ = ('export_storeroom_data',)
86 86
 logger = logging.getLogger(__name__)
87 87
 
88 88
 
89
+@exporter.register_export_vault_config_data_handler('storeroom')
90
+def export_storeroom_data(  # noqa: C901,D417,PLR0912,PLR0914,PLR0915
91
+    path: str | bytes | os.PathLike | None = None,
92
+    key: str | Buffer | None = None,
93
+    *,
94
+    format: str = 'storeroom',  # noqa: A002
95
+) -> dict[str, Any]:
96
+    """Export the full configuration stored in the storeroom.
97
+
98
+    See [`exporter.ExportVaultConfigDataFunction`][] for an explanation
99
+    of the call signature, and the exceptions to expect.
100
+
101
+    Other Args:
102
+        format:
103
+            The only supported format is `storeroom`.
104
+
105
+    """  # noqa: DOC201,DOC501
106
+    # Trigger import errors if necessary.
107
+    importlib.import_module('cryptography')
108
+    if path is None:
109
+        path = exporter.get_vault_path()
110
+    if key is None:
111
+        key = exporter.get_vault_key()
112
+    if format != 'storeroom':  # pragma: no cover
113
+        msg = exporter.INVALID_VAULT_NATIVE_CONFIGURATION_FORMAT.format(
114
+            fmt=format
115
+        )
116
+        raise ValueError(msg)
117
+    try:
118
+        master_keys_file = open(  # noqa: SIM115
119
+            os.path.join(os.fsdecode(path), '.keys'),
120
+            encoding='utf-8',
121
+        )
122
+    except FileNotFoundError as exc:
123
+        raise exporter.NotAVaultConfigError(
124
+            os.fsdecode(path),
125
+            format='storeroom',
126
+        ) from exc
127
+    with master_keys_file:
128
+        header = json.loads(master_keys_file.readline())
129
+        if header != {'version': 1}:
130
+            msg = 'bad or unsupported keys version header'
131
+            raise RuntimeError(msg)
132
+        raw_keys_data = base64.standard_b64decode(master_keys_file.readline())
133
+        encrypted_keys_params, encrypted_keys = struct.unpack(
134
+            f'B {len(raw_keys_data) - 1}s', raw_keys_data
135
+        )
136
+        if master_keys_file.read():
137
+            msg = 'trailing data; cannot make sense of .keys file'
138
+            raise RuntimeError(msg)
139
+    encrypted_keys_version = encrypted_keys_params >> 4
140
+    if encrypted_keys_version != 1:
141
+        msg = f'cannot handle version {encrypted_keys_version} encrypted keys'
142
+        raise RuntimeError(msg)
143
+    logger.info(
144
+        _msg.TranslatedString(_msg.InfoMsgTemplate.PARSING_MASTER_KEYS_DATA)
145
+    )
146
+    encrypted_keys_iterations = 2 ** (10 + (encrypted_keys_params & 0x0F))
147
+    master_keys_keys = _derive_master_keys_keys(key, encrypted_keys_iterations)
148
+    master_keys = _decrypt_master_keys_data(encrypted_keys, master_keys_keys)
149
+
150
+    config_structure: dict[str, Any] = {}
151
+    json_contents: dict[str, bytes] = {}
152
+    # Use glob.glob(..., root_dir=...) here once Python 3.9 becomes
153
+    # unsupported.
154
+    storeroom_path_str = os.fsdecode(path)
155
+    valid_hashdirs = [
156
+        hashdir_name
157
+        for hashdir_name in os.listdir(storeroom_path_str)
158
+        if fnmatch.fnmatch(hashdir_name, '[01][0-9a-f]')
159
+    ]
160
+    for file in valid_hashdirs:
161
+        logger.info(
162
+            _msg.TranslatedString(
163
+                _msg.InfoMsgTemplate.DECRYPTING_BUCKET,
164
+                bucket_number=file,
165
+            )
166
+        )
167
+        bucket_contents = [
168
+            bytes(item)
169
+            for item in _decrypt_bucket_file(file, master_keys, root_dir=path)
170
+        ]
171
+        bucket_index = json.loads(bucket_contents.pop(0))
172
+        for pos, item in enumerate(bucket_index):
173
+            json_contents[item] = bucket_contents[pos]
174
+            logger.debug(
175
+                _msg.TranslatedString(
176
+                    _msg.DebugMsgTemplate.BUCKET_ITEM_FOUND,
177
+                    path=item,
178
+                    value=bucket_contents[pos],
179
+                )
180
+            )
181
+    dirs_to_check: dict[str, list[str]] = {}
182
+    json_payload: Any
183
+    logger.info(
184
+        _msg.TranslatedString(_msg.InfoMsgTemplate.ASSEMBLING_CONFIG_STRUCTURE)
185
+    )
186
+    for item_path, json_content in sorted(json_contents.items()):
187
+        if item_path.endswith('/'):
188
+            logger.debug(
189
+                _msg.TranslatedString(
190
+                    _msg.DebugMsgTemplate.POSTPONING_DIRECTORY_CONTENTS_CHECK,
191
+                    path=item_path,
192
+                    contents=json_content.decode('utf-8'),
193
+                )
194
+            )
195
+            json_payload = json.loads(json_content)
196
+            if not isinstance(json_payload, list) or any(
197
+                not isinstance(x, str) for x in json_payload
198
+            ):
199
+                msg = (
200
+                    f'Directory index is not actually an index: '
201
+                    f'{json_content!r}'
202
+                )
203
+                raise RuntimeError(msg)
204
+            dirs_to_check[item_path] = json_payload
205
+            logger.debug(
206
+                _msg.TranslatedString(
207
+                    _msg.DebugMsgTemplate.SETTING_CONFIG_STRUCTURE_CONTENTS_EMPTY_DIRECTORY,
208
+                    path=item_path,
209
+                ),
210
+            )
211
+            _store(config_structure, item_path, b'{}')
212
+        else:
213
+            logger.debug(
214
+                _msg.TranslatedString(
215
+                    _msg.DebugMsgTemplate.SETTING_CONFIG_STRUCTURE_CONTENTS,
216
+                    path=item_path,
217
+                    value=json_content.decode('utf-8'),
218
+                ),
219
+            )
220
+            _store(config_structure, item_path, json_content)
221
+    logger.info(
222
+        _msg.TranslatedString(
223
+            _msg.InfoMsgTemplate.CHECKING_CONFIG_STRUCTURE_CONSISTENCY,
224
+        )
225
+    )
226
+    # Sorted order is important; see `maybe_obj` below.
227
+    for dir_, namelist_ in sorted(dirs_to_check.items()):
228
+        namelist = [x.rstrip('/') for x in namelist_]
229
+        obj: dict[Any, Any] = config_structure
230
+        for part in dir_.split('/'):
231
+            if part:
232
+                # Because we iterate paths in sorted order, parent
233
+                # directories are encountered before child directories.
234
+                # So parent directories always exist (lest we would have
235
+                # aborted earlier).
236
+                #
237
+                # Of course, the type checker doesn't necessarily know
238
+                # this, so we need to use assertions anyway.
239
+                maybe_obj = obj.get(part)
240
+                assert isinstance(maybe_obj, dict), (
241
+                    f'Cannot traverse storage path {dir_!r}'
242
+                )
243
+                obj = maybe_obj
244
+        if set(obj.keys()) != set(namelist):
245
+            msg = f'Object key mismatch for path {dir_!r}'
246
+            raise RuntimeError(msg)
247
+        logger.debug(
248
+            _msg.TranslatedString(
249
+                _msg.DebugMsgTemplate.DIRECTORY_CONTENTS_CHECK_OK,
250
+                path=dir_,
251
+                contents=json.dumps(namelist_),
252
+            )
253
+        )
254
+    return config_structure
255
+
256
+
89 257
 def _h(bs: Buffer) -> str:
90 258
     return '<{}>'.format(memoryview(bs).hex(' '))
91 259
 
... ...
@@ -585,174 +753,6 @@ def _store(config: dict[str, Any], path: str, json_contents: bytes) -> None:
585 753
         config[path_parts[-1]] = contents
586 754
 
587 755
 
588
-@exporter.register_export_vault_config_data_handler('storeroom')
589
-def export_storeroom_data(  # noqa: C901,D417,PLR0912,PLR0914,PLR0915
590
-    path: str | bytes | os.PathLike | None = None,
591
-    key: str | Buffer | None = None,
592
-    *,
593
-    format: str = 'storeroom',  # noqa: A002
594
-) -> dict[str, Any]:
595
-    """Export the full configuration stored in the storeroom.
596
-
597
-    See [`exporter.ExportVaultConfigDataFunction`][] for an explanation
598
-    of the call signature, and the exceptions to expect.
599
-
600
-    Other Args:
601
-        format:
602
-            The only supported format is `storeroom`.
603
-
604
-    """  # noqa: DOC201,DOC501
605
-    # Trigger import errors if necessary.
606
-    importlib.import_module('cryptography')
607
-    if path is None:
608
-        path = exporter.get_vault_path()
609
-    if key is None:
610
-        key = exporter.get_vault_key()
611
-    if format != 'storeroom':  # pragma: no cover
612
-        msg = exporter.INVALID_VAULT_NATIVE_CONFIGURATION_FORMAT.format(
613
-            fmt=format
614
-        )
615
-        raise ValueError(msg)
616
-    try:
617
-        master_keys_file = open(  # noqa: SIM115
618
-            os.path.join(os.fsdecode(path), '.keys'),
619
-            encoding='utf-8',
620
-        )
621
-    except FileNotFoundError as exc:
622
-        raise exporter.NotAVaultConfigError(
623
-            os.fsdecode(path),
624
-            format='storeroom',
625
-        ) from exc
626
-    with master_keys_file:
627
-        header = json.loads(master_keys_file.readline())
628
-        if header != {'version': 1}:
629
-            msg = 'bad or unsupported keys version header'
630
-            raise RuntimeError(msg)
631
-        raw_keys_data = base64.standard_b64decode(master_keys_file.readline())
632
-        encrypted_keys_params, encrypted_keys = struct.unpack(
633
-            f'B {len(raw_keys_data) - 1}s', raw_keys_data
634
-        )
635
-        if master_keys_file.read():
636
-            msg = 'trailing data; cannot make sense of .keys file'
637
-            raise RuntimeError(msg)
638
-    encrypted_keys_version = encrypted_keys_params >> 4
639
-    if encrypted_keys_version != 1:
640
-        msg = f'cannot handle version {encrypted_keys_version} encrypted keys'
641
-        raise RuntimeError(msg)
642
-    logger.info(
643
-        _msg.TranslatedString(_msg.InfoMsgTemplate.PARSING_MASTER_KEYS_DATA)
644
-    )
645
-    encrypted_keys_iterations = 2 ** (10 + (encrypted_keys_params & 0x0F))
646
-    master_keys_keys = derive_master_keys_keys(key, encrypted_keys_iterations)
647
-    master_keys = decrypt_master_keys_data(encrypted_keys, master_keys_keys)
648
-
649
-    config_structure: dict[str, Any] = {}
650
-    json_contents: dict[str, bytes] = {}
651
-    # Use glob.glob(..., root_dir=...) here once Python 3.9 becomes
652
-    # unsupported.
653
-    storeroom_path_str = os.fsdecode(path)
654
-    valid_hashdirs = [
655
-        hashdir_name
656
-        for hashdir_name in os.listdir(storeroom_path_str)
657
-        if fnmatch.fnmatch(hashdir_name, '[01][0-9a-f]')
658
-    ]
659
-    for file in valid_hashdirs:
660
-        logger.info(
661
-            _msg.TranslatedString(
662
-                _msg.InfoMsgTemplate.DECRYPTING_BUCKET,
663
-                bucket_number=file,
664
-            )
665
-        )
666
-        bucket_contents = [
667
-            bytes(item)
668
-            for item in decrypt_bucket_file(file, master_keys, root_dir=path)
669
-        ]
670
-        bucket_index = json.loads(bucket_contents.pop(0))
671
-        for pos, item in enumerate(bucket_index):
672
-            json_contents[item] = bucket_contents[pos]
673
-            logger.debug(
674
-                _msg.TranslatedString(
675
-                    _msg.DebugMsgTemplate.BUCKET_ITEM_FOUND,
676
-                    path=item,
677
-                    value=bucket_contents[pos],
678
-                )
679
-            )
680
-    dirs_to_check: dict[str, list[str]] = {}
681
-    json_payload: Any
682
-    logger.info(
683
-        _msg.TranslatedString(_msg.InfoMsgTemplate.ASSEMBLING_CONFIG_STRUCTURE)
684
-    )
685
-    for item_path, json_content in sorted(json_contents.items()):
686
-        if item_path.endswith('/'):
687
-            logger.debug(
688
-                _msg.TranslatedString(
689
-                    _msg.DebugMsgTemplate.POSTPONING_DIRECTORY_CONTENTS_CHECK,
690
-                    path=item_path,
691
-                    contents=json_content.decode('utf-8'),
692
-                )
693
-            )
694
-            json_payload = json.loads(json_content)
695
-            if not isinstance(json_payload, list) or any(
696
-                not isinstance(x, str) for x in json_payload
697
-            ):
698
-                msg = (
699
-                    f'Directory index is not actually an index: '
700
-                    f'{json_content!r}'
701
-                )
702
-                raise RuntimeError(msg)
703
-            dirs_to_check[item_path] = json_payload
704
-            logger.debug(
705
-                _msg.TranslatedString(
706
-                    _msg.DebugMsgTemplate.SETTING_CONFIG_STRUCTURE_CONTENTS_EMPTY_DIRECTORY,
707
-                    path=item_path,
708
-                ),
709
-            )
710
-            _store(config_structure, item_path, b'{}')
711
-        else:
712
-            logger.debug(
713
-                _msg.TranslatedString(
714
-                    _msg.DebugMsgTemplate.SETTING_CONFIG_STRUCTURE_CONTENTS,
715
-                    path=item_path,
716
-                    value=json_content.decode('utf-8'),
717
-                ),
718
-            )
719
-            _store(config_structure, item_path, json_content)
720
-    logger.info(
721
-        _msg.TranslatedString(
722
-            _msg.InfoMsgTemplate.CHECKING_CONFIG_STRUCTURE_CONSISTENCY,
723
-        )
724
-    )
725
-    # Sorted order is important; see `maybe_obj` below.
726
-    for dir_, namelist_ in sorted(dirs_to_check.items()):
727
-        namelist = [x.rstrip('/') for x in namelist_]
728
-        obj: dict[Any, Any] = config_structure
729
-        for part in dir_.split('/'):
730
-            if part:
731
-                # Because we iterate paths in sorted order, parent
732
-                # directories are encountered before child directories.
733
-                # So parent directories always exist (lest we would have
734
-                # aborted earlier).
735
-                #
736
-                # Of course, the type checker doesn't necessarily know
737
-                # this, so we need to use assertions anyway.
738
-                maybe_obj = obj.get(part)
739
-                assert isinstance(maybe_obj, dict), (
740
-                    f'Cannot traverse storage path {dir_!r}'
741
-                )
742
-                obj = maybe_obj
743
-        if set(obj.keys()) != set(namelist):
744
-            msg = f'Object key mismatch for path {dir_!r}'
745
-            raise RuntimeError(msg)
746
-        logger.debug(
747
-            _msg.TranslatedString(
748
-                _msg.DebugMsgTemplate.DIRECTORY_CONTENTS_CHECK_OK,
749
-                path=dir_,
750
-                contents=json.dumps(namelist_),
751
-            )
752
-        )
753
-    return config_structure
754
-
755
-
756 756
 if __name__ == '__main__':
757 757
     logging.basicConfig(level=('DEBUG' if os.getenv('DEBUG') else 'WARNING'))
758 758
     config_structure = export_storeroom_data(format='storeroom')
... ...
@@ -86,6 +86,49 @@ __all__ = ('export_vault_native_data',)
86 86
 logger = logging.getLogger(__name__)
87 87
 
88 88
 
89
+@exporter.register_export_vault_config_data_handler('v0.2', 'v0.3')
90
+def export_vault_native_data(  # noqa: D417
91
+    path: str | bytes | os.PathLike | None = None,
92
+    key: str | Buffer | None = None,
93
+    *,
94
+    format: str,  # noqa: A002
95
+) -> Any:  # noqa: ANN401
96
+    """Export the full configuration stored in vault native format.
97
+
98
+    See [`exporter.ExportVaultConfigDataFunction`][] for an explanation
99
+    of the call signature, and the exceptions to expect.
100
+
101
+    Other Args:
102
+        format:
103
+            The only supported formats are `v0.2` and `v0.3`.
104
+
105
+    """  # noqa: DOC201,DOC501
106
+    # Trigger import errors if necessary.
107
+    importlib.import_module('cryptography')
108
+    if path is None:
109
+        path = exporter.get_vault_path()
110
+    with open(path, 'rb') as infile:
111
+        contents = base64.standard_b64decode(infile.read())
112
+    if key is None:
113
+        key = exporter.get_vault_key()
114
+    parser_class: type[VaultNativeConfigParser] | None = {
115
+        'v0.2': VaultNativeV02ConfigParser,
116
+        'v0.3': VaultNativeV03ConfigParser,
117
+    }.get(format)
118
+    if parser_class is None:  # pragma: no cover
119
+        msg = exporter.INVALID_VAULT_NATIVE_CONFIGURATION_FORMAT.format(
120
+            fmt=format
121
+        )
122
+        raise ValueError(msg)
123
+    try:
124
+        return parser_class(contents, key)()
125
+    except ValueError as exc:
126
+        raise exporter.NotAVaultConfigError(
127
+            os.fsdecode(path),
128
+            format=format,
129
+        ) from exc
130
+
131
+
89 132
 def _h(bs: Buffer) -> str:
90 133
     return '<{}>'.format(memoryview(bs).hex(' '))
91 134
 
... ...
@@ -681,49 +724,6 @@ class VaultNativeV02ConfigParser(VaultNativeConfigParser):
681 724
         ).decryptor()
682 725
 
683 726
 
684
-@exporter.register_export_vault_config_data_handler('v0.2', 'v0.3')
685
-def export_vault_native_data(  # noqa: D417
686
-    path: str | bytes | os.PathLike | None = None,
687
-    key: str | Buffer | None = None,
688
-    *,
689
-    format: str,  # noqa: A002
690
-) -> Any:  # noqa: ANN401
691
-    """Export the full configuration stored in vault native format.
692
-
693
-    See [`exporter.ExportVaultConfigDataFunction`][] for an explanation
694
-    of the call signature, and the exceptions to expect.
695
-
696
-    Other Args:
697
-        format:
698
-            The only supported formats are `v0.2` and `v0.3`.
699
-
700
-    """  # noqa: DOC201,DOC501
701
-    # Trigger import errors if necessary.
702
-    importlib.import_module('cryptography')
703
-    if path is None:
704
-        path = exporter.get_vault_path()
705
-    with open(path, 'rb') as infile:
706
-        contents = base64.standard_b64decode(infile.read())
707
-    if key is None:
708
-        key = exporter.get_vault_key()
709
-    parser_class: type[VaultNativeConfigParser] | None = {
710
-        'v0.2': VaultNativeV02ConfigParser,
711
-        'v0.3': VaultNativeV03ConfigParser,
712
-    }.get(format)
713
-    if parser_class is None:  # pragma: no cover
714
-        msg = exporter.INVALID_VAULT_NATIVE_CONFIGURATION_FORMAT.format(
715
-            fmt=format
716
-        )
717
-        raise ValueError(msg)
718
-    try:
719
-        return parser_class(contents, key)()
720
-    except ValueError as exc:
721
-        raise exporter.NotAVaultConfigError(
722
-            os.fsdecode(path),
723
-            format=format,
724
-        ) from exc
725
-
726
-
727 727
 if __name__ == '__main__':
728 728
     import os
729 729
 
730 730