From a98810d0e7e648a09b2216acdda6e0b5b9018d0e Mon Sep 17 00:00:00 2001 From: scarreras Date: Wed, 30 Sep 2026 07:55:50 -0300 Subject: [PATCH] test: own sdkharness conformance and resilience --- .sdkharness/README.md | 12 +- .sdkharness/tests.tsv | 49 ++ .sdkharness/tests/conformance/bucket.cors | 319 +++++++++++++ .sdkharness/tests/conformance/bucket.crud | 303 ++++++++++++ .../tests/conformance/bucket.lifecycle | 291 ++++++++++++ .../conformance/bucket.notification_rules | 311 +++++++++++++ .../conformance/bucket.replication_config | 339 ++++++++++++++ .../conformance/bucket.replication_helper | 355 ++++++++++++++ .../tests/conformance/client.auth_persistence | 354 ++++++++++++++ .sdkharness/tests/conformance/client.progress | 328 +++++++++++++ .../tests/conformance/client.simulator | 300 ++++++++++++ .sdkharness/tests/conformance/client.sync | 377 +++++++++++++++ .sdkharness/tests/conformance/enc.sse_b2 | 304 ++++++++++++ .sdkharness/tests/conformance/enc.sse_c | 375 +++++++++++++++ .../tests/conformance/files.delete_version | 290 ++++++++++++ .../tests/conformance/files.download_by_id | 289 ++++++++++++ .../tests/conformance/files.download_content | 288 ++++++++++++ .sdkharness/tests/conformance/files.hide | 314 +++++++++++++ .sdkharness/tests/conformance/files.list | 306 ++++++++++++ .sdkharness/tests/conformance/files.metadata | 325 +++++++++++++ .../tests/conformance/files.server_side_copy | 308 +++++++++++++ .sdkharness/tests/conformance/files.upload | 194 ++++++++ .sdkharness/tests/conformance/keys.crud | 300 ++++++++++++ .../tests/conformance/keys.multi_bucket | 329 +++++++++++++ .../tests/conformance/large.compose_mixed | 338 ++++++++++++++ .../tests/conformance/large.concurrent_parts | 374 +++++++++++++++ .sdkharness/tests/conformance/large.multipart | 362 +++++++++++++++ .../tests/conformance/large.parallel_download | 381 +++++++++++++++ .sdkharness/tests/conformance/large.resume | 435 ++++++++++++++++++ .../conformance/large.unbound_incremental | 434 +++++++++++++++++ .../tests/conformance/lock.bucket_default | 313 +++++++++++++ .../tests/conformance/lock.bypass_governance | 310 +++++++++++++ .sdkharness/tests/conformance/lock.legal_hold | 316 +++++++++++++ .../tests/conformance/lock.per_file_retention | 303 ++++++++++++ .../tests/conformance/urls.native_download | 347 ++++++++++++++ .sdkharness/tests/lib/contract.py | 136 ++++++ .sdkharness/tests/resilience/api.backoff_503 | 174 +++++++ .../tests/resilience/api.retry_after_429 | 178 +++++++ .../tests/resilience/api.retry_after_503 | 179 +++++++ .../tests/resilience/auth.clock_expiry | 179 +++++++ .sdkharness/tests/resilience/auth.expired_401 | 164 +++++++ .../tests/resilience/download.retry_503 | 167 +++++++ .sdkharness/tests/resilience/part.retry_503 | 201 ++++++++ .../tests/resilience/upload.cap_exceeded_403 | 173 +++++++ .../tests/resilience/upload.expired_token_401 | 181 ++++++++ .../tests/resilience/upload.get_url_503 | 172 +++++++ .../resilience/upload.reset_before_response | 194 ++++++++ .../tests/resilience/upload.reset_mid_request | 182 ++++++++ .sdkharness/tests/resilience/upload.retry_408 | 179 +++++++ .sdkharness/tests/resilience/upload.retry_500 | 178 +++++++ .sdkharness/tests/resilience/upload.retry_503 | 149 ++++++ .sdkharness/tests/resilience/upload.stall | 165 +++++++ .sdkharness/tests/run-conformance | 10 + .sdkharness/tests/run-resilience | 10 + changelog.d/612.infrastructure.md | 1 + ...harness_conformance_resilience_contract.py | 108 +++++ 56 files changed, 13951 insertions(+), 2 deletions(-) create mode 100755 .sdkharness/tests/conformance/bucket.cors create mode 100755 .sdkharness/tests/conformance/bucket.crud create mode 100755 .sdkharness/tests/conformance/bucket.lifecycle create mode 100755 .sdkharness/tests/conformance/bucket.notification_rules create mode 100755 .sdkharness/tests/conformance/bucket.replication_config create mode 100755 .sdkharness/tests/conformance/bucket.replication_helper create mode 100755 .sdkharness/tests/conformance/client.auth_persistence create mode 100755 .sdkharness/tests/conformance/client.progress create mode 100755 .sdkharness/tests/conformance/client.simulator create mode 100755 .sdkharness/tests/conformance/client.sync create mode 100755 .sdkharness/tests/conformance/enc.sse_b2 create mode 100755 .sdkharness/tests/conformance/enc.sse_c create mode 100755 .sdkharness/tests/conformance/files.delete_version create mode 100755 .sdkharness/tests/conformance/files.download_by_id create mode 100755 .sdkharness/tests/conformance/files.download_content create mode 100755 .sdkharness/tests/conformance/files.hide create mode 100755 .sdkharness/tests/conformance/files.list create mode 100755 .sdkharness/tests/conformance/files.metadata create mode 100755 .sdkharness/tests/conformance/files.server_side_copy create mode 100755 .sdkharness/tests/conformance/files.upload create mode 100755 .sdkharness/tests/conformance/keys.crud create mode 100755 .sdkharness/tests/conformance/keys.multi_bucket create mode 100755 .sdkharness/tests/conformance/large.compose_mixed create mode 100755 .sdkharness/tests/conformance/large.concurrent_parts create mode 100755 .sdkharness/tests/conformance/large.multipart create mode 100755 .sdkharness/tests/conformance/large.parallel_download create mode 100755 .sdkharness/tests/conformance/large.resume create mode 100755 .sdkharness/tests/conformance/large.unbound_incremental create mode 100755 .sdkharness/tests/conformance/lock.bucket_default create mode 100755 .sdkharness/tests/conformance/lock.bypass_governance create mode 100755 .sdkharness/tests/conformance/lock.legal_hold create mode 100755 .sdkharness/tests/conformance/lock.per_file_retention create mode 100755 .sdkharness/tests/conformance/urls.native_download create mode 100644 .sdkharness/tests/lib/contract.py create mode 100755 .sdkharness/tests/resilience/api.backoff_503 create mode 100755 .sdkharness/tests/resilience/api.retry_after_429 create mode 100755 .sdkharness/tests/resilience/api.retry_after_503 create mode 100755 .sdkharness/tests/resilience/auth.clock_expiry create mode 100755 .sdkharness/tests/resilience/auth.expired_401 create mode 100755 .sdkharness/tests/resilience/download.retry_503 create mode 100755 .sdkharness/tests/resilience/part.retry_503 create mode 100755 .sdkharness/tests/resilience/upload.cap_exceeded_403 create mode 100755 .sdkharness/tests/resilience/upload.expired_token_401 create mode 100755 .sdkharness/tests/resilience/upload.get_url_503 create mode 100755 .sdkharness/tests/resilience/upload.reset_before_response create mode 100755 .sdkharness/tests/resilience/upload.reset_mid_request create mode 100755 .sdkharness/tests/resilience/upload.retry_408 create mode 100755 .sdkharness/tests/resilience/upload.retry_500 create mode 100755 .sdkharness/tests/resilience/upload.retry_503 create mode 100755 .sdkharness/tests/resilience/upload.stall create mode 100755 .sdkharness/tests/run-conformance create mode 100755 .sdkharness/tests/run-resilience create mode 100644 changelog.d/612.infrastructure.md create mode 100644 test/unit/test_sdkharness_conformance_resilience_contract.py diff --git a/.sdkharness/README.md b/.sdkharness/README.md index a57e9acf..7f0a0bbf 100644 --- a/.sdkharness/README.md +++ b/.sdkharness/README.md @@ -13,5 +13,13 @@ Executables print one five-field, tab-separated result: SDKHARNESS_RESULT health golden-path PASS - ``` -The customer-health executable accepts only a literal loopback HTTP simulator -URL. It never uses a production B2 endpoint or real credentials. +The customer-health, conformance, and resilience executables accept only +literal IPv4 loopback HTTP simulator URLs. They import `b2sdk` from this exact +checkout and never use a production B2 endpoint or real credentials. + +Conformance owns 33 capability checks and resilience owns 16 injected-fault +checks. Their small dispatchers validate the invocation, run the selected +repository-owned assertion, and translate its standing verdict into the +five-field `SDKHARNESS_RESULT` record. The central harness continues to own +scenario selection, simulator lifecycle, fleet evidence, issue reconciliation, +reporting, and notification. diff --git a/.sdkharness/tests.tsv b/.sdkharness/tests.tsv index 85b98cc2..e17eeea3 100644 --- a/.sdkharness/tests.tsv +++ b/.sdkharness/tests.tsv @@ -1,2 +1,51 @@ test_level scenario target executable health golden-path simulator ./.sdkharness/tests/health-golden-path +conformance bucket.cors simulator ./.sdkharness/tests/run-conformance +conformance bucket.crud simulator ./.sdkharness/tests/run-conformance +conformance bucket.lifecycle simulator ./.sdkharness/tests/run-conformance +conformance bucket.notification_rules simulator ./.sdkharness/tests/run-conformance +conformance bucket.replication_config simulator ./.sdkharness/tests/run-conformance +conformance bucket.replication_helper simulator ./.sdkharness/tests/run-conformance +conformance client.auth_persistence simulator ./.sdkharness/tests/run-conformance +conformance client.progress simulator ./.sdkharness/tests/run-conformance +conformance client.simulator simulator ./.sdkharness/tests/run-conformance +conformance client.sync simulator ./.sdkharness/tests/run-conformance +conformance enc.sse_b2 simulator ./.sdkharness/tests/run-conformance +conformance enc.sse_c simulator ./.sdkharness/tests/run-conformance +conformance files.delete_version simulator ./.sdkharness/tests/run-conformance +conformance files.download_by_id simulator ./.sdkharness/tests/run-conformance +conformance files.download_content simulator ./.sdkharness/tests/run-conformance +conformance files.hide simulator ./.sdkharness/tests/run-conformance +conformance files.list simulator ./.sdkharness/tests/run-conformance +conformance files.metadata simulator ./.sdkharness/tests/run-conformance +conformance files.server_side_copy simulator ./.sdkharness/tests/run-conformance +conformance files.upload simulator ./.sdkharness/tests/run-conformance +conformance keys.crud simulator ./.sdkharness/tests/run-conformance +conformance keys.multi_bucket simulator ./.sdkharness/tests/run-conformance +conformance large.compose_mixed simulator ./.sdkharness/tests/run-conformance +conformance large.concurrent_parts simulator ./.sdkharness/tests/run-conformance +conformance large.multipart simulator ./.sdkharness/tests/run-conformance +conformance large.parallel_download simulator ./.sdkharness/tests/run-conformance +conformance large.resume simulator ./.sdkharness/tests/run-conformance +conformance large.unbound_incremental simulator ./.sdkharness/tests/run-conformance +conformance lock.bucket_default simulator ./.sdkharness/tests/run-conformance +conformance lock.bypass_governance simulator ./.sdkharness/tests/run-conformance +conformance lock.legal_hold simulator ./.sdkharness/tests/run-conformance +conformance lock.per_file_retention simulator ./.sdkharness/tests/run-conformance +conformance urls.native_download simulator ./.sdkharness/tests/run-conformance +resilience api.backoff_503 simulator ./.sdkharness/tests/run-resilience +resilience api.retry_after_429 simulator ./.sdkharness/tests/run-resilience +resilience api.retry_after_503 simulator ./.sdkharness/tests/run-resilience +resilience auth.clock_expiry simulator ./.sdkharness/tests/run-resilience +resilience auth.expired_401 simulator ./.sdkharness/tests/run-resilience +resilience download.retry_503 simulator ./.sdkharness/tests/run-resilience +resilience part.retry_503 simulator ./.sdkharness/tests/run-resilience +resilience upload.cap_exceeded_403 simulator ./.sdkharness/tests/run-resilience +resilience upload.expired_token_401 simulator ./.sdkharness/tests/run-resilience +resilience upload.get_url_503 simulator ./.sdkharness/tests/run-resilience +resilience upload.reset_before_response simulator ./.sdkharness/tests/run-resilience +resilience upload.reset_mid_request simulator ./.sdkharness/tests/run-resilience +resilience upload.retry_408 simulator ./.sdkharness/tests/run-resilience +resilience upload.retry_500 simulator ./.sdkharness/tests/run-resilience +resilience upload.retry_503 simulator ./.sdkharness/tests/run-resilience +resilience upload.stall simulator ./.sdkharness/tests/run-resilience diff --git a/.sdkharness/tests/conformance/bucket.cors b/.sdkharness/tests/conformance/bucket.cors new file mode 100755 index 00000000..6ad31881 --- /dev/null +++ b/.sdkharness/tests/conformance/bucket.cors @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x bucket.cors. PARTIAL CELL -- the boundary is asserted. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Action set a CORS rule set, then read it back + Assert field-for-field equality + + The card answers `partial` for this cell and states the boundary: CORS + round-trips as an UNTYPED dict with no typed model and no dedicated + accessor, unlike lifecycle and notification rules. That boundary is the + most falsifiable claim in the matrix, so it is asserted here too -- a typed + CorsRule or a get/set_cors_rules accessor appearing upstream FAILS this + check, because the card would then be stale. + +EXPECTED VALUES -- B2's published contract, reviewed 2026-09-23. +Every field sent here is a documented rule field (corsRuleName, allowedOrigins, +allowedOperations, allowedHeaders, exposeHeaders, maxAgeSeconds), and +b2_download_file_by_name / b2_download_file_by_id are documented +allowedOperations values. Rules can be "set when you create the bucket with +b2_create_bucket, or updated on an existing bucket using b2_update_bucket", and +b2_list_buckets returns corsRules. +https://www.backblaze.com/docs/cloud-storage-cross-origin-resource-sharing-rules +https://www.backblaze.com/apidocs/b2-update-bucket +https://www.backblaze.com/apidocs/b2-list-buckets +The `partial` boundary leg tests the card's claim about b2sdk's own surface; +B2's docs do not speak to it. + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'bucket.cors' +RULE = { + 'corsRuleName': 'sdkharness-conf', + 'allowedOrigins': ['https://example.com'], + 'allowedOperations': ['b2_download_file_by_name'], + 'allowedHeaders': ['range'], + 'exposeHeaders': ['x-bz-content-sha1'], + 'maxAgeSeconds': 3600, +} + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def run(target: str) -> None: + b2, api = connect(target) + + bucket = new_bucket(api) + try: + step('set the CORS rules', lambda: bucket.update(cors_rules=[RULE])) + + fresh = step('read the bucket back', lambda: api.list_buckets(bucket_name=bucket.name)) + if not fresh: + raise Failure('read back', 'the bucket disappeared from the listing') + read_back = fresh[0].cors_rules + + if not isinstance(read_back, list) or len(read_back) != 1: + raise Failure('read back', f'the bucket reports {read_back!r} instead of one CORS rule') + rule = read_back[0] + if not isinstance(rule, dict): + raise Failure( + 'read back', f'the CORS rule came back as {type(rule).__name__}, not a dict' + ) + for key, expected in RULE.items(): + if rule.get(key) != expected: + raise Failure('read back', f'CORS field {key} did not round-trip') + + # --- the `partial` boundary ------------------------------------------ + if hasattr(b2, 'CorsRule'): + raise Failure( + 'boundary', + "b2sdk.v3 now exports a typed CorsRule; the card's `partial` answer is stale", + ) + for accessor in ('get_cors_rules', 'set_cors_rules'): + if hasattr(b2.Bucket, accessor): + raise Failure( + 'boundary', + f"Bucket.{accessor} now exists; the card's `partial` answer is stale", + ) + if type(rule) is not dict: + raise Failure( + 'boundary', + f'the CORS rule is a {type(rule).__name__}, not the untyped dict the card records', + ) + + note('boundary held: CORS round-trips as a plain dict, with no typed model and no accessor') + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/bucket.crud b/.sdkharness/tests/conformance/bucket.crud new file mode 100755 index 00000000..99283003 --- /dev/null +++ b/.sdkharness/tests/conformance/bucket.crud @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x bucket.crud. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Action create -> update the type -> read back -> delete + Assert each read-back matches, and the deleted bucket is absent from the + listing + +EXPECTED VALUES -- B2's published contract, reviewed 2026-09-23. +b2_create_bucket and b2_update_bucket take bucketType "allPublic" or +"allPrivate" and return the bucket, bucketType included. b2_list_buckets with +bucketName returns "just this bucket, if it's present in the account, or no +buckets if the account does not have a bucket with this name" -- which is what +makes "absent after delete" the expected value. b2_delete_bucket: "Only buckets +that contain no version of any files can be deleted." +https://www.backblaze.com/apidocs/b2-create-bucket +https://www.backblaze.com/apidocs/b2-update-bucket +https://www.backblaze.com/apidocs/b2-delete-bucket +https://www.backblaze.com/apidocs/b2-list-buckets + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'bucket.crud' + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def bucket_type(bucket): + value = getattr(bucket, 'type_', None) + if value is None: + try: + value = bucket.as_dict().get('bucketType') + except Exception: # noqa: BLE001 + value = None + return value + + +def run(target: str) -> None: + _b2, api = connect(target) + + bucket = new_bucket(api) + try: + name = bucket.name + + created = step('read the new bucket back', lambda: api.list_buckets(bucket_name=name)) + if len(created) != 1: + raise Failure('create', f'{len(created)} buckets match the new name, expected 1') + if bucket_type(created[0]) != 'allPrivate': + raise Failure( + 'create', f"the new bucket's type is {bucket_type(created[0])}, expected allPrivate" + ) + + step('update the bucket type', lambda: bucket.update(bucket_type='allPublic')) + + updated = step('read the update back', lambda: api.list_buckets(bucket_name=name)) + if len(updated) != 1: + raise Failure('update', f'{len(updated)} buckets match the name after the update') + if bucket_type(updated[0]) != 'allPublic': + raise Failure( + 'update', f"the bucket's type is {bucket_type(updated[0])}, expected allPublic" + ) + + step('delete the bucket', lambda: api.delete_bucket(bucket)) + bucket = None + + remaining = step('list buckets', lambda: [b.name for b in api.list_buckets()]) + if name in remaining: + raise Failure('delete', 'the deleted bucket is still in the listing') + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/bucket.lifecycle b/.sdkharness/tests/conformance/bucket.lifecycle new file mode 100755 index 00000000..d3b47f0a --- /dev/null +++ b/.sdkharness/tests/conformance/bucket.lifecycle @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x bucket.lifecycle. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Action set daysFromHidingToDeleting: 1, then read it back + Assert equality -- every field set comes back with the same value, and no + other field of the rule comes back populated + +EXPECTED VALUES -- B2's published contract, reviewed 2026-09-23. +daysFromHidingToDeleting and fileNamePrefix are documented rule fields; B2's +own example rule is daysFromHidingToDeleting 1 with daysFromUploadingToHiding +null. "Unspecified properties are treated as having a null value", which is the +expected value for every field not set here. b2_update_bucket lifecycleRules: +"If specified, the existing lifecycle rules will be replaced with this new +list." +https://www.backblaze.com/docs/cloud-storage-lifecycle-rules +https://www.backblaze.com/apidocs/b2-update-bucket + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'bucket.lifecycle' +RULE = {'fileNamePrefix': 'st/', 'daysFromHidingToDeleting': 1} +OTHER_FIELDS = ('daysFromUploadingToHiding', 'daysFromStartingToCancelingUnfinishedLargeFiles') + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def run(target: str) -> None: + _b2, api = connect(target) + + bucket = new_bucket(api) + try: + step('set the lifecycle rules', lambda: bucket.update(lifecycle_rules=[RULE])) + + fresh = step('read the bucket back', lambda: api.list_buckets(bucket_name=bucket.name)) + if not fresh: + raise Failure('read back', 'the bucket disappeared from the listing') + read_back = fresh[0].lifecycle_rules + + if not isinstance(read_back, list) or len(read_back) != 1: + raise Failure( + 'read back', f'the bucket reports {read_back!r} instead of one lifecycle rule' + ) + rule = read_back[0] + for key, expected in RULE.items(): + if rule.get(key) != expected: + raise Failure( + 'read back', + f'lifecycle field {key} came back as {rule.get(key)}, expected {expected}', + ) + for key in OTHER_FIELDS: + if rule.get(key) is not None: + raise Failure( + 'read back', + f'lifecycle field {key} came back populated although it was never set', + ) + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/bucket.notification_rules b/.sdkharness/tests/conformance/bucket.notification_rules new file mode 100755 index 00000000..cbefb378 --- /dev/null +++ b/.sdkharness/tests/conformance/bucket.notification_rules @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x bucket.notification_rules. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it; secret leg corrected 2026-09-23, see below): + + Action set a rule with a webhook target and a signing secret, read it + back, then delete it + Assert the name, eventTypes and target match AND hmacSha256SigningSecret + comes back unchanged + +The expected value is B2's published contract: b2_get_bucket_notification_rules +lists hmacSha256SigningSecret as a response field and its example response +carries it unredacted, and get_notification_rules() surfaces that response. +https://www.backblaze.com/apidocs/b2-get-bucket-notification-rules +The 2026-09-22 row asserted the secret is NOT echoed, which contradicts the +docs. The secret is generated here and compared, never printed. + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import os +import secrets +import string +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'bucket.notification_rules' +RULE_NAME = 'sdkharness-conf' +EVENT_TYPES = ['b2:ObjectCreated:*'] +WEBHOOK_URL = 'https://example.com/sdkharness-conf' + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def run(target: str) -> None: + _b2, api = connect(target) + + alphabet = string.ascii_letters + string.digits + signing_secret = ''.join(secrets.choice(alphabet) for _ in range(32)) + + rule = { + 'name': RULE_NAME, + 'eventTypes': list(EVENT_TYPES), + 'isEnabled': True, + 'objectNamePrefix': 'st/', + 'targetConfiguration': { + 'targetType': 'webhook', + 'url': WEBHOOK_URL, + 'hmacSha256SigningSecret': signing_secret, + }, + } + + bucket = new_bucket(api) + try: + step('set the notification rules', lambda: bucket.set_notification_rules([rule])) + + read_back = step('read the notification rules', lambda: bucket.get_notification_rules()) + found = [r for r in read_back if r.get('name') == RULE_NAME] + if len(found) != 1: + raise Failure('read back', f'{len(found)} rules named {RULE_NAME}, expected 1') + got = found[0] + + if list(got.get('eventTypes') or []) != EVENT_TYPES: + raise Failure('read back', 'eventTypes did not round-trip') + if got.get('objectNamePrefix') != 'st/': + raise Failure('read back', 'objectNamePrefix did not round-trip') + target_config = got.get('targetConfiguration') or {} + if target_config.get('targetType') != 'webhook': + raise Failure( + 'read back', f"targetType is {target_config.get('targetType')}, expected webhook" + ) + if target_config.get('url') != WEBHOOK_URL: + raise Failure('read back', 'the webhook url did not round-trip') + + # Compared, never printed: the detail names the field, not its value. + if target_config.get('hmacSha256SigningSecret') != signing_secret: + raise Failure('secret', 'hmacSha256SigningSecret did not round-trip') + + step('delete the notification rules', lambda: bucket.set_notification_rules([])) + after = step('read the rules after deletion', lambda: bucket.get_notification_rules()) + if any(r.get('name') == RULE_NAME for r in after): + raise Failure('delete', 'the rule survived deletion') + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/bucket.replication_config b/.sdkharness/tests/conformance/bucket.replication_config new file mode 100755 index 00000000..9164397d --- /dev/null +++ b/.sdkharness/tests/conformance/bucket.replication_config @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x bucket.replication_config. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup two buckets and a replication-capable key + Action set a source replication rule + Assert the read-back carries the rule, matching field for field + +EXPECTED VALUES -- B2's published contract, reviewed 2026-09-23. +b2_update_bucket takes replicationConfiguration; "At least one of the +asReplicationSource or asReplicationDestination parameters is required". Every +rule field set here (replicationRuleName, destinationBucketId, fileNamePrefix, +includeExistingFiles, isEnabled, priority) plus sourceApplicationKeyId is a +documented field, and "Call b2_list_buckets to retrieve all of the replication +rules associated with one or more buckets." +https://www.backblaze.com/apidocs/b2-update-bucket +https://www.backblaze.com/docs/cloud-storage-create-a-cloud-replication-rule-with-the-native-api + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'bucket.replication_config' +RULE_NAME = 'sdkharness-conf' +PREFIX = 'st/' +SOURCE_CAPABILITIES = [ + 'listBuckets', + 'listFiles', + 'readFiles', + 'shareFiles', + 'writeFiles', + 'deleteFiles', + 'readFileLegalHolds', + 'readFileRetentions', + 'readBucketReplications', + 'writeBucketReplications', +] + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def run(target: str) -> None: + b2, api = connect(target) + + source = None + destination = None + key_id = None + try: + source = new_bucket(api) + destination = new_bucket(api) + + key = step( + 'create a replication-capable key', + lambda: api.create_key( + capabilities=list(SOURCE_CAPABILITIES), + key_name=f'sdkharness-conf-{uuid.uuid4().hex[:8]}', + ), + ) + key_id = key.id_ + + # The destination must map the source key before a source rule names it: + # B2 documents both halves ("the destination needs only the source key + # ID to map the two buckets") but no order, and the harness simulator + # refuses the rule otherwise. One key serves both sides; it holds the + # writeFiles the destination side needs. Mirrors the TypeScript check. + # https://www.backblaze.com/docs/cloud-storage-create-a-cloud-replication-rule-with-the-native-api + mapping = b2.ReplicationConfiguration(source_to_destination_key_mapping={key_id: key_id}) + step('set the destination key mapping', lambda: destination.update(replication=mapping)) + + rule = b2.ReplicationRule( + destination_bucket_id=destination.id_, + name=RULE_NAME, + file_name_prefix=PREFIX, + priority=128, + ) + configuration = b2.ReplicationConfiguration(rules=[rule], source_key_id=key_id) + step('set the source replication rule', lambda: source.update(replication=configuration)) + + fresh = step('read the bucket back', lambda: api.list_buckets(bucket_name=source.name)) + if not fresh: + raise Failure('read back', 'the source bucket disappeared from the listing') + read_back = fresh[0].replication + if read_back is None: + raise Failure('read back', 'the source bucket reports no replication configuration') + + rules = list(getattr(read_back, 'rules', None) or []) + matching = [r for r in rules if getattr(r, 'name', None) == RULE_NAME] + if len(matching) != 1: + raise Failure( + 'read back', f'{len(matching)} replication rules named {RULE_NAME}, expected 1' + ) + got = matching[0] + if getattr(got, 'destination_bucket_id', None) != destination.id_: + raise Failure('read back', 'the rule points at a different destination bucket') + if getattr(got, 'file_name_prefix', None) != PREFIX: + raise Failure('read back', "the rule's file name prefix did not round-trip") + if getattr(read_back, 'source_key_id', None) != key_id: + raise Failure('read back', "the configuration's source key id did not round-trip") + finally: + for bucket in (source, destination): + if bucket is None: + continue + try: + bucket.update(replication=b2.ReplicationConfiguration()) + except Exception: # noqa: BLE001 -- cleanup must not mask the verdict + pass + teardown(api, source, destination) + drop_keys(api, [key_id]) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/bucket.replication_helper b/.sdkharness/tests/conformance/bucket.replication_helper new file mode 100755 index 00000000..7b984022 --- /dev/null +++ b/.sdkharness/tests/conformance/bucket.replication_helper @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x bucket.replication_helper. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup two buckets + Action run the setup helper end to end, then the status/monitor call + Assert the helper created BOTH the source and the destination + configuration, and the status reports the expected counts + +NO DOCUMENTED CONTRACT FOUND for the helper itself -- only for the endpoints +under it. B2 documents that a working replication needs BOTH sides: "one rule +for setting up source with asReplicationSource section and second rule for +setting up the destination with asReplicationDestination section", and that +b2_list_buckets returns them. +https://www.backblaze.com/docs/cloud-storage-create-a-cloud-replication-rule-with-the-native-api +https://www.backblaze.com/apidocs/b2-update-bucket +searched: b2sdk's published API index -- no ReplicationSetupHelper or +ReplicationMonitor entry; counter_by_status appears only under b2sdk._internal +(https://b2-sdk-python.readthedocs.io/en/master/genindex.html) -- and a web +search for the helper names. So "the status reports the expected counts" has no documented +expected value; it is asserted only as at least one file counted. + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import io +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'bucket.replication_helper' +RULE_NAME = 'sdkharness-conf' +PREFIX = 'st/' +OBJECT_NAME = 'st/replicated.txt' +PAYLOAD_SIZE = 256 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def key_ids_of(configuration): + if configuration is None: + return [] + ids = [] + source_key = getattr(configuration, 'source_key_id', None) + if source_key: + ids.append(source_key) + mapping = getattr(configuration, 'source_to_destination_key_mapping', None) or {} + for source_id, destination_id in mapping.items(): + ids.extend([source_id, destination_id]) + return [i for i in ids if i] + + +def run(target: str) -> None: + b2, api = connect(target) + + source = None + destination = None + created_keys = [] + try: + source = new_bucket(api) + destination = new_bucket(api) + + helper = b2.ReplicationSetupHelper() + source, destination = step( + 'run the replication setup helper', + lambda: helper.setup_both( + source_bucket=source, + destination_bucket=destination, + name=RULE_NAME, + prefix=PREFIX, + ), + ) + + source_config = getattr(source, 'replication', None) + destination_config = getattr(destination, 'replication', None) + created_keys = key_ids_of(source_config) + key_ids_of(destination_config) + + if source_config is None: + raise Failure( + 'helper', 'the helper left the source bucket with no replication configuration' + ) + source_rules = list(getattr(source_config, 'rules', None) or []) + if not source_rules: + raise Failure('helper', 'the helper created no source replication rule') + if getattr(source_config, 'source_key_id', None) is None: + raise Failure('helper', 'the helper provisioned no source key') + + if destination_config is None: + raise Failure( + 'helper', 'the helper left the destination bucket with no replication configuration' + ) + if not (getattr(destination_config, 'source_to_destination_key_mapping', None) or {}): + raise Failure('helper', 'the helper created no destination key mapping') + + step( + 'upload something to replicate', + lambda: source.upload_bytes(payload_of(PAYLOAD_SIZE), OBJECT_NAME), + ) + + monitor = b2.ReplicationMonitor( + bucket=source, + rule=source_rules[0], + destination_api=api, + report=b2.ProgressReport(io.StringIO(), True), + ) + report = step('scan replication status', lambda: monitor.scan(scan_destination=True)) + + counts = getattr(report, 'counter_by_status', None) + if counts is None: + raise Failure('status', 'the monitor returned no counter_by_status') + total = sum(counts.values()) + if total < 1: + raise Failure( + 'status', 'the monitor counted no files although one was uploaded under the prefix' + ) + + note(f'monitor counted {total} file(s) across {len(counts)} status bucket(s)') + finally: + for bucket in (source, destination): + if bucket is None: + continue + try: + bucket.update(replication=b2.ReplicationConfiguration()) + except Exception: # noqa: BLE001 + pass + teardown(api, source, destination) + drop_keys(api, created_keys) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/client.auth_persistence b/.sdkharness/tests/conformance/client.auth_persistence new file mode 100755 index 00000000..7ce8c08f --- /dev/null +++ b/.sdkharness/tests/conformance/client.auth_persistence @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x client.auth_persistence. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a scratch account-info path + Action authorize in process 1; run process 2 with NO credentials in its + environment but the same account-info path + Assert process 2 makes an authorized call and issues NO + b2_authorize_account request + + Two real processes, because that is the claim: the persisted state, not an + in-memory object, is what carries the authorization. The child counts + b2_authorize_account at the SDK's own raw-API injection point and prints + counts only -- never a token, never a key. + +NO DOCUMENTED CONTRACT FOUND -- this is SDK-side/ecosystem tooling, not a B2 API contract. +Persisting account info to disk is the tool's own behaviour; B2 documents no +cache. The nearest documented fact bounds, but does not promise, the reuse: +b2_authorize_account's token "is valid for at most 24 hours." +https://www.backblaze.com/apidocs/b2-authorize-account + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import os +import shutil +import subprocess +import sys +import tempfile +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'client.auth_persistence' + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +CHILD = """ +import collections +import sys + +db = sys.argv[1] +tally = collections.Counter() + +try: + import b2sdk.v3 as b2 +except Exception as error: + print("CHILD error " + type(error).__name__) + raise SystemExit(0) + + +class Counting(b2.B2RawHTTPApi): + def authorize_account(self, *args, **kwargs): + tally["authorize_account"] += 1 + return super().authorize_account(*args, **kwargs) + + +try: + info = b2.SqliteAccountInfo(file_name=db) + api = b2.B2Api(info, api_config=b2.B2HttpApiConfig(_raw_api_class=Counting)) + buckets = api.list_buckets() + print("CHILD ok buckets=%d authorize=%d" % (len(buckets), tally["authorize_account"])) +except Exception as error: + print("CHILD error " + type(error).__name__) +""" + + +def run(target: str) -> None: + b2, realm = prepare(target) + + scratch = tempfile.mkdtemp(prefix='sdkharness-conf-') + account_info_path = os.path.join(scratch, 'account-info.sqlite') + try: + api = b2.B2Api(b2.SqliteAccountInfo(file_name=account_info_path)) + authorize(api, realm) + step('an authorized call in process 1', lambda: api.list_buckets()) + + # Process 2 gets the account-info path and nothing else. + child_env = {k: v for k, v in os.environ.items() if not k.startswith('B2_')} + completed = step( + 'run process 2 with no credentials', + lambda: subprocess.run( + [sys.executable, '-c', CHILD, account_info_path], + env=child_env, + capture_output=True, + text=True, + timeout=300, + ), + ) + + lines = [line for line in completed.stdout.splitlines() if line.startswith('CHILD ')] + if not lines: + raise Failure( + 'process 2', f'the second process printed no marker (exit {completed.returncode})' + ) + marker = lines[-1] + + if marker.startswith('CHILD error '): + failed_with = marker[len('CHILD error ') :].strip() + if failed_with in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f'{failed_with} in process 2') + if failed_with in ( + 'gaierror', + 'ConnectionError', + 'ServiceError', + 'B2ConnectionError', + 'UnknownHost', + ): + raise Amber('unreachable', f'{failed_with} in process 2') + if failed_with in ('ImportError', 'ModuleNotFoundError'): + raise Amber('missing-runtime', 'b2sdk is not installed for the second process') + raise Failure('process 2', f'the second process failed with {failed_with}') + + fields = dict(part.split('=', 1) for part in marker.split() if '=' in part) + if 'authorize' not in fields: + raise Failure('process 2', 'the second process reported no authorize count') + if int(fields['authorize']) != 0: + raise Failure( + 'persistence', + f"process 2 issued {fields['authorize']} b2_authorize_account request(s); the persisted auth was not reused", + ) + + note( + f"process 2 made an authorized call over the persisted account info; {marker.split(' ', 1)[1]}" + ) + finally: + shutil.rmtree(scratch, ignore_errors=True) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/client.progress b/.sdkharness/tests/conformance/client.progress new file mode 100755 index 00000000..830a11fc --- /dev/null +++ b/.sdkharness/tests/conformance/client.progress @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x client.progress. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a large object + Action upload it with a progress listener attached + Assert the callbacks fire, are monotonically non-decreasing, and the final + value equals the total byte count + + One upload worker, deliberately: with several, cumulative progress from + concurrent parts is legitimately interleaved and 'monotonically + non-decreasing' would be testing the thread pool, not the listener. + +NO DOCUMENTED CONTRACT FOUND -- this is SDK-side/ecosystem tooling, not a B2 API contract. +Progress reporting is client-side ergonomics; the B2 API has no progress +surface, so the expected values (fires, non-decreasing, ends at the byte +count) come from the scenario, not a Backblaze doc. + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import os +import sys +import threading +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'client.progress' +OBJECT_NAME = 'st/progress.bin' +PARTS_WANTED = 3 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def recorder_class(b2): + class Recorder(b2.AbstractProgressListener): + def __init__(self): + super().__init__() + self.lock = threading.Lock() + self.total = None + self.calls = [] + + def set_total_bytes(self, total_byte_count): + with self.lock: + self.total = total_byte_count + + def bytes_completed(self, byte_count): + with self.lock: + self.calls.append(byte_count) + + def close(self): + pass + + return Recorder + + +def run(target: str) -> None: + b2, realm = prepare(target) + api = b2.B2Api(b2.InMemoryAccountInfo(), max_upload_workers=1) + authorize(api, realm) + + part_size = api.account_info.get_absolute_minimum_part_size() + payload = payload_of(part_size * PARTS_WANTED) + listener = recorder_class(b2)() + + bucket = new_bucket(api) + try: + step( + 'upload with a progress listener', + lambda: bucket.create_file( + [b2.WriteIntent(b2.UploadSourceBytes(payload))], + OBJECT_NAME, + recommended_upload_part_size=part_size, + min_part_size=part_size, + max_part_size=part_size, + progress_listener=listener, + ), + ) + + if listener.total != len(payload): + raise Failure( + 'progress', f'set_total_bytes reported {listener.total}, expected {len(payload)}' + ) + if not listener.calls: + raise Failure('progress', 'no bytes_completed callback fired') + previous = None + for value in listener.calls: + if previous is not None and value < previous: + raise Failure('progress', 'bytes_completed went backwards') + previous = value + if listener.calls[-1] != len(payload): + raise Failure( + 'progress', + f'the final bytes_completed value was {listener.calls[-1]}, expected {len(payload)}', + ) + + note( + f'{len(listener.calls)} progress callbacks, non-decreasing, ending at {listener.calls[-1]} bytes' + ) + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/client.simulator b/.sdkharness/tests/conformance/client.simulator new file mode 100755 index 00000000..17a5959a --- /dev/null +++ b/.sdkharness/tests/conformance/client.simulator @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x client.simulator. Reports @simulator -- it never touches B2. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a scratch project depending only on the PUBLISHED package + Action import the shipped fake and run a trivial upload/download against it + Assert it works with NO network + + This is a packaging / consumability check, not a B2 round trip: the claim + is that `pip install b2sdk` alone puts a usable in-process B2 fake in a + downstream project's hands. `socket.socket` is replaced for the duration, + so any attempt to reach the network is a Failure rather than a silent pass. + It needs no realm and no credential, and always reports @simulator -- + simulator evidence and staging evidence must never render alike. + +NO DOCUMENTED CONTRACT FOUND -- this is SDK-side/ecosystem tooling, not a B2 API contract. +Shipping an in-process fake is packaging; no Backblaze doc page covers it. The +fake models the same endpoints the API docs describe, but that is the tool's +claim, so a PASS here says the tool and its own fake agree, nothing about B2. + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import io +import os +import socket +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'client.simulator' +OBJECT_NAME = 'st/simulator.txt' +PAYLOAD_SIZE = 1024 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target this tool cannot reach maps to a realm or to amber. +# b2sdk ships no B2 fake reachable through a realm NAME, so `simulator` is +# `no-realm-option` here -- not a failure of the capability. +REALMS = {'staging': 'staging', 'production': 'production'} + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + raise Amber('no-realm-option', f"b2sdk exposes no '{target}' realm") + + if not os.environ.get('B2_APPLICATION_KEY_ID') or not os.environ.get('B2_APPLICATION_KEY'): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account( + os.environ['B2_APPLICATION_KEY_ID'], + os.environ['B2_APPLICATION_KEY'], + realm=realm, + ), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +class NetworkTouched(Exception): + """A check that claims 'no network' has to prove it.""" + + +def run(target: str) -> None: + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + if not hasattr(b2, 'RawSimulator'): + raise Failure('packaging', 'the installed b2sdk exports no RawSimulator') + + payload = payload_of(PAYLOAD_SIZE) + original_socket = socket.socket + + def blocked(*_args, **_kwargs): + raise NetworkTouched('the simulator check opened a socket') + + socket.socket = blocked + try: + api = b2.B2Api( + b2.InMemoryAccountInfo(), + api_config=b2.B2HttpApiConfig(_raw_api_class=b2.RawSimulator), + ) + simulator = api.session.raw_api + if not isinstance(simulator, b2.RawSimulator): + raise Failure('packaging', 'the injected raw API is not the shipped RawSimulator') + + key_id, key = step('create a simulated account', lambda: simulator.create_account()) + step( + 'authorize against the fake', + lambda: api.authorize_account(key_id, key, realm='production'), + ) + + bucket = step( + 'create a bucket in the fake', + lambda: api.create_bucket(f'sdkharness-conf-{uuid.uuid4().hex[:12]}', 'allPrivate'), + ) + uploaded = step('upload', lambda: bucket.upload_bytes(payload, OBJECT_NAME)) + if not getattr(uploaded, 'id_', None): + raise Failure('upload', 'the simulated upload returned no fileId') + + sink = io.BytesIO() + step('download', lambda: bucket.download_file_by_name(OBJECT_NAME).save(sink)) + if sink.getvalue() != payload: + raise Failure('round trip', 'the simulated download returned different bytes') + except NetworkTouched as error: + raise Failure('no network', 'the shipped fake tried to open a socket') from error + finally: + socket.socket = original_socket + + note('the shipped RawSimulator round-tripped a file with socket.socket disabled') + + +def main() -> int: + target = 'simulator' + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/client.sync b/.sdkharness/tests/conformance/client.sync new file mode 100755 index 00000000..d5ba4269 --- /dev/null +++ b/.sdkharness/tests/conformance/client.sync @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x client.sync. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a local directory with 3 files + Action sync up; modify one and delete one; sync again with a delete + policy; sync down into a fresh directory + Assert all 3 land; the remote matches the local side after the second + pass; the round-tripped bytes are identical + +NO DOCUMENTED CONTRACT FOUND -- this is SDK-side/ecosystem tooling, not a B2 API contract. +B2 documents only sync's building blocks. "Remote matches local" is read +through b2_list_file_names semantics: "Files that have been hidden will not be +returned" and it "will return each name only once" -- so the latest visible +version per name, not "no versions remain". +https://www.backblaze.com/apidocs/b2-list-file-names +The legs themselves are b2_upload_file ("Uploads one file and returns its +unique file ID"), b2_download_file_by_name, and b2_hide_file ("Hides a file +so that downloading by name will not find the file") or b2_delete_file_version. +https://www.backblaze.com/apidocs/b2-upload-file +https://www.backblaze.com/apidocs/b2-download-file-by-name +https://www.backblaze.com/apidocs/b2-hide-file +https://www.backblaze.com/apidocs/b2-delete-file-version +Which of hide/delete a delete policy issues, and the compare rule that decides +a file changed, are the tool's semantics, not B2's. + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import io +import os +import shutil +import sys +import tempfile +import time +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'client.sync' +REMOTE_PREFIX = 'st/sync' +FILES = ('a.txt', 'b.txt', 'c.txt') + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def write(path, data): + with open(path, 'wb') as handle: + handle.write(data) + + +def sync(b2, syncer, source, destination): + reporter = b2.SyncReport(io.StringIO(), True) + try: + syncer.sync_folders( + source_folder=source, + dest_folder=destination, + now_millis=int(time.time() * 1000), + reporter=reporter, + ) + finally: + try: + reporter.close() + except Exception: # noqa: BLE001 + pass + + +def run(target: str) -> None: + b2, api = connect(target) + + up_dir = tempfile.mkdtemp(prefix='sdkharness-conf-up-') + down_dir = tempfile.mkdtemp(prefix='sdkharness-conf-down-') + contents = {name: payload_of(512 + index * 64) for index, name in enumerate(FILES)} + for name, data in contents.items(): + write(os.path.join(up_dir, name), data) + + bucket = new_bucket(api) + try: + remote_url = f'b2://{bucket.name}/{REMOTE_PREFIX}' + syncer = b2.Synchronizer( + max_workers=1, + newer_file_mode=b2.NewerFileSyncMode.REPLACE, + keep_days_or_delete=b2.KeepOrDeleteMode.DELETE, + compare_version_mode=b2.CompareVersionMode.SIZE, + ) + + step( + 'sync up', + lambda: sync( + b2, syncer, b2.parse_folder(up_dir, api), b2.parse_folder(remote_url, api) + ), + ) + landed = step( + 'list what landed', + lambda: sorted( + v.file_name.rsplit('/', 1)[-1] + for v, _folder in bucket.ls(REMOTE_PREFIX + '/', recursive=True) + ), + ) + if landed != sorted(FILES): + raise Failure('sync up', f'{len(landed)} of {len(FILES)} files landed') + + # modify one, delete another + contents[FILES[1]] = payload_of(4096) + write(os.path.join(up_dir, FILES[1]), contents[FILES[1]]) + os.remove(os.path.join(up_dir, FILES[2])) + del contents[FILES[2]] + + step( + 'sync up again with a delete policy', + lambda: sync( + b2, syncer, b2.parse_folder(up_dir, api), b2.parse_folder(remote_url, api) + ), + ) + remaining = step( + 'list the remote after the second pass', + lambda: { + v.file_name.rsplit('/', 1)[-1]: v.size + for v, _folder in bucket.ls(REMOTE_PREFIX + '/', recursive=True) + }, + ) + if sorted(remaining) != sorted(contents): + raise Failure( + 'sync up again', + f'the remote holds {sorted(remaining)} after the delete pass, expected {sorted(contents)}', + ) + if remaining.get(FILES[1]) != len(contents[FILES[1]]): + raise Failure('sync up again', 'the modified file was not replaced remotely') + + step( + 'sync down', + lambda: sync( + b2, syncer, b2.parse_folder(remote_url, api), b2.parse_folder(down_dir, api) + ), + ) + for name, expected in contents.items(): + path = os.path.join(down_dir, name) + if not os.path.exists(path): + raise Failure('sync down', 'a file did not come back down') + with open(path, 'rb') as handle: + if handle.read() != expected: + raise Failure( + 'sync down', "a round-tripped file's bytes differ from the local original" + ) + extra = sorted(set(os.listdir(down_dir)) - set(contents)) + if extra: + raise Failure('sync down', f'{len(extra)} unexpected file(s) came down') + finally: + teardown(api, bucket) + shutil.rmtree(up_dir, ignore_errors=True) + shutil.rmtree(down_dir, ignore_errors=True) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/enc.sse_b2 b/.sdkharness/tests/conformance/enc.sse_b2 new file mode 100755 index 00000000..5f2a10fa --- /dev/null +++ b/.sdkharness/tests/conformance/enc.sse_b2 @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x enc.sse_b2. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a bucket + Action upload requesting SSE-B2 + Assert the metadata reports SSE-B2 / AES256 and the download returns + plaintext + +The expected value is B2's published contract. b2_upload_file's +X-Bz-Server-Side-Encryption: "B2 will encrypt the uploaded data before storing +the file using ... (SSE-B2) with the algorithm specified in this header. +Currently, the only supported value for this header is AES256." Its response +serverSideEncryption carries "the mode ("SSE-B2" or "SSE-C") and algorithm". +https://www.backblaze.com/apidocs/b2-upload-file +Bucket default: "All uploads to that bucket, from the time default encryption +is enabled onward, will then be encrypted with SSE-B2 by default unless you +explicitly specify SSE-C encryption for a given file at upload time." +https://www.backblaze.com/docs/cloud-storage-server-side-encryption +defaultServerSideEncryption is "The default server-side encryption settings +for new files uploaded to this bucket", example {"mode": "SSE-B2", +"algorithm": "AES256"}: +https://www.backblaze.com/apidocs/b2-create-bucket + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import hashlib +import io +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'enc.sse_b2' +OBJECT_NAME = 'st/sse-b2.txt' +PAYLOAD_SIZE = 1024 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def run(target: str) -> None: + b2, api = connect(target) + + payload = payload_of(PAYLOAD_SIZE) + bucket = new_bucket(api) + try: + uploaded = step( + 'upload with SSE-B2', + lambda: bucket.upload_bytes(payload, OBJECT_NAME, encryption=b2.SSE_B2_AES), + ) + + for label, obj in ( + ('upload response', uploaded), + ('metadata', step('metadata', lambda: api.get_file_info(uploaded.id_))), + ): + sse = getattr(obj, 'server_side_encryption', None) + if getattr(sse, 'mode', None) != b2.EncryptionMode.SSE_B2: + raise Failure( + label, f"encryption mode is {getattr(sse, 'mode', None)}, expected SSE-B2" + ) + if getattr(sse, 'algorithm', None) != b2.EncryptionAlgorithm.AES256: + raise Failure( + label, + f"encryption algorithm is {getattr(sse, 'algorithm', None)}, expected AES256", + ) + + sink = io.BytesIO() + step('download', lambda: bucket.download_file_by_name(OBJECT_NAME).save(sink)) + if sink.getvalue() != payload: + raise Failure('download', 'the download did not return the plaintext that was uploaded') + if hashlib.sha1(sink.getvalue()).hexdigest() != hashlib.sha1(payload).hexdigest(): + raise Failure( + 'download', 'the downloaded plaintext sha1 differs from the uploaded bytes' + ) + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/enc.sse_c b/.sdkharness/tests/conformance/enc.sse_c new file mode 100755 index 00000000..11a15409 --- /dev/null +++ b/.sdkharness/tests/conformance/enc.sse_c @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x enc.sse_c. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a 32-byte customer key + Action upload with SSE-C; download with the key; download without it + Assert the upload reports SSE-C mode and the key's identity; the + with-key download returns plaintext; the without-key download fails + + The scenario names the key MD5. b2sdk models the customer key as an + EncryptionKey carrying a key_id and surfaces that id through the + `sse_c_key_id` fileInfo entry; whichever of the two the SDK actually + exposes is asserted, and the note says which was available. The secret + itself never leaves this process. + +The expected value is B2's published contract. b2_upload_file takes the +X-Bz-Server-Side-Encryption-Customer-Algorithm (AES256), -Customer-Key and +-Customer-Key-Md5 headers; its response serverSideEncryption carries "the mode +("SSE-B2" or "SSE-C") and algorithm" -- no key md5. +https://www.backblaze.com/apidocs/b2-upload-file +The key md5 comes back on download: X-Bz-Server-Side-Encryption-Customer-Key-Md5 +"if the file was encrypted with SSE-C, then the value is the MD5 digest of the +customer-managed encryption key used for encryption". +https://www.backblaze.com/apidocs/b2-download-file-by-id +Without the key: "Backblaze B2 manages the encryption process using the key +that you provide when uploading or accessing each file"; the docs name an +error only for the WRONG key -- "a 403 error is returned" -- not for no key. +https://www.backblaze.com/docs/cloud-storage-server-side-encryption + +NEEDS REVIEW: docs say the upload response carries only mode and algorithm +and the key md5 is returned on DOWNLOAD (X-Bz-Server-Side-Encryption- +Customer-Key-Md5); the assertion checks a key md5 or key id on the UPLOAD +result, where B2 documents neither. + +NO DOCUMENTED CONTRACT FOUND for the key-identity leg as asserted -- searched: +b2-upload-file, b2-get-file-info, b2-download-file-by-id, +cloud-storage-server-side-encryption, +cloud-storage-enable-server-side-encryption-with-the-native-api. The +`sse_c_key_id` fileInfo entry is a b2sdk / CLI convention, not a B2 promise; +the identity B2 documents is the key md5 on the download response (above). + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import base64 +import hashlib +import io +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'enc.sse_c' +OBJECT_NAME = 'st/sse-c.txt' +PAYLOAD_SIZE = 1024 +KEY_ID = 'sdkharness-conf' + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def read_key_md5(setting): + """The customer key's md5, if the SDK exposes one. Never the key itself.""" + for holder in (setting, getattr(setting, 'key', None)): + value = getattr(holder, 'key_md5', None) + if callable(value): + try: + value = value() + except Exception: # noqa: BLE001 -- an md5 it cannot compute is simply absent + value = None + if isinstance(value, str) and value: + return value + return None + + +def run(target: str) -> None: + b2, api = connect(target) + + payload = payload_of(PAYLOAD_SIZE) + secret = os.urandom(32) + setting = b2.EncryptionSetting( + mode=b2.EncryptionMode.SSE_C, + algorithm=b2.EncryptionAlgorithm.AES256, + key=b2.EncryptionKey(secret=secret, key_id=KEY_ID), + ) + + bucket = new_bucket(api) + try: + uploaded = step( + 'upload with SSE-C', + lambda: bucket.upload_bytes(payload, OBJECT_NAME, encryption=setting), + ) + + sse = getattr(uploaded, 'server_side_encryption', None) + if getattr(sse, 'mode', None) != b2.EncryptionMode.SSE_C: + raise Failure( + 'upload', f"encryption mode is {getattr(sse, 'mode', None)}, expected SSE-C" + ) + if getattr(sse, 'algorithm', None) != b2.EncryptionAlgorithm.AES256: + raise Failure( + 'upload', + f"encryption algorithm is {getattr(sse, 'algorithm', None)}, expected AES256", + ) + + # Which identity does the SDK hand back -- the key md5, or the key id? + # b2sdk models the md5 as EncryptionKey.key_md5(), a METHOD that needs + # the secret; the setting handed back carries no secret, so the call is + # attempted and its absence is a fact about the SDK, not a failure. + reported_md5 = read_key_md5(sse) + file_info = { + str(k).lower(): v for k, v in (getattr(uploaded, 'file_info', None) or {}).items() + } + reported_key_id = file_info.get(b2.SSE_C_KEY_ID_FILE_INFO_KEY_NAME) + if reported_key_id is None: + reported_key_id = getattr(getattr(sse, 'key', None), 'key_id', None) + + if reported_md5 is not None: + expected = hashlib.md5(secret).digest() + if reported_md5 not in (base64.b64encode(expected).decode(), expected.hex()): + raise Failure( + 'upload', 'the reported customer-key md5 does not match the key that was used' + ) + note("the SDK reported the customer key's md5") + elif reported_key_id == KEY_ID: + note( + "b2sdk exposes no customer-key md5; the key's identity round-trips as the " + f'{b2.SSE_C_KEY_ID_FILE_INFO_KEY_NAME} fileInfo entry, which is what was asserted' + ) + else: + raise Failure( + 'upload', + 'the upload reported neither the customer-key md5 nor the key id', + ) + + sink = io.BytesIO() + step( + 'download with the key', + lambda: bucket.download_file_by_name(OBJECT_NAME, encryption=setting).save(sink), + ) + if sink.getvalue() != payload: + raise Failure('download', 'the with-key download did not return the plaintext') + + refused( + 'download without the key', + lambda: bucket.download_file_by_name(OBJECT_NAME).save(io.BytesIO()), + ('SSECKeyError', 'BadRequest', 'AccessDenied', 'InvalidArgument', 'UnknownError'), + ) + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/files.delete_version b/.sdkharness/tests/conformance/files.delete_version new file mode 100755 index 00000000..f9147a9d --- /dev/null +++ b/.sdkharness/tests/conformance/files.delete_version @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x files.delete_version. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup an object with 2 versions + Action delete the NEWER one by id + Assert the newer version is gone from the versions listing and the older + one still downloads + +The expected values are B2's published contract. b2_delete_file_version +"Deletes one version of a file. If the version you delete is the latest +version, and there are older versions, then the most recent older version +will become the current version, and be the one that you'll get when +downloading by name." +https://www.backblaze.com/apidocs/b2-delete-file-version + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import io +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'files.delete_version' +PREFIX = 'st/' +OBJECT_NAME = 'st/delete-version.txt' +PAYLOAD_SIZE = 1024 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def run(target: str) -> None: + _b2, api = connect(target) + + older = payload_of(PAYLOAD_SIZE) + newer = payload_of(PAYLOAD_SIZE)[::-1] + + bucket = new_bucket(api) + try: + v1 = step('upload v1', lambda: bucket.upload_bytes(older, OBJECT_NAME)) + v2 = step('upload v2', lambda: bucket.upload_bytes(newer, OBJECT_NAME)) + if v1.id_ == v2.id_: + raise Failure('setup', 'the two uploads share one fileId') + + step('delete the newer version', lambda: bucket.delete_file_version(v2.id_, OBJECT_NAME)) + + ids = step( + 'list versions', + lambda: [v.id_ for v, _folder in bucket.ls(PREFIX, recursive=True, latest_only=False)], + ) + if v2.id_ in ids: + raise Failure('delete', 'the deleted version is still listed') + if v1.id_ not in ids: + raise Failure('delete', 'the older version was removed too') + + sink = io.BytesIO() + step('download the older version', lambda: api.download_file_by_id(v1.id_).save(sink)) + if sink.getvalue() != older: + raise Failure('delete', 'the surviving older version no longer returns its own bytes') + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/files.download_by_id b/.sdkharness/tests/conformance/files.download_by_id new file mode 100755 index 00000000..67b0aed9 --- /dev/null +++ b/.sdkharness/tests/conformance/files.download_by_id @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x files.download_by_id. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup upload the SAME key twice -> v1, v2 + Action download by the OLDER fileId + Assert the v1 bytes come back, not v2 -- which is exactly what + distinguishes download-by-id from download-by-name + +The expected values are B2's published contract, from File Versions: +"b2_download_file_by_id This operation downloads a specific version of a file +by the given file ID." and "b2_download_file_by_name This operation downloads +the most recent version of the file." +https://www.backblaze.com/docs/cloud-storage-file-versions +https://www.backblaze.com/apidocs/b2-download-file-by-id + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import io +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'files.download_by_id' +OBJECT_NAME = 'st/by-id.txt' +PAYLOAD_SIZE = 1024 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def run(target: str) -> None: + _b2, api = connect(target) + + first = payload_of(PAYLOAD_SIZE) + second = payload_of(PAYLOAD_SIZE)[::-1] + if first == second: + raise Failure('setup', 'the two fixture versions are not distinguishable') + + bucket = new_bucket(api) + try: + v1 = step('upload v1', lambda: bucket.upload_bytes(first, OBJECT_NAME)) + v2 = step('upload v2', lambda: bucket.upload_bytes(second, OBJECT_NAME)) + if v1.id_ == v2.id_: + raise Failure('setup', 'the two uploads share one fileId; there is no older version') + + # By NAME the latest version wins -- the contrast the by-id path must break. + by_name = io.BytesIO() + step('download by name', lambda: bucket.download_file_by_name(OBJECT_NAME).save(by_name)) + if by_name.getvalue() != second: + raise Failure('download by name', 'by-name did not return the newest version') + + by_id = io.BytesIO() + step('download by the older id', lambda: api.download_file_by_id(v1.id_).save(by_id)) + got = by_id.getvalue() + if got == second: + raise Failure('download by id', "the older fileId returned the NEWER version's bytes") + if got != first: + raise Failure('download by id', "the older fileId returned neither version's bytes") + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/files.download_content b/.sdkharness/tests/conformance/files.download_content new file mode 100755 index 00000000..e77b7acd --- /dev/null +++ b/.sdkharness/tests/conformance/files.download_content @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x files.download_content. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup an ephemeral bucket holding a 1 KiB fixture of known sha1 + Action download it by name + Assert the bytes are byte-identical to what went up and the reported + length matches + +The expected values are B2's published contract. b2_download_file_by_name +"Downloads one file using a bucket name and file name", and its response +headers, Content-Length and X-Bz-Content-Sha1 among them, "contain the same +information they did when the file was uploaded". +https://www.backblaze.com/apidocs/b2-download-file-by-name + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import hashlib +import io +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'files.download_content' +OBJECT_NAME = 'st/download.txt' +PAYLOAD_SIZE = 1024 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def run(target: str) -> None: + _b2, api = connect(target) + + payload = payload_of(PAYLOAD_SIZE) + expected_sha1 = hashlib.sha1(payload).hexdigest() + + bucket = new_bucket(api) + try: + step('upload fixture', lambda: bucket.upload_bytes(payload, OBJECT_NAME)) + + downloaded = step('download', lambda: bucket.download_file_by_name(OBJECT_NAME)) + sink = io.BytesIO() + step('save', lambda: downloaded.save(sink)) + got = sink.getvalue() + + if len(got) != PAYLOAD_SIZE: + raise Failure('download', f'downloaded {len(got)} bytes, expected {PAYLOAD_SIZE}') + if got != payload: + raise Failure('download', 'downloaded bytes differ from the fixture') + if hashlib.sha1(got).hexdigest() != expected_sha1: + raise Failure('download', 'downloaded sha1 differs from the fixture sha1') + + reported = getattr(downloaded.download_version, 'content_length', None) + if reported != PAYLOAD_SIZE: + raise Failure( + 'download', f'reported content length {reported}, expected {PAYLOAD_SIZE}' + ) + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/files.hide b/.sdkharness/tests/conformance/files.hide new file mode 100755 index 00000000..542ded10 --- /dev/null +++ b/.sdkharness/tests/conformance/files.hide @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x files.hide. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a fixture object + Action hide it + Assert it is absent from the names listing; a hide marker sits ABOVE the + original in the versions listing; the original still downloads by id + +The expected values are B2's published contract. b2_hide_file "Hides a file +so that downloading by name will not find the file. Previous versions of the +file are still stored". b2_list_file_names: "Files that have been hidden will +not be returned; to see all versions of a file, including hide markers, use +b2_list_file_versions instead", which lists versions "by reverse of date/time +uploaded" -- so the marker sits above the original. +https://www.backblaze.com/apidocs/b2-hide-file +https://www.backblaze.com/apidocs/b2-list-file-names +https://www.backblaze.com/apidocs/b2-list-file-versions +The original stays reachable because "b2_download_file_by_id This operation +downloads a specific version of a file by the given file ID." +https://www.backblaze.com/docs/cloud-storage-file-versions + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import io +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'files.hide' +PREFIX = 'st/' +OBJECT_NAME = 'st/hide.txt' +PAYLOAD_SIZE = 1024 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def run(target: str) -> None: + _b2, api = connect(target) + + payload = payload_of(PAYLOAD_SIZE) + bucket = new_bucket(api) + try: + original = step('upload fixture', lambda: bucket.upload_bytes(payload, OBJECT_NAME)) + + step('hide', lambda: bucket.hide_file(OBJECT_NAME)) + + names = step( + 'list names', + lambda: [v.file_name for v, _folder in bucket.ls(PREFIX, recursive=True)], + ) + if OBJECT_NAME in names: + raise Failure('hide', 'the hidden object is still in the names listing') + + versions = step( + 'list versions', + lambda: [ + v + for v, _folder in bucket.ls(PREFIX, recursive=True, latest_only=False) + if v.file_name == OBJECT_NAME + ], + ) + if len(versions) < 2: + raise Failure( + 'hide', + f'{len(versions)} versions after hiding, expected the marker plus the original', + ) + if getattr(versions[0], 'action', None) != 'hide': + raise Failure( + 'hide', + f"the newest version's action is {getattr(versions[0], 'action', None)}, expected 'hide'", + ) + if versions[1].id_ != original.id_: + raise Failure( + 'hide', 'the original version does not sit directly below the hide marker' + ) + + sink = io.BytesIO() + step( + 'download the original by id', lambda: api.download_file_by_id(original.id_).save(sink) + ) + if sink.getvalue() != payload: + raise Failure('hide', 'the hidden original no longer downloads by id') + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/files.list b/.sdkharness/tests/conformance/files.list new file mode 100755 index 00000000..0fa637fe --- /dev/null +++ b/.sdkharness/tests/conformance/files.list @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x files.list. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup 3 objects under st/list/, 1 outside it, and 1 of the 3 uploaded twice + Action list names by prefix, then list versions + Assert exactly the 3 appear, the outsider is absent, and the + twice-uploaded key shows 2 versions + +The expected values are B2's published contract. b2_list_file_names: "There +may be many file versions for the same name, but this call will return each +name only once", and with a prefix "Files returned will be limited to those +with the given prefix". +https://www.backblaze.com/apidocs/b2-list-file-names +b2_list_file_versions "Lists all versions of all files contained in one +bucket" and supports the same prefix option. +https://www.backblaze.com/apidocs/b2-list-file-versions + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import collections +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'files.list' +PREFIX = 'st/list/' +INSIDE = ('st/list/a.txt', 'st/list/b.txt', 'st/list/c.txt') +OUTSIDE = 'st/outside.txt' +PAYLOAD_SIZE = 256 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def run(target: str) -> None: + _b2, api = connect(target) + + payload = payload_of(PAYLOAD_SIZE) + bucket = new_bucket(api) + try: + for name in INSIDE: + step('upload fixture', lambda name=name: bucket.upload_bytes(payload, name)) + step('upload outsider', lambda: bucket.upload_bytes(payload, OUTSIDE)) + step('upload a second version', lambda: bucket.upload_bytes(payload[::-1], INSIDE[0])) + + names = step( + 'list names', + lambda: [v.file_name for v, _folder in bucket.ls(PREFIX, recursive=True)], + ) + if sorted(names) != sorted(INSIDE): + raise Failure( + 'list names', f'listed {len(names)} names under the prefix, expected {len(INSIDE)}' + ) + if OUTSIDE in names: + raise Failure('list names', 'an object outside the prefix was listed') + + versions = step( + 'list versions', + lambda: [ + v.file_name for v, _folder in bucket.ls(PREFIX, recursive=True, latest_only=False) + ], + ) + if OUTSIDE in versions: + raise Failure('list versions', 'an object outside the prefix was listed') + counted = collections.Counter(versions) + if counted.get(INSIDE[0]) != 2: + raise Failure( + 'list versions', + f'the twice-uploaded key shows {counted.get(INSIDE[0])} versions, expected 2', + ) + for name in INSIDE[1:]: + if counted.get(name) != 1: + raise Failure( + 'list versions', f'a once-uploaded key shows {counted.get(name)} versions' + ) + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/files.metadata b/.sdkharness/tests/conformance/files.metadata new file mode 100755 index 00000000..5ac29460 --- /dev/null +++ b/.sdkharness/tests/conformance/files.metadata @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x files.metadata. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup an object carrying a custom fileInfo entry + Action read its metadata by id, then by name + Assert contentType, contentLength, contentSha1 and the custom entry + round-trip on BOTH paths + +The expected values are B2's published contract. By id: b2_get_file_info +"Gets information about a file" and returns contentType, contentLength, +contentSha1 and fileInfo ("The custom information that was uploaded with the +file"). +https://www.backblaze.com/apidocs/b2-get-file-info +B2 has no get-file-info by NAME; that read is b2_download_file_by_name, whose +headers (Content-Length, Content-Type, X-Bz-Content-Sha1, X-Bz-Info-*) +"contain the same information they did when the file was uploaded". +https://www.backblaze.com/apidocs/b2-download-file-by-name +A custom field name "is converted to lower case when it is stored and +returned in the fileInfo object" -- hence the lower-case key. +https://www.backblaze.com/apidocs/b2-upload-file + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import hashlib +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'files.metadata' +OBJECT_NAME = 'st/metadata.txt' +PAYLOAD_SIZE = 1024 +CONTENT_TYPE = 'text/x-sdkharness' +INFO_KEY = 'sdkharness-conf' +INFO_VALUE = 'fixture' + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def read_metadata(obj): + """Normalise FileVersion / DownloadVersion / a raw dict to one shape.""" + if isinstance(obj, dict): + return ( + obj.get('contentLength'), + obj.get('contentSha1'), + obj.get('contentType'), + dict(obj.get('fileInfo') or {}), + ) + size = getattr(obj, 'size', None) + if size is None: + size = getattr(obj, 'content_length', None) + return ( + size, + getattr(obj, 'content_sha1', None), + getattr(obj, 'content_type', None), + dict(getattr(obj, 'file_info', None) or {}), + ) + + +def run(target: str) -> None: + _b2, api = connect(target) + + payload = payload_of(PAYLOAD_SIZE) + expected_sha1 = hashlib.sha1(payload).hexdigest() + + bucket = new_bucket(api) + try: + uploaded = step( + 'upload fixture', + lambda: bucket.upload_bytes( + payload, OBJECT_NAME, content_type=CONTENT_TYPE, file_info={INFO_KEY: INFO_VALUE} + ), + ) + + by_id = step('metadata by id', lambda: api.get_file_info(uploaded.id_)) + by_name = step('metadata by name', lambda: bucket.get_file_info_by_name(OBJECT_NAME)) + + for path, obj in (('by id', by_id), ('by name', by_name)): + size, sha1, content_type, file_info = read_metadata(obj) + if size != PAYLOAD_SIZE: + raise Failure(f'metadata {path}', f'contentLength {size}, expected {PAYLOAD_SIZE}') + if sha1 != expected_sha1: + raise Failure(f'metadata {path}', 'contentSha1 does not match the uploaded bytes') + if content_type != CONTENT_TYPE: + raise Failure( + f'metadata {path}', f'contentType {content_type}, expected {CONTENT_TYPE}' + ) + lowered = {str(k).lower(): v for k, v in file_info.items()} + if lowered.get(INFO_KEY) != INFO_VALUE: + raise Failure(f'metadata {path}', 'the custom fileInfo entry did not round-trip') + + note( + 'both metadata paths taken: B2Api.get_file_info (by id) and Bucket.get_file_info_by_name' + ) + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/files.server_side_copy b/.sdkharness/tests/conformance/files.server_side_copy new file mode 100755 index 00000000..48618d59 --- /dev/null +++ b/.sdkharness/tests/conformance/files.server_side_copy @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x files.server_side_copy. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a single-part fixture (512 bytes here; see PAYLOAD_SIZE) + Action server-side copy it to a new key + Assert the destination sha1 equals the source sha1 AND the destination + fileId differs -- i.e. no local re-upload happened + +The expected values are B2's published contract. b2_copy_file "Copies an +entire file" from a sourceFileId ("The ID of the source file being copied") +to fileName ("The name of the new file being created"); the request carries +no file bytes. Its response fileId is "The unique identifier for this version +of this file" and contentSha1 is "The SHA1 of the bytes stored in the file", +so an entire copy reports the source's sha1 under a new fileId. +https://www.backblaze.com/apidocs/b2-copy-file + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import hashlib +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'files.server_side_copy' +SOURCE_NAME = 'st/copy-source.bin' +DEST_NAME = 'st/copy-dest.bin' +# Single-part on purpose: the harness simulator reports a 5,000-byte +# recommendedPartSize (bin/simulator/serve.mjs, PROFILE); above it b2sdk uploads +# the fixture as a large file, whose contentSha1 is "none", not the whole-file +# hash this scenario asserts (https://www.backblaze.com/apidocs/b2-get-file-info). +# Mirrors the TypeScript check. +PAYLOAD_SIZE = 512 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def sha1_and_size(obj): + if isinstance(obj, dict): + return obj.get('contentSha1'), obj.get('contentLength') + size = getattr(obj, 'size', None) + if size is None: + size = getattr(obj, 'content_length', None) + return getattr(obj, 'content_sha1', None), size + + +def run(target: str) -> None: + _b2, api = connect(target) + + payload = payload_of(PAYLOAD_SIZE) + expected_sha1 = hashlib.sha1(payload).hexdigest() + + bucket = new_bucket(api) + try: + source = step('upload fixture', lambda: bucket.upload_bytes(payload, SOURCE_NAME)) + copied = step('server-side copy', lambda: bucket.copy(source.id_, DEST_NAME)) + + if not getattr(copied, 'id_', None): + raise Failure('copy', 'the copy returned no fileId') + if copied.id_ == source.id_: + raise Failure('copy', 'the copy returned the SOURCE fileId; nothing new was stored') + if getattr(copied, 'file_name', None) != DEST_NAME: + raise Failure('copy', 'the copy landed under a different fileName') + + source_sha1, source_size = sha1_and_size( + step('source metadata', lambda: api.get_file_info(source.id_)) + ) + dest_sha1, dest_size = sha1_and_size( + step('destination metadata', lambda: bucket.get_file_info_by_name(DEST_NAME)) + ) + + if source_sha1 != expected_sha1: + raise Failure('copy', 'the source no longer reports the fixture sha1') + if dest_sha1 != source_sha1: + raise Failure('copy', 'the destination sha1 does not match the source sha1') + if dest_size != source_size: + raise Failure('copy', f'the destination is {dest_size} bytes, the source {source_size}') + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/files.upload b/.sdkharness/tests/conformance/files.upload new file mode 100755 index 00000000..c6691e2a --- /dev/null +++ b/.sdkharness/tests/conformance/files.upload @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""THE REFERENCE CHECK — b2-sdk-python x files.upload. + +The other 134 checks are written from this one. Read bin/conformance/README.md +for the contract; this file is what obeying it looks like. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup an ephemeral bucket and a 1 KiB payload of known sha1 + Action upload it to st/upload.txt + Assert the call returns fileId + fileName; metadata reports + contentLength 1024 and the matching contentSha1; the downloaded + bytes are identical to what went up + +The expected values are B2's published contract. b2_upload_file "Uploads +one file and returns its unique file ID"; its response carries fileId ("The +unique identifier for this version of this file"), fileName, contentLength +("The number of bytes stored in the file") and contentSha1 ("The SHA1 of the +bytes stored in the file as a 40-digit hex string"). +https://www.backblaze.com/apidocs/b2-upload-file +The round trip holds because b2_download_file_by_name returns headers that +"contain the same information they did when the file was uploaded". +https://www.backblaze.com/apidocs/b2-download-file-by-name + +It exercises b2sdk from this exact repository checkout. The API was originally the API was taken from docs/sdks/b2-sdk-python/card.md and the +published b2sdk docs, which is what every check author does. +""" + +import hashlib +import io +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'files.upload' +OBJECT_NAME = 'st/upload.txt' +PAYLOAD_SIZE = 1024 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def step(name: str, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def run(target: str) -> None: + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if target == 'simulator': + key_id, key = SIM_CREDENTIAL + else: + key_id = os.environ.get('B2_APPLICATION_KEY_ID') + key = os.environ.get('B2_APPLICATION_KEY') + if not key_id or not key: + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + from b2sdk.v3 import B2Api, InMemoryAccountInfo + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + api = B2Api(InMemoryAccountInfo()) + step( + 'authenticate', + lambda: api.authorize_account(key_id, key, realm=realm), + ) + + payload = (b'sdkharness conformance ' * 64)[:PAYLOAD_SIZE] + assert len(payload) == PAYLOAD_SIZE + expected_sha1 = hashlib.sha1(payload).hexdigest() + + bucket_name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + bucket = step('create bucket', lambda: api.create_bucket(bucket_name, 'allPrivate')) + try: + uploaded = step('upload', lambda: bucket.upload_bytes(payload, OBJECT_NAME)) + + if not getattr(uploaded, 'id_', None): + raise Failure('upload', 'the upload returned no fileId') + if getattr(uploaded, 'file_name', None) != OBJECT_NAME: + raise Failure('upload', 'the upload returned a different fileName') + + info = step('metadata', lambda: api.get_file_info(uploaded.id_)) + size = info.get('contentLength') if isinstance(info, dict) else getattr(info, 'size', None) + sha1 = ( + info.get('contentSha1') + if isinstance(info, dict) + else getattr(info, 'content_sha1', None) + ) + if size != PAYLOAD_SIZE: + raise Failure('metadata', f'contentLength {size}, expected {PAYLOAD_SIZE}') + if sha1 != expected_sha1: + raise Failure('metadata', 'contentSha1 does not match the uploaded bytes') + + sink = io.BytesIO() + step('download', lambda: bucket.download_file_by_name(OBJECT_NAME).save(sink)) + if sink.getvalue() != payload: + raise Failure('round trip', 'downloaded bytes differ from what was uploaded') + finally: + # Owns what it created, on the error path too. + try: + for version, _folder in bucket.ls(OBJECT_NAME, recursive=True, latest_only=False): + bucket.delete_file_version(version.id_, version.file_name) + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 -- cleanup must not mask the verdict + pass + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/keys.crud b/.sdkharness/tests/conformance/keys.crud new file mode 100755 index 00000000..fd5299d9 --- /dev/null +++ b/.sdkharness/tests/conformance/keys.crud @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x keys.crud. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Action create a restricted key, list it and find it, delete it + Assert the secret is returned exactly ONCE, on create; the key is gone + after the delete + + Needs broader credential scope than a health check: a key that cannot + create keys reports amber here, which is the honest answer. + +DOCS. The expected values are B2's published contract. b2_create_key's +applicationKey is "The secret part of the key. This is the only time it will +be returned, so you need to keep it. This is not returned when you list the +keys in your account." b2_list_keys' key objects carry no applicationKey +field; b2_delete_key "Deletes the application key specified". +https://www.backblaze.com/apidocs/b2-create-key +https://www.backblaze.com/apidocs/b2-list-keys +https://www.backblaze.com/apidocs/b2-delete-key + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'keys.crud' +CAPABILITIES = ['listBuckets', 'listFiles', 'readFiles'] + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def run(target: str) -> None: + _b2, api = connect(target) + + key_name = f'sdkharness-conf-{uuid.uuid4().hex[:8]}' + key_id = None + try: + created = step( + 'create a restricted key', + lambda: api.create_key(capabilities=list(CAPABILITIES), key_name=key_name), + ) + key_id = created.id_ + + if not getattr(created, 'application_key', None): + raise Failure('create', 'the create response carried no secret') + if sorted(getattr(created, 'capabilities', None) or []) != sorted(CAPABILITIES): + raise Failure( + 'create', 'the created key does not carry the capabilities that were asked for' + ) + + listed = step('list keys', lambda: [k for k in api.list_keys() if k.id_ == key_id]) + if len(listed) != 1: + raise Failure('list', f'{len(listed)} keys match the new key id, expected 1') + found = listed[0] + if getattr(found, 'key_name', None) != key_name: + raise Failure('list', 'the listed key carries a different name') + if getattr(found, 'application_key', None): + raise Failure( + 'list', "the listing echoed the key's secret; it is returned only on create" + ) + + step('delete the key', lambda: api.delete_key_by_id(key_id)) + survivor = step( + 'list keys after the delete', lambda: [k for k in api.list_keys() if k.id_ == key_id] + ) + key_id = None + if survivor: + raise Failure('delete', 'the deleted key is still listed') + finally: + drop_keys(api, [key_id]) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/keys.multi_bucket b/.sdkharness/tests/conformance/keys.multi_bucket new file mode 100755 index 00000000..988d4018 --- /dev/null +++ b/.sdkharness/tests/conformance/keys.multi_bucket @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x keys.multi_bucket. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup three buckets + Action create a key scoped to TWO bucketIds, then authorize with it + Assert the read-back lists both ids; the key reaches both buckets and is + refused on the third + +DOCS. The expected values are B2's published contract. b2_create_key's +bucketIds: "When provided, the new key can only access the specified +buckets", echoed in its response; the v4 API (April 24, 2025) removed the +singular bucketId for it. v4 b2_authorize_account reports the scope as +allowed.buckets. A restricted token that asks b2_list_buckets for another +bucket "will be denied" -- documented as 401 unauthorized. +https://www.backblaze.com/apidocs/b2-create-key +https://www.backblaze.com/apidocs/b2-authorize-account +https://www.backblaze.com/apidocs/b2-list-buckets + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'keys.multi_bucket' +CAPABILITIES = ['listBuckets', 'listFiles', 'readFiles'] + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def bucket_ids_of(key): + ids = getattr(key, 'bucket_ids', None) + if ids is None: + single = getattr(key, 'bucket_id', None) + ids = [single] if single else [] + return sorted(i for i in ids if i) + + +def run(target: str) -> None: + b2, realm = prepare(target) + api = authorize(b2.B2Api(b2.InMemoryAccountInfo()), realm) + + first = second = third = None + key_id = None + try: + first = new_bucket(api) + second = new_bucket(api) + third = new_bucket(api) + + created = step( + 'create a key scoped to two buckets', + lambda: api.create_key( + capabilities=list(CAPABILITIES), + key_name=f'sdkharness-conf-{uuid.uuid4().hex[:8]}', + bucket_ids=[first.id_, second.id_], + ), + ) + key_id = created.id_ + secret = created.application_key + + listed = step('read the key back', lambda: [k for k in api.list_keys() if k.id_ == key_id]) + if len(listed) != 1: + raise Failure('read back', f'{len(listed)} keys match the new key id, expected 1') + if bucket_ids_of(listed[0]) != sorted([first.id_, second.id_]): + raise Failure( + 'read back', + f'the key reports {len(bucket_ids_of(listed[0]))} bucket id(s), expected both', + ) + + restricted = b2.B2Api(b2.InMemoryAccountInfo()) + step( + 'authorize with the restricted key', + lambda: restricted.authorize_account(key_id, secret, realm=realm), + ) + + for bucket in (first, second): + reached = step( + 'list an allowed bucket', + lambda bucket=bucket: restricted.list_buckets(bucket_name=bucket.name), + ) + if len(reached) != 1 or reached[0].id_ != bucket.id_: + raise Failure( + 'scope', 'an allowed bucket was not reachable with the restricted key' + ) + + refused( + 'list the third bucket', + lambda: restricted.list_buckets(bucket_name=third.name), + ( + 'RestrictedBucket', + 'BucketNotAllowed', + 'NotAllowedByAppKeyError', + 'AccessDenied', + 'Unauthorized', + ), + ) + finally: + drop_keys(api, [key_id]) + teardown(api, first, second, third) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/large.compose_mixed b/.sdkharness/tests/conformance/large.compose_mixed new file mode 100755 index 00000000..cbd9eae9 --- /dev/null +++ b/.sdkharness/tests/conformance/large.compose_mixed @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x large.compose_mixed. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a local byte range, a range copied from an existing object, and + an already-stored object used whole + Action compose one file from all three (Bucket.concatenate) + Assert the result equals the ordered concatenation of the three + + Every piece is at least the account's absolute minimum part size, because + b2_copy_part refuses a smaller non-final part -- the scenario's shape, sized + to what the service will actually accept. + +DOCS -- "a large file can be assembled from a mix of uploaded and copied +parts." https://www.backblaze.com/docs/cloud-storage-large-files +b2_copy_part: "Copies from an existing B2 file, storing it as a part of a +large file which has already been started with b2_start_large_file." Its +range: "The range of bytes to copy. If not provided, the whole source file +will be copied." https://www.backblaze.com/apidocs/b2-copy-part +Part sizes: "the minimum part size is 5 MB except for the last part in a +file which has a minimum size of 1 byte." https://www.backblaze.com/docs/cloud-storage-create-large-files-with-the-native-api + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import hashlib +import io +import os +import sys +import tempfile +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'large.compose_mixed' +LOCAL_SOURCE = 'st/compose-local-source.bin' +STORED_A = 'st/compose-a.bin' +STORED_B = 'st/compose-b.bin' +COMPOSED = 'st/composed.bin' + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def run(target: str) -> None: + b2, realm = prepare(target) + api = authorize(b2.B2Api(b2.InMemoryAccountInfo()), realm) + + part_size = api.account_info.get_absolute_minimum_part_size() + + local_bytes = payload_of(part_size * 2) + stored_a_bytes = payload_of(part_size * 2)[::-1] + stored_b_bytes = payload_of(part_size) + + scratch = tempfile.mkdtemp(prefix='sdkharness-conf-') + local = os.path.join(scratch, 'compose-local.bin') + with open(local, 'wb') as handle: + handle.write(local_bytes) + + bucket = new_bucket(api) + try: + a = step('upload stored source A', lambda: bucket.upload_bytes(stored_a_bytes, STORED_A)) + b = step('upload stored source B', lambda: bucket.upload_bytes(stored_b_bytes, STORED_B)) + + # 1. a range of a LOCAL file 2. a RANGE of a stored object + # 3. a stored object used WHOLE (the already-stored piece) + sources = [ + b2.UploadSourceLocalFileRange(local, offset=0, length=part_size), + b2.CopySource(a.id_, offset=part_size, length=part_size), + b2.CopySource(b.id_, offset=0, length=part_size), + ] + expected = ( + local_bytes[:part_size] + stored_a_bytes[part_size : part_size * 2] + stored_b_bytes + ) + + composed = step( + 'compose', + lambda: bucket.concatenate( + sources, + COMPOSED, + recommended_upload_part_size=part_size, + min_part_size=part_size, + max_part_size=part_size, + ), + ) + if not getattr(composed, 'id_', None): + raise Failure('compose', 'the composition returned no fileId') + + sink = io.BytesIO() + step('download the composition', lambda: bucket.download_file_by_name(COMPOSED).save(sink)) + got = sink.getvalue() + + if len(got) != len(expected): + raise Failure( + 'compose', f'the composition is {len(got)} bytes, expected {len(expected)}' + ) + if got != expected: + raise Failure( + 'compose', 'the composition is not the ordered concatenation of its three sources' + ) + if hashlib.sha1(got).hexdigest() != hashlib.sha1(expected).hexdigest(): + raise Failure( + 'compose', "the composition's sha1 differs from the expected concatenation" + ) + + note(f'three sources of {part_size} bytes each: local range, copied range, stored object') + finally: + teardown(api, bucket) + try: + os.remove(local) + os.rmdir(scratch) + except Exception: # noqa: BLE001 + pass + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/large.concurrent_parts b/.sdkharness/tests/conformance/large.concurrent_parts new file mode 100755 index 00000000..6151f73a --- /dev/null +++ b/.sdkharness/tests/conformance/large.concurrent_parts @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x large.concurrent_parts. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a large payload, uploaded with concurrency 1 and then 4 + Action upload it twice + Assert with 4 workers, more than 1 part upload is in flight at once + + KNOWN WEAK by design: without an instrumented transport this degrades to a + wall-clock ratio, which is not proof. This check therefore instruments the + SDK's own raw-API injection point and counts SIMULTANEOUS b2_upload_part + calls -- still an in-process observation, not a packet capture, and the + note() line says so. The wall-clock ratio is reported beside it as the + weaker corroboration it is, never as the verdict. + +DOCS -- B2 PERMITS concurrent part uploads and says how: +"Parts of a large file can be uploaded and copied in parallel, which can +significantly reduce the time it takes to upload terabytes of data." +https://www.backblaze.com/docs/cloud-storage-large-files +"Use b2_get_upload_part_url to get the target for uploading parts. Each +thread that uploads should get its own URL." +https://www.backblaze.com/docs/cloud-storage-create-large-files-with-the-native-api +PERMISSION ONLY: no B2 document requires a client to upload parts +concurrently, so the ">1 in flight at 4" expected value traces to the +tool's own concurrency setting, not to B2. + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import os +import sys +import threading +import time +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'large.concurrent_parts' +OBJECT_SERIAL = 'st/concurrent-serial.bin' +OBJECT_PARALLEL = 'st/concurrent-parallel.bin' +PARTS_WANTED = 4 +WORKERS = 4 +OBSERVATION_DWELL_SECONDS = 0.1 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +class Flight: + """Counts b2_upload_part calls that overlap in time.""" + + def __init__(self): + self.lock = threading.Lock() + self.in_flight = 0 + self.peak = 0 + self.parts = 0 + + def enter(self): + with self.lock: + self.in_flight += 1 + self.parts += 1 + self.peak = max(self.peak, self.in_flight) + + def leave(self): + with self.lock: + self.in_flight -= 1 + + +def flight_raw_api(b2, flight): + class Watched(b2.B2RawHTTPApi): + def upload_part(self, *args, **kwargs): + flight.enter() + try: + # Hold each part open briefly so simultaneous uploads are + # OBSERVABLE. Without it a fast call can finish before the next + # one starts and true concurrency reads as serial -- the + # observation window, not the concurrency, would be what failed. + time.sleep(OBSERVATION_DWELL_SECONDS) + return super().upload_part(*args, **kwargs) + finally: + flight.leave() + + return Watched + + +def upload_once(b2, realm, bucket_name, object_name, workers, payload, part_size): + flight = Flight() + api = b2.B2Api( + b2.InMemoryAccountInfo(), + api_config=b2.B2HttpApiConfig(_raw_api_class=flight_raw_api(b2, flight)), + max_upload_workers=workers, + ) + authorize(api, realm) + bucket = step('open bucket', lambda: api.get_bucket_by_name(bucket_name)) + started = time.monotonic() + step( + f'upload with {workers} worker(s)', + lambda: bucket.create_file( + [b2.WriteIntent(b2.UploadSourceBytes(payload))], + object_name, + recommended_upload_part_size=part_size, + min_part_size=part_size, + max_part_size=part_size, + ), + ) + return flight, time.monotonic() - started + + +def run(target: str) -> None: + b2, realm = prepare(target) + api = authorize(b2.B2Api(b2.InMemoryAccountInfo()), realm) + + part_size = api.account_info.get_absolute_minimum_part_size() + payload = payload_of(part_size * PARTS_WANTED) + + bucket = new_bucket(api) + try: + serial, serial_seconds = upload_once( + b2, realm, bucket.name, OBJECT_SERIAL, 1, payload, part_size + ) + parallel, parallel_seconds = upload_once( + b2, realm, bucket.name, OBJECT_PARALLEL, WORKERS, payload, part_size + ) + + if serial.parts < 2 or parallel.parts < 2: + raise Failure( + 'setup', + f'only {min(serial.parts, parallel.parts)} part(s) uploaded; there is no concurrency to observe', + ) + if serial.peak != 1: + raise Failure( + 'concurrency', + f'with max_upload_workers=1 the peak in-flight part count was {serial.peak}, expected 1', + ) + if parallel.peak < 2: + raise Failure( + 'concurrency', + f'with max_upload_workers={WORKERS} the peak in-flight part count was still {parallel.peak}', + ) + + ratio = (serial_seconds / parallel_seconds) if parallel_seconds > 0 else 0.0 + note( + f'evidence: peak simultaneous b2_upload_part calls {serial.peak} -> {parallel.peak}, ' + f'observed in-process at the raw-API boundary (each part held {OBSERVATION_DWELL_SECONDS}s ' + f'to widen the observation window), NOT at the transport. ' + f'wall-clock ratio {ratio:.2f}x is corroboration only and proves nothing on its own.' + ) + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/large.multipart b/.sdkharness/tests/conformance/large.multipart new file mode 100755 index 00000000..253dd948 --- /dev/null +++ b/.sdkharness/tests/conformance/large.multipart @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x large.multipart. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a payload above the tool's large-file threshold + Action upload it + Assert more than 1 part was uploaded; contentSha1 is `none` with + large_file_sha1 in fileInfo; the downloaded bytes match + +DOCS -- the acceptance criteria; what the tool does is the result: +- contentSha1 `none`: b2_finish_large_file -- "Large files do not have SHA1 + checksums, and the value is "none"." + https://www.backblaze.com/apidocs/b2-finish-large-file +- more than 1 part: "Each large file must consist of at least two parts, + and all of the parts except the last one must be at least 5 MB." + https://www.backblaze.com/docs/cloud-storage-large-files +- large_file_sha1: b2_start_large_file -- "If the caller knows the SHA1 of + the entire large file being uploaded, Backblaze recommends using + large_file_sha1 as the name, and a 40 byte hex string representing the + SHA1." https://www.backblaze.com/apidocs/b2-start-large-file +NEEDS REVIEW: docs say large_file_sha1 is RECOMMENDED, not required; the +assertion FAILs a large file that lacks it. + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import collections +import hashlib +import io +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'large.multipart' +OBJECT_NAME = 'st/multipart.bin' +PARTS_WANTED = 3 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def counting_raw_api(b2, tally): + """A raw API that counts the native calls this scenario is about. + + `B2HttpApiConfig(_raw_api_class=...)` is the SDK's own injection point, so + this observes the real transport instead of standing in for it. + """ + + class Counting(b2.B2RawHTTPApi): + def start_large_file(self, *args, **kwargs): + tally['start_large_file'] += 1 + return super().start_large_file(*args, **kwargs) + + def upload_part(self, *args, **kwargs): + tally['upload_part'] += 1 + return super().upload_part(*args, **kwargs) + + def copy_part(self, *args, **kwargs): + tally['copy_part'] += 1 + return super().copy_part(*args, **kwargs) + + def upload_file(self, *args, **kwargs): + tally['upload_file'] += 1 + return super().upload_file(*args, **kwargs) + + def authorize_account(self, *args, **kwargs): + tally['authorize_account'] += 1 + return super().authorize_account(*args, **kwargs) + + return Counting + + +def file_info_of(obj): + if isinstance(obj, dict): + return obj.get('contentSha1'), dict(obj.get('fileInfo') or {}) + return getattr(obj, 'content_sha1', None), dict(getattr(obj, 'file_info', None) or {}) + + +def run(target: str) -> None: + b2, realm = prepare(target) + tally = collections.Counter() + api = b2.B2Api( + b2.InMemoryAccountInfo(), + api_config=b2.B2HttpApiConfig(_raw_api_class=counting_raw_api(b2, tally)), + max_upload_workers=1, + ) + authorize(api, realm) + + # The SDK's own floor for a part, read from the authorized account rather + # than guessed -- the threshold is the service's, not this check's. + part_size = api.account_info.get_absolute_minimum_part_size() + payload = payload_of(part_size * PARTS_WANTED) + expected_sha1 = hashlib.sha1(payload).hexdigest() + + bucket = new_bucket(api) + try: + uploaded = step( + 'upload', + lambda: bucket.create_file( + [b2.WriteIntent(b2.UploadSourceBytes(payload))], + OBJECT_NAME, + recommended_upload_part_size=part_size, + min_part_size=part_size, + max_part_size=part_size, + ), + ) + + if tally['start_large_file'] != 1: + raise Failure( + 'multipart', + f"b2_start_large_file called {tally['start_large_file']} times, expected 1", + ) + if tally['upload_part'] < 2: + raise Failure( + 'multipart', + f"{tally['upload_part']} b2_upload_part calls; a large file needs more than 1", + ) + + sha1, info = file_info_of(step('metadata', lambda: api.get_file_info(uploaded.id_))) + if sha1 != 'none': + raise Failure('multipart', f"contentSha1 is {sha1}, expected 'none' for a large file") + lowered = {str(k).lower(): v for k, v in info.items()} + if 'large_file_sha1' not in lowered: + raise Failure('multipart', 'fileInfo carries no large_file_sha1') + if lowered['large_file_sha1'] != expected_sha1: + raise Failure('multipart', 'large_file_sha1 does not match the uploaded bytes') + + sink = io.BytesIO() + step('download', lambda: bucket.download_file_by_name(OBJECT_NAME).save(sink)) + if sink.getvalue() != payload: + raise Failure('round trip', 'downloaded bytes differ from what was uploaded') + + note(f"{tally['upload_part']} parts of {part_size} bytes, counted at the raw-API boundary") + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/large.parallel_download b/.sdkharness/tests/conformance/large.parallel_download new file mode 100755 index 00000000..554050d7 --- /dev/null +++ b/.sdkharness/tests/conformance/large.parallel_download @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x large.parallel_download. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a large object + Action download it with multiple streams + Assert the bytes are correct and more than 1 concurrent ranged GET is + observed + + The SDK picks its parallel downloader by comparing the object against + DownloadManager.DEFAULT_MIN_PART_SIZE, which ships at 100 MiB. Rather than + move 200 MiB to trip it, this check lowers that floor through the PUBLIC + b2sdk.v3 classes (DownloadManager / Services / B2Api) to the realm's + absoluteMinimumPartSize, read from the realm and never hardcoded, and says + so in the note: the claim proved is that the parallel downloader issues concurrent + ranged GETs, not that the shipped default policy chooses it for this size. + +DOCS -- b2_download_file_by_id, Range: "A byte-range request that returns +part of the stored file. NOTE: Only byte-range requests with a single range +of bytes are supported." and "The status code for a successful response to +a Range request is 206 Partial Content when only part of the stored file is +returned." https://www.backblaze.com/apidocs/b2-download-file-by-id +Parallelism is PERMITTED: "You can use the URL and authorization token that +are returned from the b2_authorize_account operation by multiple downloading +threads. You can download in parallel to increase overall throughput." +https://www.backblaze.com/docs/cloud-storage-download-files-with-the-native-api +PERMISSION ONLY: no B2 document requires a client to download in parallel, +so the ">1 concurrent ranged GET" expected value traces to the tool's +stream setting, not to B2. + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import io +import os +import sys +import threading +import time +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'large.parallel_download' +OBJECT_NAME = 'st/parallel-download.bin' +PARTS_WANTED = 4 +STREAMS = 4 +OBSERVATION_DWELL_SECONDS = 0.1 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +class Flight: + """Counts ranged GETs that overlap in time.""" + + def __init__(self): + self.lock = threading.Lock() + self.in_flight = 0 + self.peak = 0 + self.ranged = 0 + + def enter(self, ranged): + with self.lock: + self.in_flight += 1 + if ranged: + self.ranged += 1 + self.peak = max(self.peak, self.in_flight) + + def leave(self): + with self.lock: + self.in_flight -= 1 + + +def downloading_api(b2, flight, streams, min_part_size): + """A B2Api whose parallel downloader is reachable for a modest object.""" + + class Watched(b2.B2RawHTTPApi): + def download_file_from_url(self, *args, **kwargs): + flight.enter(kwargs.get('range_') is not None or len(args) > 2) + try: + # Hold each GET briefly so simultaneous streams are OBSERVABLE; + # otherwise a fast response can finish before the next stream + # starts and real parallelism reads as serial. + time.sleep(OBSERVATION_DWELL_SECONDS) + return super().download_file_from_url(*args, **kwargs) + finally: + flight.leave() + + class SmallPartDownloadManager(b2.DownloadManager): + DEFAULT_MIN_PART_SIZE = min_part_size + + class ConformanceServices(b2.Services): + DOWNLOAD_MANAGER_CLASS = SmallPartDownloadManager + + class ConformanceB2Api(b2.B2Api): + SERVICES_CLASS = ConformanceServices + + return ConformanceB2Api( + b2.InMemoryAccountInfo(), + api_config=b2.B2HttpApiConfig(_raw_api_class=Watched), + max_download_streams_per_file=streams, + ) + + +def run(target: str) -> None: + b2, realm = prepare(target) + api = authorize(b2.B2Api(b2.InMemoryAccountInfo()), realm) + + part_size = api.account_info.get_absolute_minimum_part_size() + payload = payload_of(part_size * PARTS_WANTED) + + bucket = new_bucket(api) + try: + step( + 'upload fixture', + lambda: bucket.create_file( + [b2.WriteIntent(b2.UploadSourceBytes(payload))], + OBJECT_NAME, + recommended_upload_part_size=part_size, + min_part_size=part_size, + max_part_size=part_size, + ), + ) + + flight = Flight() + reader = downloading_api(b2, flight, STREAMS, part_size) + authorize(reader, realm) + reading_bucket = step('open bucket', lambda: reader.get_bucket_by_name(bucket.name)) + + sink = io.BytesIO() + step( + 'parallel download', + lambda: reading_bucket.download_file_by_name(OBJECT_NAME).save(sink), + ) + + if sink.getvalue() != payload: + raise Failure('round trip', 'downloaded bytes differ from what was uploaded') + if flight.peak < 2: + raise Failure( + 'parallelism', + f'peak simultaneous GETs was {flight.peak} with max_download_streams_per_file={STREAMS}', + ) + if flight.ranged < 2: + raise Failure( + 'parallelism', f'{flight.ranged} ranged GETs observed, expected more than 1' + ) + + note( + f'evidence: peak {flight.peak} simultaneous GETs, {flight.ranged} of them ranged, counted ' + f'at the raw-API boundary, each GET held {OBSERVATION_DWELL_SECONDS}s to widen the ' + f'observation window. DownloadManager.DEFAULT_MIN_PART_SIZE was lowered to {part_size} bytes ' + f'through the public v3 classes so a {len(payload)}-byte object reaches the parallel ' + f'downloader; the shipped default floor is 100 MiB.' + ) + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/large.resume b/.sdkharness/tests/conformance/large.resume new file mode 100755 index 00000000..aaba4f4b --- /dev/null +++ b/.sdkharness/tests/conformance/large.resume @@ -0,0 +1,435 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x large.resume. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a large upload interrupted after at least 1 part completes + Action re-run the same upload + Assert an unfinished large file existed; the rerun REUSES that fileId + instead of calling b2_start_large_file again; it uploads fewer + parts than the total; the final bytes are correct + + The interruption is injected at the SDK's own raw-API injection point, so + the first run fails exactly where a dropped connection would -- after a + part has been stored and before the file is finished. + +DOCS -- "Any number of large files can be in progress at the same time. +You can use b2_list_unfinished_large_files to get a list of them. For any +one unfinished large file, you can use b2_list_parts to get a list of the +parts that were uploaded so far." +https://www.backblaze.com/docs/cloud-storage-create-large-files-with-the-native-api +b2_list_parts: "Lists the parts that have been uploaded for a large file +that has not completed uploading" https://www.backblaze.com/apidocs/b2-list-parts +B2 makes reusing the unfinished fileId POSSIBLE; that a rerun reuses it +instead of calling b2_start_large_file is the capability under test, not a +B2 promise. + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import collections +import hashlib +import io +import os +import sys +import tempfile +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'large.resume' +OBJECT_NAME = 'st/resume.bin' +PARTS_WANTED = 4 +PARTS_BEFORE_INTERRUPT = 1 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def counting_raw_api(b2, tally): + """A raw API that counts the native calls this scenario is about. + + `B2HttpApiConfig(_raw_api_class=...)` is the SDK's own injection point, so + this observes the real transport instead of standing in for it. + """ + + class Counting(b2.B2RawHTTPApi): + def start_large_file(self, *args, **kwargs): + tally['start_large_file'] += 1 + return super().start_large_file(*args, **kwargs) + + def upload_part(self, *args, **kwargs): + tally['upload_part'] += 1 + return super().upload_part(*args, **kwargs) + + def copy_part(self, *args, **kwargs): + tally['copy_part'] += 1 + return super().copy_part(*args, **kwargs) + + def upload_file(self, *args, **kwargs): + tally['upload_file'] += 1 + return super().upload_file(*args, **kwargs) + + def authorize_account(self, *args, **kwargs): + tally['authorize_account'] += 1 + return super().authorize_account(*args, **kwargs) + + return Counting + + +class Interrupted(Exception): + """Deliberate. Stands in for a connection dropped mid-large-file.""" + + +def interrupting_raw_api(b2, tally, fail_after): + class Interrupting(b2.B2RawHTTPApi): + def start_large_file(self, *args, **kwargs): + tally['start_large_file'] += 1 + return super().start_large_file(*args, **kwargs) + + def upload_part(self, *args, **kwargs): + if tally['upload_part'] >= fail_after: + raise Interrupted('deliberate interruption') + result = super().upload_part(*args, **kwargs) + tally['upload_part'] += 1 + return result + + return Interrupting + + +def upload_call(b2, bucket, path, part_size): + return lambda: bucket.create_file( + [b2.WriteIntent(b2.UploadSourceLocalFile(path))], + OBJECT_NAME, + recommended_upload_part_size=part_size, + min_part_size=part_size, + max_part_size=part_size, + ) + + +def run(target: str) -> None: + b2, realm = prepare(target) + api = authorize(b2.B2Api(b2.InMemoryAccountInfo()), realm) + + part_size = api.account_info.get_absolute_minimum_part_size() + payload = payload_of(part_size * PARTS_WANTED) + expected_sha1 = hashlib.sha1(payload).hexdigest() + + scratch = tempfile.mkdtemp(prefix='sdkharness-conf-') + local = os.path.join(scratch, 'resume.bin') + with open(local, 'wb') as handle: + handle.write(payload) + + bucket = new_bucket(api) + try: + # --- run 1: interrupted after PARTS_BEFORE_INTERRUPT parts ------------- + first_tally = collections.Counter() + broken = b2.B2Api( + b2.InMemoryAccountInfo(), + api_config=b2.B2HttpApiConfig( + _raw_api_class=interrupting_raw_api(b2, first_tally, PARTS_BEFORE_INTERRUPT) + ), + max_upload_workers=1, + ) + authorize(broken, realm) + broken_bucket = step('open bucket', lambda: broken.get_bucket_by_name(bucket.name)) + try: + upload_call(b2, broken_bucket, local, part_size)() + except Exception as error: # noqa: BLE001 -- the interruption is the setup + if not _caused_by_interruption(error): + raise Failure( + 'interrupt', + f'the first run failed with {type(error).__name__}, not the injected interruption', + ) + else: + raise Failure('interrupt', 'the interrupted upload finished anyway') + + if first_tally['upload_part'] < PARTS_BEFORE_INTERRUPT: + raise Failure( + 'interrupt', 'the first run stored no part at all; there is nothing to resume' + ) + + unfinished = step( + 'list unfinished large files', + lambda: [u for u in bucket.list_unfinished_large_files() if u.file_name == OBJECT_NAME], + ) + if not unfinished: + raise Failure('resume', 'the interrupted upload left no unfinished large file') + unfinished_id = unfinished[0].file_id + + # --- run 2: the same upload again -------------------------------------- + second_tally = collections.Counter() + resumed_api = b2.B2Api( + b2.InMemoryAccountInfo(), + api_config=b2.B2HttpApiConfig(_raw_api_class=counting_raw_api(b2, second_tally)), + max_upload_workers=1, + ) + authorize(resumed_api, realm) + resumed_bucket = step('open bucket', lambda: resumed_api.get_bucket_by_name(bucket.name)) + finished = step('re-run the upload', upload_call(b2, resumed_bucket, local, part_size)) + + if second_tally['start_large_file'] != 0: + raise Failure( + 'resume', + f"the rerun called b2_start_large_file {second_tally['start_large_file']} time(s) instead of resuming", + ) + if finished.id_ != unfinished_id: + raise Failure('resume', 'the rerun finished a DIFFERENT fileId than the unfinished one') + if second_tally['upload_part'] >= PARTS_WANTED: + raise Failure( + 'resume', + f"the rerun uploaded {second_tally['upload_part']} of {PARTS_WANTED} parts; it re-sent everything", + ) + + sink = io.BytesIO() + step('download', lambda: bucket.download_file_by_name(OBJECT_NAME).save(sink)) + if sink.getvalue() != payload: + raise Failure('round trip', "the resumed file's bytes differ from the source") + if hashlib.sha1(sink.getvalue()).hexdigest() != expected_sha1: + raise Failure('round trip', "the resumed file's sha1 differs from the source") + + note( + f"resumed fileId reused; parts {first_tally['upload_part']} + " + f"{second_tally['upload_part']} of {PARTS_WANTED}" + ) + finally: + teardown(api, bucket) + try: + os.remove(local) + os.rmdir(scratch) + except Exception: # noqa: BLE001 + pass + + +def _caused_by_interruption(error) -> bool: + seen = 0 + while error is not None and seen < 10: + if isinstance(error, Interrupted): + return True + error = getattr(error, '__cause__', None) or getattr(error, '__context__', None) + seen += 1 + return False + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/large.unbound_incremental b/.sdkharness/tests/conformance/large.unbound_incremental new file mode 100755 index 00000000..30abbfd7 --- /dev/null +++ b/.sdkharness/tests/conformance/large.unbound_incremental @@ -0,0 +1,434 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x large.unbound_incremental. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a stream of undeclared length + Action upload it without a size + Assert the correct length and sha1 land + + b2-sdk-python's capabilities.tsv answers `yes`, not `partial`: it offers + BOTH halves of this capability, so both are exercised. The unbound half + uploads a read-only stream whose length is genuinely unknown to the caller; + the incremental half appends to an already-stored file and is proved by the + b2_copy_part call that reuses the stored prefix -- without that, an + incremental upload is indistinguishable from a full re-upload. + +NO DOCUMENTED CONTRACT FOUND for an upload of unknown length -- searched: +apidocs/b2-upload-file, apidocs/b2-upload-part, docs/cloud-storage-large-files, +docs/cloud-storage-create-large-files-with-the-native-api. At the wire B2 +documents the opposite: "You must set the Content-Length HTTP header when +using either b2_upload_file or b2_upload_part. Chunked transfer encoding, +which allows the Content-Length header to be omitted, is not supported." +https://www.backblaze.com/apidocs/b2-upload-part +So "unbound" is built by the tool; the expected length and sha1 are those +of the bytes this check streamed. +The incremental half's b2_copy_part reuse of the stored prefix: "a large +file can be assembled from a mix of uploaded and copied parts." https://www.backblaze.com/docs/cloud-storage-large-files +NEEDS REVIEW: docs say a large file's contentSha1 is "none"; the assertion +also accepts None, and for a large file requires a matching large_file_sha1, +which b2_start_large_file recommends only "If the caller knows the SHA1 +of the entire large file being uploaded" (https://www.backblaze.com/apidocs/b2-start-large-file) +-- an unbound stream's uploader cannot know it when the file is started. + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import collections +import hashlib +import io +import os +import sys +import tempfile +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'large.unbound_incremental' +UNBOUND_NAME = 'st/unbound.bin' +INCREMENTAL_NAME = 'st/incremental.bin' +UNBOUND_SIZE = 4096 +APPENDED_SIZE = 1024 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def counting_raw_api(b2, tally): + """A raw API that counts the native calls this scenario is about. + + `B2HttpApiConfig(_raw_api_class=...)` is the SDK's own injection point, so + this observes the real transport instead of standing in for it. + """ + + class Counting(b2.B2RawHTTPApi): + def start_large_file(self, *args, **kwargs): + tally['start_large_file'] += 1 + return super().start_large_file(*args, **kwargs) + + def upload_part(self, *args, **kwargs): + tally['upload_part'] += 1 + return super().upload_part(*args, **kwargs) + + def copy_part(self, *args, **kwargs): + tally['copy_part'] += 1 + return super().copy_part(*args, **kwargs) + + def upload_file(self, *args, **kwargs): + tally['upload_file'] += 1 + return super().upload_file(*args, **kwargs) + + def authorize_account(self, *args, **kwargs): + tally['authorize_account'] += 1 + return super().authorize_account(*args, **kwargs) + + return Counting + + +class UndeclaredLengthStream: + """read() and nothing else -- the caller cannot know the length.""" + + def __init__(self, data: bytes) -> None: + self._inner = io.BytesIO(data) + + def read(self, size=-1): + return self._inner.read(size) + + +def sha1_and_size(obj): + if isinstance(obj, dict): + return obj.get('contentSha1'), obj.get('contentLength'), dict(obj.get('fileInfo') or {}) + size = getattr(obj, 'size', None) + if size is None: + size = getattr(obj, 'content_length', None) + return ( + getattr(obj, 'content_sha1', None), + size, + dict(getattr(obj, 'file_info', None) or {}), + ) + + +def run(target: str) -> None: + b2, realm = prepare(target) + tally = collections.Counter() + api = b2.B2Api( + b2.InMemoryAccountInfo(), + api_config=b2.B2HttpApiConfig(_raw_api_class=counting_raw_api(b2, tally)), + max_upload_workers=1, + ) + authorize(api, realm) + + part_size = api.account_info.get_absolute_minimum_part_size() + unbound_payload = payload_of(UNBOUND_SIZE) + unbound_sha1 = hashlib.sha1(unbound_payload).hexdigest() + + scratch = tempfile.mkdtemp(prefix='sdkharness-conf-') + local = os.path.join(scratch, 'incremental.bin') + base = payload_of(part_size) + with open(local, 'wb') as handle: + handle.write(base) + + bucket = new_bucket(api) + try: + # --- unbound: a stream whose length is not declared -------------------- + uploaded = step( + 'upload an unbound stream', + lambda: bucket.upload_unbound_stream( + UndeclaredLengthStream(unbound_payload), + UNBOUND_NAME, + recommended_upload_part_size=part_size, + buffer_size=part_size, + ), + ) + sha1, size, info = sha1_and_size(step('metadata', lambda: api.get_file_info(uploaded.id_))) + if size != UNBOUND_SIZE: + raise Failure('unbound', f'contentLength {size}, expected {UNBOUND_SIZE}') + lowered = {str(k).lower(): v for k, v in info.items()} + landed_sha1 = sha1 if sha1 not in (None, 'none') else lowered.get('large_file_sha1') + if landed_sha1 != unbound_sha1: + raise Failure('unbound', 'the sha1 that landed does not match the streamed bytes') + + sink = io.BytesIO() + step( + 'download the unbound upload', + lambda: bucket.download_file_by_name(UNBOUND_NAME).save(sink), + ) + if sink.getvalue() != unbound_payload: + raise Failure('unbound', 'the streamed bytes did not round-trip') + + # --- incremental: append to what is already stored --------------------- + step( + 'upload the incremental base', + lambda: bucket.upload_local_file(local, INCREMENTAL_NAME, min_part_size=part_size), + ) + appended = payload_of(APPENDED_SIZE)[::-1] + with open(local, 'ab') as handle: + handle.write(appended) + expected = base + appended + + before = tally['copy_part'] + step( + 'upload incrementally', + lambda: bucket.upload_local_file( + local, + INCREMENTAL_NAME, + min_part_size=part_size, + upload_mode=b2.UploadMode.INCREMENTAL, + ), + ) + if tally['copy_part'] <= before: + raise Failure( + 'incremental', + 'no b2_copy_part call: the stored prefix was re-uploaded, not appended to', + ) + + sink = io.BytesIO() + step( + 'download the appended file', + lambda: bucket.download_file_by_name(INCREMENTAL_NAME).save(sink), + ) + got = sink.getvalue() + if len(got) != len(expected): + raise Failure( + 'incremental', f'the appended file is {len(got)} bytes, expected {len(expected)}' + ) + if got != expected: + raise Failure( + 'incremental', 'the appended file is not the base plus the appended bytes' + ) + + note( + f"unbound stream of {UNBOUND_SIZE} undeclared bytes landed; incremental append reused " + f"the stored prefix via {tally['copy_part'] - before} b2_copy_part call(s)" + ) + finally: + teardown(api, bucket) + try: + os.remove(local) + os.rmdir(scratch) + except Exception: # noqa: BLE001 + pass + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/lock.bucket_default b/.sdkharness/tests/conformance/lock.bucket_default new file mode 100755 index 00000000..8c6743d1 --- /dev/null +++ b/.sdkharness/tests/conformance/lock.bucket_default @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x lock.bucket_default. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a file-lock-enabled bucket + Action set a default retention of governance / 1 day + Assert the read-back reports the mode and the period, and a newly + uploaded object INHERITS a retention of about now + 1 day + +The expected value is B2's published contract. Object Lock: a bucket's +default retention means "All of the files that are uploaded or copied to that +bucket, from that time on, are protected by those Object Lock retention +settings by default." +https://www.backblaze.com/docs/cloud-storage-object-lock +The read-back shape -- defaultRetention {mode, period {duration, unit}} -- is +the native API's: +https://www.backblaze.com/docs/cloud-storage-enable-object-lock-with-the-native-api + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import os +import sys +import time +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'lock.bucket_default' +OBJECT_NAME = 'st/default-retention.txt' +PAYLOAD_SIZE = 256 +DAY_MILLIS = 24 * 60 * 60 * 1000 +TOLERANCE_MILLIS = 30 * 60 * 1000 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def run(target: str) -> None: + b2, api = connect(target) + + bucket = None + try: + bucket = step( + 'create a file-lock-enabled bucket', + lambda: api.create_bucket( + f'sdkharness-conf-{uuid.uuid4().hex[:12]}', 'allPrivate', is_file_lock_enabled=True + ), + ) + + setting = b2.BucketRetentionSetting(b2.RetentionMode.GOVERNANCE, b2.RetentionPeriod(days=1)) + step('set the bucket default retention', lambda: bucket.update(default_retention=setting)) + + fresh = step('read the bucket back', lambda: api.list_buckets(bucket_name=bucket.name)) + if not fresh: + raise Failure('read back', 'the bucket disappeared from the listing') + read_back = fresh[0].default_retention + if getattr(read_back, 'mode', None) != b2.RetentionMode.GOVERNANCE: + raise Failure( + 'read back', + f"default retention mode is {getattr(read_back, 'mode', None)}, expected governance", + ) + period = getattr(read_back, 'period', None) + if getattr(period, 'duration', None) != 1 or getattr(period, 'unit', None) != 'days': + raise Failure('read back', f'default retention period is {period}, expected 1 day') + + before = int(time.time() * 1000) + uploaded = step( + 'upload into the locked bucket', + lambda: bucket.upload_bytes(payload_of(PAYLOAD_SIZE), OBJECT_NAME), + ) + inherited = getattr(uploaded, 'file_retention', None) + if getattr(inherited, 'mode', None) != b2.RetentionMode.GOVERNANCE: + raise Failure( + 'inheritance', + f"the new object's retention mode is {getattr(inherited, 'mode', None)}", + ) + retain_until = getattr(inherited, 'retain_until', None) + if retain_until is None: + raise Failure('inheritance', 'the new object inherited no retain-until timestamp') + expected = before + DAY_MILLIS + if abs(retain_until - expected) > TOLERANCE_MILLIS: + raise Failure( + 'inheritance', + f'retain-until is {retain_until - expected} ms from now + 1 day, outside the tolerance', + ) + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/lock.bypass_governance b/.sdkharness/tests/conformance/lock.bypass_governance new file mode 100755 index 00000000..d386ee37 --- /dev/null +++ b/.sdkharness/tests/conformance/lock.bypass_governance @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x lock.bypass_governance. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup an object under governance retention and a key holding the + bypassGovernance capability + Action delete it without bypass, then with + Assert the first is refused with the expected error, the second succeeds, + and the version is gone + +The expected value is B2's published contract. b2_delete_file_version's +bypassGovernance "Must be specified and set to true if deleting a file version +protected by Object Lock governance mode retention settings. Setting the value +requires the bypassGovernance application key capability." Without it the +delete is refused: 401 access_denied, "The delete was not allowed because +Object Lock is enabled on the file." +https://www.backblaze.com/apidocs/b2-delete-file-version + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import os +import sys +import time +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'lock.bypass_governance' +OBJECT_NAME = 'st/bypass-governance.txt' +PAYLOAD_SIZE = 256 +DAY_MILLIS = 24 * 60 * 60 * 1000 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def run(target: str) -> None: + b2, api = connect(target) + + bucket = None + try: + bucket = step( + 'create a file-lock-enabled bucket', + lambda: api.create_bucket( + f'sdkharness-conf-{uuid.uuid4().hex[:12]}', 'allPrivate', is_file_lock_enabled=True + ), + ) + uploaded = step( + 'upload fixture', lambda: bucket.upload_bytes(payload_of(PAYLOAD_SIZE), OBJECT_NAME) + ) + step( + 'put the version under governance retention', + lambda: api.update_file_retention( + uploaded.id_, + OBJECT_NAME, + b2.FileRetentionSetting( + b2.RetentionMode.GOVERNANCE, int(time.time() * 1000) + DAY_MILLIS + ), + ), + ) + + blocked_by = refused( + 'delete without bypass', + lambda: bucket.delete_file_version(uploaded.id_, OBJECT_NAME), + ('AccessDenied', 'RetentionWriteError', 'Conflict', 'BadRequest'), + ) + + step( + 'delete with bypass', + lambda: bucket.delete_file_version(uploaded.id_, OBJECT_NAME, bypass_governance=True), + ) + + ids = step( + 'list versions', + lambda: [v.id_ for v, _folder in bucket.ls('', recursive=True, latest_only=False)], + ) + if uploaded.id_ in ids: + raise Failure( + 'bypass', 'the bypassing delete reported success but the version is still listed' + ) + + note(f'the unbypassed delete was refused with {blocked_by}') + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/lock.legal_hold b/.sdkharness/tests/conformance/lock.legal_hold new file mode 100755 index 00000000..7f179e8c --- /dev/null +++ b/.sdkharness/tests/conformance/lock.legal_hold @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x lock.legal_hold. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a lock-enabled bucket and an object in it + Action set the hold on, read it back, set it off + Assert on -> the read-back says on and deleting the version is refused + off -> deleting the version is permitted + +The expected value is B2's published contract. b2_update_file_legal_hold takes +legalHold "on" to enable legal hold for the file, "off" to disable it", and is +"Used to enable legal hold for a file in an Object Lock-enabled bucket, +preventing it from being deleted, or to disable legal hold protections": +https://www.backblaze.com/apidocs/b2-update-file-legal-hold +"If either legal hold or retention settings, or even both, are active for a +given file, then that file is protected from being deleted." +https://www.backblaze.com/docs/cloud-storage-object-lock + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import os +import sys +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'lock.legal_hold' +OBJECT_NAME = 'st/legal-hold.txt' +PAYLOAD_SIZE = 256 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def run(target: str) -> None: + b2, api = connect(target) + + bucket = None + try: + bucket = step( + 'create a file-lock-enabled bucket', + lambda: api.create_bucket( + f'sdkharness-conf-{uuid.uuid4().hex[:12]}', 'allPrivate', is_file_lock_enabled=True + ), + ) + uploaded = step( + 'upload fixture', lambda: bucket.upload_bytes(payload_of(PAYLOAD_SIZE), OBJECT_NAME) + ) + + step( + 'set the legal hold on', + lambda: api.update_file_legal_hold(uploaded.id_, OBJECT_NAME, b2.LegalHold.ON), + ) + held = step('metadata while held', lambda: api.get_file_info(uploaded.id_)) + if getattr(held, 'legal_hold', None) != b2.LegalHold.ON: + raise Failure( + 'read back', f"legal hold reads {getattr(held, 'legal_hold', None)}, expected on" + ) + + refused( + 'delete while held', + lambda: bucket.delete_file_version(uploaded.id_, OBJECT_NAME), + ('AccessDenied', 'RetentionWriteError', 'Conflict', 'BadRequest'), + ) + + step( + 'set the legal hold off', + lambda: api.update_file_legal_hold(uploaded.id_, OBJECT_NAME, b2.LegalHold.OFF), + ) + released = step('metadata after release', lambda: api.get_file_info(uploaded.id_)) + if getattr(released, 'legal_hold', None) != b2.LegalHold.OFF: + raise Failure( + 'read back', + f"legal hold reads {getattr(released, 'legal_hold', None)}, expected off", + ) + + step( + 'delete once released', + lambda: bucket.delete_file_version(uploaded.id_, OBJECT_NAME), + ) + ids = step( + 'list versions', + lambda: [v.id_ for v, _folder in bucket.ls('', recursive=True, latest_only=False)], + ) + if uploaded.id_ in ids: + raise Failure( + 'delete', 'the version survived a delete made after the hold was released' + ) + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/lock.per_file_retention b/.sdkharness/tests/conformance/lock.per_file_retention new file mode 100755 index 00000000..fa973ff1 --- /dev/null +++ b/.sdkharness/tests/conformance/lock.per_file_retention @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x lock.per_file_retention. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a lock-enabled bucket and an object in it + Action set a per-file retention of governance / now + 2 days + Assert the metadata reports it, and deleting that version WITHOUT bypass + is refused + +The expected value is B2's published contract. b2_update_file_retention sets +fileRetention {mode: "governance" | "compliance", retainUntilTimestamp} and +returns it: +https://www.backblaze.com/apidocs/b2-update-file-retention +The refused delete: "If either legal hold or retention settings, or even both, +are active for a given file, then that file is protected from being deleted." +https://www.backblaze.com/docs/cloud-storage-object-lock +and bypassGovernance "Must be specified and set to true if deleting a file +version protected by Object Lock governance mode retention settings." +https://www.backblaze.com/apidocs/b2-delete-file-version + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import os +import sys +import time +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'lock.per_file_retention' +OBJECT_NAME = 'st/per-file-retention.txt' +PAYLOAD_SIZE = 256 +TWO_DAYS_MILLIS = 2 * 24 * 60 * 60 * 1000 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def run(target: str) -> None: + b2, api = connect(target) + + bucket = None + try: + bucket = step( + 'create a file-lock-enabled bucket', + lambda: api.create_bucket( + f'sdkharness-conf-{uuid.uuid4().hex[:12]}', 'allPrivate', is_file_lock_enabled=True + ), + ) + uploaded = step( + 'upload fixture', lambda: bucket.upload_bytes(payload_of(PAYLOAD_SIZE), OBJECT_NAME) + ) + + retain_until = int(time.time() * 1000) + TWO_DAYS_MILLIS + setting = b2.FileRetentionSetting(b2.RetentionMode.GOVERNANCE, retain_until) + step( + 'set the per-file retention', + lambda: api.update_file_retention(uploaded.id_, OBJECT_NAME, setting), + ) + + info = step('metadata', lambda: api.get_file_info(uploaded.id_)) + read_back = getattr(info, 'file_retention', None) + if getattr(read_back, 'mode', None) != b2.RetentionMode.GOVERNANCE: + raise Failure( + 'read back', + f"retention mode is {getattr(read_back, 'mode', None)}, expected governance", + ) + if getattr(read_back, 'retain_until', None) != retain_until: + raise Failure('read back', 'the retain-until timestamp did not round-trip') + + refused( + 'delete without bypass', + lambda: bucket.delete_file_version(uploaded.id_, OBJECT_NAME), + ('AccessDenied', 'RetentionWriteError', 'Conflict', 'BadRequest'), + ) + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/conformance/urls.native_download b/.sdkharness/tests/conformance/urls.native_download new file mode 100755 index 00000000..8cfd373c --- /dev/null +++ b/.sdkharness/tests/conformance/urls.native_download @@ -0,0 +1,347 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x urls.native_download. + +Scenario (approved 2026-09-22, one per capability, identical on every tool that +claims it): + + Setup a fixture object + Action get a download authorization for a prefix with a short TTL, fetch + it over plain HTTP, then wait past the expiry + Assert 200 with the correct bytes, then 401 + + The share token MUST be the B2-NATIVE shape: b2sdk has no S3 presigning at + all (the card records that), so a SigV4 query string appearing here would + mean this check is testing something other than what the cell claims. + +DOCS. The expected values are B2's published NATIVE contract, not SigV4. +b2_get_download_authorization "Returns an authorization token that can be +passed to b2_download_file_by_name in the Authorization header or as an +Authorization parameter", limited to fileNamePrefix and expiring after +validDurationInSeconds. b2_download_file_by_name addresses +/file//, accepts the token "in the URL query string +instead of being passed in the HTTP header", and answers an expired one with +401 expired_auth_token. +https://www.backblaze.com/apidocs/b2-get-download-authorization +https://www.backblaze.com/apidocs/b2-download-file-by-name +The assertion below matches using the header form: a /file/ URL with no +query string, the token sent as Authorization, no X-Amz-* material, 401. + +Written from bin/conformance/b2-sdk-python/files.upload, the reference check, +and bin/conformance/README.md, the contract. It exercises b2sdk from this exact repository +checkout; the API was taken from +docs/sdks/b2-sdk-python/card.md and the published b2sdk docs; the assertion now lives with the SDK code it exercises. +""" + +import hashlib +import os +import sys +import time +import urllib.error +import urllib.request +import uuid + +SLUG = 'b2-sdk-python' +CAPABILITY = 'urls.native_download' +PREFIX = 'st/' +OBJECT_NAME = 'st/shared.txt' +PAYLOAD_SIZE = 1024 +TTL_SECONDS = 1 +EXPIRY_SLACK_SECONDS = 4 + +# The closed reason set, mirrored from bin/conformance/README.md. The runner +# enforces it; naming one outside this tuple is a FAIL it manufactures. +REASONS = ( + 'no-realm-option', + 'unreachable', + 'unauthorized', + 'no-credential', + 'missing-runtime', + 'not-claimed', +) + +# A requested target maps to a realm or to amber. `simulator` is the harness's +# own server (bin/simulator/serve.mjs), whose URL the runner exports as +# CONFORMANCE_SIMULATOR_URL; unset, there is nothing to reach: no-realm-option. +REALMS = { + 'staging': 'staging', + 'production': 'production', + 'simulator': os.environ.get('CONFORMANCE_SIMULATOR_URL') or None, +} + +# The simulator's fixed test credential, which the runner also injects. At +# @simulator the check uses it directly, so a real B2_* value can never be +# sent there. +SIM_CREDENTIAL = ('test-key-id', 'test-key') + + +def credential(realm): + """The (key id, key) pair for a realm: the fixed one at @simulator.""" + if realm == REALMS['simulator']: + return SIM_CREDENTIAL + return os.environ.get('B2_APPLICATION_KEY_ID'), os.environ.get('B2_APPLICATION_KEY') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + """A golden-path step that did not do what the card claims.""" + + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(target: str, verdict: str) -> None: + print(f'CONFORMANCE {SLUG} {CAPABILITY} @{target}: {verdict}') + + +def note(text: str) -> None: + """Context for the log. NOT a result line. + + The runner reads only the CONFORMANCE line and a PASS carries no detail, so + anything qualifying the STRENGTH of the evidence is said here, where it + survives in the retained log without being mistaken for a verdict. + """ + print(f'note {SLUG} {CAPABILITY}: {text}') + + +def step(name, action): + """Run one golden-path step, converting the environment's own errors. + + A credential NEVER reaches the detail: only the exception class name and, + for a classified network/auth error, the class that classified it. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified below, never echoed + name_of = type(error).__name__ + if name_of in ('Unauthorized', 'InvalidAuthToken', 'AccessDenied'): + raise Amber('unauthorized', f"{name_of} at step '{name}'") from error + if name_of in ('gaierror', 'ConnectionError', 'ServiceError', 'B2ConnectionError'): + raise Amber('unreachable', f"{name_of} at step '{name}'") from error + raise Failure(name, name_of) from error + + +def refused(name, action, expected): + """Assert something is REFUSED, and record what refused it. + + `expected` is a tuple of exception class names. A different class -- or no + exception at all -- is a Failure, because the refusal IS the claim here. + """ + try: + action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- classified, never echoed + name_of = type(error).__name__ + if name_of in expected: + return name_of + raise Failure(name, f"refused with {name_of}, expected {'/'.join(expected)}") from error + raise Failure(name, 'the call was permitted; the claim under test is that it is refused') + + +def payload_of(size: int) -> bytes: + """Deterministic filler of an exact size.""" + unit = b'sdkharness conformance ' + return (unit * (size // len(unit) + 1))[:size] + + +def prepare(target): + """Realm, credentials and the INSTALLED package -- before any network call.""" + realm = REALMS.get(target) + if realm is None: + detail = ( + 'CONFORMANCE_SIMULATOR_URL is unset' + if target == 'simulator' + else f"b2sdk exposes no '{target}' realm" + ) + raise Amber('no-realm-option', detail) + + if not all(credential(realm)): + raise Amber('no-credential', 'B2_APPLICATION_KEY_ID / B2_APPLICATION_KEY unset') + + try: + import b2sdk.v3 as b2 + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + return b2, realm + + +def authorize(api, realm): + """Authorize against the named realm. Credentials never leave this call.""" + step( + 'authenticate', + lambda: api.authorize_account(*credential(realm), realm=realm), + ) + return api + + +def connect(target, **api_kwargs): + """The common case: one authorized B2Api against the requested realm.""" + b2, realm = prepare(target) + return b2, authorize(b2.B2Api(b2.InMemoryAccountInfo(), **api_kwargs), realm) + + +def new_bucket(api, **kwargs): + """An ephemeral bucket, named as the contract requires.""" + name = f'sdkharness-conf-{uuid.uuid4().hex[:12]}' + return step('create bucket', lambda: api.create_bucket(name, 'allPrivate', **kwargs)) + + +def _delete_hard(api, bucket, version): + """Delete one version, lifting a legal hold or a governance lock if it blocks.""" + for attempt in (0, 1): + try: + bucket.delete_file_version( + version.id_, version.file_name, bypass_governance=attempt > 0 + ) + return + except Exception: # noqa: BLE001 -- cleanup must never mask the verdict + if attempt == 0: + try: + import b2sdk.v3 as b2 + + api.update_file_legal_hold(version.id_, version.file_name, b2.LegalHold.OFF) + except Exception: # noqa: BLE001 + pass + + +def teardown(api, *buckets): + """Owns what it created and destroys it, on the error path too.""" + for bucket in buckets: + if bucket is None: + continue + try: + for unfinished in bucket.list_unfinished_large_files(): + try: + bucket.cancel_large_file(unfinished.file_id) + except Exception: # noqa: BLE001 + pass + except Exception: # noqa: BLE001 + pass + try: + versions = [v for v, _folder in bucket.ls('', recursive=True, latest_only=False)] + except Exception: # noqa: BLE001 + versions = [] + for version in versions: + _delete_hard(api, bucket, version) + try: + api.delete_bucket(bucket) + except Exception: # noqa: BLE001 + pass + + +def drop_keys(api, key_ids): + """Application keys are resources too.""" + for key_id in key_ids: + if not key_id: + continue + try: + api.delete_key_by_id(key_id) + except Exception: # noqa: BLE001 + pass + + +def fetch(url, token): + """Plain HTTP, outside the SDK. Returns (status, body) or raises.""" + request = urllib.request.Request(url, headers={'Authorization': token}) + with urllib.request.urlopen(request, timeout=60) as response: + return response.getcode(), response.read() + + +def run(target: str) -> None: + _b2, api = connect(target) + + payload = payload_of(PAYLOAD_SIZE) + bucket = new_bucket(api) + try: + step('upload fixture', lambda: bucket.upload_bytes(payload, OBJECT_NAME)) + + token = step( + 'get a download authorization', + lambda: bucket.get_download_authorization(PREFIX, TTL_SECONDS), + ) + if not isinstance(token, str) or not token: + raise Failure('authorization', 'the call returned no share token') + + url = step('build the download url', lambda: bucket.get_download_url(OBJECT_NAME)) + + # --- the B2-NATIVE shape, not a SigV4 query string -------------------- + if '/file/' not in url: + raise Failure('shape', 'the download url is not a B2-native /file// url') + for marker in ('X-Amz-Signature', 'X-Amz-Credential', 'X-Amz-Algorithm'): + if marker.lower() in url.lower() or marker.lower() in token.lower(): + raise Failure( + 'shape', f'{marker} appears in the share token or url; this is a SigV4 presign' + ) + if '?' in url: + raise Failure( + 'shape', 'the download url carries a query string; the B2 token travels separately' + ) + + try: + status, body = fetch(url, token) + except urllib.error.HTTPError as error: + raise Failure('fetch', f'the authorized fetch returned HTTP {error.code}') from error + except urllib.error.URLError as error: + raise Amber( + 'unreachable', f'{type(error.reason).__name__} fetching the download url' + ) from error + + if status != 200: + raise Failure('fetch', f'the authorized fetch returned HTTP {status}, expected 200') + if body != payload: + raise Failure('fetch', 'the authorized fetch returned different bytes') + if hashlib.sha1(body).hexdigest() != hashlib.sha1(payload).hexdigest(): + raise Failure('fetch', 'the fetched sha1 differs from the fixture') + + time.sleep(TTL_SECONDS + EXPIRY_SLACK_SECONDS) + + try: + expired_status, _body = fetch(url, token) + except urllib.error.HTTPError as error: + if error.code != 401: + raise Failure( + 'expiry', f'the expired token returned HTTP {error.code}, expected 401' + ) from error + except urllib.error.URLError as error: + raise Amber( + 'unreachable', f'{type(error.reason).__name__} re-fetching after expiry' + ) from error + else: + raise Failure('expiry', f'the expired token still returned HTTP {expired_status}') + + note(f'token TTL {TTL_SECONDS}s honoured; B2-native share token, no SigV4 material present') + finally: + teardown(api, bucket) + + +def main() -> int: + target = os.environ.get('CONFORMANCE_TARGET', 'staging') + try: + run(target) + except Amber as amber: + say(target, f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(target, f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(target, f'FAIL (setup -- {type(error).__name__})') + return 1 + say(target, 'PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/lib/contract.py b/.sdkharness/tests/lib/contract.py new file mode 100644 index 00000000..86da62df --- /dev/null +++ b/.sdkharness/tests/lib/contract.py @@ -0,0 +1,136 @@ +"""Dispatcher for repository-owned sdkharness conformance and resilience checks.""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from collections.abc import Mapping +from pathlib import Path +from urllib.parse import urlsplit + +SLUG = 'b2-sdk-python' +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +SCENARIO_ROOT = Path(__file__).resolve().parents[1] +SCENARIO_RE = re.compile(r'^[A-Za-z0-9_.-]+$') + + +class ContractFailure(Exception): + """A credential-safe contract or execution failure.""" + + +def result(level: str, scenario: str, outcome: str, reason: str) -> None: + safe_reason = reason.replace('\t', ' ').replace('\r', ' ').replace('\n', ' ') + print(f'SDKHARNESS_RESULT\t{level}\t{scenario}\t{outcome}\t{safe_reason}') + + +def validate_loopback_origin(value: str) -> None: + try: + parsed = urlsplit(value) + port = parsed.port + except ValueError as error: + raise ContractFailure('configuration: invalid simulator URL') from error + if ( + parsed.scheme != 'http' + or parsed.hostname != '127.0.0.1' + or port is None + or parsed.username is not None + or parsed.password is not None + or parsed.path not in {'', '/'} + or parsed.query + or parsed.fragment + ): + raise ContractFailure('configuration: simulator URLs must be IPv4 loopback HTTP origins') + + +def validate_environment(level: str, environment: Mapping[str, str]) -> tuple[str, str]: + if level not in {'conformance', 'resilience'}: + raise ContractFailure('configuration: unsupported test level') + if environment.get('SDKHARNESS_TEST_LEVEL') != level: + raise ContractFailure('configuration: unexpected test level') + scenario = environment.get('SDKHARNESS_SCENARIO', '') + if not SCENARIO_RE.fullmatch(scenario): + raise ContractFailure('configuration: invalid scenario') + simulator_url = environment.get('SDKHARNESS_SIMULATOR_URL', '') + validate_loopback_origin(simulator_url) + if level == 'resilience': + validate_loopback_origin(environment.get('SDKHARNESS_SIMULATOR_CONTROL_URL', '')) + return scenario, simulator_url + + +def child_environment(level: str, environment: Mapping[str, str]) -> dict[str, str]: + child = {name: value for name, value in environment.items() if not name.startswith('B2_')} + child['PYTHONPATH'] = str(REPOSITORY_ROOT) + child['B2_APPLICATION_KEY_ID'] = 'test-key-id' + child['B2_APPLICATION_KEY'] = 'test-key' + child[f'{level.upper()}_TARGET'] = 'simulator' + child[f'{level.upper()}_SIMULATOR_URL'] = environment['SDKHARNESS_SIMULATOR_URL'] + child[f'{level.upper()}_SIMULATOR_HTTPS_URL'] = environment.get( + 'SDKHARNESS_SIMULATOR_HTTPS_URL', '' + ) + child[f'{level.upper()}_SIMULATOR_CA'] = environment.get('SDKHARNESS_SIMULATOR_CA', '') + if level == 'resilience': + child['RESILIENCE_CONTROL_URL'] = environment['SDKHARNESS_SIMULATOR_CONTROL_URL'] + return child + + +def translate(level: str, scenario: str, returncode: int, output: str) -> tuple[str, str, int]: + prefix = f'{level.upper()} {SLUG} {scenario} @' + standing = [line for line in output.splitlines() if line.startswith(prefix)] + for line in output.splitlines(): + if not line.startswith(prefix): + print(line) + if len(standing) != 1: + return 'FAIL', f'contract: expected one standing result, got {len(standing)}', 1 + try: + verdict = standing[0].split(': ', 1)[1] + except IndexError: + return 'FAIL', 'contract: malformed standing verdict', 1 + if verdict == 'PASS': + if returncode != 0: + return 'FAIL', f'contract: PASS exited {returncode}', 1 + return 'PASS', '-', 0 + if verdict.startswith('FAIL (') and verdict.endswith(')'): + return 'FAIL', verdict[6:-1], 1 + if verdict.startswith('COULD-NOT-RUN (') and verdict.endswith(')'): + if returncode != 0: + return 'FAIL', f'contract: SKIP exited {returncode}', 1 + return 'SKIP', verdict[15:-1], 0 + return 'FAIL', 'contract: malformed standing verdict', 1 + + +def run(level: str, environment: Mapping[str, str] = os.environ) -> int: + scenario = environment.get('SDKHARNESS_SCENARIO', 'unknown') + try: + scenario, _ = validate_environment(level, environment) + executable = SCENARIO_ROOT / level / scenario + if not executable.is_file() or not os.access(executable, os.X_OK): + raise ContractFailure('configuration: scenario executable is unavailable') + completed = subprocess.run( + [str(executable)], + cwd=REPOSITORY_ROOT, + env=child_environment(level, environment), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + outcome, reason, returncode = translate( + level, scenario, completed.returncode, completed.stdout + ) + except ContractFailure as error: + outcome, reason, returncode = 'FAIL', str(error), 1 + except Exception as error: + outcome, reason, returncode = 'FAIL', f'setup: {type(error).__name__}', 1 + result(level, scenario, outcome, reason) + return returncode + + +def main(level: str) -> int: + return run(level) + + +if __name__ == '__main__': + sys.exit(2) diff --git a/.sdkharness/tests/resilience/api.backoff_503 b/.sdkharness/tests/resilience/api.backoff_503 new file mode 100755 index 00000000..40fc0e4e --- /dev/null +++ b/.sdkharness/tests/resilience/api.backoff_503 @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x api.backoff_503. + +Written from the reference check, bin/resilience/b2-sdk-python/upload.retry_503. +The contract is bin/resilience/README.md; the question is the api.backoff_503 row of +bin/resilience/scenarios.tsv. + +Fault fault b2_list_file_names 503 service_unavailable, NO Retry-After (count 1), armed after setup +Assert the end state: the listing names exactly the fixture; the recovery path, + from GET /journal: one injected 503, then a successful b2_list_file_names; + and timing, a LOWER BOUND only: the whole list call took >= 1.0 s + (weak evidence, stated on a NOTE line -- the journal has no timestamps) + +The expected behaviour is B2's, not the tool's. +The Integration Checklist, Error Handling, 503 on non-upload APIs: "If the header is +not present, the developer should retry using an exponential backoff starting with +1 second." +https://www.backblaze.com/docs/cloud-storage-integration-checklist + +It exercises b2sdk from this exact repository checkout; +the API is taken from docs/sdks/b2-sdk-python/card.md. +""" + +import json +import os +import sys +import time +import urllib.request +import uuid + +SLUG = 'b2-sdk-python' +SCENARIO = 'api.backoff_503' +OBJECT_NAME = 'res/backoff_503.txt' +ARM_PATH = '/faults' +FAULT = {'on': 'b2_list_file_names', 'status': 503, 'code': 'service_unavailable', 'count': 1} + + +def is_faulted(e): + return e['endpoint'] == 'b2_list_file_names' and e['fault'] == 'injected' and e['status'] == 503 + + +FLOOR_S = 1.0 + +# The closed reason set, mirrored from bin/resilience/README.md. This +# scenario's `needs` is `-`, so no-client-option is not available to it. +REASONS = ('missing-runtime',) + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(verdict: str) -> None: + print(f'RESILIENCE {SLUG} {SCENARIO} @simulator: {verdict}') + + +def note(text: str) -> None: + print(f'NOTE {SLUG} {SCENARIO}: {text}') + + +def step(name: str, action): + """Run one step; any error is a FAIL naming the step and exception class. + + There is no unreachable/unauthorized amber here: the runner supplies the + server and the credential, so either failing is a harness defect. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- only the class name is reported + raise Failure(name, type(error).__name__) from error + + +def control(method: str, path: str, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + os.environ['RESILIENCE_CONTROL_URL'] + path, + data=data, + method=method, + headers={'content-type': 'application/json'}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + + +def journal(): + return step('journal', lambda: control('GET', '/journal'))['entries'] + + +def after(entries, seq, endpoint, status=None): + """Entries on `endpoint` later than `seq`, optionally with one status.""" + return [ + e + for e in entries + if e['seq'] > seq + and e['endpoint'] == endpoint + and (status is None or e['status'] == status) + ] + + +def authorized_bucket(**api_kwargs): + realm = os.environ['RESILIENCE_SIMULATOR_URL'] + try: + from b2sdk.v3 import B2Api, InMemoryAccountInfo + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + api = B2Api(InMemoryAccountInfo(), **api_kwargs) + step('authenticate', lambda: api.authorize_account('test-key-id', 'test-key', realm=realm)) + bucket = step( + 'create bucket', + lambda: api.create_bucket(f'sdkharness-res-{uuid.uuid4().hex[:12]}', 'allPrivate'), + ) + return api, bucket + + +def run() -> None: + api, bucket = authorized_bucket() + payload = b'sdkharness resilience listing' + step('upload fixture', lambda: bucket.upload_bytes(payload, OBJECT_NAME)) + + step('arm fault', lambda: control('POST', ARM_PATH, FAULT)) + started = time.monotonic() + names = step('list', lambda: [v.file_name for v, _folder in bucket.ls('', recursive=True)]) + if names != [OBJECT_NAME]: + raise Failure('listing', f'listed {len(names)} names, expected exactly the one fixture') + elapsed = time.monotonic() - started + + entries = journal() + faulted = [e for e in entries if is_faulted(e)] + if len(faulted) != 1: + raise Failure('journal', f'{len(faulted)} injected 503 on b2_list_file_names, expected 1') + if not after(entries, faulted[0]['seq'], 'b2_list_file_names', 200): + raise Failure('journal', 'no successful b2_list_file_names after the 503') + note( + f'the whole list call took {elapsed:.2f} s against a floor of {FLOOR_S} s; ' + 'a lower bound on the call, not a measured gap between attempts (the journal has no timestamps)' + ) + if elapsed < FLOOR_S: + raise Failure( + 'recovery path', + f'retried after {elapsed:.2f} s, sooner than the documented 1 s first backoff step', + ) + + +def main() -> int: + try: + run() + except Amber as amber: + say(f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(f'FAIL (setup -- {type(error).__name__})') + return 1 + say('PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/resilience/api.retry_after_429 b/.sdkharness/tests/resilience/api.retry_after_429 new file mode 100755 index 00000000..612bad3e --- /dev/null +++ b/.sdkharness/tests/resilience/api.retry_after_429 @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x api.retry_after_429. + +Written from the reference check, bin/resilience/b2-sdk-python/upload.retry_503. +The contract is bin/resilience/README.md; the question is the api.retry_after_429 row of +bin/resilience/scenarios.tsv. + +Fault fault b2_list_file_names 429 too_many_requests retryAfter=2 (count 1), armed after setup +Assert the end state: the listing names exactly the fixture; the recovery path, + from GET /journal: one injected 429, then a successful b2_list_file_names; + and timing, a LOWER BOUND only: the whole list call took >= 2.0 s + (weak evidence, stated on a NOTE line -- the journal has no timestamps) + +The expected behaviour is B2's, not the tool's. +The Integration Checklist, Error Handling, 429: "the response may also include a +'Retry-After' header where the value is the number of seconds a developer should wait." +https://www.backblaze.com/docs/cloud-storage-integration-checklist + +It exercises b2sdk from this exact repository checkout; +the API is taken from docs/sdks/b2-sdk-python/card.md. +""" + +import json +import os +import sys +import time +import urllib.request +import uuid + +SLUG = 'b2-sdk-python' +SCENARIO = 'api.retry_after_429' +OBJECT_NAME = 'res/retry_after_429.txt' +ARM_PATH = '/faults' +FAULT = { + 'on': 'b2_list_file_names', + 'status': 429, + 'code': 'too_many_requests', + 'count': 1, + 'retryAfter': 2, +} + + +def is_faulted(e): + return e['endpoint'] == 'b2_list_file_names' and e['fault'] == 'injected' and e['status'] == 429 + + +FLOOR_S = 2.0 + +# The closed reason set, mirrored from bin/resilience/README.md. This +# scenario's `needs` is `-`, so no-client-option is not available to it. +REASONS = ('missing-runtime',) + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(verdict: str) -> None: + print(f'RESILIENCE {SLUG} {SCENARIO} @simulator: {verdict}') + + +def note(text: str) -> None: + print(f'NOTE {SLUG} {SCENARIO}: {text}') + + +def step(name: str, action): + """Run one step; any error is a FAIL naming the step and exception class. + + There is no unreachable/unauthorized amber here: the runner supplies the + server and the credential, so either failing is a harness defect. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- only the class name is reported + raise Failure(name, type(error).__name__) from error + + +def control(method: str, path: str, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + os.environ['RESILIENCE_CONTROL_URL'] + path, + data=data, + method=method, + headers={'content-type': 'application/json'}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + + +def journal(): + return step('journal', lambda: control('GET', '/journal'))['entries'] + + +def after(entries, seq, endpoint, status=None): + """Entries on `endpoint` later than `seq`, optionally with one status.""" + return [ + e + for e in entries + if e['seq'] > seq + and e['endpoint'] == endpoint + and (status is None or e['status'] == status) + ] + + +def authorized_bucket(**api_kwargs): + realm = os.environ['RESILIENCE_SIMULATOR_URL'] + try: + from b2sdk.v3 import B2Api, InMemoryAccountInfo + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + api = B2Api(InMemoryAccountInfo(), **api_kwargs) + step('authenticate', lambda: api.authorize_account('test-key-id', 'test-key', realm=realm)) + bucket = step( + 'create bucket', + lambda: api.create_bucket(f'sdkharness-res-{uuid.uuid4().hex[:12]}', 'allPrivate'), + ) + return api, bucket + + +def run() -> None: + api, bucket = authorized_bucket() + payload = b'sdkharness resilience listing' + step('upload fixture', lambda: bucket.upload_bytes(payload, OBJECT_NAME)) + + step('arm fault', lambda: control('POST', ARM_PATH, FAULT)) + started = time.monotonic() + names = step('list', lambda: [v.file_name for v, _folder in bucket.ls('', recursive=True)]) + if names != [OBJECT_NAME]: + raise Failure('listing', f'listed {len(names)} names, expected exactly the one fixture') + elapsed = time.monotonic() - started + + entries = journal() + faulted = [e for e in entries if is_faulted(e)] + if len(faulted) != 1: + raise Failure('journal', f'{len(faulted)} injected 429 on b2_list_file_names, expected 1') + if not after(entries, faulted[0]['seq'], 'b2_list_file_names', 200): + raise Failure('journal', 'no successful b2_list_file_names after the 429') + note( + f'the whole list call took {elapsed:.2f} s against a floor of {FLOOR_S} s; ' + 'a lower bound on the call, not a measured gap between attempts (the journal has no timestamps)' + ) + if elapsed < FLOOR_S: + raise Failure( + 'recovery path', f'retried after {elapsed:.2f} s, sooner than the 2 s Retry-After' + ) + + +def main() -> int: + try: + run() + except Amber as amber: + say(f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(f'FAIL (setup -- {type(error).__name__})') + return 1 + say('PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/resilience/api.retry_after_503 b/.sdkharness/tests/resilience/api.retry_after_503 new file mode 100755 index 00000000..fd1c9cc3 --- /dev/null +++ b/.sdkharness/tests/resilience/api.retry_after_503 @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x api.retry_after_503. + +Written from the reference check, bin/resilience/b2-sdk-python/upload.retry_503. +The contract is bin/resilience/README.md; the question is the api.retry_after_503 row of +bin/resilience/scenarios.tsv. + +Fault fault b2_list_file_names 503 service_unavailable retryAfter=2 (count 1), armed after setup +Assert the end state: the listing names exactly the fixture; the recovery path, + from GET /journal: one injected 503, then a successful b2_list_file_names; + and timing, a LOWER BOUND only: the whole list call took >= 2.0 s + (weak evidence, stated on a NOTE line -- the journal has no timestamps) + +The expected behaviour is B2's, not the tool's. +The Integration Checklist, Error Handling, 503 on non-upload APIs: "it may include a +'Retry-After' header where the value is the number of seconds a developer should wait +before re-issuing the command." +https://www.backblaze.com/docs/cloud-storage-integration-checklist + +It exercises b2sdk from this exact repository checkout; +the API is taken from docs/sdks/b2-sdk-python/card.md. +""" + +import json +import os +import sys +import time +import urllib.request +import uuid + +SLUG = 'b2-sdk-python' +SCENARIO = 'api.retry_after_503' +OBJECT_NAME = 'res/retry_after_503.txt' +ARM_PATH = '/faults' +FAULT = { + 'on': 'b2_list_file_names', + 'status': 503, + 'code': 'service_unavailable', + 'count': 1, + 'retryAfter': 2, +} + + +def is_faulted(e): + return e['endpoint'] == 'b2_list_file_names' and e['fault'] == 'injected' and e['status'] == 503 + + +FLOOR_S = 2.0 + +# The closed reason set, mirrored from bin/resilience/README.md. This +# scenario's `needs` is `-`, so no-client-option is not available to it. +REASONS = ('missing-runtime',) + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(verdict: str) -> None: + print(f'RESILIENCE {SLUG} {SCENARIO} @simulator: {verdict}') + + +def note(text: str) -> None: + print(f'NOTE {SLUG} {SCENARIO}: {text}') + + +def step(name: str, action): + """Run one step; any error is a FAIL naming the step and exception class. + + There is no unreachable/unauthorized amber here: the runner supplies the + server and the credential, so either failing is a harness defect. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- only the class name is reported + raise Failure(name, type(error).__name__) from error + + +def control(method: str, path: str, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + os.environ['RESILIENCE_CONTROL_URL'] + path, + data=data, + method=method, + headers={'content-type': 'application/json'}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + + +def journal(): + return step('journal', lambda: control('GET', '/journal'))['entries'] + + +def after(entries, seq, endpoint, status=None): + """Entries on `endpoint` later than `seq`, optionally with one status.""" + return [ + e + for e in entries + if e['seq'] > seq + and e['endpoint'] == endpoint + and (status is None or e['status'] == status) + ] + + +def authorized_bucket(**api_kwargs): + realm = os.environ['RESILIENCE_SIMULATOR_URL'] + try: + from b2sdk.v3 import B2Api, InMemoryAccountInfo + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + api = B2Api(InMemoryAccountInfo(), **api_kwargs) + step('authenticate', lambda: api.authorize_account('test-key-id', 'test-key', realm=realm)) + bucket = step( + 'create bucket', + lambda: api.create_bucket(f'sdkharness-res-{uuid.uuid4().hex[:12]}', 'allPrivate'), + ) + return api, bucket + + +def run() -> None: + api, bucket = authorized_bucket() + payload = b'sdkharness resilience listing' + step('upload fixture', lambda: bucket.upload_bytes(payload, OBJECT_NAME)) + + step('arm fault', lambda: control('POST', ARM_PATH, FAULT)) + started = time.monotonic() + names = step('list', lambda: [v.file_name for v, _folder in bucket.ls('', recursive=True)]) + if names != [OBJECT_NAME]: + raise Failure('listing', f'listed {len(names)} names, expected exactly the one fixture') + elapsed = time.monotonic() - started + + entries = journal() + faulted = [e for e in entries if is_faulted(e)] + if len(faulted) != 1: + raise Failure('journal', f'{len(faulted)} injected 503 on b2_list_file_names, expected 1') + if not after(entries, faulted[0]['seq'], 'b2_list_file_names', 200): + raise Failure('journal', 'no successful b2_list_file_names after the 503') + note( + f'the whole list call took {elapsed:.2f} s against a floor of {FLOOR_S} s; ' + 'a lower bound on the call, not a measured gap between attempts (the journal has no timestamps)' + ) + if elapsed < FLOOR_S: + raise Failure( + 'recovery path', f'retried after {elapsed:.2f} s, sooner than the 2 s Retry-After' + ) + + +def main() -> int: + try: + run() + except Amber as amber: + say(f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(f'FAIL (setup -- {type(error).__name__})') + return 1 + say('PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/resilience/auth.clock_expiry b/.sdkharness/tests/resilience/auth.clock_expiry new file mode 100755 index 00000000..fc25ec10 --- /dev/null +++ b/.sdkharness/tests/resilience/auth.clock_expiry @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x auth.clock_expiry. + +Written from the reference check, bin/resilience/b2-sdk-python/upload.retry_503. +The contract is bin/resilience/README.md; the question is the auth.clock_expiry row of +bin/resilience/scenarios.tsv. + +Fault clock 86400001 -- POST /clock {advanceMs: 86400001}, after setup, so the + account token the one live B2Api session holds is past its 24 h life. + Nothing is injected: the 401 is the simulator's own +Assert the recovery path, from GET /journal: after the advance, a REAL 401 (fault + null), then b2_authorize_account, then a successful b2_list_file_names; + and the end state: the listing names exactly the fixture + +The expected behaviour is B2's, not the tool's. +b2_authorize_account: "This authorization token is valid for at most 24 hours." +https://www.backblaze.com/apidocs/b2-authorize-account +The Integration Checklist, Error Handling, 401: "When the code is either bad_auth_token +or expired_auth_token you should call b2_authorize_account again to get a new auth token." +https://www.backblaze.com/docs/cloud-storage-integration-checklist + +`needs: long-lived-session` is met: one B2Api over an InMemoryAccountInfo is +one session that outlives the virtual advance, and the card records the token +as "cached in an AccountInfo and auto-refreshed on expiry" (card.md, Auth model: +b2sdk/_internal/api.py:206, session.py:107, session.py:92). + +It exercises b2sdk from this exact repository checkout; +the API is taken from docs/sdks/b2-sdk-python/card.md. +""" + +import json +import os +import sys +import urllib.request +import uuid + +SLUG = 'b2-sdk-python' +SCENARIO = 'auth.clock_expiry' +OBJECT_NAME = 'res/clock_expiry.txt' +ARM_PATH = '/clock' +FAULT = {'advanceMs': 86400001} + + +def is_faulted(e): + return e['status'] == 401 and e['fault'] is None + + +# The closed reason set, mirrored from bin/resilience/README.md. This +# scenario's `needs` is `long-lived-session`; the card shows b2sdk meets it, +# so this check never emits no-client-option. +REASONS = ('missing-runtime', 'no-client-option') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(verdict: str) -> None: + print(f'RESILIENCE {SLUG} {SCENARIO} @simulator: {verdict}') + + +def note(text: str) -> None: + print(f'NOTE {SLUG} {SCENARIO}: {text}') + + +def step(name: str, action): + """Run one step; any error is a FAIL naming the step and exception class. + + There is no unreachable/unauthorized amber here: the runner supplies the + server and the credential, so either failing is a harness defect. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- only the class name is reported + raise Failure(name, type(error).__name__) from error + + +def control(method: str, path: str, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + os.environ['RESILIENCE_CONTROL_URL'] + path, + data=data, + method=method, + headers={'content-type': 'application/json'}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + + +def journal(): + return step('journal', lambda: control('GET', '/journal'))['entries'] + + +def after(entries, seq, endpoint, status=None): + """Entries on `endpoint` later than `seq`, optionally with one status.""" + return [ + e + for e in entries + if e['seq'] > seq + and e['endpoint'] == endpoint + and (status is None or e['status'] == status) + ] + + +def authorized_bucket(**api_kwargs): + realm = os.environ['RESILIENCE_SIMULATOR_URL'] + try: + from b2sdk.v3 import B2Api, InMemoryAccountInfo + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + api = B2Api(InMemoryAccountInfo(), **api_kwargs) + step('authenticate', lambda: api.authorize_account('test-key-id', 'test-key', realm=realm)) + bucket = step( + 'create bucket', + lambda: api.create_bucket(f'sdkharness-res-{uuid.uuid4().hex[:12]}', 'allPrivate'), + ) + return api, bucket + + +def run() -> None: + api, bucket = authorized_bucket() + payload = b'sdkharness resilience listing' + step('upload fixture', lambda: bucket.upload_bytes(payload, OBJECT_NAME)) + before = max(e['seq'] for e in journal()) + step('advance clock', lambda: control('POST', ARM_PATH, FAULT)) + names = step('list', lambda: [v.file_name for v, _folder in bucket.ls('', recursive=True)]) + if names != [OBJECT_NAME]: + raise Failure('listing', f'listed {len(names)} names, expected exactly the one fixture') + + entries = [e for e in journal() if e['seq'] > before] + if any(e['fault'] is not None for e in entries): + raise Failure('journal', 'a fault was injected; this scenario must see only real answers') + expired = [e for e in entries if is_faulted(e)] + if not expired: + raise Failure( + 'journal', 'no real 401 after the 24 h advance; the expired token was never presented' + ) + reauth = after(entries, expired[0]['seq'], 'b2_authorize_account', 200) + if not reauth: + raise Failure( + 'recovery path', + f"b2_authorize_account was not called again after the real 401 on {expired[0]['endpoint']}", + ) + if not after(entries, reauth[0]['seq'], 'b2_list_file_names', 200): + raise Failure('recovery path', 'no successful b2_list_file_names after the reauthorization') + note(f"the real 401 came from {expired[0]['endpoint']}") + + +def main() -> int: + try: + run() + except Amber as amber: + say(f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(f'FAIL (setup -- {type(error).__name__})') + return 1 + say('PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/resilience/auth.expired_401 b/.sdkharness/tests/resilience/auth.expired_401 new file mode 100755 index 00000000..08470d54 --- /dev/null +++ b/.sdkharness/tests/resilience/auth.expired_401 @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x auth.expired_401. + +Written from the reference check, bin/resilience/b2-sdk-python/upload.retry_503. +The contract is bin/resilience/README.md; the question is the auth.expired_401 row of +bin/resilience/scenarios.tsv. + +Fault fault b2_list_file_names 401 expired_auth_token (count 1), armed after setup +Assert the recovery path, from GET /journal: exactly one injected 401 on + b2_list_file_names, then b2_authorize_account, then a successful + b2_list_file_names after that reauthorization; and the end state: the + listing names exactly the fixture + +The expected behaviour is B2's, not the tool's. +b2_list_file_names's 401 expired_auth_token: "The auth token used has expired. Call +b2_authorize_account again to get a new one." +https://www.backblaze.com/apidocs/b2-list-file-names +The Integration Checklist, Error Handling, 401: "When the code is either bad_auth_token +or expired_auth_token you should call b2_authorize_account again to get a new auth token." +https://www.backblaze.com/docs/cloud-storage-integration-checklist + +It exercises b2sdk from this exact repository checkout; +the API is taken from docs/sdks/b2-sdk-python/card.md. +""" + +import json +import os +import sys +import urllib.request +import uuid + +SLUG = 'b2-sdk-python' +SCENARIO = 'auth.expired_401' +OBJECT_NAME = 'res/expired_401.txt' +ARM_PATH = '/faults' +FAULT = {'on': 'b2_list_file_names', 'status': 401, 'code': 'expired_auth_token', 'count': 1} + + +def is_faulted(e): + return e['endpoint'] == 'b2_list_file_names' and e['fault'] == 'injected' and e['status'] == 401 + + +# The closed reason set, mirrored from bin/resilience/README.md. This +# scenario's `needs` is `-`, so no-client-option is not available to it. +REASONS = ('missing-runtime',) + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(verdict: str) -> None: + print(f'RESILIENCE {SLUG} {SCENARIO} @simulator: {verdict}') + + +def note(text: str) -> None: + print(f'NOTE {SLUG} {SCENARIO}: {text}') + + +def step(name: str, action): + """Run one step; any error is a FAIL naming the step and exception class. + + There is no unreachable/unauthorized amber here: the runner supplies the + server and the credential, so either failing is a harness defect. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- only the class name is reported + raise Failure(name, type(error).__name__) from error + + +def control(method: str, path: str, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + os.environ['RESILIENCE_CONTROL_URL'] + path, + data=data, + method=method, + headers={'content-type': 'application/json'}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + + +def journal(): + return step('journal', lambda: control('GET', '/journal'))['entries'] + + +def after(entries, seq, endpoint, status=None): + """Entries on `endpoint` later than `seq`, optionally with one status.""" + return [ + e + for e in entries + if e['seq'] > seq + and e['endpoint'] == endpoint + and (status is None or e['status'] == status) + ] + + +def authorized_bucket(**api_kwargs): + realm = os.environ['RESILIENCE_SIMULATOR_URL'] + try: + from b2sdk.v3 import B2Api, InMemoryAccountInfo + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + api = B2Api(InMemoryAccountInfo(), **api_kwargs) + step('authenticate', lambda: api.authorize_account('test-key-id', 'test-key', realm=realm)) + bucket = step( + 'create bucket', + lambda: api.create_bucket(f'sdkharness-res-{uuid.uuid4().hex[:12]}', 'allPrivate'), + ) + return api, bucket + + +def run() -> None: + api, bucket = authorized_bucket() + payload = b'sdkharness resilience listing' + step('upload fixture', lambda: bucket.upload_bytes(payload, OBJECT_NAME)) + step('arm fault', lambda: control('POST', ARM_PATH, FAULT)) + names = step('list', lambda: [v.file_name for v, _folder in bucket.ls('', recursive=True)]) + if names != [OBJECT_NAME]: + raise Failure('listing', f'listed {len(names)} names, expected exactly the one fixture') + + entries = journal() + faulted = [e for e in entries if is_faulted(e)] + if len(faulted) != 1: + raise Failure('journal', f'{len(faulted)} injected 401s on b2_list_file_names, expected 1') + reauth = after(entries, faulted[0]['seq'], 'b2_authorize_account', 200) + if not reauth: + raise Failure('recovery path', 'b2_authorize_account was not called again after the 401') + if not after(entries, reauth[0]['seq'], 'b2_list_file_names', 200): + raise Failure('recovery path', 'no successful b2_list_file_names after the reauthorization') + + +def main() -> int: + try: + run() + except Amber as amber: + say(f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(f'FAIL (setup -- {type(error).__name__})') + return 1 + say('PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/resilience/download.retry_503 b/.sdkharness/tests/resilience/download.retry_503 new file mode 100755 index 00000000..ca51ccb0 --- /dev/null +++ b/.sdkharness/tests/resilience/download.retry_503 @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x download.retry_503. + +Written from the reference check, bin/resilience/b2-sdk-python/upload.retry_503. +The contract is bin/resilience/README.md; the question is the download.retry_503 row of +bin/resilience/scenarios.tsv. + +Fault fault b2_download_file_by_id 503 service_unavailable (count 1), armed after + the fixture is uploaded +Assert the recovery path, from GET /journal: exactly one injected 503 on + b2_download_file_by_id, then a successful b2_download_file_by_id; and the + end state: the bytes delivered are the bytes uploaded + +The expected behaviour is B2's, not the tool's. +The Integration Checklist, Error Handling, 503 on non-upload APIs: "it may include a +'Retry-After' header ... If the header is not present, the developer should retry +using an exponential backoff starting with 1 second." +https://www.backblaze.com/docs/cloud-storage-integration-checklist + +It exercises b2sdk from this exact repository checkout; +the API is taken from docs/sdks/b2-sdk-python/card.md. +""" + +import io +import json +import os +import sys +import urllib.request +import uuid + +SLUG = 'b2-sdk-python' +SCENARIO = 'download.retry_503' +OBJECT_NAME = 'res/download_retry_503.bin' +ARM_PATH = '/faults' +FAULT = {'on': 'b2_download_file_by_id', 'status': 503, 'code': 'service_unavailable', 'count': 1} + + +def is_faulted(e): + return ( + e['endpoint'] == 'b2_download_file_by_id' + and e['fault'] == 'injected' + and e['status'] == 503 + ) + + +# The closed reason set, mirrored from bin/resilience/README.md. This +# scenario's `needs` is `-`, so no-client-option is not available to it. +REASONS = ('missing-runtime',) + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(verdict: str) -> None: + print(f'RESILIENCE {SLUG} {SCENARIO} @simulator: {verdict}') + + +def note(text: str) -> None: + print(f'NOTE {SLUG} {SCENARIO}: {text}') + + +def step(name: str, action): + """Run one step; any error is a FAIL naming the step and exception class. + + There is no unreachable/unauthorized amber here: the runner supplies the + server and the credential, so either failing is a harness defect. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- only the class name is reported + raise Failure(name, type(error).__name__) from error + + +def control(method: str, path: str, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + os.environ['RESILIENCE_CONTROL_URL'] + path, + data=data, + method=method, + headers={'content-type': 'application/json'}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + + +def journal(): + return step('journal', lambda: control('GET', '/journal'))['entries'] + + +def after(entries, seq, endpoint, status=None): + """Entries on `endpoint` later than `seq`, optionally with one status.""" + return [ + e + for e in entries + if e['seq'] > seq + and e['endpoint'] == endpoint + and (status is None or e['status'] == status) + ] + + +def authorized_bucket(**api_kwargs): + realm = os.environ['RESILIENCE_SIMULATOR_URL'] + try: + from b2sdk.v3 import B2Api, InMemoryAccountInfo + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + api = B2Api(InMemoryAccountInfo(), **api_kwargs) + step('authenticate', lambda: api.authorize_account('test-key-id', 'test-key', realm=realm)) + bucket = step( + 'create bucket', + lambda: api.create_bucket(f'sdkharness-res-{uuid.uuid4().hex[:12]}', 'allPrivate'), + ) + return api, bucket + + +def run() -> None: + api, bucket = authorized_bucket() + payload = bytes(i % 251 for i in range(4096)) + uploaded = step('upload fixture', lambda: bucket.upload_bytes(payload, OBJECT_NAME)) + step('arm fault', lambda: control('POST', ARM_PATH, FAULT)) + sink = io.BytesIO() + step('download', lambda: api.download_file_by_id(uploaded.id_).save(sink)) + if sink.getvalue() != payload: + raise Failure('round trip', 'downloaded bytes differ from what was uploaded') + + entries = journal() + faulted = [e for e in entries if is_faulted(e)] + if len(faulted) != 1: + raise Failure( + 'journal', f'{len(faulted)} injected 503s on b2_download_file_by_id, expected 1' + ) + if not after(entries, faulted[0]['seq'], 'b2_download_file_by_id', 200): + raise Failure('recovery path', 'b2_download_file_by_id was not retried after the 503') + + +def main() -> int: + try: + run() + except Amber as amber: + say(f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(f'FAIL (setup -- {type(error).__name__})') + return 1 + say('PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/resilience/part.retry_503 b/.sdkharness/tests/resilience/part.retry_503 new file mode 100755 index 00000000..855180c3 --- /dev/null +++ b/.sdkharness/tests/resilience/part.retry_503 @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x part.retry_503. + +Written from the reference check, bin/resilience/b2-sdk-python/upload.retry_503. +The contract is bin/resilience/README.md; the question is the part.retry_503 row of +bin/resilience/scenarios.tsv. + +Fault fault b2_upload_part 503 service_unavailable (count 1), armed after setup, so + the first part of a three-part large file gets it (one upload worker) +Assert the recovery path, from GET /journal: exactly one injected 503 on + b2_upload_part, then b2_get_upload_part_url again, then a successful + b2_upload_part on a DIFFERENT uploadUrlId, and b2_finish_large_file; and + the end state: the large file downloads with its bytes intact + +The expected behaviour is B2's, not the tool's. +b2_upload_part's 503 service_unavailable: "Call b2_get_upload_part_url again to get a +new auth token." +https://www.backblaze.com/apidocs/b2-upload-part +The Integration Checklist, Upload Retry Guidelines, lists the failures after which "the +developer should call b2_get_upload_url or b2_get_upload_part_url and re-attempt the +upload using the new URL and auth key pair"; among them: +"The HTTP response status is between 500-599, including 503 Service Unavailable." +https://www.backblaze.com/docs/cloud-storage-integration-checklist + +It exercises b2sdk from this exact repository checkout; +the API is taken from docs/sdks/b2-sdk-python/card.md. +""" + +import io +import json +import os +import sys +import urllib.request +import uuid + +SLUG = 'b2-sdk-python' +SCENARIO = 'part.retry_503' +OBJECT_NAME = 'res/part_retry_503.bin' +ARM_PATH = '/faults' +FAULT = {'on': 'b2_upload_part', 'status': 503, 'code': 'service_unavailable', 'count': 1} + + +def is_faulted(e): + return e['endpoint'] == 'b2_upload_part' and e['fault'] == 'injected' and e['status'] == 503 + + +PARTS = 3 + +# The closed reason set, mirrored from bin/resilience/README.md. This +# scenario's `needs` is `-`, so no-client-option is not available to it. +REASONS = ('missing-runtime',) + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(verdict: str) -> None: + print(f'RESILIENCE {SLUG} {SCENARIO} @simulator: {verdict}') + + +def note(text: str) -> None: + print(f'NOTE {SLUG} {SCENARIO}: {text}') + + +def step(name: str, action): + """Run one step; any error is a FAIL naming the step and exception class. + + There is no unreachable/unauthorized amber here: the runner supplies the + server and the credential, so either failing is a harness defect. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- only the class name is reported + raise Failure(name, type(error).__name__) from error + + +def control(method: str, path: str, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + os.environ['RESILIENCE_CONTROL_URL'] + path, + data=data, + method=method, + headers={'content-type': 'application/json'}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + + +def journal(): + return step('journal', lambda: control('GET', '/journal'))['entries'] + + +def after(entries, seq, endpoint, status=None): + """Entries on `endpoint` later than `seq`, optionally with one status.""" + return [ + e + for e in entries + if e['seq'] > seq + and e['endpoint'] == endpoint + and (status is None or e['status'] == status) + ] + + +def authorized_bucket(**api_kwargs): + realm = os.environ['RESILIENCE_SIMULATOR_URL'] + try: + from b2sdk.v3 import B2Api, InMemoryAccountInfo + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + api = B2Api(InMemoryAccountInfo(), **api_kwargs) + step('authenticate', lambda: api.authorize_account('test-key-id', 'test-key', realm=realm)) + bucket = step( + 'create bucket', + lambda: api.create_bucket(f'sdkharness-res-{uuid.uuid4().hex[:12]}', 'allPrivate'), + ) + return api, bucket + + +def run() -> None: + # One worker makes the part order, and so the faulted part, deterministic. + api, bucket = authorized_bucket(max_upload_workers=1) + from b2sdk.v3 import UploadSourceBytes, WriteIntent + + # The SDK's own floor for a part, read from the authorized account (the + # simulator reports 5,000 bytes), exactly as conformance large.multipart does. + part_size = api.account_info.get_absolute_minimum_part_size() + payload = bytes(i % 251 for i in range(part_size * PARTS)) + step('arm fault', lambda: control('POST', ARM_PATH, FAULT)) + step( + 'upload', + lambda: bucket.create_file( + [WriteIntent(UploadSourceBytes(payload))], + OBJECT_NAME, + recommended_upload_part_size=part_size, + min_part_size=part_size, + max_part_size=part_size, + ), + ) + + sink = io.BytesIO() + step('download', lambda: bucket.download_file_by_name(OBJECT_NAME).save(sink)) + if sink.getvalue() != payload: + raise Failure('round trip', 'downloaded bytes differ from what was uploaded') + + entries = journal() + if not [e for e in entries if e['endpoint'] == 'b2_start_large_file' and e['status'] == 200]: + raise Failure( + 'journal', 'no b2_start_large_file; the upload never took the large-file path' + ) + faulted = [e for e in entries if is_faulted(e)] + if len(faulted) != 1: + raise Failure('journal', f'{len(faulted)} injected 503s on b2_upload_part, expected 1') + seq = faulted[0]['seq'] + if not after(entries, seq, 'b2_get_upload_part_url', 200): + raise Failure('recovery path', 'no b2_get_upload_part_url after the 503') + retries = after(entries, seq, 'b2_upload_part', 200) + if not retries: + raise Failure('journal', 'no successful b2_upload_part after the 503') + if retries[0]['uploadUrlId'] in (None, faulted[0]['uploadUrlId']): + raise Failure('recovery path', 'the retried part reused the part URL that returned 503') + if ( + len([e for e in entries if e['endpoint'] == 'b2_upload_part' and e['status'] == 200]) + < PARTS + ): + raise Failure('journal', f'fewer than {PARTS} successful b2_upload_part calls') + if not after(entries, seq, 'b2_finish_large_file', 200): + raise Failure('journal', 'the large file was never finished after the retried part') + + +def main() -> int: + try: + run() + except Amber as amber: + say(f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(f'FAIL (setup -- {type(error).__name__})') + return 1 + say('PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/resilience/upload.cap_exceeded_403 b/.sdkharness/tests/resilience/upload.cap_exceeded_403 new file mode 100755 index 00000000..b7b62dce --- /dev/null +++ b/.sdkharness/tests/resilience/upload.cap_exceeded_403 @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x upload.cap_exceeded_403. + +Written from the reference check, bin/resilience/b2-sdk-python/upload.retry_503. +The contract is bin/resilience/README.md; the question is the upload.cap_exceeded_403 row of +bin/resilience/scenarios.tsv. + +Fault fault b2_upload_file 403 cap_exceeded (count 1), armed after setup +Assert the end state: the upload RAISES a b2sdk error to the caller; and the + recovery path, from GET /journal: exactly one b2_upload_file (the 403), + so the tool did not retry + +The expected behaviour is B2's, not the tool's. +b2_upload_file's 403 cap_exceeded: "Usage cap exceeded." +https://www.backblaze.com/apidocs/b2-upload-file +The Integration Checklist, Error Handling, 403 cap exceeded: "Quit immediately and +inform the user they need to log in and review their B2 Caps." +https://www.backblaze.com/docs/cloud-storage-integration-checklist + +It exercises b2sdk from this exact repository checkout; +the API is taken from docs/sdks/b2-sdk-python/card.md. +""" + +import json +import os +import sys +import urllib.request +import uuid + +SLUG = 'b2-sdk-python' +SCENARIO = 'upload.cap_exceeded_403' +OBJECT_NAME = 'res/cap_exceeded.txt' +ARM_PATH = '/faults' +FAULT = {'on': 'b2_upload_file', 'status': 403, 'code': 'cap_exceeded', 'count': 1} + + +def is_faulted(e): + return e['endpoint'] == 'b2_upload_file' and e['fault'] == 'injected' and e['status'] == 403 + + +# The closed reason set, mirrored from bin/resilience/README.md. This +# scenario's `needs` is `-`, so no-client-option is not available to it. +REASONS = ('missing-runtime',) + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(verdict: str) -> None: + print(f'RESILIENCE {SLUG} {SCENARIO} @simulator: {verdict}') + + +def note(text: str) -> None: + print(f'NOTE {SLUG} {SCENARIO}: {text}') + + +def step(name: str, action): + """Run one step; any error is a FAIL naming the step and exception class. + + There is no unreachable/unauthorized amber here: the runner supplies the + server and the credential, so either failing is a harness defect. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- only the class name is reported + raise Failure(name, type(error).__name__) from error + + +def control(method: str, path: str, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + os.environ['RESILIENCE_CONTROL_URL'] + path, + data=data, + method=method, + headers={'content-type': 'application/json'}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + + +def journal(): + return step('journal', lambda: control('GET', '/journal'))['entries'] + + +def after(entries, seq, endpoint, status=None): + """Entries on `endpoint` later than `seq`, optionally with one status.""" + return [ + e + for e in entries + if e['seq'] > seq + and e['endpoint'] == endpoint + and (status is None or e['status'] == status) + ] + + +def authorized_bucket(**api_kwargs): + realm = os.environ['RESILIENCE_SIMULATOR_URL'] + try: + from b2sdk.v3 import B2Api, InMemoryAccountInfo + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + api = B2Api(InMemoryAccountInfo(), **api_kwargs) + step('authenticate', lambda: api.authorize_account('test-key-id', 'test-key', realm=realm)) + bucket = step( + 'create bucket', + lambda: api.create_bucket(f'sdkharness-res-{uuid.uuid4().hex[:12]}', 'allPrivate'), + ) + return api, bucket + + +def run() -> None: + api, bucket = authorized_bucket() + from b2sdk.v3.exception import B2Error + + payload = (b'sdkharness resilience ' * 64)[:1024] + step('arm fault', lambda: control('POST', ARM_PATH, FAULT)) + try: + bucket.upload_bytes(payload, OBJECT_NAME) + except B2Error as error: + note( + f'the caller received {type(error).__name__}; B2 names the code cap_exceeded, and a ' + 'generic class tells the caller nothing about caps' + ) + except Exception as error: # noqa: BLE001 -- only the class name is reported + raise Failure( + 'upload', f'raised {type(error).__name__}, which is not a b2sdk B2Error' + ) from error + else: + raise Failure('upload', 'the upload succeeded; the cap_exceeded never reached the caller') + + entries = journal() + uploads = [e for e in entries if e['endpoint'] == 'b2_upload_file'] + if len([e for e in uploads if is_faulted(e)]) != 1: + raise Failure('journal', 'the injected 403 on b2_upload_file did not fire exactly once') + if len(uploads) != 1: + raise Failure( + 'recovery path', + f'{len(uploads)} b2_upload_file calls; cap_exceeded must not be retried', + ) + + +def main() -> int: + try: + run() + except Amber as amber: + say(f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(f'FAIL (setup -- {type(error).__name__})') + return 1 + say('PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/resilience/upload.expired_token_401 b/.sdkharness/tests/resilience/upload.expired_token_401 new file mode 100755 index 00000000..38dc1618 --- /dev/null +++ b/.sdkharness/tests/resilience/upload.expired_token_401 @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x upload.expired_token_401. + +Written from the reference check, bin/resilience/b2-sdk-python/upload.retry_503. +The contract is bin/resilience/README.md; the question is the upload.expired_token_401 row of +bin/resilience/scenarios.tsv. + +Fault fault b2_upload_file 401 expired_auth_token (count 1), armed after setup +Assert the recovery path, from GET /journal: exactly one faulted b2_upload_file, + then a successful b2_upload_file on a DIFFERENT uploadUrlId; and the + end state, through the normal API: the file downloads intact + +The expected behaviour is B2's, not the tool's. +b2_upload_file's 401 expired_auth_token: "The auth token used has expired. Call +b2_get_upload_url again to get a new one." +https://www.backblaze.com/apidocs/b2-upload-file +The Integration Checklist, Upload Retry Guidelines: "the developer should call +b2_get_upload_url or b2_get_upload_part_url and re-attempt the upload using the new URL +and auth key pair". +https://www.backblaze.com/docs/cloud-storage-integration-checklist + +It exercises b2sdk from this exact repository checkout; +the API is taken from docs/sdks/b2-sdk-python/card.md. +""" + +import hashlib +import io +import json +import os +import sys +import urllib.request +import uuid + +SLUG = 'b2-sdk-python' +SCENARIO = 'upload.expired_token_401' +OBJECT_NAME = 'res/expired_token_401.txt' +ARM_PATH = '/faults' +FAULT = {'on': 'b2_upload_file', 'status': 401, 'code': 'expired_auth_token', 'count': 1} + + +def is_faulted(e): + return e['endpoint'] == 'b2_upload_file' and e['fault'] == 'injected' and e['status'] == 401 + + +# The closed reason set, mirrored from bin/resilience/README.md. This +# scenario's `needs` is `-`, so no-client-option is not available to it. +REASONS = ('missing-runtime',) + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(verdict: str) -> None: + print(f'RESILIENCE {SLUG} {SCENARIO} @simulator: {verdict}') + + +def note(text: str) -> None: + print(f'NOTE {SLUG} {SCENARIO}: {text}') + + +def step(name: str, action): + """Run one step; any error is a FAIL naming the step and exception class. + + There is no unreachable/unauthorized amber here: the runner supplies the + server and the credential, so either failing is a harness defect. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- only the class name is reported + raise Failure(name, type(error).__name__) from error + + +def control(method: str, path: str, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + os.environ['RESILIENCE_CONTROL_URL'] + path, + data=data, + method=method, + headers={'content-type': 'application/json'}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + + +def journal(): + return step('journal', lambda: control('GET', '/journal'))['entries'] + + +def after(entries, seq, endpoint, status=None): + """Entries on `endpoint` later than `seq`, optionally with one status.""" + return [ + e + for e in entries + if e['seq'] > seq + and e['endpoint'] == endpoint + and (status is None or e['status'] == status) + ] + + +def authorized_bucket(**api_kwargs): + realm = os.environ['RESILIENCE_SIMULATOR_URL'] + try: + from b2sdk.v3 import B2Api, InMemoryAccountInfo + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + api = B2Api(InMemoryAccountInfo(), **api_kwargs) + step('authenticate', lambda: api.authorize_account('test-key-id', 'test-key', realm=realm)) + bucket = step( + 'create bucket', + lambda: api.create_bucket(f'sdkharness-res-{uuid.uuid4().hex[:12]}', 'allPrivate'), + ) + return api, bucket + + +def run() -> None: + api, bucket = authorized_bucket() + payload = (b'sdkharness resilience ' * 64)[:1024] + step('arm fault', lambda: control('POST', ARM_PATH, FAULT)) + try: + uploaded = bucket.upload_bytes(payload, OBJECT_NAME) + except Exception as error: # noqa: BLE001 -- only the class name is reported + tries = len([e for e in journal() if e['endpoint'] == 'b2_upload_file']) + raise Failure( + 'upload', + f'{type(error).__name__} reached the caller after {tries} ' + 'b2_upload_file attempt(s); the upload was not recovered', + ) from error + if hashlib.sha1(payload).hexdigest() != getattr(uploaded, 'content_sha1', None): + raise Failure('upload', 'the returned contentSha1 does not match the bytes sent') + + sink = io.BytesIO() + step('download', lambda: bucket.download_file_by_name(OBJECT_NAME).save(sink)) + if sink.getvalue() != payload: + raise Failure('round trip', 'downloaded bytes differ from what was uploaded') + + entries = journal() + uploads = [e for e in entries if e['endpoint'] == 'b2_upload_file'] + faulted = [e for e in uploads if is_faulted(e)] + if len(faulted) != 1: + raise Failure('journal', f'{len(faulted)} injected 401s on b2_upload_file, expected 1') + retries = after(entries, faulted[0]['seq'], 'b2_upload_file', 200) + if not retries: + raise Failure('journal', 'no successful b2_upload_file after the 401') + if retries[0]['uploadUrlId'] in (None, faulted[0]['uploadUrlId']): + raise Failure('recovery path', 'the retry reused the upload URL that got the 401') + if not after(entries, faulted[0]['seq'], 'b2_get_upload_url', 200): + raise Failure('recovery path', 'no b2_get_upload_url after the 401') + + +def main() -> int: + try: + run() + except Amber as amber: + say(f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(f'FAIL (setup -- {type(error).__name__})') + return 1 + say('PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/resilience/upload.get_url_503 b/.sdkharness/tests/resilience/upload.get_url_503 new file mode 100755 index 00000000..d5068ff0 --- /dev/null +++ b/.sdkharness/tests/resilience/upload.get_url_503 @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x upload.get_url_503. + +Written from the reference check, bin/resilience/b2-sdk-python/upload.retry_503. +The contract is bin/resilience/README.md; the question is the upload.get_url_503 row of +bin/resilience/scenarios.tsv. + +Fault fault b2_get_upload_url 503 service_unavailable (count 1), armed after setup, + so the first attempt to obtain an upload URL gets it +Assert the recovery path, from GET /journal: exactly one injected 503 on + b2_get_upload_url, then a successful b2_get_upload_url, then a successful + b2_upload_file on THAT url's uploadUrlId; and the end state: the file + downloads intact + +The expected behaviour is B2's, not the tool's. +The Integration Checklist, Error Handling, 503 on non-upload APIs: "it may include a +'Retry-After' header ... If the header is not present, the developer should retry +using an exponential backoff starting with 1 second." +https://www.backblaze.com/docs/cloud-storage-integration-checklist + +It exercises b2sdk from this exact repository checkout; +the API is taken from docs/sdks/b2-sdk-python/card.md. +""" + +import hashlib +import io +import json +import os +import sys +import urllib.request +import uuid + +SLUG = 'b2-sdk-python' +SCENARIO = 'upload.get_url_503' +OBJECT_NAME = 'res/get_url_503.txt' +ARM_PATH = '/faults' +FAULT = {'on': 'b2_get_upload_url', 'status': 503, 'code': 'service_unavailable', 'count': 1} + + +def is_faulted(e): + return e['endpoint'] == 'b2_get_upload_url' and e['fault'] == 'injected' and e['status'] == 503 + + +# The closed reason set, mirrored from bin/resilience/README.md. This +# scenario's `needs` is `-`, so no-client-option is not available to it. +REASONS = ('missing-runtime',) + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(verdict: str) -> None: + print(f'RESILIENCE {SLUG} {SCENARIO} @simulator: {verdict}') + + +def note(text: str) -> None: + print(f'NOTE {SLUG} {SCENARIO}: {text}') + + +def step(name: str, action): + """Run one step; any error is a FAIL naming the step and exception class. + + There is no unreachable/unauthorized amber here: the runner supplies the + server and the credential, so either failing is a harness defect. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- only the class name is reported + raise Failure(name, type(error).__name__) from error + + +def control(method: str, path: str, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + os.environ['RESILIENCE_CONTROL_URL'] + path, + data=data, + method=method, + headers={'content-type': 'application/json'}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + + +def journal(): + return step('journal', lambda: control('GET', '/journal'))['entries'] + + +def after(entries, seq, endpoint, status=None): + """Entries on `endpoint` later than `seq`, optionally with one status.""" + return [ + e + for e in entries + if e['seq'] > seq + and e['endpoint'] == endpoint + and (status is None or e['status'] == status) + ] + + +def authorized_bucket(**api_kwargs): + realm = os.environ['RESILIENCE_SIMULATOR_URL'] + try: + from b2sdk.v3 import B2Api, InMemoryAccountInfo + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + api = B2Api(InMemoryAccountInfo(), **api_kwargs) + step('authenticate', lambda: api.authorize_account('test-key-id', 'test-key', realm=realm)) + bucket = step( + 'create bucket', + lambda: api.create_bucket(f'sdkharness-res-{uuid.uuid4().hex[:12]}', 'allPrivate'), + ) + return api, bucket + + +def run() -> None: + api, bucket = authorized_bucket() + payload = (b'sdkharness resilience ' * 64)[:1024] + step('arm fault', lambda: control('POST', ARM_PATH, FAULT)) + uploaded = step('upload', lambda: bucket.upload_bytes(payload, OBJECT_NAME)) + if hashlib.sha1(payload).hexdigest() != getattr(uploaded, 'content_sha1', None): + raise Failure('upload', 'the returned contentSha1 does not match the bytes sent') + + sink = io.BytesIO() + step('download', lambda: bucket.download_file_by_name(OBJECT_NAME).save(sink)) + if sink.getvalue() != payload: + raise Failure('round trip', 'downloaded bytes differ from what was uploaded') + + entries = journal() + faulted = [e for e in entries if is_faulted(e)] + if len(faulted) != 1: + raise Failure('journal', f'{len(faulted)} injected 503s on b2_get_upload_url, expected 1') + minted = after(entries, faulted[0]['seq'], 'b2_get_upload_url', 200) + if not minted: + raise Failure('recovery path', 'b2_get_upload_url was never retried after the 503') + used = after(entries, minted[0]['seq'], 'b2_upload_file', 200) + if not used or used[0]['uploadUrlId'] != minted[0]['uploadUrlId']: + raise Failure( + 'recovery path', 'the upload did not use the URL the retried b2_get_upload_url minted' + ) + + +def main() -> int: + try: + run() + except Amber as amber: + say(f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(f'FAIL (setup -- {type(error).__name__})') + return 1 + say('PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/resilience/upload.reset_before_response b/.sdkharness/tests/resilience/upload.reset_before_response new file mode 100755 index 00000000..7505c5f2 --- /dev/null +++ b/.sdkharness/tests/resilience/upload.reset_before_response @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x upload.reset_before_response. + +Written from the reference check, bin/resilience/b2-sdk-python/upload.retry_503. +The contract is bin/resilience/README.md; the question is the upload.reset_before_response row of +bin/resilience/scenarios.tsv. + +Fault wire b2_upload_file reset-before-response (count 1), armed after setup: the + upload IS dispatched (state changed), then the connection drops with no + response; a second version of the same name is acceptable +Assert the recovery path, from GET /journal: exactly one faulted b2_upload_file, + then a successful b2_upload_file on a DIFFERENT uploadUrlId; and the + end state, through the normal API: the file downloads intact + +The expected behaviour is B2's, not the tool's. +The Integration Checklist, Upload Retry Guidelines, lists the failures after which "the +developer should call b2_get_upload_url or b2_get_upload_part_url and re-attempt the +upload using the new URL and auth key pair"; among them: +"The HTTPS connection is broken. (e.g., broken pipe or connection reset)" +https://www.backblaze.com/docs/cloud-storage-integration-checklist + +It exercises b2sdk from this exact repository checkout; +the API is taken from docs/sdks/b2-sdk-python/card.md. +""" + +import hashlib +import io +import json +import os +import sys +import urllib.request +import uuid + +SLUG = 'b2-sdk-python' +SCENARIO = 'upload.reset_before_response' +OBJECT_NAME = 'res/reset_before_response.txt' +ARM_PATH = '/wire-faults' +FAULT = {'on': 'b2_upload_file', 'kind': 'reset-before-response', 'count': 1} + + +def is_faulted(e): + return e['endpoint'] == 'b2_upload_file' and e['fault'] == 'reset-before-response' + + +# The closed reason set, mirrored from bin/resilience/README.md. This +# scenario's `needs` is `-`, so no-client-option is not available to it. +REASONS = ('missing-runtime',) + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(verdict: str) -> None: + print(f'RESILIENCE {SLUG} {SCENARIO} @simulator: {verdict}') + + +def note(text: str) -> None: + print(f'NOTE {SLUG} {SCENARIO}: {text}') + + +def step(name: str, action): + """Run one step; any error is a FAIL naming the step and exception class. + + There is no unreachable/unauthorized amber here: the runner supplies the + server and the credential, so either failing is a harness defect. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- only the class name is reported + raise Failure(name, type(error).__name__) from error + + +def control(method: str, path: str, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + os.environ['RESILIENCE_CONTROL_URL'] + path, + data=data, + method=method, + headers={'content-type': 'application/json'}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + + +def journal(): + return step('journal', lambda: control('GET', '/journal'))['entries'] + + +def after(entries, seq, endpoint, status=None): + """Entries on `endpoint` later than `seq`, optionally with one status.""" + return [ + e + for e in entries + if e['seq'] > seq + and e['endpoint'] == endpoint + and (status is None or e['status'] == status) + ] + + +def authorized_bucket(**api_kwargs): + realm = os.environ['RESILIENCE_SIMULATOR_URL'] + try: + from b2sdk.v3 import B2Api, InMemoryAccountInfo + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + api = B2Api(InMemoryAccountInfo(), **api_kwargs) + step('authenticate', lambda: api.authorize_account('test-key-id', 'test-key', realm=realm)) + bucket = step( + 'create bucket', + lambda: api.create_bucket(f'sdkharness-res-{uuid.uuid4().hex[:12]}', 'allPrivate'), + ) + return api, bucket + + +def run() -> None: + api, bucket = authorized_bucket() + payload = (b'sdkharness resilience ' * 64)[:1024] + step('arm fault', lambda: control('POST', ARM_PATH, FAULT)) + try: + uploaded = bucket.upload_bytes(payload, OBJECT_NAME) + except Exception as error: # noqa: BLE001 -- only the class name is reported + tries = len([e for e in journal() if e['endpoint'] == 'b2_upload_file']) + raise Failure( + 'upload', + f'{type(error).__name__} reached the caller after {tries} ' + 'b2_upload_file attempt(s); the upload was not recovered', + ) from error + if hashlib.sha1(payload).hexdigest() != getattr(uploaded, 'content_sha1', None): + raise Failure('upload', 'the returned contentSha1 does not match the bytes sent') + + sink = io.BytesIO() + step('download', lambda: bucket.download_file_by_name(OBJECT_NAME).save(sink)) + if sink.getvalue() != payload: + raise Failure('round trip', 'downloaded bytes differ from what was uploaded') + # The first attempt landed before the reset, so one or two versions may + # exist; every one of them must carry exactly the bytes that went up. + versions = step( + 'list versions', lambda: [v for v, _f in bucket.ls('', recursive=True, latest_only=False)] + ) + mine = [v for v in versions if v.file_name == OBJECT_NAME] + if not 1 <= len(mine) <= 2: + raise Failure('end state', f'{len(mine)} versions of the object, expected 1 or 2') + if any(v.content_sha1 != hashlib.sha1(payload).hexdigest() for v in mine): + raise Failure('end state', 'a stored version does not carry the uploaded bytes') + note(f'{len(mine)} version(s) of the object after the reset-before-response') + + entries = journal() + uploads = [e for e in entries if e['endpoint'] == 'b2_upload_file'] + faulted = [e for e in uploads if is_faulted(e)] + if len(faulted) != 1: + raise Failure( + 'journal', f'{len(faulted)} reset-before-response faults on b2_upload_file, expected 1' + ) + retries = after(entries, faulted[0]['seq'], 'b2_upload_file', 200) + if not retries: + raise Failure('journal', 'no successful b2_upload_file after the connection reset') + if retries[0]['uploadUrlId'] in (None, faulted[0]['uploadUrlId']): + raise Failure( + 'recovery path', 'the retry reused the upload URL that got the connection reset' + ) + + +def main() -> int: + try: + run() + except Amber as amber: + say(f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(f'FAIL (setup -- {type(error).__name__})') + return 1 + say('PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/resilience/upload.reset_mid_request b/.sdkharness/tests/resilience/upload.reset_mid_request new file mode 100755 index 00000000..36210e16 --- /dev/null +++ b/.sdkharness/tests/resilience/upload.reset_mid_request @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x upload.reset_mid_request. + +Written from the reference check, bin/resilience/b2-sdk-python/upload.retry_503. +The contract is bin/resilience/README.md; the question is the upload.reset_mid_request row of +bin/resilience/scenarios.tsv. + +Fault wire b2_upload_file reset-mid-request (count 1), armed after setup: the + connection drops after the first request-body chunk, never dispatched +Assert the recovery path, from GET /journal: exactly one faulted b2_upload_file, + then a successful b2_upload_file on a DIFFERENT uploadUrlId; and the + end state, through the normal API: the file downloads intact + +The expected behaviour is B2's, not the tool's. +The Integration Checklist, Upload Retry Guidelines, lists the failures after which "the +developer should call b2_get_upload_url or b2_get_upload_part_url and re-attempt the +upload using the new URL and auth key pair"; among them: +"The HTTPS connection is broken. (e.g., broken pipe or connection reset)" +https://www.backblaze.com/docs/cloud-storage-integration-checklist + +It exercises b2sdk from this exact repository checkout; +the API is taken from docs/sdks/b2-sdk-python/card.md. +""" + +import hashlib +import io +import json +import os +import sys +import urllib.request +import uuid + +SLUG = 'b2-sdk-python' +SCENARIO = 'upload.reset_mid_request' +OBJECT_NAME = 'res/reset_mid_request.txt' +ARM_PATH = '/wire-faults' +FAULT = {'on': 'b2_upload_file', 'kind': 'reset-mid-request', 'count': 1} + + +def is_faulted(e): + return e['endpoint'] == 'b2_upload_file' and e['fault'] == 'reset-mid-request' + + +# The closed reason set, mirrored from bin/resilience/README.md. This +# scenario's `needs` is `-`, so no-client-option is not available to it. +REASONS = ('missing-runtime',) + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(verdict: str) -> None: + print(f'RESILIENCE {SLUG} {SCENARIO} @simulator: {verdict}') + + +def note(text: str) -> None: + print(f'NOTE {SLUG} {SCENARIO}: {text}') + + +def step(name: str, action): + """Run one step; any error is a FAIL naming the step and exception class. + + There is no unreachable/unauthorized amber here: the runner supplies the + server and the credential, so either failing is a harness defect. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- only the class name is reported + raise Failure(name, type(error).__name__) from error + + +def control(method: str, path: str, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + os.environ['RESILIENCE_CONTROL_URL'] + path, + data=data, + method=method, + headers={'content-type': 'application/json'}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + + +def journal(): + return step('journal', lambda: control('GET', '/journal'))['entries'] + + +def after(entries, seq, endpoint, status=None): + """Entries on `endpoint` later than `seq`, optionally with one status.""" + return [ + e + for e in entries + if e['seq'] > seq + and e['endpoint'] == endpoint + and (status is None or e['status'] == status) + ] + + +def authorized_bucket(**api_kwargs): + realm = os.environ['RESILIENCE_SIMULATOR_URL'] + try: + from b2sdk.v3 import B2Api, InMemoryAccountInfo + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + api = B2Api(InMemoryAccountInfo(), **api_kwargs) + step('authenticate', lambda: api.authorize_account('test-key-id', 'test-key', realm=realm)) + bucket = step( + 'create bucket', + lambda: api.create_bucket(f'sdkharness-res-{uuid.uuid4().hex[:12]}', 'allPrivate'), + ) + return api, bucket + + +def run() -> None: + api, bucket = authorized_bucket() + payload = (b'sdkharness resilience ' * 64)[:1024] + step('arm fault', lambda: control('POST', ARM_PATH, FAULT)) + try: + uploaded = bucket.upload_bytes(payload, OBJECT_NAME) + except Exception as error: # noqa: BLE001 -- only the class name is reported + tries = len([e for e in journal() if e['endpoint'] == 'b2_upload_file']) + raise Failure( + 'upload', + f'{type(error).__name__} reached the caller after {tries} ' + 'b2_upload_file attempt(s); the upload was not recovered', + ) from error + if hashlib.sha1(payload).hexdigest() != getattr(uploaded, 'content_sha1', None): + raise Failure('upload', 'the returned contentSha1 does not match the bytes sent') + + sink = io.BytesIO() + step('download', lambda: bucket.download_file_by_name(OBJECT_NAME).save(sink)) + if sink.getvalue() != payload: + raise Failure('round trip', 'downloaded bytes differ from what was uploaded') + + entries = journal() + uploads = [e for e in entries if e['endpoint'] == 'b2_upload_file'] + faulted = [e for e in uploads if is_faulted(e)] + if len(faulted) != 1: + raise Failure( + 'journal', f'{len(faulted)} reset-mid-request faults on b2_upload_file, expected 1' + ) + retries = after(entries, faulted[0]['seq'], 'b2_upload_file', 200) + if not retries: + raise Failure('journal', 'no successful b2_upload_file after the connection reset') + if retries[0]['uploadUrlId'] in (None, faulted[0]['uploadUrlId']): + raise Failure( + 'recovery path', 'the retry reused the upload URL that got the connection reset' + ) + + +def main() -> int: + try: + run() + except Amber as amber: + say(f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(f'FAIL (setup -- {type(error).__name__})') + return 1 + say('PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/resilience/upload.retry_408 b/.sdkharness/tests/resilience/upload.retry_408 new file mode 100755 index 00000000..3a9e5c71 --- /dev/null +++ b/.sdkharness/tests/resilience/upload.retry_408 @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x upload.retry_408. + +Written from the reference check, bin/resilience/b2-sdk-python/upload.retry_503. +The contract is bin/resilience/README.md; the question is the upload.retry_408 row of +bin/resilience/scenarios.tsv. + +Fault fault b2_upload_file 408 request_timeout (count 1), armed after setup +Assert the recovery path, from GET /journal: exactly one faulted b2_upload_file, + then a successful b2_upload_file on a DIFFERENT uploadUrlId; and the + end state, through the normal API: the file downloads intact + +The expected behaviour is B2's, not the tool's. +b2_upload_file's 408 request_timeout: "The service timed out reading the uploaded file". +https://www.backblaze.com/apidocs/b2-upload-file +The Integration Checklist, Upload Retry Guidelines, lists the failures after which "the +developer should call b2_get_upload_url or b2_get_upload_part_url and re-attempt the +upload using the new URL and auth key pair"; among them: +"The HTTP response status is 408 (Request Timeout)." +https://www.backblaze.com/docs/cloud-storage-integration-checklist + +It exercises b2sdk from this exact repository checkout; +the API is taken from docs/sdks/b2-sdk-python/card.md. +""" + +import hashlib +import io +import json +import os +import sys +import urllib.request +import uuid + +SLUG = 'b2-sdk-python' +SCENARIO = 'upload.retry_408' +OBJECT_NAME = 'res/retry_408.txt' +ARM_PATH = '/faults' +FAULT = {'on': 'b2_upload_file', 'status': 408, 'code': 'request_timeout', 'count': 1} + + +def is_faulted(e): + return e['endpoint'] == 'b2_upload_file' and e['fault'] == 'injected' and e['status'] == 408 + + +# The closed reason set, mirrored from bin/resilience/README.md. This +# scenario's `needs` is `-`, so no-client-option is not available to it. +REASONS = ('missing-runtime',) + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(verdict: str) -> None: + print(f'RESILIENCE {SLUG} {SCENARIO} @simulator: {verdict}') + + +def note(text: str) -> None: + print(f'NOTE {SLUG} {SCENARIO}: {text}') + + +def step(name: str, action): + """Run one step; any error is a FAIL naming the step and exception class. + + There is no unreachable/unauthorized amber here: the runner supplies the + server and the credential, so either failing is a harness defect. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- only the class name is reported + raise Failure(name, type(error).__name__) from error + + +def control(method: str, path: str, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + os.environ['RESILIENCE_CONTROL_URL'] + path, + data=data, + method=method, + headers={'content-type': 'application/json'}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + + +def journal(): + return step('journal', lambda: control('GET', '/journal'))['entries'] + + +def after(entries, seq, endpoint, status=None): + """Entries on `endpoint` later than `seq`, optionally with one status.""" + return [ + e + for e in entries + if e['seq'] > seq + and e['endpoint'] == endpoint + and (status is None or e['status'] == status) + ] + + +def authorized_bucket(**api_kwargs): + realm = os.environ['RESILIENCE_SIMULATOR_URL'] + try: + from b2sdk.v3 import B2Api, InMemoryAccountInfo + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + api = B2Api(InMemoryAccountInfo(), **api_kwargs) + step('authenticate', lambda: api.authorize_account('test-key-id', 'test-key', realm=realm)) + bucket = step( + 'create bucket', + lambda: api.create_bucket(f'sdkharness-res-{uuid.uuid4().hex[:12]}', 'allPrivate'), + ) + return api, bucket + + +def run() -> None: + api, bucket = authorized_bucket() + payload = (b'sdkharness resilience ' * 64)[:1024] + step('arm fault', lambda: control('POST', ARM_PATH, FAULT)) + try: + uploaded = bucket.upload_bytes(payload, OBJECT_NAME) + except Exception as error: # noqa: BLE001 -- only the class name is reported + tries = len([e for e in journal() if e['endpoint'] == 'b2_upload_file']) + raise Failure( + 'upload', + f'{type(error).__name__} reached the caller after {tries} ' + 'b2_upload_file attempt(s); the upload was not recovered', + ) from error + if hashlib.sha1(payload).hexdigest() != getattr(uploaded, 'content_sha1', None): + raise Failure('upload', 'the returned contentSha1 does not match the bytes sent') + + sink = io.BytesIO() + step('download', lambda: bucket.download_file_by_name(OBJECT_NAME).save(sink)) + if sink.getvalue() != payload: + raise Failure('round trip', 'downloaded bytes differ from what was uploaded') + + entries = journal() + uploads = [e for e in entries if e['endpoint'] == 'b2_upload_file'] + faulted = [e for e in uploads if is_faulted(e)] + if len(faulted) != 1: + raise Failure('journal', f'{len(faulted)} injected 408s on b2_upload_file, expected 1') + retries = after(entries, faulted[0]['seq'], 'b2_upload_file', 200) + if not retries: + raise Failure('journal', 'no successful b2_upload_file after the 408') + if retries[0]['uploadUrlId'] in (None, faulted[0]['uploadUrlId']): + raise Failure('recovery path', 'the retry reused the upload URL that got the 408') + + +def main() -> int: + try: + run() + except Amber as amber: + say(f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(f'FAIL (setup -- {type(error).__name__})') + return 1 + say('PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/resilience/upload.retry_500 b/.sdkharness/tests/resilience/upload.retry_500 new file mode 100755 index 00000000..7af24478 --- /dev/null +++ b/.sdkharness/tests/resilience/upload.retry_500 @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x upload.retry_500. + +Written from the reference check, bin/resilience/b2-sdk-python/upload.retry_503. +The contract is bin/resilience/README.md; the question is the upload.retry_500 row of +bin/resilience/scenarios.tsv. + +Fault fault b2_upload_file 500 internal_error (count 1), armed after setup; asserted + on the 500 STATUS only -- the code string is not doc-sourced (PROVENANCE.md) +Assert the recovery path, from GET /journal: exactly one faulted b2_upload_file, + then a successful b2_upload_file on a DIFFERENT uploadUrlId; and the + end state, through the normal API: the file downloads intact + +The expected behaviour is B2's, not the tool's. +The Integration Checklist, Upload Retry Guidelines, lists the failures after which "the +developer should call b2_get_upload_url or b2_get_upload_part_url and re-attempt the +upload using the new URL and auth key pair"; among them: +"The HTTP response status is between 500-599, including 503 Service Unavailable." +https://www.backblaze.com/docs/cloud-storage-integration-checklist + +It exercises b2sdk from this exact repository checkout; +the API is taken from docs/sdks/b2-sdk-python/card.md. +""" + +import hashlib +import io +import json +import os +import sys +import urllib.request +import uuid + +SLUG = 'b2-sdk-python' +SCENARIO = 'upload.retry_500' +OBJECT_NAME = 'res/retry_500.txt' +ARM_PATH = '/faults' +FAULT = {'on': 'b2_upload_file', 'status': 500, 'code': 'internal_error', 'count': 1} + + +def is_faulted(e): + return e['endpoint'] == 'b2_upload_file' and e['fault'] == 'injected' and e['status'] == 500 + + +# The closed reason set, mirrored from bin/resilience/README.md. This +# scenario's `needs` is `-`, so no-client-option is not available to it. +REASONS = ('missing-runtime',) + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(verdict: str) -> None: + print(f'RESILIENCE {SLUG} {SCENARIO} @simulator: {verdict}') + + +def note(text: str) -> None: + print(f'NOTE {SLUG} {SCENARIO}: {text}') + + +def step(name: str, action): + """Run one step; any error is a FAIL naming the step and exception class. + + There is no unreachable/unauthorized amber here: the runner supplies the + server and the credential, so either failing is a harness defect. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- only the class name is reported + raise Failure(name, type(error).__name__) from error + + +def control(method: str, path: str, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + os.environ['RESILIENCE_CONTROL_URL'] + path, + data=data, + method=method, + headers={'content-type': 'application/json'}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + + +def journal(): + return step('journal', lambda: control('GET', '/journal'))['entries'] + + +def after(entries, seq, endpoint, status=None): + """Entries on `endpoint` later than `seq`, optionally with one status.""" + return [ + e + for e in entries + if e['seq'] > seq + and e['endpoint'] == endpoint + and (status is None or e['status'] == status) + ] + + +def authorized_bucket(**api_kwargs): + realm = os.environ['RESILIENCE_SIMULATOR_URL'] + try: + from b2sdk.v3 import B2Api, InMemoryAccountInfo + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + api = B2Api(InMemoryAccountInfo(), **api_kwargs) + step('authenticate', lambda: api.authorize_account('test-key-id', 'test-key', realm=realm)) + bucket = step( + 'create bucket', + lambda: api.create_bucket(f'sdkharness-res-{uuid.uuid4().hex[:12]}', 'allPrivate'), + ) + return api, bucket + + +def run() -> None: + api, bucket = authorized_bucket() + payload = (b'sdkharness resilience ' * 64)[:1024] + step('arm fault', lambda: control('POST', ARM_PATH, FAULT)) + try: + uploaded = bucket.upload_bytes(payload, OBJECT_NAME) + except Exception as error: # noqa: BLE001 -- only the class name is reported + tries = len([e for e in journal() if e['endpoint'] == 'b2_upload_file']) + raise Failure( + 'upload', + f'{type(error).__name__} reached the caller after {tries} ' + 'b2_upload_file attempt(s); the upload was not recovered', + ) from error + if hashlib.sha1(payload).hexdigest() != getattr(uploaded, 'content_sha1', None): + raise Failure('upload', 'the returned contentSha1 does not match the bytes sent') + + sink = io.BytesIO() + step('download', lambda: bucket.download_file_by_name(OBJECT_NAME).save(sink)) + if sink.getvalue() != payload: + raise Failure('round trip', 'downloaded bytes differ from what was uploaded') + + entries = journal() + uploads = [e for e in entries if e['endpoint'] == 'b2_upload_file'] + faulted = [e for e in uploads if is_faulted(e)] + if len(faulted) != 1: + raise Failure('journal', f'{len(faulted)} injected 500s on b2_upload_file, expected 1') + retries = after(entries, faulted[0]['seq'], 'b2_upload_file', 200) + if not retries: + raise Failure('journal', 'no successful b2_upload_file after the 500') + if retries[0]['uploadUrlId'] in (None, faulted[0]['uploadUrlId']): + raise Failure('recovery path', 'the retry reused the upload URL that got the 500') + + +def main() -> int: + try: + run() + except Amber as amber: + say(f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(f'FAIL (setup -- {type(error).__name__})') + return 1 + say('PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/resilience/upload.retry_503 b/.sdkharness/tests/resilience/upload.retry_503 new file mode 100755 index 00000000..c6ed4332 --- /dev/null +++ b/.sdkharness/tests/resilience/upload.retry_503 @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""THE REFERENCE CHECK -- b2-sdk-python x upload.retry_503. + +Every other resilience check is written from this one, the way +bin/conformance/b2-sdk-python/files.upload is for conformance. The contract is +bin/resilience/README.md; the question is the upload.retry_503 row of +bin/resilience/scenarios.tsv. + +Fault fault b2_upload_file 503 service_unavailable (count 1), armed after + setup, so the first upload attempt is the one that gets it +Assert the recovery path, from GET /journal: exactly one injected 503 on + b2_upload_file, then a successful b2_upload_file on a DIFFERENT + uploadUrlId; and the end state, through the normal API: the file + downloads with the bytes that went up + +The expected behaviour is B2's, not the tool's. b2_upload_file's 503 +service_unavailable: "Call b2_get_upload_url again to get a new auth token." +https://www.backblaze.com/apidocs/b2-upload-file +The Integration Checklist, Uploading Files: if "The HTTP response status is +between 500-599, including 503 Service Unavailable", "the developer should call +b2_get_upload_url or b2_get_upload_part_url and re-attempt the upload using the +new URL and auth key pair". +https://www.backblaze.com/docs/cloud-storage-integration-checklist + +It exercises b2sdk from this exact repository checkout; +the API is taken from docs/sdks/b2-sdk-python/card.md. +""" + +import hashlib +import io +import json +import os +import sys +import urllib.request +import uuid + +SLUG = 'b2-sdk-python' +SCENARIO = 'upload.retry_503' +OBJECT_NAME = 'res/retry_503.txt' +FAULT = {'on': 'b2_upload_file', 'status': 503, 'code': 'service_unavailable', 'count': 1} + +# The closed reason set, mirrored from bin/resilience/README.md. This +# scenario's `needs` is `-`, so no-client-option is not available to it. +REASONS = ('missing-runtime',) + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(verdict: str) -> None: + print(f'RESILIENCE {SLUG} {SCENARIO} @simulator: {verdict}') + + +def step(name: str, action): + """Run one step; any error is a FAIL naming the step and exception class. + + There is no unreachable/unauthorized amber here: the runner supplies the + server and the credential, so either failing is a harness defect. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- only the class name is reported + raise Failure(name, type(error).__name__) from error + + +def control(method: str, path: str, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + os.environ['RESILIENCE_CONTROL_URL'] + path, + data=data, + method=method, + headers={'content-type': 'application/json'}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + + +def run() -> None: + realm = os.environ['RESILIENCE_SIMULATOR_URL'] + try: + from b2sdk.v3 import B2Api, InMemoryAccountInfo + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + + api = B2Api(InMemoryAccountInfo()) + step('authenticate', lambda: api.authorize_account('test-key-id', 'test-key', realm=realm)) + bucket = step( + 'create bucket', + lambda: api.create_bucket(f'sdkharness-res-{uuid.uuid4().hex[:12]}', 'allPrivate'), + ) + + payload = (b'sdkharness resilience ' * 64)[:1024] + step('arm fault', lambda: control('POST', '/faults', FAULT)) + uploaded = step('upload', lambda: bucket.upload_bytes(payload, OBJECT_NAME)) + if hashlib.sha1(payload).hexdigest() != getattr(uploaded, 'content_sha1', None): + raise Failure('upload', 'the returned contentSha1 does not match the bytes sent') + + sink = io.BytesIO() + step('download', lambda: bucket.download_file_by_name(OBJECT_NAME).save(sink)) + if sink.getvalue() != payload: + raise Failure('round trip', 'downloaded bytes differ from what was uploaded') + + uploads = [ + e + for e in step('journal', lambda: control('GET', '/journal'))['entries'] + if e['endpoint'] == 'b2_upload_file' + ] + faulted = [e for e in uploads if e['fault'] == 'injected' and e['status'] == 503] + if len(faulted) != 1: + raise Failure('journal', f'{len(faulted)} injected 503s on b2_upload_file, expected 1') + retries = [e for e in uploads if e['seq'] > faulted[0]['seq'] and e['status'] == 200] + if not retries: + raise Failure('journal', 'no successful b2_upload_file after the 503') + if retries[0]['uploadUrlId'] in (None, faulted[0]['uploadUrlId']): + raise Failure('recovery path', 'the retry reused the upload URL that returned 503') + + +def main() -> int: + try: + run() + except Amber as amber: + say(f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(f'FAIL (setup -- {type(error).__name__})') + return 1 + say('PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/resilience/upload.stall b/.sdkharness/tests/resilience/upload.stall new file mode 100755 index 00000000..a29f8ff7 --- /dev/null +++ b/.sdkharness/tests/resilience/upload.stall @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""b2-sdk-python x upload.stall. + +Written from the reference check, bin/resilience/b2-sdk-python/upload.retry_503. +The contract is bin/resilience/README.md; the question is the upload.stall row of +bin/resilience/scenarios.tsv. + +Fault wire b2_upload_file stall ms=60000 -- NOT ARMED. The scenario needs a client + request timeout short enough to beat a 60 s stall (`needs: request-timeout`) +Assert nothing: docs/sdks/b2-sdk-python/card.md cites no request-timeout option, so + the check reports no-client-option instead of guessing at one + +The expected behaviour is B2's, not the tool's. +The Integration Checklist, Upload Retry Guidelines, lists the failures after which "the +developer should call b2_get_upload_url or b2_get_upload_part_url and re-attempt the +upload using the new URL and auth key pair"; among them: +"The HTTPS connection times out waiting for a response (socket timeout)." +https://www.backblaze.com/docs/cloud-storage-integration-checklist + +It exercises b2sdk from this exact repository checkout; +the API is taken from docs/sdks/b2-sdk-python/card.md. +""" + +import json +import os +import sys +import urllib.request +import uuid + +SLUG = 'b2-sdk-python' +SCENARIO = 'upload.stall' +OBJECT_NAME = 'res/stall.txt' +ARM_PATH = '/wire-faults' +FAULT = {'on': 'b2_upload_file', 'kind': 'stall', 'ms': 60000, 'count': 1} + + +def is_faulted(e): + return e['endpoint'] == 'b2_upload_file' and e['fault'] == 'stall' + + +# The closed reason set, mirrored from bin/resilience/README.md. This +# scenario's `needs` is `request-timeout`, so no-client-option is available to it. +REASONS = ('missing-runtime', 'no-client-option') + + +class Amber(Exception): + """The question could not be asked. Reason must be in REASONS.""" + + def __init__(self, reason: str, detail: str) -> None: + assert reason in REASONS, reason + self.reason = reason + self.detail = detail + + +class Failure(Exception): + def __init__(self, step: str, detail: str) -> None: + self.step = step + self.detail = detail + + +def say(verdict: str) -> None: + print(f'RESILIENCE {SLUG} {SCENARIO} @simulator: {verdict}') + + +def note(text: str) -> None: + print(f'NOTE {SLUG} {SCENARIO}: {text}') + + +def step(name: str, action): + """Run one step; any error is a FAIL naming the step and exception class. + + There is no unreachable/unauthorized amber here: the runner supplies the + server and the credential, so either failing is a harness defect. + """ + try: + return action() + except (Amber, Failure): + raise + except Exception as error: # noqa: BLE001 -- only the class name is reported + raise Failure(name, type(error).__name__) from error + + +def control(method: str, path: str, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + os.environ['RESILIENCE_CONTROL_URL'] + path, + data=data, + method=method, + headers={'content-type': 'application/json'}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + + +def journal(): + return step('journal', lambda: control('GET', '/journal'))['entries'] + + +def after(entries, seq, endpoint, status=None): + """Entries on `endpoint` later than `seq`, optionally with one status.""" + return [ + e + for e in entries + if e['seq'] > seq + and e['endpoint'] == endpoint + and (status is None or e['status'] == status) + ] + + +def authorized_bucket(**api_kwargs): + realm = os.environ['RESILIENCE_SIMULATOR_URL'] + try: + from b2sdk.v3 import B2Api, InMemoryAccountInfo + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + api = B2Api(InMemoryAccountInfo(), **api_kwargs) + step('authenticate', lambda: api.authorize_account('test-key-id', 'test-key', realm=realm)) + bucket = step( + 'create bucket', + lambda: api.create_bucket(f'sdkharness-res-{uuid.uuid4().hex[:12]}', 'allPrivate'), + ) + return api, bucket + + +# What the card was searched for, and why it does not settle the precondition. +# Only sdk-inspector reads the clone, so this check never looks past the card. +UNSETTLED = ( + "docs/sdks/b2-sdk-python/card.md cites no request-timeout option: B2HttpApiConfig's " + 'cited parameters are http_session_factory and _raw_api_class, and no citation names ' + 'the timeout B2Http passes to requests or a way to shorten it' +) + + +def run() -> None: + try: + import b2sdk.v3 # noqa: F401 -- only presence is asked + except ImportError as error: + raise Amber('missing-runtime', 'b2sdk is not installed') from error + note( + 'unconfirmed, not absent: an http_session_factory returning a Session that forces a ' + 'short timeout MIGHT meet the need, but the card marks session-level behaviour as ' + 'inferred; an sdk-inspector re-inspection asking what timeout b2http.py passes to ' + 'Session.request, and whether it is configurable, would settle it' + ) + raise Amber('no-client-option', UNSETTLED) + + +def main() -> int: + try: + run() + except Amber as amber: + say(f'COULD-NOT-RUN ({amber.reason} -- {amber.detail})') + return 0 + except Failure as failure: + say(f'FAIL ({failure.step} -- {failure.detail})') + return 1 + except Exception as error: # noqa: BLE001 -- never a silent crash + say(f'FAIL (setup -- {type(error).__name__})') + return 1 + say('PASS') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.sdkharness/tests/run-conformance b/.sdkharness/tests/run-conformance new file mode 100755 index 00000000..041630a8 --- /dev/null +++ b/.sdkharness/tests/run-conformance @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +"""Run one repository-owned sdkharness conformance scenario.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent / 'lib')) +from contract import main # noqa: E402 + +sys.exit(main('conformance')) diff --git a/.sdkharness/tests/run-resilience b/.sdkharness/tests/run-resilience new file mode 100755 index 00000000..3990dc21 --- /dev/null +++ b/.sdkharness/tests/run-resilience @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +"""Run one repository-owned sdkharness resilience scenario.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent / 'lib')) +from contract import main # noqa: E402 + +sys.exit(main('resilience')) diff --git a/changelog.d/612.infrastructure.md b/changelog.d/612.infrastructure.md new file mode 100644 index 00000000..69746558 --- /dev/null +++ b/changelog.d/612.infrastructure.md @@ -0,0 +1 @@ +Moved sdkharness conformance and resilience assertions into this repository so they execute against the exact b2sdk checkout. diff --git a/test/unit/test_sdkharness_conformance_resilience_contract.py b/test/unit/test_sdkharness_conformance_resilience_contract.py new file mode 100644 index 00000000..d3f7d1d1 --- /dev/null +++ b/test/unit/test_sdkharness_conformance_resilience_contract.py @@ -0,0 +1,108 @@ +###################################################################### +# +# File: test/unit/test_sdkharness_conformance_resilience_contract.py +# +# Copyright 2026 Backblaze Inc. All Rights Reserved. +# +# License https://www.backblaze.com/using_b2_code.html +# +###################################################################### +from __future__ import annotations + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +import pytest + +MODULE = Path(__file__).parents[2] / '.sdkharness' / 'tests' / 'lib' / 'contract.py' + + +def load_contract(): + spec = spec_from_file_location('sdkharness_contract', MODULE) + module = module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def environment(level='conformance', scenario='files.upload'): + result = { + 'SDKHARNESS_TEST_LEVEL': level, + 'SDKHARNESS_SCENARIO': scenario, + 'SDKHARNESS_SIMULATOR_URL': 'http://127.0.0.1:8180', + } + if level == 'resilience': + result['SDKHARNESS_SIMULATOR_CONTROL_URL'] = 'http://127.0.0.1:8181' + return result + + +@pytest.mark.parametrize( + 'url', + [ + 'https://127.0.0.1:8180/', + 'http://localhost:8180/', + 'http://127.0.0.1/', + 'http://user@127.0.0.1:8180/', + 'http://127.0.0.1:8180/not-root', + 'http://127.0.0.2:8180/', + ], +) +def test_rejects_unsafe_simulator_origins(url): + contract = load_contract() + values = environment() + values['SDKHARNESS_SIMULATOR_URL'] = url + with pytest.raises(contract.ContractFailure, match='configuration'): + contract.validate_environment('conformance', values) + + +def test_requires_matching_level_and_safe_scenario(): + contract = load_contract() + with pytest.raises(contract.ContractFailure, match='unexpected test level'): + contract.validate_environment('resilience', environment()) + with pytest.raises(contract.ContractFailure, match='invalid scenario'): + contract.validate_environment('conformance', environment(scenario='../escape')) + + +def test_translates_pass_fail_skip_and_rejects_ambiguous_output(capsys): + contract = load_contract() + prefix = 'CONFORMANCE b2-sdk-python files.upload @simulator: ' + assert contract.translate('conformance', 'files.upload', 0, prefix + 'PASS\n') == ( + 'PASS', + '-', + 0, + ) + assert contract.translate( + 'conformance', 'files.upload', 1, prefix + 'FAIL (upload -- AssertionError)\n' + ) == ('FAIL', 'upload -- AssertionError', 1) + assert contract.translate( + 'conformance', + 'files.upload', + 0, + prefix + 'COULD-NOT-RUN (missing-runtime -- unavailable)\n', + ) == ('SKIP', 'missing-runtime -- unavailable', 0) + assert contract.translate( + 'conformance', 'files.upload', 0, prefix + 'PASS\n' + prefix + 'PASS\n' + ) == ('FAIL', 'contract: expected one standing result, got 2', 1) + assert capsys.readouterr().out == '' + + +def test_result_is_exactly_five_fields_and_sanitizes_reason(capsys): + contract = load_contract() + contract.result('resilience', 'upload.retry_503', 'FAIL', 'step\tsecret\nnext') + assert capsys.readouterr().out == ( + 'SDKHARNESS_RESULT\tresilience\tupload.retry_503\tFAIL\tstep secret next\n' + ) + + +def test_child_environment_scrubs_real_b2_credentials_and_pins_checkout(): + contract = load_contract() + values = environment() + values['B2_APPLICATION_KEY_ID'] = 'real-key-id' + values['B2_APPLICATION_KEY'] = 'real-secret' + values['B2_OPERATOR_EXTRA'] = 'must-not-pass' + + child = contract.child_environment('conformance', values) + + assert child['B2_APPLICATION_KEY_ID'] == 'test-key-id' + assert child['B2_APPLICATION_KEY'] == 'test-key' + assert 'B2_OPERATOR_EXTRA' not in child + assert child['PYTHONPATH'] == str(contract.REPOSITORY_ROOT)