tiny-ca 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,166 @@
1
+ """
2
+ base_storage.py
3
+
4
+ Abstract base class that defines the storage contract for certificate artefacts.
5
+
6
+ Any class that inherits from ``BaseStorage`` and implements both abstract methods
7
+ can be used wherever the application expects a storage backend, without requiring
8
+ any changes to the calling code (LSP / DIP).
9
+
10
+ SOLID notes
11
+ -----------
12
+ SRP : ``BaseStorage`` declares *what* the storage layer must do; concrete
13
+ subclasses decide *how* (local filesystem, S3, database BLOB, etc.).
14
+ OCP : New backends are added by subclassing; existing implementations are
15
+ not modified.
16
+ LSP : Subclasses must honour the documented return contracts (e.g. returning
17
+ ``True`` for idempotent deletes rather than raising) so callers can
18
+ substitute them freely.
19
+ ISP : The interface is intentionally narrow — exactly the two operations that
20
+ the application layer requires.
21
+ DIP : ``CertLifecycleManager`` depends on ``BaseStorage``, never on
22
+ ``LocalStorage`` or any other concrete class.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from abc import ABC, abstractmethod
28
+ from pathlib import Path
29
+
30
+ from cryptography.hazmat.primitives import serialization
31
+
32
+ from .const import CryptoObject
33
+
34
+
35
+ class BaseStorage(ABC):
36
+ """
37
+ Abstract contract for certificate artefact storage backends.
38
+
39
+ Subclasses persist cryptographic objects (certificates, keys, CSRs, CRLs)
40
+ to some durable medium and provide a way to remove an entire issuance
41
+ folder in a single atomic call.
42
+
43
+ The two operations map directly to the two storage events in the
44
+ certificate lifecycle:
45
+
46
+ - **Issuance** — ``save_certificate`` is called once per artefact
47
+ (``.pem``, ``.key``, ``.csr``) with the same *uuid_str* so all three
48
+ files end up in the same directory.
49
+ - **Revocation / overwrite** — ``delete_certificate_folder`` removes the
50
+ entire UUID directory in one call, avoiding orphaned files.
51
+ """
52
+
53
+ @abstractmethod
54
+ def save_certificate(
55
+ self,
56
+ cert: CryptoObject,
57
+ file_name: str,
58
+ cert_path: str | Path | None = None,
59
+ uuid_str: str | None = None,
60
+ encoding: serialization.Encoding | None = None,
61
+ private_format: serialization.PrivateFormat | None = None,
62
+ public_format: serialization.PublicFormat | None = None,
63
+ encryption_algorithm: serialization.KeySerializationEncryption | None = None,
64
+ is_add_uuid: bool = True,
65
+ is_overwrite: bool = False,
66
+ ) -> tuple[Path, str | None]:
67
+ """
68
+ Serialise *cert* and persist it to the storage backend.
69
+
70
+ The implementation must:
71
+ 1. Determine the correct serialisation format and file extension from
72
+ the type of *cert* (certificate → ``.pem``, private key → ``.key``,
73
+ CSR → ``.csr``, public key → ``.pub``, CRL → ``.pem``).
74
+ 2. Assemble the output path from *cert_path*, the UUID directory
75
+ (when *is_add_uuid* is ``True``), and *file_name* + extension.
76
+ 3. Auto-generate a UUID when *uuid_str* is ``None`` and *is_add_uuid*
77
+ is ``True``; reuse *uuid_str* when provided (so that multiple calls
78
+ for the same issuance all land in the same folder).
79
+ 4. Honour *is_overwrite*: raise ``FileAlreadyExists`` when ``False``
80
+ and the target path already exists; silently replace when ``True``.
81
+ 5. Return the absolute path to the saved file and the UUID used.
82
+
83
+ Parameters
84
+ ----------
85
+ cert : CryptoObject
86
+ The cryptographic object to serialise and save.
87
+ file_name : str
88
+ Base filename without extension (e.g. ``"ca"``, ``"my-service"``).
89
+ cert_path : str | Path | None
90
+ Optional sub-directory appended after the backend's root folder.
91
+ ``None`` saves directly under the root.
92
+ uuid_str : str | None
93
+ Explicit UUID for the issuance sub-directory. Pass the UUID
94
+ returned by a previous call to group multiple artefacts in the
95
+ same folder. ``None`` triggers auto-generation.
96
+ Ignored when *is_add_uuid* is ``False``.
97
+ encoding : serialization.Encoding | None
98
+ Serialisation encoding override. ``None`` falls back to the
99
+ implementation's default (typically PEM).
100
+ private_format : serialization.PrivateFormat | None
101
+ Format for private-key serialisation
102
+ (e.g. ``TraditionalOpenSSL``, ``PKCS8``).
103
+ ``None`` uses the implementation default.
104
+ public_format : serialization.PublicFormat | None
105
+ Format for public-key serialisation
106
+ (e.g. ``SubjectPublicKeyInfo``).
107
+ ``None`` uses the implementation default.
108
+ encryption_algorithm : serialization.KeySerializationEncryption | None
109
+ Encryption wrapper for private keys
110
+ (e.g. ``NoEncryption``, ``BestAvailableEncryption``).
111
+ ``None`` uses the implementation default.
112
+ is_add_uuid : bool
113
+ When ``True`` (default), a UUID subdirectory is inserted into the
114
+ path. Set to ``False`` for singleton files such as CRLs that are
115
+ regenerated in-place (``crl.pem``).
116
+ is_overwrite : bool
117
+ When ``True``, silently replace an existing file at the computed
118
+ path. When ``False`` (default), raise ``FileAlreadyExists``.
119
+
120
+ Returns
121
+ -------
122
+ tuple[Path, str | None]
123
+ ``(absolute_path_to_saved_file, uuid_str_used)``.
124
+ The second element is the UUID that was used (auto-generated or
125
+ the value of *uuid_str*), or ``None`` when *is_add_uuid* is
126
+ ``False``.
127
+
128
+ Raises
129
+ ------
130
+ FileAlreadyExists
131
+ If the target file already exists and *is_overwrite* is ``False``.
132
+ TypeError
133
+ If *cert* is not a recognised cryptographic type.
134
+ """
135
+
136
+ @abstractmethod
137
+ def delete_certificate_folder(
138
+ self,
139
+ uuid_str: str,
140
+ cert_path: str | Path | None = None,
141
+ ) -> bool:
142
+ """
143
+ Remove the directory that contains all artefacts for a given UUID.
144
+
145
+ Implementations must be idempotent: if the target directory does not
146
+ exist, return ``True`` (no action was needed) rather than raising an
147
+ error. Only genuine unexpected failures (permission errors, I/O
148
+ errors) should return ``False``.
149
+
150
+ Parameters
151
+ ----------
152
+ uuid_str : str
153
+ UUID string that identifies the sub-directory to delete. This is
154
+ the value returned as the second element by ``save_certificate``.
155
+ cert_path : str | Path | None
156
+ Optional sub-path prepended before *uuid_str*, matching the
157
+ *cert_path* used when the artefacts were saved. ``None`` means
158
+ *uuid_str* is directly under the backend's root folder.
159
+
160
+ Returns
161
+ -------
162
+ bool
163
+ ``True`` — the directory was deleted, or did not exist (idempotent).
164
+ ``False`` — an unexpected error occurred during deletion; the
165
+ implementation must log the exception.
166
+ """
@@ -0,0 +1,39 @@
1
+ """
2
+ storage/const.py
3
+
4
+ Shared type aliases for the storage layer.
5
+
6
+ Exports ``CryptoObject``, a union type alias covering every cryptographic
7
+ object that ``BaseStorage.save_certificate`` is capable of serialising and
8
+ persisting. Declaring it once here prevents duplication across
9
+ ``base_storage.py``, ``local_storage.py``, and any future storage backends.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import cryptography
15
+ from cryptography import x509
16
+ from cryptography.hazmat.primitives.asymmetric import rsa
17
+
18
+ #: Union of all cryptographic object types accepted by the storage layer.
19
+ #:
20
+ #: ======================================= =========
21
+ #: Type Extension
22
+ #: ======================================= =========
23
+ #: ``x509.Certificate`` ``.pem``
24
+ #: ``x509.CertificateRevocationList`` ``.pem``
25
+ #: ``CertificateSigningRequest`` (Rust) ``.csr``
26
+ #: ``rsa.RSAPrivateKey`` ``.key``
27
+ #: ``rsa.RSAPublicKey`` ``.pub``
28
+ #: ======================================= =========
29
+ #:
30
+ #: The internal Rust-backed ``CertificateSigningRequest`` type is included
31
+ #: because the ``cryptography`` library exposes CSR objects through its Rust
32
+ #: bindings rather than a pure-Python class.
33
+ CryptoObject = (
34
+ x509.Certificate
35
+ | x509.CertificateRevocationList
36
+ | cryptography.hazmat.bindings._rust.x509.CertificateSigningRequest # type: ignore
37
+ | rsa.RSAPrivateKey
38
+ | rsa.RSAPublicKey
39
+ )
@@ -0,0 +1,448 @@
1
+ """
2
+ local_storage.py
3
+
4
+ Local filesystem implementation of ``BaseStorage``.
5
+
6
+ Module-level contents
7
+ ---------------------
8
+ ``_CertSerializer`` — stateless helper that converts a cryptographic object
9
+ to raw bytes and determines the correct file extension.
10
+ Private to this module; not part of the public API.
11
+ ``LocalStorage`` — concrete ``BaseStorage`` that writes artefacts to
12
+ a configurable directory tree on the local filesystem.
13
+
14
+ SOLID notes
15
+ -----------
16
+ SRP : ``_CertSerializer`` is responsible for serialisation only.
17
+ ``LocalStorage`` is responsible for path resolution and file I/O only.
18
+ Neither class performs cryptographic operations or database access.
19
+ OCP : New crypto object types are handled by adding a branch in
20
+ ``_CertSerializer.serialise`` without modifying ``LocalStorage``.
21
+ LSP : ``LocalStorage`` fully honours the ``BaseStorage`` contract, including
22
+ the idempotent-delete and ``FileAlreadyExists`` raise semantics.
23
+ DIP : Consumers depend on ``BaseStorage``; ``LocalStorage`` is injected
24
+ at construction time.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from logging import Logger
30
+ from pathlib import Path
31
+ import shutil
32
+ from uuid import uuid4
33
+ import warnings
34
+
35
+ import cryptography
36
+ from cryptography import x509
37
+ from cryptography.hazmat.primitives import serialization
38
+ from cryptography.hazmat.primitives.asymmetric import rsa
39
+
40
+ from tiny_ca.exc import FileAlreadyExists
41
+ from tiny_ca.settings import DEFAULT_LOGGER
42
+
43
+ from .base_storage import BaseStorage
44
+ from .const import CryptoObject
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # SRP: serialisation logic isolated from storage logic
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ class _CertSerializer:
52
+ """
53
+ Converts a ``CryptoObject`` to raw bytes and the matching file extension.
54
+
55
+ This is a private, stateless helper used exclusively by ``LocalStorage``.
56
+ Isolating serialisation here keeps ``LocalStorage`` free of type-dispatch
57
+ logic and makes it trivial to add support for new cryptographic types
58
+ (OCP: add one branch here, nothing else changes).
59
+
60
+ Supported types
61
+ ~~~~~~~~~~~~~~~
62
+ ======================================= ======== ============================
63
+ Type Ext Method called
64
+ ======================================= ======== ============================
65
+ ``x509.Certificate`` ``.pem`` ``public_bytes``
66
+ ``x509.CertificateRevocationList`` ``.pem`` ``public_bytes``
67
+ ``CertificateSigningRequest`` (Rust) ``.csr`` ``public_bytes``
68
+ ``rsa.RSAPrivateKey`` ``.key`` ``private_bytes``
69
+ ``rsa.RSAPublicKey`` ``.pub`` ``public_bytes``
70
+ ======================================= ======== ============================
71
+ """
72
+
73
+ @staticmethod
74
+ def serialise(
75
+ cert: CryptoObject,
76
+ encoding: serialization.Encoding,
77
+ private_format: serialization.PrivateFormat,
78
+ public_format: serialization.PublicFormat,
79
+ encryption_algorithm: serialization.KeySerializationEncryption,
80
+ ) -> tuple[bytes, str]:
81
+ """
82
+ Serialise *cert* to raw bytes and return its file extension.
83
+
84
+ Dispatches to the appropriate serialisation method based on the
85
+ runtime type of *cert*. All format/encoding parameters are passed
86
+ through regardless of type; the ones irrelevant to the object type
87
+ (e.g. *private_format* for a certificate) are silently ignored by the
88
+ cryptography library's own methods.
89
+
90
+ Parameters
91
+ ----------
92
+ cert : CryptoObject
93
+ The cryptographic object to serialise.
94
+ encoding : serialization.Encoding
95
+ Target encoding, e.g. ``Encoding.PEM`` or ``Encoding.DER``.
96
+ private_format : serialization.PrivateFormat
97
+ Format for RSA private-key output
98
+ (``TraditionalOpenSSL`` / ``PKCS8``).
99
+ public_format : serialization.PublicFormat
100
+ Format for RSA public-key output
101
+ (``SubjectPublicKeyInfo`` / ``PKCS1``).
102
+ encryption_algorithm : serialization.KeySerializationEncryption
103
+ Encryption wrapper applied to private-key output
104
+ (``NoEncryption`` / ``BestAvailableEncryption``).
105
+
106
+ Returns
107
+ -------
108
+ tuple[bytes, str]
109
+ ``(raw_bytes, file_extension)`` where *file_extension* is one of
110
+ ``".pem"``, ``".csr"``, ``".key"``, or ``".pub"``, including the
111
+ leading dot.
112
+
113
+ Raises
114
+ ------
115
+ TypeError
116
+ If *cert* is not one of the five supported cryptographic types.
117
+ """
118
+ if isinstance(cert, (x509.Certificate, x509.CertificateRevocationList)):
119
+ return cert.public_bytes(encoding=encoding), ".pem"
120
+
121
+ if isinstance(
122
+ cert,
123
+ cryptography.hazmat.bindings._rust.x509.CertificateSigningRequest, # type: ignore
124
+ ):
125
+ return cert.public_bytes(encoding=encoding), ".csr"
126
+
127
+ if isinstance(cert, rsa.RSAPrivateKey):
128
+ return (
129
+ cert.private_bytes(
130
+ encoding=encoding,
131
+ format=private_format,
132
+ encryption_algorithm=encryption_algorithm,
133
+ ),
134
+ ".key",
135
+ )
136
+
137
+ if isinstance(cert, rsa.RSAPublicKey):
138
+ return cert.public_bytes(encoding=encoding, format=public_format), ".pub"
139
+
140
+ raise TypeError(
141
+ f"Unsupported crypto object type: {type(cert)!r}. "
142
+ "Expected one of: x509.Certificate, x509.CertificateRevocationList, "
143
+ "CertificateSigningRequest, rsa.RSAPrivateKey, rsa.RSAPublicKey."
144
+ )
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # SRP: filesystem persistence
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ class LocalStorage(BaseStorage):
153
+ """
154
+ Local filesystem storage backend for certificate artefacts.
155
+
156
+ Writes serialised cryptographic objects to a configurable directory tree.
157
+ Each issuance group (certificate + key + CSR) is placed in a dedicated
158
+ UUID subdirectory so that all artefacts for a given certificate can be
159
+ found and deleted together.
160
+
161
+ Directory layout
162
+ ~~~~~~~~~~~~~~~~
163
+ ::
164
+
165
+ <base_folder>/
166
+ └── [cert_path/]
167
+ └── [<uuid>/]
168
+ ├── <file_name>.pem # x509.Certificate or CRL
169
+ ├── <file_name>.key # RSA private key
170
+ ├── <file_name>.csr # certificate signing request
171
+ └── <file_name>.pub # RSA public key (if applicable)
172
+
173
+ Parameters
174
+ ----------
175
+ base_folder : str | Path
176
+ Root directory under which all certificates are stored. The directory
177
+ is created on first write if it does not exist.
178
+ Default: ``"./certs"``.
179
+ base_encoding : serialization.Encoding
180
+ Default encoding for all serialised objects.
181
+ Default: ``Encoding.PEM``.
182
+ base_private_format : serialization.PrivateFormat
183
+ Default format for RSA private-key files.
184
+ Default: ``PrivateFormat.TraditionalOpenSSL`` (PKCS#1, OpenSSL-compatible).
185
+ base_public_format : serialization.PublicFormat
186
+ Default format for RSA public-key files.
187
+ Default: ``PublicFormat.SubjectPublicKeyInfo`` (PKCS#8 / X.509 SubjectPublicKeyInfo).
188
+ base_encryption_algorithm : serialization.KeySerializationEncryption
189
+ Default encryption applied to private-key files.
190
+ Default: ``NoEncryption()`` — keys are stored in plaintext.
191
+ logger : Logger | None
192
+ Logger for diagnostic messages. Falls back to ``DEFAULT_LOGGER``.
193
+ """
194
+
195
+ def __init__(
196
+ self,
197
+ base_folder: str | Path = "./certs",
198
+ base_encoding: serialization.Encoding = serialization.Encoding.PEM,
199
+ base_private_format: serialization.PrivateFormat = serialization.PrivateFormat.TraditionalOpenSSL,
200
+ base_public_format: serialization.PublicFormat = serialization.PublicFormat.SubjectPublicKeyInfo,
201
+ base_encryption_algorithm: serialization.KeySerializationEncryption = serialization.NoEncryption(),
202
+ logger: Logger | None = None,
203
+ ) -> None:
204
+ self._base_folder = Path(base_folder)
205
+ self._base_encoding = base_encoding
206
+ self._base_private_format = base_private_format
207
+ self._base_public_format = base_public_format
208
+ self._base_encryption_algorithm = base_encryption_algorithm
209
+ self._logger = logger or DEFAULT_LOGGER
210
+
211
+ # ------------------------------------------------------------------
212
+ # BaseStorage interface
213
+ # ------------------------------------------------------------------
214
+
215
+ def save_certificate(
216
+ self,
217
+ cert: CryptoObject,
218
+ file_name: str,
219
+ cert_path: str | Path | None = None,
220
+ uuid_str: str | None = None,
221
+ encoding: serialization.Encoding | None = None,
222
+ private_format: serialization.PrivateFormat | None = None,
223
+ public_format: serialization.PublicFormat | None = None,
224
+ encryption_algorithm: serialization.KeySerializationEncryption | None = None,
225
+ is_add_uuid: bool = True,
226
+ is_overwrite: bool = False,
227
+ ) -> tuple[Path, str | None]:
228
+ """
229
+ Serialise *cert* and write the result to the local filesystem.
230
+
231
+ Assembles the output path as::
232
+
233
+ <base_folder> / [cert_path/] / [<uuid>/] / <file_name><ext>
234
+
235
+ Where *ext* is determined automatically from the type of *cert*.
236
+
237
+ Parameters
238
+ ----------
239
+ cert : CryptoObject
240
+ Cryptographic object to serialise and persist.
241
+ file_name : str
242
+ Base filename without extension (e.g. ``"ca"``, ``"nginx"``).
243
+ cert_path : str | Path | None
244
+ Optional sub-directory appended after *base_folder*.
245
+ uuid_str : str | None
246
+ Reuse an existing UUID directory by passing the value returned by
247
+ a previous ``save_certificate`` call. ``None`` auto-generates a
248
+ new UUID. Ignored when *is_add_uuid* is ``False``.
249
+ encoding : serialization.Encoding | None
250
+ Encoding override. ``None`` uses *base_encoding*.
251
+ private_format : serialization.PrivateFormat | None
252
+ Private-key format override. ``None`` uses *base_private_format*.
253
+ public_format : serialization.PublicFormat | None
254
+ Public-key format override. ``None`` uses *base_public_format*.
255
+ encryption_algorithm : serialization.KeySerializationEncryption | None
256
+ Private-key encryption override. ``None`` uses
257
+ *base_encryption_algorithm*.
258
+ is_add_uuid : bool
259
+ When ``True`` (default), a UUID subdirectory is inserted.
260
+ Set to ``False`` for singleton files such as CRL that are
261
+ regenerated in-place.
262
+ is_overwrite : bool
263
+ When ``True``, silently replace an existing file.
264
+ When ``False`` (default), raise ``FileAlreadyExists``.
265
+
266
+ Returns
267
+ -------
268
+ tuple[Path, str | None]
269
+ ``(absolute_path_to_written_file, uuid_used)``.
270
+ *uuid_used* is ``None`` when *is_add_uuid* is ``False``.
271
+
272
+ Raises
273
+ ------
274
+ FileAlreadyExists
275
+ If the computed target path already exists and *is_overwrite*
276
+ is ``False``.
277
+ TypeError
278
+ If *cert* is not a supported cryptographic type.
279
+ """
280
+ output_dir, effective_uuid = self._resolve_output_dir(
281
+ cert_path=cert_path,
282
+ uuid_str=uuid_str,
283
+ is_add_uuid=is_add_uuid,
284
+ )
285
+
286
+ cert_data, extension = _CertSerializer.serialise(
287
+ cert=cert,
288
+ encoding=encoding or self._base_encoding,
289
+ private_format=private_format or self._base_private_format,
290
+ public_format=public_format or self._base_public_format,
291
+ encryption_algorithm=encryption_algorithm
292
+ or self._base_encryption_algorithm,
293
+ )
294
+
295
+ output_path = output_dir / f"{file_name}{extension}"
296
+ self._write_file(path=output_path, data=cert_data, is_overwrite=is_overwrite)
297
+
298
+ self._logger.debug(
299
+ "Saved %s → %s (uuid=%s)", type(cert).__name__, output_path, effective_uuid
300
+ )
301
+ return output_path, effective_uuid
302
+
303
+ def delete_certificate_folder(
304
+ self,
305
+ uuid_str: str,
306
+ cert_path: str | Path | None = None,
307
+ ) -> bool:
308
+ """
309
+ Recursively remove the directory identified by *uuid_str*.
310
+
311
+ The target path is resolved as::
312
+
313
+ <base_folder> / [cert_path/] / <uuid_str>
314
+
315
+ The operation is idempotent: if the directory does not exist a
316
+ ``UserWarning`` is emitted and ``True`` is returned (no action needed).
317
+ If the path exists but is a regular file rather than a directory, a
318
+ ``UserWarning`` is emitted and ``True`` is returned (not our directory).
319
+ Only a genuine ``OSError`` during ``shutil.rmtree`` causes ``False``.
320
+
321
+ Parameters
322
+ ----------
323
+ uuid_str : str
324
+ UUID sub-directory name to remove.
325
+ cert_path : str | Path | None
326
+ Optional sub-path under *base_folder* containing *uuid_str*.
327
+
328
+ Returns
329
+ -------
330
+ bool
331
+ ``True`` — directory removed, or path was already absent.
332
+ ``False`` — ``OSError`` occurred; check logs for details.
333
+
334
+ Warns
335
+ -----
336
+ UserWarning
337
+ If the target path does not exist or is not a directory.
338
+ """
339
+ target = self._base_folder
340
+ if cert_path:
341
+ target = target / cert_path
342
+ target = target / uuid_str
343
+
344
+ if not target.exists():
345
+ warnings.warn(
346
+ f"Certificate folder not deleted — path does not exist: {target}",
347
+ UserWarning,
348
+ stacklevel=2,
349
+ )
350
+ return True
351
+
352
+ if not target.is_dir():
353
+ warnings.warn(
354
+ f"Certificate folder not deleted — path is not a directory: {target}",
355
+ UserWarning,
356
+ stacklevel=2,
357
+ )
358
+ return True
359
+
360
+ try:
361
+ shutil.rmtree(target)
362
+ self._logger.info("Deleted certificate folder: %s", target)
363
+ return True
364
+ except OSError as exc:
365
+ self._logger.error(
366
+ "Failed to delete certificate folder %s: %s", target, exc
367
+ )
368
+ return False
369
+
370
+ # ------------------------------------------------------------------
371
+ # Private helpers
372
+ # ------------------------------------------------------------------
373
+
374
+ def _resolve_output_dir(
375
+ self,
376
+ cert_path: str | Path | None,
377
+ uuid_str: str | None,
378
+ is_add_uuid: bool,
379
+ ) -> tuple[Path, str | None]:
380
+ """
381
+ Compute the directory path where the certificate file will be written.
382
+
383
+ Builds the path incrementally:
384
+ 1. Start from *base_folder*.
385
+ 2. Append *cert_path* if provided.
386
+ 3. Append a UUID segment if *is_add_uuid* is ``True``.
387
+
388
+ The UUID is taken from *uuid_str* if provided, otherwise a new UUID4
389
+ string is generated. When *is_add_uuid* is ``False`` the second return
390
+ value is always ``None``.
391
+
392
+ Parameters
393
+ ----------
394
+ cert_path : str | Path | None
395
+ Optional sub-path under *base_folder*.
396
+ uuid_str : str | None
397
+ Explicit UUID for the sub-directory, or ``None`` to auto-generate.
398
+ is_add_uuid : bool
399
+ Whether to append a UUID segment to the path.
400
+
401
+ Returns
402
+ -------
403
+ tuple[Path, str | None]
404
+ ``(resolved_output_directory, uuid_used_or_None)``.
405
+ """
406
+ directory = self._base_folder
407
+ if cert_path:
408
+ directory = directory / cert_path
409
+
410
+ effective_uuid: str | None = None
411
+ if is_add_uuid:
412
+ effective_uuid = uuid_str or str(uuid4())
413
+ directory = directory / effective_uuid
414
+
415
+ return directory, effective_uuid
416
+
417
+ def _write_file(self, path: Path, data: bytes, is_overwrite: bool) -> None:
418
+ """
419
+ Write *data* to *path*, creating any missing parent directories.
420
+
421
+ Parent directories are created with ``mkdir(parents=True,
422
+ exist_ok=True)`` so the caller never needs to pre-create the
423
+ directory tree manually.
424
+
425
+ Parameters
426
+ ----------
427
+ path : Path
428
+ Absolute target path for the output file.
429
+ data : bytes
430
+ Raw serialised bytes to write.
431
+ is_overwrite : bool
432
+ When ``False``, raise ``FileAlreadyExists`` if *path* already
433
+ exists. When ``True``, the existing file is silently replaced.
434
+
435
+ Raises
436
+ ------
437
+ FileAlreadyExists
438
+ If *path* already exists on disk and *is_overwrite* is ``False``.
439
+ """
440
+ if path.exists() and not is_overwrite:
441
+ raise FileAlreadyExists(path_save_cert=path)
442
+
443
+ path.parent.mkdir(parents=True, exist_ok=True)
444
+
445
+ with open(path, "wb") as fh:
446
+ fh.write(data)
447
+
448
+ self._logger.debug("Written %d bytes → %s", len(data), path)
File without changes