From 98f8d0f0f3f9efc4322797d0a6a322d4e5f9ca18 Mon Sep 17 00:00:00 2001 From: Jack Cushman Date: Sun, 1 Dec 2024 22:18:32 -0500 Subject: [PATCH] Add tests, add --hard-link option, fixups --- .gitignore | 1 + README.md | 33 +- pyproject.toml | 19 +- scripts/update_docs.py | 15 +- src/nabit/__init__.py | 2 +- src/nabit/bin/__init__.py | 2 - src/nabit/bin/cli.py | 51 ++- src/nabit/bin/utils.py | 21 +- src/nabit/lib/__init__.py | 2 - src/nabit/lib/archive.py | 68 ++-- src/nabit/lib/capture.py | 11 +- src/nabit/lib/sign.py | 7 +- src/nabit/lib/utils.py | 2 +- tests/__init__.py | 0 tests/conftest.py | 93 +++++ {test => tests/fixtures}/generate_certs.sh | 68 ++-- {test => tests}/fixtures/pki/domain-chain.pem | 0 .../fixtures/pki/domain-signing.crt | 0 .../fixtures/pki/domain-signing.key | 0 {test => tests}/fixtures/pki/email-chain.pem | 0 .../fixtures/pki/email-signing.crt | 0 .../fixtures/pki/email-signing.key | 0 .../fixtures/pki/intermediate-ca.crt | 0 .../fixtures/pki/intermediate-ca.key | 0 {test => tests}/fixtures/pki/root-ca.crt | 0 {test => tests}/fixtures/pki/root-ca.key | 0 tests/test_archive.py | 28 ++ tests/test_cli.py | 374 ++++++++++++++++++ tests/test_scripts.py | 5 + tests/test_validation.py | 283 +++++++++++++ tests/utils.py | 38 ++ uv.lock | 309 +++++++++++++++ 32 files changed, 1319 insertions(+), 113 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py rename {test => tests/fixtures}/generate_certs.sh (51%) rename {test => tests}/fixtures/pki/domain-chain.pem (100%) rename {test => tests}/fixtures/pki/domain-signing.crt (100%) rename {test => tests}/fixtures/pki/domain-signing.key (100%) rename {test => tests}/fixtures/pki/email-chain.pem (100%) rename {test => tests}/fixtures/pki/email-signing.crt (100%) rename {test => tests}/fixtures/pki/email-signing.key (100%) rename {test => tests}/fixtures/pki/intermediate-ca.crt (100%) rename {test => tests}/fixtures/pki/intermediate-ca.key (100%) rename {test => tests}/fixtures/pki/root-ca.crt (100%) rename {test => tests}/fixtures/pki/root-ca.key (100%) create mode 100644 tests/test_archive.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_scripts.py create mode 100644 tests/test_validation.py create mode 100644 tests/utils.py diff --git a/.gitignore b/.gitignore index 8ec36e9..f8e72c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /temp __pycache__ *.pyc +.coverage diff --git a/README.md b/README.md index e7d502b..cd331af 100644 --- a/README.md +++ b/README.md @@ -128,18 +128,20 @@ Options: -u, --url TEXT URL to archive (can be repeated) -p, --path PATH File or directory to archive (can be repeated) + --hard-link Use hard links when copying files (when + possible) -i, --info TEXT bag-info.txt metadata in key:value format (can be repeated) --signed-metadata FILE JSON file to be copied to data/signed- metadata.json --unsigned-metadata FILE JSON file to be copied to unsigned- metadata.json - -s, --sign : - Sign using private key and certificate chain + -s, --sign : + Sign using certificate chain and private key files (can be repeated) - -t, --timestamp | : + -t, --timestamp | : Timestamp using either a TSA keyword or a - URL and cert chain (can be repeated) + cert chain path and URL (can be repeated) --help Show this message and exit. ``` @@ -311,6 +313,29 @@ but the provided filenames are encouraged to ensure that users will understand t `bag-nabit` does not currently specify anything regarding the contents of the metadata files. +Development +----------- + +We use [uv](https://docs.astral.sh/uv/) to manage development dependencies. After cloning the repository, to run from source: + +``` +uv run nabit +``` + +This will automatically install dependencies and run the command. + +To run tests: + +``` +uv run pytest +``` + +Some tests use the [inline-snapshot](https://github.com/15r10nk/inline-snapshot/) library. If the tool output changes +intentionally, you may need to run `uv run pytest --inline-snapshot=review` to review the changes and apply them +to test files. + +After making changes to the command line interface, run `uv run scripts/update_docs.py` to update README.md. + Limitations and Caveats ----------------------- diff --git a/pyproject.toml b/pyproject.toml index db0c602..05dfff3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "warcio>=1.7.4", "requests>=2.32.3", "bagit>=1.8.1", - "setuptools>=75.6.0", + "setuptools>=75.6.0", # required by bagit ] [project.scripts] @@ -18,3 +18,20 @@ nabit = "nabit.bin.cli:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" + +[tool.uv] +dev-dependencies = [ + "pytest>=8.3.3", + "inline-snapshot>=0.14.0", + "pytest-cov>=6.0.0", + "pytest-httpserver>=1.1.0", +] + +[tool.pytest.ini_options] +addopts = "--cov=nabit --cov-report=term-missing" +testpaths = ["tests"] + +[tool.coverage.run] +source = ["nabit"] +branch = true + diff --git a/scripts/update_docs.py b/scripts/update_docs.py index d931f7f..bb67725 100644 --- a/scripts/update_docs.py +++ b/scripts/update_docs.py @@ -4,13 +4,13 @@ import re # Add the project root to Python path so we can import the CLI project_root = Path(__file__).parent.parent -sys.path.append(str(project_root)) -from nabit.bin.cli import main, archive, validate +from nabit.bin.cli import main +readme_path = project_root / 'README.md' -def update_readme(): - """Update README.md with latest command help""" + +def get_new_readme_text(): ctx = click.Context(main) # Get help text for the main command @@ -33,7 +33,12 @@ def update_readme(): readme_content, flags=re.DOTALL ) - readme_path.write_text(readme_content) + + return readme_content + +def update_readme(): + """Update README.md with latest command help""" + readme_path.write_text(get_new_readme_text()) print("README.md updated") if __name__ == '__main__': diff --git a/src/nabit/__init__.py b/src/nabit/__init__.py index 1c48390..26df180 100644 --- a/src/nabit/__init__.py +++ b/src/nabit/__init__.py @@ -2,6 +2,6 @@ try: __version__ = version("nabit") -except PackageNotFoundError: +except PackageNotFoundError: # pragma: no cover # package is not installed __version__ = "0.0.0.dev0" diff --git a/src/nabit/bin/__init__.py b/src/nabit/bin/__init__.py index b2aea9e..e69de29 100644 --- a/src/nabit/bin/__init__.py +++ b/src/nabit/bin/__init__.py @@ -1,2 +0,0 @@ -def hello() -> str: - return "Hello from bagit-sign!" diff --git a/src/nabit/bin/cli.py b/src/nabit/bin/cli.py index 085bbce..1bae9b8 100644 --- a/src/nabit/bin/cli.py +++ b/src/nabit/bin/cli.py @@ -1,9 +1,9 @@ from collections import defaultdict import click -import sys +import json from pathlib import Path -from .utils import assert_file_exists, assert_url, cli_validate +from .utils import assert_file_exists, assert_url, cli_validate, CaptureCommand from ..lib.archive import package, validate_package from ..lib.sign import KNOWN_TSAS @@ -12,34 +12,42 @@ def main(): """BagIt package signing tool""" pass -@main.command() + +@main.command(cls=CaptureCommand) @click.argument('bag_path', type=click.Path(path_type=Path)) @click.option('--amend', '-a', is_flag=True, help='Update an existing archive. May add OR OVERWRITE existing data.') -@click.option('--url', '-u', multiple=True, help='URL to archive (can be repeated)') -@click.option('--path', '-p', multiple=True, type=click.Path(exists=True, path_type=Path), help='File or directory to archive (can be repeated)') +@click.option('--url', '-u', 'urls', multiple=True, help='URL to archive (can be repeated)') +@click.option('--path', '-p', 'paths', multiple=True, type=click.Path(exists=True, path_type=Path), help='File or directory to archive (can be repeated)') +@click.option('--hard-link', is_flag=True, help='Use hard links when copying files (when possible)') @click.option('--info', '-i', multiple=True, help='bag-info.txt metadata in key:value format (can be repeated)') @click.option('--signed-metadata', type=click.Path(exists=True, path_type=Path, dir_okay=False), help='JSON file to be copied to data/signed-metadata.json') @click.option('--unsigned-metadata', type=click.Path(exists=True, path_type=Path, dir_okay=False), help='JSON file to be copied to unsigned-metadata.json') @click.option('--sign', '-s', 'signature_args', multiple=True, - help='Sign using private key and certificate chain files (can be repeated)', - metavar=':', + help='Sign using certificate chain and private key files (can be repeated)', + metavar=':', ) @click.option('--timestamp', '-t', 'signature_args', multiple=True, - help='Timestamp using either a TSA keyword or a URL and cert chain (can be repeated)', - metavar=' | :', + help='Timestamp using either a TSA keyword or a cert chain path and URL (can be repeated)', + metavar=' | :', ) @click.pass_context -def archive(ctx, bag_path, amend, url, path, info, signed_metadata, unsigned_metadata, signature_args): +def archive(ctx, bag_path, amend, urls, paths, hard_link, info, signed_metadata, unsigned_metadata, signature_args): """ Archive files and URLs into a BagIt package. bag_path is the destination directory for the package. """ # Validate JSON files if provided for metadata_path in (signed_metadata, unsigned_metadata): - if metadata_path and not metadata_path.suffix.lower() == '.json': + if not metadata_path: + continue + if not metadata_path.suffix.lower() == '.json': raise click.BadParameter(f'Metadata file must be a .json file, got "{metadata_path}"') + try: + json.loads(metadata_path.read_text()) + except json.JSONDecodeError as e: + raise click.BadParameter(f'Metadata file must be valid JSON, got "{metadata_path}": {e}') # Check if output directory exists and is not empty if bag_path.exists() and any(bag_path.iterdir()): @@ -63,19 +71,22 @@ def archive(ctx, bag_path, amend, url, path, info, signed_metadata, unsigned_met raise click.BadParameter(f'Metadata must be in "key:value" format, got "{item}"') bag_info[key.strip()].append(value.strip()) + # validate URLs + for url in urls: + assert_url(url) + ## handle --sign and --timestamp options # order matters, so get ordered list of signature flags from sys.argv - command_index = sys.argv.index(ctx.info_name) - signature_flags = [arg for arg in sys.argv[command_index + 1:] if arg in ['-s', '--sign', '-t', '--timestamp']] + signature_flags = [arg for arg in ctx.raw_args if arg in ['-s', '--sign', '-t', '--timestamp']] # process each signature flag signatures = [] for kind, value in zip(signature_flags, signature_args): if kind in ['-s', '--sign']: # Convert sign list of ":" strings into a list of signature operations try: - key, cert_chain = value.split(':', 1) + cert_chain, key = value.split(':', 1) except ValueError: - raise click.BadParameter(f'Sign must be in "key:cert_chain" format, got "{value}"') + raise click.BadParameter(f'Sign must be in "cert_chain:key_file" format, got "{value}"') assert_file_exists(key) assert_file_exists(cert_chain) signatures.append({ @@ -88,7 +99,7 @@ def archive(ctx, bag_path, amend, url, path, info, signed_metadata, unsigned_met params = KNOWN_TSAS[value] else: try: - url, cert_chain = value.split(':', 1) + cert_chain, url = value.split(':', 1) except ValueError: all_tsas = ', '.join(f'"{key}"' for key in KNOWN_TSAS.keys()) raise click.BadParameter(f'Timestamp must be in "url:cert_chain" format, or one of {all_tsas}. Got "{value}".') @@ -101,13 +112,14 @@ def archive(ctx, bag_path, amend, url, path, info, signed_metadata, unsigned_met package( output_path=bag_path, - paths=path, - urls=url, + paths=paths, + urls=urls, bag_info=bag_info, signatures=signatures, signed_metadata=signed_metadata, unsigned_metadata=unsigned_metadata, amend=amend, + use_hard_links=hard_link, ) cli_validate(bag_path) @@ -122,6 +134,3 @@ def validate(bag_path): bag_path is the path to the package directory to validate. """ cli_validate(bag_path) - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/src/nabit/bin/utils.py b/src/nabit/bin/utils.py index 1dfe4df..948d07a 100644 --- a/src/nabit/bin/utils.py +++ b/src/nabit/bin/utils.py @@ -1,5 +1,6 @@ from urllib.parse import urlparse import click +import requests from ..lib.archive import validate_package @@ -7,9 +8,10 @@ def assert_file_exists(path): click.Path(exists=True, path_type=str, dir_okay=False)(path) def assert_url(url): - parsed = urlparse(url) - if parsed.scheme not in ['http', 'https']: - raise click.BadParameter(f'Expected a URL with http or https scheme, got "{url}"') + try: + requests.Request('GET', url).prepare() + except requests.RequestException as e: + raise click.BadParameter(str(e)) def cli_validate(bag_path): """ @@ -18,6 +20,7 @@ def cli_validate(bag_path): click.echo(f"Validating package at {bag_path} ...") has_errors = False def error(message: str, metadata: dict | None = None) -> None: + nonlocal has_errors click.secho("ERROR:", fg='red', bold=True, nl=False) click.echo(f" {message}") has_errors = True @@ -33,7 +36,13 @@ def success(message: str, metadata: dict | None = None) -> None: validate_package(bag_path, error, warn, success) if has_errors: - click.echo("Errors found in package") - click.exit(1) + raise click.ClickException("Errors found in package") - click.echo("Package is valid") \ No newline at end of file + click.echo("Package is valid") + + +class CaptureCommand(click.Command): + """ Custom click command that captures raw args to the command.""" + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + ctx.raw_args = list(args) + return super().parse_args(ctx, args) diff --git a/src/nabit/lib/__init__.py b/src/nabit/lib/__init__.py index b2aea9e..e69de29 100644 --- a/src/nabit/lib/__init__.py +++ b/src/nabit/lib/__init__.py @@ -1,2 +0,0 @@ -def hello() -> str: - return "Hello from bagit-sign!" diff --git a/src/nabit/lib/archive.py b/src/nabit/lib/archive.py index 8feebf6..4163f11 100644 --- a/src/nabit/lib/archive.py +++ b/src/nabit/lib/archive.py @@ -12,15 +12,15 @@ # files to ignore when copying directories IGNORE_PATTERNS = ['.DS_Store'] - - - def validate_bag_format(bag_path: Path, error, warn, success) -> None: """Verify bag format.""" - bag = bagit.Bag(str(bag_path)) try: + bag = bagit.Bag(str(bag_path)) bag.validate() - except bagit.BagValidationError as e: + # BagIt considers tagmanifest-sha256.txt optional, but we require it + if not (bag_path / "tagmanifest-sha256.txt").exists(): + raise bagit.BagValidationError("No tagmanifest-sha256.txt found") + except bagit.BagError as e: error(f"bag format is invalid: {e}") else: success("bag format is valid") @@ -31,7 +31,7 @@ def validate_data_files(bag_path: Path, error = None, warn = noop, success = noo actual_files = set(f.name for f in bag_path.glob('data/*')) unexpected_files = actual_files - expected_files if unexpected_files: - warn(f"{len(unexpected_files)} unexpected files in data/. Expected: {expected_files}") + warn(f"{len(unexpected_files)} unexpected files in data/, starting with {sorted(unexpected_files)[0]}.") def validate_package(bag_path: Path, error = None, warn = noop, success = noop) -> None: """ @@ -53,6 +53,29 @@ def error(message: str, metadata: dict | None = None) -> None: validate_bag_format(bag_path, error, warn, success) validate_signatures(tagmanifest_path, error, warn, success) +def copy_paths(source_paths: list[Path | str], dest_dir: Path, use_hard_links: bool = False) -> None: + """Copy paths to a destination directory, optionally using hard links.""" + for path in source_paths: + path = Path(path) + dest_path = get_unique_path(dest_dir / path.name) + # can only use hard links if source and destination are on the same device + use_hard_links = use_hard_links and os.stat(path).st_dev == os.stat(dest_dir).st_dev + if path.is_file(): + if use_hard_links: + os.link(path, dest_path) + else: + shutil.copy2(path, dest_path) + else: + copy_function = os.link if use_hard_links else shutil.copy2 + # link directory contents recursively + shutil.copytree( + path, + dest_path, + dirs_exist_ok=True, + copy_function=copy_function, + ignore=shutil.ignore_patterns(*IGNORE_PATTERNS) + ) + def package( output_path: Path | str, amend: bool = False, @@ -61,7 +84,8 @@ def package( bag_info: dict | None = None, signatures: list[dict] | None = None, signed_metadata: Path | str | None = None, - unsigned_metadata: Path | str | None = None + unsigned_metadata: Path | str | None = None, + use_hard_links: bool = False, ) -> None: """ Create a BagIt package. @@ -72,32 +96,18 @@ def package( Copy signed_metadata to data/signed-metadata.json. Copy unsigned_metadata to unsigned-metadata.json. """ - urls = urls or [] - paths = paths or [] bag_info = bag_info or {} # add data files output_path = Path(output_path) data_path = output_path / 'data' - data_path.mkdir(exist_ok=True, parents=True) + files_path = data_path / 'files' + files_path.mkdir(exist_ok=True, parents=True) if urls: capture(urls, data_path / 'headers.warc') - for path in paths: - path = Path(path) - dest_path = get_unique_path(data_path / "files" / path.name) - if path.is_file(): - # Use hard link for single files - os.link(path, dest_path) - else: - # link directory contents recursively - shutil.copytree( - path, - dest_path, - dirs_exist_ok=True, - copy_function=os.link, - ignore=shutil.ignore_patterns(*IGNORE_PATTERNS) - ) + if paths: + copy_paths(paths, files_path, use_hard_links) # Add metadata files if signed_metadata is not None: @@ -153,13 +163,15 @@ def package( def error(message: str, metadata: dict | None = None) -> None: print(f"Signature file {metadata['file']} no longer validates. Removing.") os.remove(metadata['file']) - if 'pem_file' in metadata: - os.remove(metadata['pem_file']) + def warn(message: str, metadata: dict | None = None) -> None: + if metadata and 'file' in metadata: + print(f"Signature file {metadata['file']} unrecognized. Removing.") + os.remove(metadata['file']) def success(message: str, metadata: dict | None = None) -> None: nonlocal sign_path print(f"Signature file {metadata['file']} still validates. Retaining.") sign_path = metadata['file'] - validate_signatures(sign_path, error=error, success=success) + validate_signatures(sign_path, error=error, warn=warn, success=success) if signatures: add_signatures(sign_path, output_path / "signatures", signatures) diff --git a/src/nabit/lib/capture.py b/src/nabit/lib/capture.py index ef1fcd3..5c9c1c9 100644 --- a/src/nabit/lib/capture.py +++ b/src/nabit/lib/capture.py @@ -56,10 +56,10 @@ def _write_warc_record(self, out, record): # set extension extension = filename.suffix if not extension: - if content_type := record.http_headers.get_header('Content-Type'): + if content_type := record.http_headers.get_header('Content-Type'): # pragma: no branch extension = mimetypes.guess_extension(content_type.split(';')[0], strict=False) - if not extension: - extension = '.unknown' + if not extension: + extension = '.unknown' # pragma: no cover out_path = get_unique_path(self.files_path / f'{stem}{extension}') relative_path = out_path.relative_to(self.warc_path.parent) @@ -74,7 +74,8 @@ def _write_warc_record(self, out, record): for buf in self._iter_stream(record.content_stream()): fh.write(buf) finally: - if hasattr(record, '_orig_stream'): + if hasattr(record, '_orig_stream'): # pragma: no cover + # kept for compatibility with warcio, but not sure when used record.raw_stream.close() record.raw_stream = record._orig_stream @@ -119,7 +120,7 @@ def validate_warc_headers(headers_path: Path, error, warn, success) -> None: if record.rec_type != 'revisit': continue profile = record.rec_headers.get_header('WARC-Profile') - if profile.startswith('file-content'): + if profile.startswith('file-content'): # pragma: no branch # extract file path from header 'file-content; filename="..."' file_path = profile.split(';')[1].split('=')[1].strip('"') # normalize path to prevent directory traversal attacks diff --git a/src/nabit/lib/sign.py b/src/nabit/lib/sign.py index 6288446..232c907 100644 --- a/src/nabit/lib/sign.py +++ b/src/nabit/lib/sign.py @@ -8,7 +8,7 @@ from .utils import noop -# for testing, set ROOT_CA=test/fixtures/pki/root-ca.crt +# for testing, set ROOT_CA=tests/fixtures/pki/root-ca.crt ROOT_CA = os.environ.get("ROOT_CA") # Add this constant near the top of the file, after imports @@ -36,7 +36,6 @@ def run_openssl(args: list[str | Path]) -> subprocess.CompletedProcess: result = subprocess.run( command, capture_output=True, - text=True, check=True ) return result @@ -112,7 +111,7 @@ def verify_signature(signature_file: Path, file_to_verify: Path) -> None: "-inform", "PEM", "-purpose", "any", # we are using domain and email certs ] - if ROOT_CA: + if ROOT_CA: # pragma: no branch args.extend(["-CAfile", ROOT_CA]) return run_openssl(args) @@ -168,6 +167,8 @@ def add_signatures(file_to_sign: Path, signatures_path: Path, signatures: list[d elif action == "timestamp": output_path = output_path.with_suffix(output_path.suffix + '.tsr') timestamp(file_to_sign, output_path, **params) + else: + raise ValueError(f"Unknown action: {action}") # pragma: no cover file_to_sign = output_path def validate_signatures(file_to_verify: Path, error=noop, warn=noop, success=noop) -> None: diff --git a/src/nabit/lib/utils.py b/src/nabit/lib/utils.py index 2d27bc3..848e2a3 100644 --- a/src/nabit/lib/utils.py +++ b/src/nabit/lib/utils.py @@ -14,4 +14,4 @@ def get_unique_path(path: Path) -> Path: def noop(*args, **kwargs): """Default callback function that does nothing.""" - pass + pass # pragma: no cover diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9fa6962 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,93 @@ +from click.testing import CliRunner +import pytest +from pytest_httpserver import HTTPServer + +from nabit.lib.archive import package +from nabit.lib.sign import KNOWN_TSAS + + +@pytest.fixture +def test_files(tmp_path): + """Create some test files to package""" + file1 = tmp_path / "test1.txt" + file2 = tmp_path / "test2.txt" + signed_metadata = tmp_path / "signed-metadata.json" + unsigned_metadata = tmp_path / "unsigned-metadata.json" + file1.write_text("Test content 1") + file2.write_text("Test content 2") + signed_metadata.write_text('{"metadata": "signed"}') + unsigned_metadata.write_text('{"metadata": "unsigned"}') + return {"payload": [file1, file2], "signed_metadata": signed_metadata, "unsigned_metadata": unsigned_metadata} + + +@pytest.fixture +def test_bag(tmp_path, test_files): + """Create a basic valid bag""" + bag_path = tmp_path / "test_bag" + package( + output_path=bag_path, + paths=test_files["payload"], + signed_metadata=test_files["signed_metadata"], + unsigned_metadata=test_files["unsigned_metadata"], + bag_info={"Source-Organization": "Test Org"} + ) + return bag_path + + +@pytest.fixture +def warc_bag(tmp_path, server): + """Create a basic valid bag with headers.warc""" + bag_path = tmp_path / "warc_bag" + package( + output_path=bag_path, + urls=[server.url_for("/")], + bag_info={"Source-Organization": "Test Org"} + ) + return bag_path + + +@pytest.fixture +def root_ca(monkeypatch): + """Monkeypatch ROOT_CA to use our test CA so validate will accept it""" + monkeypatch.setattr('nabit.lib.sign.ROOT_CA', 'tests/fixtures/pki/root-ca.crt') + + +@pytest.fixture +def signed_bag(tmp_path, test_files, root_ca): + """Create a basic valid signed bag""" + bag_path = tmp_path / "signed_bag" + # TODO: don't call out to live TSA server + package( + output_path=bag_path, + paths=test_files["payload"], + signed_metadata=test_files["signed_metadata"], + unsigned_metadata=test_files["unsigned_metadata"], + bag_info={"Source-Organization": "Test Org"}, + signatures=[ + { + "action": "sign", + "params": { + "key": "tests/fixtures/pki/domain-signing.key", + "cert_chain": "tests/fixtures/pki/domain-chain.pem" + } + }, + { + "action": "timestamp", + "params": KNOWN_TSAS["digicert"] + } + ] + ) + return bag_path + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def server(httpserver): + httpserver.expect_request("/").respond_with_data("root content", content_type="text/html") + httpserver.expect_request("/another.html").respond_with_data("another content", content_type="text/html") + httpserver.expect_request("/test.txt").respond_with_data("test content", content_type="text/plain") + return httpserver diff --git a/test/generate_certs.sh b/tests/fixtures/generate_certs.sh similarity index 51% rename from test/generate_certs.sh rename to tests/fixtures/generate_certs.sh index 19d0e2a..d23c504 100644 --- a/test/generate_certs.sh +++ b/tests/fixtures/generate_certs.sh @@ -1,36 +1,36 @@ #! /bin/bash -# script to regenerate files in test/fixtures/pki +# script to regenerate files in tests/fixtures/pki set -euxo pipefail # Create a directory for the test fixtures -mkdir -p test/fixtures/pki +mkdir -p tests/fixtures/pki # Generate ECDSA private key for root CA -openssl ecparam -name prime256v1 -genkey -noout -out test/fixtures/pki/root-ca.key +openssl ecparam -name prime256v1 -genkey -noout -out tests/fixtures/pki/root-ca.key # Create self-signed root certificate openssl req -x509 -new -nodes \ - -key test/fixtures/pki/root-ca.key \ + -key tests/fixtures/pki/root-ca.key \ -sha256 -days 3650 \ - -out test/fixtures/pki/root-ca.crt \ - -subj "/CN=Test Root CA/O=Data Vault Test/C=US" + -out tests/fixtures/pki/root-ca.crt \ + -subj "/CN=Test Root CA/O=Data Vault tests/C=US" # Generate ECDSA private key for intermediate CA -openssl ecparam -name prime256v1 -genkey -noout -out test/fixtures/pki/intermediate-ca.key +openssl ecparam -name prime256v1 -genkey -noout -out tests/fixtures/pki/intermediate-ca.key # Create CSR for intermediate CA openssl req -new \ - -key test/fixtures/pki/intermediate-ca.key \ - -out test/fixtures/pki/intermediate-ca.csr \ - -subj "/CN=Test Intermediate CA/O=Data Vault Test/C=US" + -key tests/fixtures/pki/intermediate-ca.key \ + -out tests/fixtures/pki/intermediate-ca.csr \ + -subj "/CN=Test Intermediate CA/O=Data Vault tests/C=US" # Sign intermediate certificate with root CA openssl x509 -req \ - -in test/fixtures/pki/intermediate-ca.csr \ - -CA test/fixtures/pki/root-ca.crt \ - -CAkey test/fixtures/pki/root-ca.key \ + -in tests/fixtures/pki/intermediate-ca.csr \ + -CA tests/fixtures/pki/root-ca.crt \ + -CAkey tests/fixtures/pki/root-ca.key \ -CAcreateserial \ - -out test/fixtures/pki/intermediate-ca.crt \ + -out tests/fixtures/pki/intermediate-ca.crt \ -days 1825 \ -sha256 \ -extensions v3_ca \ @@ -39,21 +39,21 @@ basicConstraints=critical,CA:TRUE keyUsage=critical,digitalSignature,keyCertSign,cRLSign") # Generate ECDSA private key for domain signing certificate -openssl ecparam -name prime256v1 -genkey -noout -out test/fixtures/pki/domain-signing.key +openssl ecparam -name prime256v1 -genkey -noout -out tests/fixtures/pki/domain-signing.key # Create CSR for domain signing certificate openssl req -new \ - -key test/fixtures/pki/domain-signing.key \ - -out test/fixtures/pki/domain-signing.csr \ - -subj "/CN=example.com/O=Data Vault Test/C=US" + -key tests/fixtures/pki/domain-signing.key \ + -out tests/fixtures/pki/domain-signing.csr \ + -subj "/CN=example.com/O=Data Vault tests/C=US" # Sign domain end-entity certificate with intermediate CA openssl x509 -req \ - -in test/fixtures/pki/domain-signing.csr \ - -CA test/fixtures/pki/intermediate-ca.crt \ - -CAkey test/fixtures/pki/intermediate-ca.key \ + -in tests/fixtures/pki/domain-signing.csr \ + -CA tests/fixtures/pki/intermediate-ca.crt \ + -CAkey tests/fixtures/pki/intermediate-ca.key \ -CAcreateserial \ - -out test/fixtures/pki/domain-signing.crt \ + -out tests/fixtures/pki/domain-signing.crt \ -days 365 \ -sha256 \ -extensions v3_domain \ @@ -64,21 +64,21 @@ extendedKeyUsage=serverAuth,clientAuth subjectAltName=DNS:example.com,DNS:www.example.com") # Generate ECDSA private key for email signing certificate -openssl ecparam -name prime256v1 -genkey -noout -out test/fixtures/pki/email-signing.key +openssl ecparam -name prime256v1 -genkey -noout -out tests/fixtures/pki/email-signing.key # Create CSR for email signing certificate openssl req -new \ - -key test/fixtures/pki/email-signing.key \ - -out test/fixtures/pki/email-signing.csr \ - -subj "/CN=user@example.com/O=Data Vault Test/C=US" + -key tests/fixtures/pki/email-signing.key \ + -out tests/fixtures/pki/email-signing.csr \ + -subj "/CN=user@example.com/O=Data Vault tests/C=US" # Sign email end-entity certificate with intermediate CA openssl x509 -req \ - -in test/fixtures/pki/email-signing.csr \ - -CA test/fixtures/pki/intermediate-ca.crt \ - -CAkey test/fixtures/pki/intermediate-ca.key \ + -in tests/fixtures/pki/email-signing.csr \ + -CA tests/fixtures/pki/intermediate-ca.crt \ + -CAkey tests/fixtures/pki/intermediate-ca.key \ -CAcreateserial \ - -out test/fixtures/pki/email-signing.crt \ + -out tests/fixtures/pki/email-signing.crt \ -days 365 \ -sha256 \ -extensions v3_email \ @@ -88,12 +88,12 @@ keyUsage=critical,digitalSignature,keyEncipherment extendedKeyUsage=emailProtection") # Create the certificate chain files -cat test/fixtures/pki/domain-signing.crt test/fixtures/pki/intermediate-ca.crt test/fixtures/pki/root-ca.crt > test/fixtures/pki/domain-chain.pem -cat test/fixtures/pki/email-signing.crt test/fixtures/pki/intermediate-ca.crt test/fixtures/pki/root-ca.crt > test/fixtures/pki/email-chain.pem +cat tests/fixtures/pki/domain-signing.crt tests/fixtures/pki/intermediate-ca.crt tests/fixtures/pki/root-ca.crt > tests/fixtures/pki/domain-chain.pem +cat tests/fixtures/pki/email-signing.crt tests/fixtures/pki/intermediate-ca.crt tests/fixtures/pki/root-ca.crt > tests/fixtures/pki/email-chain.pem # Clean up intermediate files -rm test/fixtures/pki/*.csr # Remove Certificate Signing Requests -rm test/fixtures/pki/*.srl # Remove serial number files +rm tests/fixtures/pki/*.csr # Remove Certificate Signing Requests +rm tests/fixtures/pki/*.srl # Remove serial number files # Generated files: # - root-ca.key (Root CA private key) diff --git a/test/fixtures/pki/domain-chain.pem b/tests/fixtures/pki/domain-chain.pem similarity index 100% rename from test/fixtures/pki/domain-chain.pem rename to tests/fixtures/pki/domain-chain.pem diff --git a/test/fixtures/pki/domain-signing.crt b/tests/fixtures/pki/domain-signing.crt similarity index 100% rename from test/fixtures/pki/domain-signing.crt rename to tests/fixtures/pki/domain-signing.crt diff --git a/test/fixtures/pki/domain-signing.key b/tests/fixtures/pki/domain-signing.key similarity index 100% rename from test/fixtures/pki/domain-signing.key rename to tests/fixtures/pki/domain-signing.key diff --git a/test/fixtures/pki/email-chain.pem b/tests/fixtures/pki/email-chain.pem similarity index 100% rename from test/fixtures/pki/email-chain.pem rename to tests/fixtures/pki/email-chain.pem diff --git a/test/fixtures/pki/email-signing.crt b/tests/fixtures/pki/email-signing.crt similarity index 100% rename from test/fixtures/pki/email-signing.crt rename to tests/fixtures/pki/email-signing.crt diff --git a/test/fixtures/pki/email-signing.key b/tests/fixtures/pki/email-signing.key similarity index 100% rename from test/fixtures/pki/email-signing.key rename to tests/fixtures/pki/email-signing.key diff --git a/test/fixtures/pki/intermediate-ca.crt b/tests/fixtures/pki/intermediate-ca.crt similarity index 100% rename from test/fixtures/pki/intermediate-ca.crt rename to tests/fixtures/pki/intermediate-ca.crt diff --git a/test/fixtures/pki/intermediate-ca.key b/tests/fixtures/pki/intermediate-ca.key similarity index 100% rename from test/fixtures/pki/intermediate-ca.key rename to tests/fixtures/pki/intermediate-ca.key diff --git a/test/fixtures/pki/root-ca.crt b/tests/fixtures/pki/root-ca.crt similarity index 100% rename from test/fixtures/pki/root-ca.crt rename to tests/fixtures/pki/root-ca.crt diff --git a/test/fixtures/pki/root-ca.key b/tests/fixtures/pki/root-ca.key similarity index 100% rename from test/fixtures/pki/root-ca.key rename to tests/fixtures/pki/root-ca.key diff --git a/tests/test_archive.py b/tests/test_archive.py new file mode 100644 index 0000000..baaaf26 --- /dev/null +++ b/tests/test_archive.py @@ -0,0 +1,28 @@ +import pytest +from nabit.lib.archive import copy_paths, validate_package + + +def test_ds_store_ignored(tmp_path): + """Test that files in IGNORE_PATTERNS are ignored when copying directories""" + # Setup source directory + source_dir = tmp_path / "test_dir" + source_dir.mkdir() + (source_dir / ".DS_Store").write_text("ignored") + (source_dir / "test.txt").write_text("included") + + # Setup destination directory + dest_dir = tmp_path / "output" + dest_dir.mkdir() + + # Test copying + copy_paths([source_dir], dest_dir) + + # Verify results + assert not (dest_dir / "test_dir/.DS_Store").exists() + assert (dest_dir / "test_dir/test.txt").read_text() == "included" + +def test_validate_raises(tmp_path): + # make sure that vanilla validate_package raises an error + # unless there's an error callback that does something else + with pytest.raises(ValueError, match='No files in data/files'): + validate_package(tmp_path) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..2c0930f --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,374 @@ +from nabit.bin.cli import main +from nabit.lib.sign import KNOWN_TSAS +from inline_snapshot import snapshot +import json +import re + +from tests.utils import validate_passing +from .utils import validate_passing, validate_failing + +### helpers + +def run(runner, args, exit_code=0, output="Package created"): + result = runner.invoke(main, args, catch_exceptions=False) + assert result.exit_code == exit_code + if output: + assert output in result.output + return result + +### tests + +## validate command + +def test_validate_valid_bag(runner, tmp_path, test_files): + run(runner, [ + 'archive', + str(tmp_path / 'bag'), + '-p', str(test_files["payload"][0]), + ]) + run(runner, [ + 'validate', + str(tmp_path / 'bag'), + ], output='Package is valid') + +def test_validate_invalid_bag(runner, tmp_path, test_files): + run(runner, [ + 'archive', + str(tmp_path / 'bag'), + '-p', str(test_files["payload"][0]), + ]) + + (tmp_path / 'bag' / 'data' / 'files' / 'extra.txt').write_text('extra') + + run(runner, [ + 'validate', + str(tmp_path / 'bag'), + ], exit_code=1, output='Errors found in package') + +## archive command - happy paths + +def test_file_payload(runner, tmp_path, test_files): + run(runner, [ + 'archive', + str(tmp_path / 'bag'), + '-p', str(test_files["payload"][0]), + '-p', str(test_files["payload"][1]), + ]) + assert validate_passing(tmp_path / 'bag') == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +def test_url_payload(runner, tmp_path, server): + bag_path = tmp_path / 'bag' + run(runner, [ + 'archive', + str(bag_path), + '-u', server.url_for("/"), + '-u', server.url_for("/another.html"), + '-u', server.url_for("/test.txt"), + ]) + assert validate_passing(bag_path) == snapshot("""\ +SUCCESS: headers.warc found +SUCCESS: bag format is valid +WARNING: No signatures found +WARNING: No timestamps found\ +""") + assert (bag_path / 'data/files/data.html').read_text() == 'root content' + assert (bag_path / 'data/files/another.html').read_text() == 'another content' + assert (bag_path / 'data/files/test.txt').read_text() == 'test content' + +def test_metadata(runner, tmp_path, test_files): + bag_path = tmp_path / 'bag' + run(runner, [ + 'archive', + str(bag_path), + '-p', str(test_files["payload"][0]), + '-i', 'Source-Organization:Test Org', + '-i', 'Contact-Email:test1@example.com', + '-i', 'Contact-Email:test2@example.com', + '--unsigned-metadata', str(test_files["unsigned_metadata"]), + '--signed-metadata', str(test_files["signed_metadata"]), + ]) + assert validate_passing(bag_path) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +WARNING: No signatures found +WARNING: No timestamps found\ +""") + + # check metadata files + assert json.loads((bag_path / 'unsigned-metadata.json').read_text()) == {'metadata': 'unsigned'} + assert json.loads((bag_path / 'data/signed-metadata.json').read_text()) == {'metadata': 'signed'} + + # check bag-info.txt metadata + bag_info = (bag_path / 'bag-info.txt').read_text() + assert 'Source-Organization: Test Org' in bag_info + assert 'Contact-Email: test1@example.com' in bag_info + assert 'Contact-Email: test2@example.com' in bag_info + +def test_signatures(runner, tmp_path, test_files, root_ca): + bag_path = tmp_path / 'bag' + run(runner, [ + 'archive', + str(bag_path), + '-p', str(test_files["payload"][0]), + '-s', 'tests/fixtures/pki/domain-chain.pem:tests/fixtures/pki/domain-signing.key', + '-s', 'tests/fixtures/pki/email-chain.pem:tests/fixtures/pki/email-signing.key', + '-t', 'digicert' + ]) + assert validate_passing(bag_path) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +SUCCESS: signature /signatures/tagmanifest-sha256.txt.p7s verified +SUCCESS: signature /signatures/tagmanifest-sha256.txt.p7s.p7s verified +SUCCESS: Timestamp /signatures/tagmanifest-sha256.txt.p7s.p7s.tsr verified\ +""") + +def test_just_timestamp_no_signatures(runner, tmp_path, test_files, root_ca): + bag_path = tmp_path / 'bag' + run(runner, [ + 'archive', + str(bag_path), + '-p', str(test_files["payload"][0]), + '-t', 'digicert', + '-t', f"{KNOWN_TSAS['sectigo']['cert_chain']}:{KNOWN_TSAS['sectigo']['url']}", + ]) + assert validate_passing(bag_path) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +SUCCESS: Timestamp /signatures/tagmanifest-sha256.txt.tsr verified +SUCCESS: Timestamp /signatures/tagmanifest-sha256.txt.tsr.tsr verified +WARNING: No signatures found\ +""") + +def test_create_then_sign(runner, tmp_path, test_files, root_ca): + bag_path = tmp_path / 'bag' + run(runner, [ + 'archive', + str(bag_path), + '-p', str(test_files["payload"][0]), + '-t', 'digicert', + ]) + assert validate_passing(bag_path) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +SUCCESS: Timestamp /signatures/tagmanifest-sha256.txt.tsr verified +WARNING: No signatures found\ +""") + + # should add signatures without invalidating existing timestamp + run(runner, [ + 'archive', + '--amend', + str(bag_path), + '-s', 'tests/fixtures/pki/domain-chain.pem:tests/fixtures/pki/domain-signing.key', + '-t', 'digicert' + ], output='Package amended') + assert validate_passing(bag_path) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +SUCCESS: Timestamp /signatures/tagmanifest-sha256.txt.tsr verified +SUCCESS: signature /signatures/tagmanifest-sha256.txt.tsr.p7s verified +SUCCESS: Timestamp /signatures/tagmanifest-sha256.txt.tsr.p7s.tsr verified\ +""") + + # changing anything else should invalidate the signatures + (bag_path / 'data' / 'files' / 'extra.txt').write_text('extra') + result = run(runner, [ + 'archive', + '--amend', + str(bag_path), + ], output='Package amended') + assert validate_passing(bag_path) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +WARNING: No signatures found +WARNING: No timestamps found\ +""") + assert list(bag_path.glob('signatures/*')) == [] + +def test_recreate_tag_files(runner, tmp_path, test_files, root_ca): + bag_path = tmp_path / 'bag' + run(runner, [ + 'archive', + str(bag_path), + '-p', str(test_files["payload"][0]), + ]) + assert validate_passing(bag_path) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +WARNING: No signatures found +WARNING: No timestamps found\ +""") + + # after we add a file, the bag should be invalid + (bag_path / 'data/files/extra.txt').write_text('extra file') + assert validate_failing(bag_path) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +ERROR: bag format is invalid: Bag validation failed: data/files/extra.txt exists on filesystem but is not in the manifest +WARNING: No signatures found +WARNING: No timestamps found\ +""") + + # a bare amend should fix it + run(runner, [ + 'archive', + '--amend', + str(bag_path), + ], output='Package amended') + assert validate_passing(bag_path) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +def test_hard_links(runner, tmp_path, test_files): + ## in regular operation, files are not hard linked + source_dir = tmp_path / 'source' + source_dir.mkdir() + (source_dir / 'payload.txt').write_text('payload') + bag_path = tmp_path / 'bag' + run(runner, [ + 'archive', + str(bag_path), + '-p', str(source_dir), + '-p', str(test_files["payload"][0]), + ]) + + # check that files are not hard linked by default + source_file = source_dir / 'payload.txt' + dest_file = bag_path / 'data/files/source/payload.txt' + assert dest_file.stat().st_ino != source_file.stat().st_ino + + test_payload_file = test_files["payload"][0] + dest_payload_file = bag_path / f'data/files/{test_payload_file.name}' + assert dest_payload_file.stat().st_ino != test_payload_file.stat().st_ino + + ## with --hard-link, files should be hard linked + bag_path = tmp_path / 'hard_linked' + run(runner, [ + 'archive', + str(bag_path), + '-p', str(source_dir), + '-p', str(test_files["payload"][0]), + '--hard-link' + ]) + assert validate_passing(bag_path) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +WARNING: No signatures found +WARNING: No timestamps found\ +""") + + # verify files are hard linked + dest_file = bag_path / 'data/files/source/payload.txt' + assert dest_file.stat().st_ino == source_file.stat().st_ino + + dest_payload_file = bag_path / f'data/files/{test_payload_file.name}' + assert dest_payload_file.stat().st_ino == test_payload_file.stat().st_ino + +def test_duplicate_file_names(runner, tmp_path, server): + """Test handling of duplicate filenames by checking unique path generation""" + bag_path = tmp_path / 'bag' + + # Create two files with the same name in different directories + dir1 = tmp_path / "dir1" + dir2 = tmp_path / "dir2" + dir1.mkdir() + dir2.mkdir() + (dir1 / "data.html").write_text("content1") + (dir2 / "data.html").write_text("content2") + + run(runner, [ + 'archive', + str(bag_path), + '-p', str(dir1 / "data.html"), + '-p', str(dir2 / "data.html"), + '-u', server.url_for("/"), + ]) + + # Verify files exist; one will be data.html and the others will be data-a1b2c3.html + files = sorted((p.name for p in (bag_path / "data" / "files").glob("data*.html"))) + assert re.match(r"data-[0-9a-zA-Z]{6}\.html;data-[0-9a-zA-Z]{6}\.html;data\.html", ";".join(files)) + +## validation errors + +def test_invalid_metadata_file_extension(runner, tmp_path): + (tmp_path / 'metadata.txt').write_text('test') + run(runner, [ + 'archive', + str(tmp_path / 'bag'), + '--signed-metadata', str(tmp_path / 'metadata.txt'), + ], exit_code=2, output='Metadata file must be a .json file') + +def test_invalid_metadata_file_contents(runner, tmp_path, test_files): + (tmp_path / 'metadata.json').write_text('invalid') + run(runner, [ + 'archive', + str(tmp_path / 'bag'), + '--signed-metadata', str(tmp_path / 'metadata.json'), + ], exit_code=2, output='Metadata file must be valid JSON') + +def test_invalid_info_format(runner, tmp_path): + run(runner, [ + 'archive', + str(tmp_path), + '-i', 'InvalidFormat', # missing colon + ], exit_code=2, output='Metadata must be in "key:value" format') + +def test_archive_to_non_empty_dir(runner, tmp_path): + (tmp_path / 'extra.txt').write_text('test') + run(runner, [ + 'archive', + str(tmp_path), + ], exit_code=2, output='already exists and is not empty') + +def test_amend_non_bagit(runner, tmp_path): + (tmp_path / 'some_file.txt').write_text('test') + run(runner, [ + 'archive', + '--amend', + str(tmp_path), + ], exit_code=2, output='No bagit.txt found') + +def test_invalid_timestamp_format(runner, tmp_path): + run(runner, [ + 'archive', + str(tmp_path), + '-t', 'unknown', # unknown TSA + ], exit_code=2, output='Timestamp must be in "url:cert_chain" format') + +def test_invalid_signature_format(runner, tmp_path): + """Test error handling for malformed signature parameter""" + run(runner, [ + 'archive', + str(tmp_path), + '-s', 'invalid_format', # missing colon + ], exit_code=2, output='Sign must be in "cert_chain:key_file" format') + +def test_nonexistent_key_file(runner, tmp_path): + """Test error handling for nonexistent key file""" + run(runner, [ + 'archive', + str(tmp_path), + '-s', 'nonexistent.key:tests/fixtures/pki/domain-chain.pem', + ], exit_code=2, output='does not exist') + +def test_invalid_url(runner, tmp_path): + """Test error handling for invalid URL format""" + run(runner, [ + 'archive', + str(tmp_path), + '-u', 'not_a_url', + ], exit_code=2, output='Invalid URL') + +def test_empty_package(runner, tmp_path): + """Test creating a package with no content""" + run(runner, [ + 'archive', + str(tmp_path), + ], exit_code=1, output='No files in data/files') diff --git a/tests/test_scripts.py b/tests/test_scripts.py new file mode 100644 index 0000000..f14c2e0 --- /dev/null +++ b/tests/test_scripts.py @@ -0,0 +1,5 @@ +from pathlib import Path +from scripts.update_docs import get_new_readme_text, readme_path + +def test_get_new_readme_text(): + assert readme_path.read_text() == get_new_readme_text(), "README.md is out of date, run `uv run scripts/update_docs.py` to update" diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..4dd6a11 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,283 @@ +import hashlib +import shutil +from inline_snapshot import snapshot +from nabit.lib.archive import make_manifest +from .utils import validate_failing, validate_passing, append_text + +## test valid packages + +def test_valid_package(test_bag): + assert validate_passing(test_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +def test_valid_warc_package(warc_bag): + assert validate_passing(warc_bag) == snapshot("""\ +SUCCESS: headers.warc found +SUCCESS: bag format is valid +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +def test_valid_signed_package(signed_bag): + assert validate_passing(signed_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +SUCCESS: signature /signatures/tagmanifest-sha256.txt.p7s verified +SUCCESS: Timestamp /signatures/tagmanifest-sha256.txt.p7s.tsr verified\ +""") + +## for errors we test from inside out, starting with changes inside data/files/ + +def test_modified_payload(test_bag): + (test_bag / "data/files/test1.txt").write_text("modified payload") + assert validate_failing(test_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +ERROR: bag format is invalid: Bag validation failed: data/files/test1.txt sha256 validation failed: expected="166cb94a04ebaef4ae79c2a0674d8cea1b7fc354eb2ea436b28c3531de10449c" found="0ef0c788f2de3fe11f1086f4a7c557ac8c812d01786b76d22477832d2e6326f9" +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +def test_extra_payload(test_bag): + (test_bag / "data/files/extra.txt").write_text("extra payload") + assert validate_failing(test_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +ERROR: bag format is invalid: Bag validation failed: data/files/extra.txt exists on filesystem but is not in the manifest +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +def test_missing_warc_file(warc_bag): + (warc_bag / "data/files/data.html").unlink() + assert validate_failing(warc_bag) == snapshot("""\ +ERROR: No files in data/files +SUCCESS: headers.warc found +ERROR: headers.warc specifies files that do not exist in data/files. Example: files/data.html +ERROR: bag format is invalid: Bag validation failed: data/files/data.html exists in manifest but was not found on filesystem +WARNING: Cannot verify the validity of empty directories: /data/files +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +def test_extra_warc_file(warc_bag): + (warc_bag / "data/files/extra.html").write_text("extra payload") + assert validate_failing(warc_bag) == snapshot("""\ +SUCCESS: headers.warc found +WARNING: Some files in data/files are not specified in headers.warc. Example: /data/files/extra.html +ERROR: bag format is invalid: Bag validation failed: data/files/extra.html exists on filesystem but is not in the manifest +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +def test_empty_folder(test_bag): + # make sure we warn that we can't verify the validity of empty directories, + # since they aren't included in the manifest + (test_bag / "data/empty").mkdir() + assert validate_passing(test_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +WARNING: 1 unexpected files in data/, starting with empty. +SUCCESS: bag format is valid +WARNING: Cannot verify the validity of empty directories: /data/empty +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +## next look at changes inside data/ + +def test_extra_data(test_bag): + (test_bag / "data/extra.txt").write_text("extra data") + assert validate_failing(test_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +WARNING: 1 unexpected files in data/, starting with extra.txt. +ERROR: bag format is invalid: Bag validation failed: data/extra.txt exists on filesystem but is not in the manifest +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +def test_missing_data(test_bag): + shutil.rmtree(test_bag / "data") + assert validate_failing(test_bag) == snapshot("""\ +ERROR: No files in data/files +WARNING: No headers.warc found; archive lacks request and response metadata +ERROR: bag format is invalid: Expected data directory /data does not exist +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +def test_signed_metadata_modified(test_bag): + append_text(test_bag / "data/signed-metadata.json", " ") + assert validate_failing(test_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +ERROR: bag format is invalid: Bag validation failed: data/signed-metadata.json sha256 validation failed: expected="54de45672f15c85afecc685b1099a34fb2371c7e1c667eeb71f576ea58031d53" found="82642a7d2637303352c00bb68515059cc9f8dcd7f8939638e5bf317afd42d567" +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +def test_unsigned_metadata_modified(signed_bag): + # modifying unsigned metadata is allowed, even in a signed bag + append_text(signed_bag / "unsigned-metadata.json", " ") + assert validate_passing(signed_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +SUCCESS: signature /signatures/tagmanifest-sha256.txt.p7s verified +SUCCESS: Timestamp /signatures/tagmanifest-sha256.txt.p7s.tsr verified\ +""") + +## next look at changes to standard BagIt tag files + +# bagit.txt + +def test_missing_bagit(test_bag): + (test_bag / "bagit.txt").unlink() + assert validate_failing(test_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +ERROR: bag format is invalid: Expected bagit.txt does not exist: /bagit.txt +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +def test_modified_bagit(test_bag): + append_text(test_bag / "bagit.txt", " ") + assert validate_failing(test_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +ERROR: bag format is invalid: Bag validation failed: bagit.txt sha256 validation failed: expected="e91f941be5973ff71f1dccbdd1a32d598881893a7f21be516aca743da38b1689" found="aa0a5b23d0e6a29e67136c07bc81636c4c6dbf24dc4d7d100120f8271eb02b53" +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +# bag-info.txt + +def test_missing_bag_info(test_bag): + (test_bag / "bag-info.txt").unlink() + assert validate_failing(test_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +ERROR: bag format is invalid: Bag validation failed: bag-info.txt exists in manifest but was not found on filesystem +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +def test_modified_bag_info(test_bag): + append_text(test_bag / "bag-info.txt", " ") + assert validate_failing(test_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +ERROR: bag format is invalid: Bag validation failed: bag-info.txt sha256 validation failed: expected="927411016fb7c206d5a4cf304c1d6a1fbfea06a0b0d9ff38ca976052ecde5a49" found="b56b885f8a084f9aeb9d104385946b0799c0fc7cefbb307f36b170ae8b2968b5" +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +# manifest-sha256.txt + +def test_missing_manifest(test_bag): + (test_bag / "manifest-sha256.txt").unlink() + assert validate_failing(test_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +ERROR: bag format is invalid: No manifest files found +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +def test_simple_manifest_modification(test_bag): + append_text(test_bag / "manifest-sha256.txt", " ") + assert validate_failing(test_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +ERROR: bag format is invalid: Bag validation failed: manifest-sha256.txt sha256 validation failed: expected="1eb4ef1aeaa8f1db13cd056ce3b74060ba0cf25d60e511c3844791c41408d87f" found="deedd551235b23948fd5abfe29c7ad4ce09c721c0bb65328da210ec93c1ee757" +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +def test_modified_manifest_new_file(test_bag): + # validation fails if we add a new file to the manifest, + # because tagmanifest won't match + new_file = test_bag / "data/files/extra.txt" + new_file.write_bytes(b"extra payload") + hash = hashlib.sha256(b"extra payload").hexdigest() + manifest = test_bag / "manifest-sha256.txt" + manifest.write_text(manifest.read_text() + f"{hash} data/files/extra.txt\n") + assert validate_failing(test_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +ERROR: bag format is invalid: Bag validation failed: manifest-sha256.txt sha256 validation failed: expected="1eb4ef1aeaa8f1db13cd056ce3b74060ba0cf25d60e511c3844791c41408d87f" found="fd34a1bf6f432cd8105c4ef2f8557130b2da249799d87a163cdc8f5080c8200b" +WARNING: No signatures found +WARNING: No timestamps found\ +""") + + # once we fix the tag manifest, validation should pass (this ensures our attempted attack is correct) + tagmanifest = test_bag / "tagmanifest-sha256.txt" + new_tagmanifest = make_manifest([test_bag / "bagit.txt", test_bag / "bag-info.txt", test_bag / "manifest-sha256.txt"], test_bag) + tagmanifest.write_text(new_tagmanifest) + assert validate_passing(test_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +# tagmanifest-sha256.txt + +def test_missing_tagmanifest(test_bag): + (test_bag / "tagmanifest-sha256.txt").unlink() + assert validate_failing(test_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +ERROR: bag format is invalid: No tagmanifest-sha256.txt found +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +def test_modified_tagmanifest(test_bag, signed_bag): + # unsigned tagmanifest can be modified + append_text(test_bag / "tagmanifest-sha256.txt", " ") + assert validate_passing(test_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +WARNING: No signatures found +WARNING: No timestamps found\ +""") + + # signed tagmanifest cannot be modified + append_text(signed_bag / "tagmanifest-sha256.txt", " ") + assert validate_failing(signed_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +ERROR: Signature verification failed: Command '['openssl', 'cms', '-verify', '-binary', '-content', PosixPath('/tagmanifest-sha256.txt'), '-in', PosixPath('/signatures/tagmanifest-sha256.txt.p7s'), '-inform', 'PEM', '-purpose', 'any', '-CAfile', 'tests/fixtures/pki/root-ca.crt']' returned non-zero exit status 4. +WARNING: Unknown signature file: /signatures/tagmanifest-sha256.txt.p7s.tsr +WARNING: Unknown signature file: /signatures/tagmanifest-sha256.txt.p7s.tsr.crt +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +## finally, look at changes to signatures/ directory + +def test_invalid_signature(signed_bag): + (signed_bag / "signatures/tagmanifest-sha256.txt.p7s").write_bytes(b"invalid signature") + assert validate_failing(signed_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +ERROR: Signature verification failed: Command '['openssl', 'cms', '-verify', '-binary', '-content', PosixPath('/tagmanifest-sha256.txt'), '-in', PosixPath('/signatures/tagmanifest-sha256.txt.p7s'), '-inform', 'PEM', '-purpose', 'any', '-CAfile', 'tests/fixtures/pki/root-ca.crt']' returned non-zero exit status 2. +WARNING: Unknown signature file: /signatures/tagmanifest-sha256.txt.p7s.tsr +WARNING: Unknown signature file: /signatures/tagmanifest-sha256.txt.p7s.tsr.crt +WARNING: No signatures found +WARNING: No timestamps found\ +""") + +def test_invalid_timestamp(signed_bag): + (signed_bag / "signatures/tagmanifest-sha256.txt.p7s.tsr").write_bytes(b"invalid timestamp") + assert validate_failing(signed_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +SUCCESS: signature /signatures/tagmanifest-sha256.txt.p7s verified +ERROR: Signature verification failed: Command '['openssl', 'ts', '-verify', '-data', PosixPath('/signatures/tagmanifest-sha256.txt.p7s'), '-in', PosixPath('/signatures/tagmanifest-sha256.txt.p7s.tsr'), '-CAfile', PosixPath('/signatures/tagmanifest-sha256.txt.p7s.tsr.crt')]' returned non-zero exit status 1. +WARNING: Unknown signature file: /signatures/tagmanifest-sha256.txt.p7s.tsr.crt +WARNING: No timestamps found\ +""") + +def test_missing_cert(signed_bag): + (signed_bag / "signatures/tagmanifest-sha256.txt.p7s.tsr.crt").unlink() + assert validate_failing(signed_bag) == snapshot("""\ +WARNING: No headers.warc found; archive lacks request and response metadata +SUCCESS: bag format is valid +SUCCESS: signature /signatures/tagmanifest-sha256.txt.p7s verified +ERROR: timestamp response file /signatures/tagmanifest-sha256.txt.p7s.tsr does not have corresponding .crt file +WARNING: No timestamps found\ +""") diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..b545f99 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,38 @@ +from pathlib import Path + +from nabit.lib.archive import validate_package + +def append_text(path: Path, text: str) -> None: + """Append text to a file.""" + with open(path, 'a') as f: + f.write(text) + + +## helpers + +def _validate(bag_path: Path): + """Capture validation output""" + responses = [] + validate_package( + bag_path, + error=lambda msg, metadata=None: responses.append(f"ERROR: {msg}"), + warn=lambda msg, metadata=None: responses.append(f"WARNING: {msg}"), + success=lambda msg, metadata=None: responses.append(f"SUCCESS: {msg}") + ) + out = "\n".join(responses) + out = out.replace(str(bag_path), "") + return out + + +def validate_failing(bag_path: Path): + """Capture validation output, asserting that it fails""" + output = _validate(bag_path) + assert "ERROR:" in output + return output + + +def validate_passing(bag_path: Path): + """Capture validation output, asserting that it passes""" + output = _validate(bag_path) + assert "ERROR:" not in output + return output diff --git a/uv.lock b/uv.lock index a056e29..5028af3 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,15 @@ version = 1 requires-python = ">=3.12" +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, +] + [[package]] name = "bagit" version = "1.8.1" @@ -10,6 +19,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/fc/58b3c209fdd383744b27914d0b88d0f9db72aa043e1475618d981d7089d9/bagit-1.8.1-py2.py3-none-any.whl", hash = "sha256:d14dd7e373dd24d41f6748c42f123f7db77098dfa4a0125dbacb4c8bdf767c09", size = 35137 }, ] +[[package]] +name = "black" +version = "24.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/0d/cc2fb42b8c50d80143221515dd7e4766995bd07c56c9a3ed30baf080b6dc/black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875", size = 645813 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/04/bf74c71f592bcd761610bbf67e23e6a3cff824780761f536512437f1e655/black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3", size = 1644256 }, + { url = "https://files.pythonhosted.org/packages/4c/ea/a77bab4cf1887f4b2e0bce5516ea0b3ff7d04ba96af21d65024629afedb6/black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65", size = 1448534 }, + { url = "https://files.pythonhosted.org/packages/4e/3e/443ef8bc1fbda78e61f79157f303893f3fddf19ca3c8989b163eb3469a12/black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f", size = 1761892 }, + { url = "https://files.pythonhosted.org/packages/52/93/eac95ff229049a6901bc84fec6908a5124b8a0b7c26ea766b3b8a5debd22/black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8", size = 1434796 }, + { url = "https://files.pythonhosted.org/packages/d0/a0/a993f58d4ecfba035e61fca4e9f64a2ecae838fc9f33ab798c62173ed75c/black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981", size = 1643986 }, + { url = "https://files.pythonhosted.org/packages/37/d5/602d0ef5dfcace3fb4f79c436762f130abd9ee8d950fa2abdbf8bbc555e0/black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b", size = 1448085 }, + { url = "https://files.pythonhosted.org/packages/47/6d/a3a239e938960df1a662b93d6230d4f3e9b4a22982d060fc38c42f45a56b/black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2", size = 1760928 }, + { url = "https://files.pythonhosted.org/packages/dd/cf/af018e13b0eddfb434df4d9cd1b2b7892bab119f7a20123e93f6910982e8/black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b", size = 1436875 }, + { url = "https://files.pythonhosted.org/packages/8d/a7/4b27c50537ebca8bec139b872861f9d2bf501c5ec51fcf897cb924d9e264/black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", size = 206898 }, +] + [[package]] name = "certifi" version = "2024.8.30" @@ -79,6 +112,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "coverage" +version = "7.6.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/75/aecfd0a3adbec6e45753976bc2a9fed62b42cea9a206d10fd29244a77953/coverage-7.6.8.tar.gz", hash = "sha256:8b2b8503edb06822c86d82fa64a4a5cb0760bb8f31f26e138ec743f422f37cfc", size = 801425 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/ce/3edf581c8fe429ed8ced6e6d9ac693c25975ef9093413276dab6ed68a80a/coverage-7.6.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e683e6ecc587643f8cde8f5da6768e9d165cd31edf39ee90ed7034f9ca0eefee", size = 207285 }, + { url = "https://files.pythonhosted.org/packages/09/9c/cf102ab046c9cf8895c3f7aadcde6f489a4b2ec326757e8c6e6581829b5e/coverage-7.6.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1defe91d41ce1bd44b40fabf071e6a01a5aa14de4a31b986aa9dfd1b3e3e414a", size = 207522 }, + { url = "https://files.pythonhosted.org/packages/39/06/42aa6dd13dbfca72e1fd8ffccadbc921b6e75db34545ebab4d955d1e7ad3/coverage-7.6.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7ad66e8e50225ebf4236368cc43c37f59d5e6728f15f6e258c8639fa0dd8e6d", size = 240543 }, + { url = "https://files.pythonhosted.org/packages/a0/20/2932971dc215adeca8eeff446266a7fef17a0c238e881ffedebe7bfa0669/coverage-7.6.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fe47da3e4fda5f1abb5709c156eca207eacf8007304ce3019eb001e7a7204cb", size = 237577 }, + { url = "https://files.pythonhosted.org/packages/ac/85/4323ece0cd5452c9522f4b6e5cc461e6c7149a4b1887c9e7a8b1f4e51146/coverage-7.6.8-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:202a2d645c5a46b84992f55b0a3affe4f0ba6b4c611abec32ee88358db4bb649", size = 239646 }, + { url = "https://files.pythonhosted.org/packages/77/52/b2537487d8f36241e518e84db6f79e26bc3343b14844366e35b090fae0d4/coverage-7.6.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4674f0daa1823c295845b6a740d98a840d7a1c11df00d1fd62614545c1583787", size = 239128 }, + { url = "https://files.pythonhosted.org/packages/7c/99/7f007762012186547d0ecc3d328da6b6f31a8c99f05dc1e13dcd929918cd/coverage-7.6.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:74610105ebd6f33d7c10f8907afed696e79c59e3043c5f20eaa3a46fddf33b4c", size = 237434 }, + { url = "https://files.pythonhosted.org/packages/97/53/e9b5cf0682a1cab9352adfac73caae0d77ae1d65abc88975d510f7816389/coverage-7.6.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cda8712145917105e07aab96388ae76e787270ec04bcb9d5cc786d7cbb8443", size = 239095 }, + { url = "https://files.pythonhosted.org/packages/0c/50/054f0b464fbae0483217186478eefa2e7df3a79917ed7f1d430b6da2cf0d/coverage-7.6.8-cp312-cp312-win32.whl", hash = "sha256:9e89d5c8509fbd6c03d0dd1972925b22f50db0792ce06324ba069f10787429ad", size = 209895 }, + { url = "https://files.pythonhosted.org/packages/df/d0/09ba870360a27ecf09e177ca2ff59d4337fc7197b456f22ceff85cffcfa5/coverage-7.6.8-cp312-cp312-win_amd64.whl", hash = "sha256:379c111d3558272a2cae3d8e57e6b6e6f4fe652905692d54bad5ea0ca37c5ad4", size = 210684 }, + { url = "https://files.pythonhosted.org/packages/9a/84/6f0ccf94a098ac3d6d6f236bd3905eeac049a9e0efcd9a63d4feca37ac4b/coverage-7.6.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b0c69f4f724c64dfbfe79f5dfb503b42fe6127b8d479b2677f2b227478db2eb", size = 207313 }, + { url = "https://files.pythonhosted.org/packages/db/2b/e3b3a3a12ebec738c545897ac9f314620470fcbc368cdac88cf14974ba20/coverage-7.6.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c15b32a7aca8038ed7644f854bf17b663bc38e1671b5d6f43f9a2b2bd0c46f63", size = 207574 }, + { url = "https://files.pythonhosted.org/packages/db/c0/5bf95d42b6a8d21dfce5025ce187f15db57d6460a59b67a95fe8728162f1/coverage-7.6.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63068a11171e4276f6ece913bde059e77c713b48c3a848814a6537f35afb8365", size = 240090 }, + { url = "https://files.pythonhosted.org/packages/57/b8/d6fd17d1a8e2b0e1a4e8b9cb1f0f261afd422570735899759c0584236916/coverage-7.6.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f4548c5ead23ad13fb7a2c8ea541357474ec13c2b736feb02e19a3085fac002", size = 237237 }, + { url = "https://files.pythonhosted.org/packages/d4/e4/a91e9bb46809c8b63e68fc5db5c4d567d3423b6691d049a4f950e38fbe9d/coverage-7.6.8-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4b4299dd0d2c67caaaf286d58aef5e75b125b95615dda4542561a5a566a1e3", size = 239225 }, + { url = "https://files.pythonhosted.org/packages/31/9c/9b99b0591ec4555b7292d271e005f27b465388ce166056c435b288db6a69/coverage-7.6.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9ebfb2507751f7196995142f057d1324afdab56db1d9743aab7f50289abd022", size = 238888 }, + { url = "https://files.pythonhosted.org/packages/a6/85/285c2df9a04bc7c31f21fd9d4a24d19e040ec5e2ff06e572af1f6514c9e7/coverage-7.6.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c1b4474beee02ede1eef86c25ad4600a424fe36cff01a6103cb4533c6bf0169e", size = 236974 }, + { url = "https://files.pythonhosted.org/packages/cb/a1/95ec8522206f76cdca033bf8bb61fff56429fb414835fc4d34651dfd29fc/coverage-7.6.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9fd2547e6decdbf985d579cf3fc78e4c1d662b9b0ff7cc7862baaab71c9cc5b", size = 238815 }, + { url = "https://files.pythonhosted.org/packages/8d/ac/687e9ba5e6d0979e9dab5c02e01c4f24ac58260ef82d88d3b433b3f84f1e/coverage-7.6.8-cp313-cp313-win32.whl", hash = "sha256:8aae5aea53cbfe024919715eca696b1a3201886ce83790537d1c3668459c7146", size = 209957 }, + { url = "https://files.pythonhosted.org/packages/2f/a3/b61cc8e3fcf075293fb0f3dee405748453c5ba28ac02ceb4a87f52bdb105/coverage-7.6.8-cp313-cp313-win_amd64.whl", hash = "sha256:ae270e79f7e169ccfe23284ff5ea2d52a6f401dc01b337efb54b3783e2ce3f28", size = 210711 }, + { url = "https://files.pythonhosted.org/packages/ee/4b/891c8b9acf1b62c85e4a71dac142ab9284e8347409b7355de02e3f38306f/coverage-7.6.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de38add67a0af869b0d79c525d3e4588ac1ffa92f39116dbe0ed9753f26eba7d", size = 208053 }, + { url = "https://files.pythonhosted.org/packages/18/a9/9e330409b291cc002723d339346452800e78df1ce50774ca439ade1d374f/coverage-7.6.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b07c25d52b1c16ce5de088046cd2432b30f9ad5e224ff17c8f496d9cb7d1d451", size = 208329 }, + { url = "https://files.pythonhosted.org/packages/9c/0d/33635fd429f6589c6e1cdfc7bf581aefe4c1792fbff06383f9d37f59db60/coverage-7.6.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62a66ff235e4c2e37ed3b6104d8b478d767ff73838d1222132a7a026aa548764", size = 251052 }, + { url = "https://files.pythonhosted.org/packages/23/32/8a08da0e46f3830bbb9a5b40614241b2e700f27a9c2889f53122486443ed/coverage-7.6.8-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b9f848b28081e7b975a3626e9081574a7b9196cde26604540582da60235fdf", size = 246765 }, + { url = "https://files.pythonhosted.org/packages/56/3f/3b86303d2c14350fdb1c6c4dbf9bc76000af2382f42ca1d4d99c6317666e/coverage-7.6.8-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:093896e530c38c8e9c996901858ac63f3d4171268db2c9c8b373a228f459bbc5", size = 249125 }, + { url = "https://files.pythonhosted.org/packages/36/cb/c4f081b9023f9fd8646dbc4ef77be0df090263e8f66f4ea47681e0dc2cff/coverage-7.6.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a7b8ac36fd688c8361cbc7bf1cb5866977ece6e0b17c34aa0df58bda4fa18a4", size = 248615 }, + { url = "https://files.pythonhosted.org/packages/32/ee/53bdbf67760928c44b57b2c28a8c0a4bf544f85a9ee129a63ba5c78fdee4/coverage-7.6.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:38c51297b35b3ed91670e1e4efb702b790002e3245a28c76e627478aa3c10d83", size = 246507 }, + { url = "https://files.pythonhosted.org/packages/57/49/5a57910bd0af6d8e802b4ca65292576d19b54b49f81577fd898505dee075/coverage-7.6.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2e4e0f60cb4bd7396108823548e82fdab72d4d8a65e58e2c19bbbc2f1e2bfa4b", size = 247785 }, + { url = "https://files.pythonhosted.org/packages/bd/37/e450c9f6b297c79bb9858407396ed3e084dcc22990dd110ab01d5ceb9770/coverage-7.6.8-cp313-cp313t-win32.whl", hash = "sha256:6535d996f6537ecb298b4e287a855f37deaf64ff007162ec0afb9ab8ba3b8b71", size = 210605 }, + { url = "https://files.pythonhosted.org/packages/44/79/7d0c7dd237c6905018e2936cd1055fe1d42e7eba2ebab3c00f4aad2a27d7/coverage-7.6.8-cp313-cp313t-win_amd64.whl", hash = "sha256:c79c0685f142ca53256722a384540832420dff4ab15fec1863d7e5bc8691bdcc", size = 211777 }, +] + +[[package]] +name = "executing" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/e3/7d45f492c2c4a0e8e0fad57d081a7c8a0286cdd86372b070cca1ec0caa1e/executing-2.1.0.tar.gz", hash = "sha256:8ea27ddd260da8150fa5a708269c4a10e76161e2496ec3e587da9e3c0fe4b9ab", size = 977485 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/fd/afcd0496feca3276f509df3dbd5dae726fcc756f1a08d9e25abe1733f962/executing-2.1.0-py2.py3-none-any.whl", hash = "sha256:8d63781349375b5ebccc3142f4b30350c0cd9c79f921cde38be2be4637e98eaf", size = 25805 }, +] + [[package]] name = "idna" version = "3.10" @@ -88,6 +168,100 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "inline-snapshot" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "black" }, + { name = "click" }, + { name = "executing" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/a9/b6b9db4f2ef1e3261460701a429f8248e517cb8d18e27ff05f4690ac0a73/inline_snapshot-0.14.0.tar.gz", hash = "sha256:54fdf7831055d06a2423054875d640102865a164cc8291a8086e44dd9b4fd316", size = 209662 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/a3/8ca14974625632d56d7e9f899d76d15dc4acd94ec15c179ca528beadeb4a/inline_snapshot-0.14.0-py3-none-any.whl", hash = "sha256:dc246d28b720f6050404b72cc1d171b0671e1494249197753d23771ff228748c", size = 31807 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + [[package]] name = "nabit" version = "0.1.0" @@ -100,6 +274,14 @@ dependencies = [ { name = "warcio" }, ] +[package.dev-dependencies] +dev = [ + { name = "inline-snapshot" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-httpserver" }, +] + [package.metadata] requires-dist = [ { name = "bagit", specifier = ">=1.8.1" }, @@ -109,6 +291,99 @@ requires-dist = [ { name = "warcio", specifier = ">=1.7.4" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "inline-snapshot", specifier = ">=0.14.0" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "pytest-httpserver", specifier = ">=1.1.0" }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pygments" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, +] + +[[package]] +name = "pytest" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, +] + +[[package]] +name = "pytest-cov" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, +] + +[[package]] +name = "pytest-httpserver" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/53/aa5aa8518246848251243a21440f167b812bd40896546061c1fda814c10c/pytest_httpserver-1.1.0.tar.gz", hash = "sha256:6b1cb0199e2ed551b1b94d43f096863bbf6ae5bcd7c75c2c06845e5ce2dc8701", size = 67210 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/29/c5dfce47d5f575e46e2d4a4257cd43cc199a8014f506d14d808e03d6f8cd/pytest_httpserver-1.1.0-py3-none-any.whl", hash = "sha256:7ef88be8ed3354b6784daa3daa75a422370327c634053cefb124903fa8d73a41", size = 20671 }, +] + [[package]] name = "requests" version = "2.32.3" @@ -124,6 +399,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, ] +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + [[package]] name = "setuptools" version = "75.6.0" @@ -142,6 +430,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, ] +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + [[package]] name = "urllib3" version = "2.2.3" @@ -162,3 +459,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/32/2c/c813f0576c8557ee5 wheels = [ { url = "https://files.pythonhosted.org/packages/24/eb/060b7e1c76abf24692784d5cf9c52ec05ff21249c88515d7f03c676434db/warcio-1.7.4-py2.py3-none-any.whl", hash = "sha256:ced1a162d76434d56abd81b37ac152821d1a11e1db835ead5d649f58068c2203", size = 40164 }, ] + +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 }, +]