diff --git a/CHANGELOG.md b/CHANGELOG.md index c81b18a3..3d7c17e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ All versions prior to 1.0.0 are untracked. ## [Unreleased] ### Added --Added the `digest` subcommand to compute and print a model's digest. This enables other tools to easily pair the attestations with a model directory. +- Added the `digest` subcommand to compute and print a model's digest. This enables other tools to easily pair the attestations with a model directory. +- Added `--oci-manifest` flag to sign and verify OCI image manifests directly (e.g., from `skopeo inspect --raw`), without requiring model files on disk. ### Changed - Standardized CLI flags to use hyphens (e.g., `--trust-config` instead of `--trust_config`). Underscore variants are still accepted for backwards compatibility via token normalization. diff --git a/README.md b/README.md index f1417a21..e7edae0f 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,18 @@ All signing methods support changing the signature name and location via the Consult the help for a list of all flags (`model_signing --help`, or directly `model_signing` with no arguments) +#### Signing OCI Image Manifests + +To sign an OCI image manifest directly (e.g., from `skopeo inspect --raw`), +use the `--oci-manifest` flag: + +```bash +[...]$ skopeo inspect --raw docker://registry.example.com/model:latest > manifest.json +[...]$ model_signing sign key manifest.json --oci-manifest --private-key key.priv +``` + +This enables signing container images without requiring model files on disk. + On verification we use the `verify` subcommand. To verify a Sigstore signed model we use diff --git a/src/model_signing/_cli.py b/src/model_signing/_cli.py index 94da05be..54d7199f 100644 --- a/src/model_signing/_cli.py +++ b/src/model_signing/_cli.py @@ -16,6 +16,7 @@ from collections.abc import Iterable, Sequence import contextlib +import json import logging import pathlib import sys @@ -42,11 +43,45 @@ def set_attribute(self, key, value): tracer = None +def _load_oci_manifest(path: pathlib.Path) -> dict: + """Load an OCI manifest JSON file. + + Args: + path: Path to the OCI manifest JSON file. + + Returns: + The parsed OCI manifest as a dictionary. + + Raises: + click.ClickException: If the file cannot be read or parsed. + """ + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict) or "layers" not in data: + raise click.ClickException( + f"Invalid OCI manifest: {path} missing 'layers' field" + ) + return data + except json.JSONDecodeError as e: + raise click.ClickException(f"Invalid JSON in {path}: {e}") from e + except OSError as e: + raise click.ClickException(f"Cannot read {path}: {e}") from e + + # Decorator for the commonly used argument for the model path. _model_path_argument = click.argument( "model_path", type=pathlib.Path, metavar="MODEL_PATH" ) +# Decorator for the OCI manifest flag +_oci_manifest_option = click.option( + "--oci-manifest", + is_flag=True, + default=False, + help="Treat MODEL_PATH as an OCI image manifest JSON file.", +) + # Decorator for the commonly used option to set the signature path when signing. _write_signature_option = click.option( @@ -332,6 +367,7 @@ def _sign() -> None: @_ignore_git_paths_option @_allow_symlinks_option @_write_signature_option +@_oci_manifest_option @_sigstore_staging_option @_trust_config_option @click.option( @@ -376,6 +412,7 @@ def _sign_sigstore( ignore_git_paths: bool, allow_symlinks: bool, signature: pathlib.Path, + oci_manifest: bool, use_ambient_credentials: bool, use_staging: bool, oauth_force_oob: bool, @@ -422,10 +459,7 @@ def _sign_sigstore( ) span.set_attribute("sigstore.use_staging", use_staging) try: - ignored = _resolve_ignore_paths( - model_path, list(ignore_paths) + [signature] - ) - model_signing.signing.Config().use_sigstore_signer( + signing_config = model_signing.signing.Config().use_sigstore_signer( use_ambient_credentials=use_ambient_credentials, use_staging=use_staging, identity_token=identity_token, @@ -433,13 +467,27 @@ def _sign_sigstore( client_id=client_id, client_secret=client_secret, trust_config=trust_config, - ).set_hashing_config( - model_signing.hashing.Config() - .set_ignored_paths( - paths=ignored, ignore_git_paths=ignore_git_paths + ) + + if oci_manifest: + oci_data = _load_oci_manifest(model_path) + manifest = ( + model_signing.hashing.create_manifest_from_oci_layers( + oci_data, model_name=model_path.stem + ) ) - .set_allow_symlinks(allow_symlinks) - ).sign(model_path, signature) + signing_config.sign_manifest(manifest, signature) + else: + ignored = _resolve_ignore_paths( + model_path, list(ignore_paths) + [signature] + ) + signing_config.set_hashing_config( + model_signing.hashing.Config() + .set_ignored_paths( + paths=ignored, ignore_git_paths=ignore_git_paths + ) + .set_allow_symlinks(allow_symlinks) + ).sign(model_path, signature) except Exception as err: click.echo(f"Signing failed with error: {err}", err=True) sys.exit(1) @@ -453,6 +501,7 @@ def _sign_sigstore( @_ignore_git_paths_option @_allow_symlinks_option @_write_signature_option +@_oci_manifest_option @_private_key_option @click.option( "--password", @@ -466,6 +515,7 @@ def _sign_private_key( ignore_git_paths: bool, allow_symlinks: bool, signature: pathlib.Path, + oci_manifest: bool, private_key: pathlib.Path, password: str | None = None, ) -> None: @@ -483,16 +533,27 @@ def _sign_private_key( management protocols. """ try: - ignored = _resolve_ignore_paths( - model_path, list(ignore_paths) + [signature] - ) - model_signing.signing.Config().use_elliptic_key_signer( + signing_config = model_signing.signing.Config().use_elliptic_key_signer( private_key=private_key, password=password - ).set_hashing_config( - model_signing.hashing.Config() - .set_ignored_paths(paths=ignored, ignore_git_paths=ignore_git_paths) - .set_allow_symlinks(allow_symlinks) - ).sign(model_path, signature) + ) + + if oci_manifest: + oci_data = _load_oci_manifest(model_path) + manifest = model_signing.hashing.create_manifest_from_oci_layers( + oci_data, model_name=model_path.stem + ) + signing_config.sign_manifest(manifest, signature) + else: + ignored = _resolve_ignore_paths( + model_path, list(ignore_paths) + [signature] + ) + signing_config.set_hashing_config( + model_signing.hashing.Config() + .set_ignored_paths( + paths=ignored, ignore_git_paths=ignore_git_paths + ) + .set_allow_symlinks(allow_symlinks) + ).sign(model_path, signature) except Exception as err: click.echo(f"Signing failed with error: {err}", err=True) sys.exit(1) @@ -506,6 +567,7 @@ def _sign_private_key( @_ignore_git_paths_option @_allow_symlinks_option @_write_signature_option +@_oci_manifest_option @_pkcs11_uri_option def _sign_pkcs11_key( model_path: pathlib.Path, @@ -513,6 +575,7 @@ def _sign_pkcs11_key( ignore_git_paths: bool, allow_symlinks: bool, signature: pathlib.Path, + oci_manifest: bool, pkcs11_uri: str, ) -> None: """Sign using a private key using a PKCS #11 URI. @@ -529,16 +592,27 @@ def _sign_pkcs11_key( management protocols. """ try: - ignored = _resolve_ignore_paths( - model_path, list(ignore_paths) + [signature] - ) - model_signing.signing.Config().use_pkcs11_signer( + signing_config = model_signing.signing.Config().use_pkcs11_signer( pkcs11_uri=pkcs11_uri - ).set_hashing_config( - model_signing.hashing.Config() - .set_ignored_paths(paths=ignored, ignore_git_paths=ignore_git_paths) - .set_allow_symlinks(allow_symlinks) - ).sign(model_path, signature) + ) + + if oci_manifest: + oci_data = _load_oci_manifest(model_path) + manifest = model_signing.hashing.create_manifest_from_oci_layers( + oci_data, model_name=model_path.stem + ) + signing_config.sign_manifest(manifest, signature) + else: + ignored = _resolve_ignore_paths( + model_path, list(ignore_paths) + [signature] + ) + signing_config.set_hashing_config( + model_signing.hashing.Config() + .set_ignored_paths( + paths=ignored, ignore_git_paths=ignore_git_paths + ) + .set_allow_symlinks(allow_symlinks) + ).sign(model_path, signature) except Exception as err: click.echo(f"Signing failed with error: {err}", err=True) sys.exit(1) @@ -552,6 +626,7 @@ def _sign_pkcs11_key( @_ignore_git_paths_option @_allow_symlinks_option @_write_signature_option +@_oci_manifest_option @_private_key_option @_signing_certificate_option @_certificate_root_of_trust_option @@ -561,6 +636,7 @@ def _sign_certificate( ignore_git_paths: bool, allow_symlinks: bool, signature: pathlib.Path, + oci_manifest: bool, private_key: pathlib.Path, signing_certificate: pathlib.Path, certificate_chain: Iterable[pathlib.Path], @@ -582,18 +658,29 @@ def _sign_certificate( Note that we don't offer certificate and key management protocols. """ try: - ignored = _resolve_ignore_paths( - model_path, list(ignore_paths) + [signature] - ) - model_signing.signing.Config().use_certificate_signer( + signing_config = model_signing.signing.Config().use_certificate_signer( private_key=private_key, signing_certificate=signing_certificate, certificate_chain=certificate_chain, - ).set_hashing_config( - model_signing.hashing.Config() - .set_ignored_paths(paths=ignored, ignore_git_paths=ignore_git_paths) - .set_allow_symlinks(allow_symlinks) - ).sign(model_path, signature) + ) + + if oci_manifest: + oci_data = _load_oci_manifest(model_path) + manifest = model_signing.hashing.create_manifest_from_oci_layers( + oci_data, model_name=model_path.stem + ) + signing_config.sign_manifest(manifest, signature) + else: + ignored = _resolve_ignore_paths( + model_path, list(ignore_paths) + [signature] + ) + signing_config.set_hashing_config( + model_signing.hashing.Config() + .set_ignored_paths( + paths=ignored, ignore_git_paths=ignore_git_paths + ) + .set_allow_symlinks(allow_symlinks) + ).sign(model_path, signature) except Exception as err: click.echo(f"Signing failed with error: {err}", err=True) sys.exit(1) @@ -607,6 +694,7 @@ def _sign_certificate( @_ignore_git_paths_option @_allow_symlinks_option @_write_signature_option +@_oci_manifest_option @_pkcs11_uri_option @_signing_certificate_option @_certificate_root_of_trust_option @@ -616,6 +704,7 @@ def _sign_pkcs11_certificate( ignore_git_paths: bool, allow_symlinks: bool, signature: pathlib.Path, + oci_manifest: bool, pkcs11_uri: str, signing_certificate: pathlib.Path, certificate_chain: Iterable[pathlib.Path], @@ -638,18 +727,31 @@ def _sign_pkcs11_certificate( Note that we don't offer certificate and key management protocols. """ try: - ignored = _resolve_ignore_paths( - model_path, list(ignore_paths) + [signature] + signing_config = ( + model_signing.signing.Config().use_pkcs11_certificate_signer( + pkcs11_uri=pkcs11_uri, + signing_certificate=signing_certificate, + certificate_chain=certificate_chain, + ) ) - model_signing.signing.Config().use_pkcs11_certificate_signer( - pkcs11_uri=pkcs11_uri, - signing_certificate=signing_certificate, - certificate_chain=certificate_chain, - ).set_hashing_config( - model_signing.hashing.Config() - .set_ignored_paths(paths=ignored, ignore_git_paths=ignore_git_paths) - .set_allow_symlinks(allow_symlinks) - ).sign(model_path, signature) + + if oci_manifest: + oci_data = _load_oci_manifest(model_path) + manifest = model_signing.hashing.create_manifest_from_oci_layers( + oci_data, model_name=model_path.stem + ) + signing_config.sign_manifest(manifest, signature) + else: + ignored = _resolve_ignore_paths( + model_path, list(ignore_paths) + [signature] + ) + signing_config.set_hashing_config( + model_signing.hashing.Config() + .set_ignored_paths( + paths=ignored, ignore_git_paths=ignore_git_paths + ) + .set_allow_symlinks(allow_symlinks) + ).sign(model_path, signature) except Exception as err: click.echo(f"Signing failed with error: {err}", err=True) sys.exit(1) @@ -686,6 +788,7 @@ def _verify() -> None: @_ignore_paths_option @_ignore_git_paths_option @_allow_symlinks_option +@_oci_manifest_option @_sigstore_staging_option @_trust_config_option @click.option( @@ -709,6 +812,7 @@ def _verify_sigstore( ignore_paths: Iterable[pathlib.Path], ignore_git_paths: bool, allow_symlinks: bool, + oci_manifest: bool, identity: str, identity_provider: str, use_staging: bool, @@ -733,23 +837,36 @@ def _verify_sigstore( span.set_attribute("sigstore.oidc_issuer", identity_provider) span.set_attribute("sigstore.use_staging", use_staging) try: - ignored = _resolve_ignore_paths( - model_path, list(ignore_paths) + [signature] - ) - model_signing.verifying.Config().use_sigstore_verifier( - identity=identity, - oidc_issuer=identity_provider, - use_staging=use_staging, - trust_config=trust_config, - ).set_hashing_config( - model_signing.hashing.Config() - .set_ignored_paths( - paths=ignored, ignore_git_paths=ignore_git_paths + verifying_config = ( + model_signing.verifying.Config().use_sigstore_verifier( + identity=identity, + oidc_issuer=identity_provider, + use_staging=use_staging, + trust_config=trust_config, ) - .set_allow_symlinks(allow_symlinks) - ).set_ignore_unsigned_files(ignore_unsigned_files).verify( - model_path, signature ) + + if oci_manifest: + oci_data = _load_oci_manifest(model_path) + manifest = ( + model_signing.hashing.create_manifest_from_oci_layers( + oci_data, model_name=model_path.stem + ) + ) + verifying_config.verify_manifest(manifest, signature) + else: + ignored = _resolve_ignore_paths( + model_path, list(ignore_paths) + [signature] + ) + verifying_config.set_hashing_config( + model_signing.hashing.Config() + .set_ignored_paths( + paths=ignored, ignore_git_paths=ignore_git_paths + ) + .set_allow_symlinks(allow_symlinks) + ).set_ignore_unsigned_files(ignore_unsigned_files).verify( + model_path, signature + ) except Exception as err: click.echo(f"Verification failed with error: {err}", err=True) sys.exit(1) @@ -763,6 +880,7 @@ def _verify_sigstore( @_ignore_paths_option @_ignore_git_paths_option @_allow_symlinks_option +@_oci_manifest_option @click.option( "--public-key", type=pathlib.Path, @@ -777,6 +895,7 @@ def _verify_private_key( ignore_paths: Iterable[pathlib.Path], ignore_git_paths: bool, allow_symlinks: bool, + oci_manifest: bool, public_key: pathlib.Path, ignore_unsigned_files: bool, ) -> None: @@ -794,18 +913,31 @@ def _verify_private_key( management protocols. """ try: - ignored = _resolve_ignore_paths( - model_path, list(ignore_paths) + [signature] - ) - model_signing.verifying.Config().use_elliptic_key_verifier( - public_key=public_key - ).set_hashing_config( - model_signing.hashing.Config() - .set_ignored_paths(paths=ignored, ignore_git_paths=ignore_git_paths) - .set_allow_symlinks(allow_symlinks) - ).set_ignore_unsigned_files(ignore_unsigned_files).verify( - model_path, signature + verifying_config = ( + model_signing.verifying.Config().use_elliptic_key_verifier( + public_key=public_key + ) ) + + if oci_manifest: + oci_data = _load_oci_manifest(model_path) + manifest = model_signing.hashing.create_manifest_from_oci_layers( + oci_data, model_name=model_path.stem + ) + verifying_config.verify_manifest(manifest, signature) + else: + ignored = _resolve_ignore_paths( + model_path, list(ignore_paths) + [signature] + ) + verifying_config.set_hashing_config( + model_signing.hashing.Config() + .set_ignored_paths( + paths=ignored, ignore_git_paths=ignore_git_paths + ) + .set_allow_symlinks(allow_symlinks) + ).set_ignore_unsigned_files(ignore_unsigned_files).verify( + model_path, signature + ) except Exception as err: click.echo(f"Verification failed with error: {err}", err=True) sys.exit(1) @@ -819,6 +951,7 @@ def _verify_private_key( @_ignore_paths_option @_ignore_git_paths_option @_allow_symlinks_option +@_oci_manifest_option @_certificate_root_of_trust_option @click.option( "--log-fingerprints", @@ -835,6 +968,7 @@ def _verify_certificate( ignore_paths: Iterable[pathlib.Path], ignore_git_paths: bool, allow_symlinks: bool, + oci_manifest: bool, certificate_chain: Iterable[pathlib.Path], log_fingerprints: bool, ignore_unsigned_files: bool, @@ -856,19 +990,32 @@ def _verify_certificate( logging.basicConfig(format="%(message)s", level=logging.INFO) try: - ignored = _resolve_ignore_paths( - model_path, list(ignore_paths) + [signature] - ) - model_signing.verifying.Config().use_certificate_verifier( - certificate_chain=certificate_chain, - log_fingerprints=log_fingerprints, - ).set_hashing_config( - model_signing.hashing.Config() - .set_ignored_paths(paths=ignored, ignore_git_paths=ignore_git_paths) - .set_allow_symlinks(allow_symlinks) - ).set_ignore_unsigned_files(ignore_unsigned_files).verify( - model_path, signature + verifying_config = ( + model_signing.verifying.Config().use_certificate_verifier( + certificate_chain=certificate_chain, + log_fingerprints=log_fingerprints, + ) ) + + if oci_manifest: + oci_data = _load_oci_manifest(model_path) + manifest = model_signing.hashing.create_manifest_from_oci_layers( + oci_data, model_name=model_path.stem + ) + verifying_config.verify_manifest(manifest, signature) + else: + ignored = _resolve_ignore_paths( + model_path, list(ignore_paths) + [signature] + ) + verifying_config.set_hashing_config( + model_signing.hashing.Config() + .set_ignored_paths( + paths=ignored, ignore_git_paths=ignore_git_paths + ) + .set_allow_symlinks(allow_symlinks) + ).set_ignore_unsigned_files(ignore_unsigned_files).verify( + model_path, signature + ) except Exception as err: click.echo(f"Verification failed with error: {err}", err=True) sys.exit(1) diff --git a/src/model_signing/hashing.py b/src/model_signing/hashing.py index c897a297..73929d90 100644 --- a/src/model_signing/hashing.py +++ b/src/model_signing/hashing.py @@ -104,6 +104,123 @@ def hash(model_path: PathLike) -> manifest.Manifest: return Config().hash(model_path) +def parse_digest_string(digest_str: str) -> hashing.Digest: + """Parses a digest string in OCI format into a Digest object. + + Supports both OCI-style digest strings (e.g., "sha256:abc123...") and + bare hex strings (assumes sha256). + + Args: + digest_str: The digest string to parse. + + Returns: + A Digest object with the algorithm and digest value. + + Raises: + ValueError: If the hex digest value is invalid. + """ + if ":" in digest_str: + algorithm, hex_value = digest_str.split(":", 1) + algorithm = algorithm.lower() + else: + algorithm = "sha256" + hex_value = digest_str + + try: + digest_value = bytes.fromhex(hex_value) + except ValueError as e: + raise ValueError( + f"Invalid hex digest value in '{digest_str}': {e}" + ) from e + + return hashing.Digest(algorithm, digest_value) + + +def create_manifest_from_oci_layers( + oci_manifest: dict, + model_name: str | None = None, + include_config: bool = True, +) -> manifest.Manifest: + """Create a model signing manifest from an OCI image manifest. + + This function extracts layer digests from an OCI image manifest (as returned + by `skopeo inspect --raw`) and creates a model signing manifest. Each layer + is treated as a file entry in the manifest. + + This enables signing OCI images without requiring the actual model files + on disk - useful for CI/CD pipelines where the image exists in a registry. + + Args: + oci_manifest: The OCI image manifest as a dictionary (from JSON). + Expected to have "layers" array with "digest" fields, and optionally + a "config" field with a "digest". + model_name: Optional name for the model. If not provided, will attempt + to extract from annotations or use "oci-image". + include_config: Whether to include the config blob digest as a file + entry. Default is True. + + Returns: + A Manifest object ready for signing. + + Raises: + ValueError: If the OCI manifest structure is invalid or missing required + fields. + """ + if "layers" not in oci_manifest: + raise ValueError("OCI manifest missing 'layers' field") + + manifest_items = [] + + if include_config and "config" in oci_manifest: + config = oci_manifest["config"] + if "digest" in config: + config_digest = parse_digest_string(config["digest"]) + config_path = pathlib.PurePosixPath("config.json") + manifest_items.append( + manifest.FileManifestItem( + path=config_path, digest=config_digest + ) + ) + + for i, layer in enumerate(oci_manifest["layers"]): + if "digest" not in layer: + continue + + layer_digest = parse_digest_string(layer["digest"]) + + layer_path = None + if "annotations" in layer: + annotations = layer["annotations"] + if "org.opencontainers.image.title" in annotations: + title = annotations["org.opencontainers.image.title"] + layer_path = pathlib.PurePosixPath(title) + + if layer_path is None: + layer_path = pathlib.PurePosixPath(f"layer_{i:03d}.tar.gz") + + manifest_items.append( + manifest.FileManifestItem(path=layer_path, digest=layer_digest) + ) + + if not manifest_items: + raise ValueError("No digests found in OCI manifest") + + if model_name is None: + annotations = oci_manifest.get("annotations", {}) + if "org.opencontainers.image.name" in annotations: + model_name = annotations["org.opencontainers.image.name"] + elif "org.opencontainers.image.base.name" in annotations: + model_name = annotations["org.opencontainers.image.base.name"] + else: + model_name = "oci-image" + + serialization_type = manifest._FileSerialization( + hash_type="sha256", allow_symlinks=False, ignore_paths=frozenset() + ) + + return manifest.Manifest(model_name, manifest_items, serialization_type) + + class Config: """Configuration to use when hashing models. diff --git a/src/model_signing/signing.py b/src/model_signing/signing.py index 4de79be2..a8e0e9d3 100644 --- a/src/model_signing/signing.py +++ b/src/model_signing/signing.py @@ -47,6 +47,7 @@ import sys from model_signing import hashing +from model_signing import manifest as manifest_module from model_signing._signing import sign_certificate as certificate from model_signing._signing import sign_ec_key as ec_key from model_signing._signing import sign_sigstore as sigstore @@ -108,6 +109,27 @@ def sign( signature = self._signer.sign(payload) signature.write(pathlib.Path(signature_path)) + def sign_manifest( + self, + manifest: manifest_module.Manifest, + signature_path: hashing.PathLike, + ): + """Signs a pre-built manifest using the current configuration. + + This method bypasses hashing and signs a manifest directly. This is + useful for signing OCI image manifests where the digests are already + available without needing the actual files on disk. + + Args: + manifest: The manifest to sign. + signature_path: The path of the resulting signature. + """ + if self._signer is None: + self.use_sigstore_signer() + payload = signing.Payload(manifest) + signature = self._signer.sign(payload) + signature.write(pathlib.Path(signature_path)) + def set_hashing_config(self, hashing_config: hashing.Config) -> Self: """Sets the new configuration for hashing models. diff --git a/src/model_signing/verifying.py b/src/model_signing/verifying.py index 87303490..7fb607c0 100644 --- a/src/model_signing/verifying.py +++ b/src/model_signing/verifying.py @@ -123,6 +123,40 @@ def verify( ) raise ValueError(f"Signature mismatch: {diff_message}") + def verify_manifest( + self, + actual_manifest: manifest.Manifest, + signature_path: hashing.PathLike, + ): + """Verifies that a pre-built manifest matches a signature. + + This method bypasses hashing and verifies a manifest directly. This is + useful for verifying OCI image manifests where the digests are already + available without needing the actual files on disk. + + Args: + actual_manifest: The manifest to verify against the signature. + signature_path: The path to the signature file. + + Raises: + ValueError: No verifier has been configured or signature mismatch. + """ + if self._verifier is None: + raise ValueError("Attempting to verify with no configured verifier") + + if self._uses_sigstore: + signature = sigstore.Signature.read(pathlib.Path(signature_path)) + else: + signature = sigstore_pb.Signature.read(pathlib.Path(signature_path)) + + expected_manifest = self._verifier.verify(signature) + + if actual_manifest != expected_manifest: + diff_message = self._get_manifest_diff( + actual_manifest, expected_manifest + ) + raise ValueError(f"Signature mismatch: {diff_message}") + def _get_manifest_diff(self, actual, expected) -> list[str]: diffs = []