From fe8d1122ae8dc38a971343a850f90b2efa91bed1 Mon Sep 17 00:00:00 2001 From: scarreras Date: Thu, 1 Oct 2026 01:22:15 -0300 Subject: [PATCH 1/2] sdkharness: leaf checks refuse to run outside a loopback simulator The 33 conformance and 16 resilience leaf checks are executable on their own. Conformance leaves defaulted CONFORMANCE_TARGET to staging, mapped staging and production realms, and fell back to ambient B2_APPLICATION_KEY_ID/KEY, so a direct run could mutate an external account. Each leaf now calls a shared guard first: only the simulator target, only http://127.0.0.1: (and https for the TLS URL, and the loopback control URL for resilience), and only the fixed test credential. client.simulator no longer carries unused realm/ambient credential helpers. Adds unit tests (54 fail on the previous leaves) and a README section for running one check locally. --- .sdkharness/README.md | 54 ++++++++++++++- .sdkharness/tests/conformance/bucket.cors | 13 ++-- .sdkharness/tests/conformance/bucket.crud | 13 ++-- .../tests/conformance/bucket.lifecycle | 13 ++-- .../conformance/bucket.notification_rules | 13 ++-- .../conformance/bucket.replication_config | 13 ++-- .../conformance/bucket.replication_helper | 13 ++-- .../tests/conformance/client.auth_persistence | 13 ++-- .sdkharness/tests/conformance/client.progress | 13 ++-- .../tests/conformance/client.simulator | 49 +++----------- .sdkharness/tests/conformance/client.sync | 13 ++-- .sdkharness/tests/conformance/enc.sse_b2 | 13 ++-- .sdkharness/tests/conformance/enc.sse_c | 13 ++-- .../tests/conformance/files.delete_version | 13 ++-- .../tests/conformance/files.download_by_id | 13 ++-- .../tests/conformance/files.download_content | 13 ++-- .sdkharness/tests/conformance/files.hide | 13 ++-- .sdkharness/tests/conformance/files.list | 13 ++-- .sdkharness/tests/conformance/files.metadata | 13 ++-- .../tests/conformance/files.server_side_copy | 13 ++-- .sdkharness/tests/conformance/files.upload | 19 +++--- .sdkharness/tests/conformance/keys.crud | 13 ++-- .../tests/conformance/keys.multi_bucket | 13 ++-- .../tests/conformance/large.compose_mixed | 13 ++-- .../tests/conformance/large.concurrent_parts | 13 ++-- .sdkharness/tests/conformance/large.multipart | 13 ++-- .../tests/conformance/large.parallel_download | 13 ++-- .sdkharness/tests/conformance/large.resume | 13 ++-- .../conformance/large.unbound_incremental | 13 ++-- .../tests/conformance/lock.bucket_default | 13 ++-- .../tests/conformance/lock.bypass_governance | 13 ++-- .sdkharness/tests/conformance/lock.legal_hold | 13 ++-- .../tests/conformance/lock.per_file_retention | 13 ++-- .../tests/conformance/urls.native_download | 13 ++-- .sdkharness/tests/lib/loopback_guard.py | 55 ++++++++++++++++ .sdkharness/tests/resilience/api.backoff_503 | 7 ++ .../tests/resilience/api.retry_after_429 | 7 ++ .../tests/resilience/api.retry_after_503 | 7 ++ .../tests/resilience/auth.clock_expiry | 7 ++ .sdkharness/tests/resilience/auth.expired_401 | 7 ++ .../tests/resilience/download.retry_503 | 7 ++ .sdkharness/tests/resilience/part.retry_503 | 7 ++ .../tests/resilience/upload.cap_exceeded_403 | 7 ++ .../tests/resilience/upload.expired_token_401 | 7 ++ .../tests/resilience/upload.get_url_503 | 7 ++ .../resilience/upload.reset_before_response | 7 ++ .../tests/resilience/upload.reset_mid_request | 7 ++ .sdkharness/tests/resilience/upload.retry_408 | 7 ++ .sdkharness/tests/resilience/upload.retry_500 | 7 ++ .sdkharness/tests/resilience/upload.retry_503 | 7 ++ .sdkharness/tests/resilience/upload.stall | 7 ++ ...dkharness-loopback-guard.infrastructure.md | 1 + ...harness_conformance_resilience_contract.py | 66 +++++++++++++++++++ 53 files changed, 584 insertions(+), 175 deletions(-) create mode 100644 .sdkharness/tests/lib/loopback_guard.py create mode 100644 changelog.d/+sdkharness-loopback-guard.infrastructure.md diff --git a/.sdkharness/README.md b/.sdkharness/README.md index 7f0a0bbf..1291a6cc 100644 --- a/.sdkharness/README.md +++ b/.sdkharness/README.md @@ -15,7 +15,11 @@ SDKHARNESS_RESULT health golden-path PASS - 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. +checkout and never use a production B2 endpoint or real credentials. The +individual conformance and resilience check files refuse to run at all unless +they are given a loopback simulator URL (they use only the fixed simulator +credential `test-key-id` / `test-key`), so run them through the dispatchers +below, never with real `B2_*` values in the environment. Conformance owns 33 capability checks and resilience owns 16 injected-fault checks. Their small dispatchers validate the invocation, run the selected @@ -23,3 +27,51 @@ 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. + +## Run one check locally + +Nothing here touches B2. You need Python 3.10+ and Node 22+ (for the simulator). + +```bash +# 1. This checkout, in a virtualenv (the checks import b2sdk from here) +python -m venv .venv && . .venv/bin/activate && pip install -e . + +# 2. A local simulator (any one of these; it needs access to backblaze-labs/b2-simulator) +git clone https://github.com/backblaze-labs/b2-simulator /tmp/b2-simulator +node /tmp/b2-simulator/bin/simulator/serve.mjs --control > /tmp/sim.out & # prints the URLs +# ...or use the simulator embedded in the harness: sdkharness/bin/simulator/serve.mjs + +# 3. Read the URLs it printed +export SDKHARNESS_SIMULATOR_URL=$(sed -n 's/^SIMULATOR-LISTENING \(http:.*\)/\1/p' /tmp/sim.out) +export SDKHARNESS_SIMULATOR_HTTPS_URL=$(sed -n 's/^SIMULATOR-LISTENING \(https:.*\)/\1/p' /tmp/sim.out) +export SDKHARNESS_SIMULATOR_CONTROL_URL=$(sed -n 's/^SIMULATOR-CONTROL \(.*\)/\1/p' /tmp/sim.out) +export SDKHARNESS_SIMULATOR_CA=/tmp/b2-simulator/bin/simulator/loopback-cert.pem +``` + +The standalone simulator also exports the same values as `B2SIM_URL`, +`B2SIM_HTTPS_URL`, `B2SIM_CONTROL_URL` and `B2SIM_CA` (its `bin/lib/simulator.sh` +helper); the checks read the `SDKHARNESS_SIMULATOR_*` names above. + +```bash +# conformance (one capability) +SDKHARNESS_TEST_LEVEL=conformance SDKHARNESS_SCENARIO=files.upload .sdkharness/tests/run-conformance + +# resilience (one injected fault; needs the control URL, i.e. serve.mjs --control) +SDKHARNESS_TEST_LEVEL=resilience SDKHARNESS_SCENARIO=api.backoff_503 .sdkharness/tests/run-resilience + +# customer health (needs a bucket in the simulator first) +python - <<'PY' +import os +from b2sdk.v3 import B2Api, InMemoryAccountInfo +api = B2Api(InMemoryAccountInfo()) +api.authorize_account('test-key-id', 'test-key', realm=os.environ['SDKHARNESS_SIMULATOR_URL']) +api.create_bucket('sdkharness-healthcheck', 'allPrivate') +PY +HEALTHCHECK_REALM_URL=$SDKHARNESS_SIMULATOR_URL B2_TEST_APPLICATION_KEY_ID=test-key-id \ + B2_TEST_APPLICATION_KEY=test-key B2_BUCKET_NAME=sdkharness-healthcheck \ + .sdkharness/tests/health-golden-path +``` + +Each prints one `SDKHARNESS_RESULT` line. Scenario ids are in `tests.tsv`. +A `FAIL` for `api.retry_after_503`, `upload.retry_408` is a known SDK finding, not a setup problem. + diff --git a/.sdkharness/tests/conformance/bucket.cors b/.sdkharness/tests/conformance/bucket.cors index 6ad31881..d370b27d 100755 --- a/.sdkharness/tests/conformance/bucket.cors +++ b/.sdkharness/tests/conformance/bucket.cors @@ -37,6 +37,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'bucket.cors' RULE = { @@ -63,8 +66,6 @@ REASONS = ( # 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, } @@ -78,7 +79,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -299,7 +300,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/bucket.crud b/.sdkharness/tests/conformance/bucket.crud index 99283003..de5cd114 100755 --- a/.sdkharness/tests/conformance/bucket.crud +++ b/.sdkharness/tests/conformance/bucket.crud @@ -30,6 +30,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'bucket.crud' @@ -48,8 +51,6 @@ REASONS = ( # 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, } @@ -63,7 +64,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -283,7 +284,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/bucket.lifecycle b/.sdkharness/tests/conformance/bucket.lifecycle index d3b47f0a..a8246e43 100755 --- a/.sdkharness/tests/conformance/bucket.lifecycle +++ b/.sdkharness/tests/conformance/bucket.lifecycle @@ -28,6 +28,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'bucket.lifecycle' RULE = {'fileNamePrefix': 'st/', 'daysFromHidingToDeleting': 1} @@ -48,8 +51,6 @@ REASONS = ( # 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, } @@ -63,7 +64,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -271,7 +272,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/bucket.notification_rules b/.sdkharness/tests/conformance/bucket.notification_rules index cbefb378..62f69093 100755 --- a/.sdkharness/tests/conformance/bucket.notification_rules +++ b/.sdkharness/tests/conformance/bucket.notification_rules @@ -28,6 +28,9 @@ import string import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'bucket.notification_rules' RULE_NAME = 'sdkharness-conf' @@ -49,8 +52,6 @@ REASONS = ( # 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, } @@ -64,7 +65,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -291,7 +292,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/bucket.replication_config b/.sdkharness/tests/conformance/bucket.replication_config index 9164397d..5d5d95c8 100755 --- a/.sdkharness/tests/conformance/bucket.replication_config +++ b/.sdkharness/tests/conformance/bucket.replication_config @@ -28,6 +28,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'bucket.replication_config' RULE_NAME = 'sdkharness-conf' @@ -60,8 +63,6 @@ REASONS = ( # 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, } @@ -75,7 +76,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -319,7 +320,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/bucket.replication_helper b/.sdkharness/tests/conformance/bucket.replication_helper index 7b984022..34483db0 100755 --- a/.sdkharness/tests/conformance/bucket.replication_helper +++ b/.sdkharness/tests/conformance/bucket.replication_helper @@ -33,6 +33,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'bucket.replication_helper' RULE_NAME = 'sdkharness-conf' @@ -55,8 +58,6 @@ REASONS = ( # 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, } @@ -70,7 +71,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -335,7 +336,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/client.auth_persistence b/.sdkharness/tests/conformance/client.auth_persistence index 7ce8c08f..46fb5b40 100755 --- a/.sdkharness/tests/conformance/client.auth_persistence +++ b/.sdkharness/tests/conformance/client.auth_persistence @@ -34,6 +34,9 @@ import sys import tempfile import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'client.auth_persistence' @@ -52,8 +55,6 @@ REASONS = ( # 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, } @@ -67,7 +68,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -334,7 +335,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/client.progress b/.sdkharness/tests/conformance/client.progress index 830a11fc..8c39d335 100755 --- a/.sdkharness/tests/conformance/client.progress +++ b/.sdkharness/tests/conformance/client.progress @@ -29,6 +29,9 @@ import sys import threading import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'client.progress' OBJECT_NAME = 'st/progress.bin' @@ -49,8 +52,6 @@ REASONS = ( # 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, } @@ -64,7 +65,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -308,7 +309,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/client.simulator b/.sdkharness/tests/conformance/client.simulator index 17a5959a..1936bfa1 100755 --- a/.sdkharness/tests/conformance/client.simulator +++ b/.sdkharness/tests/conformance/client.simulator @@ -32,6 +32,9 @@ import socket import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'client.simulator' OBJECT_NAME = 'st/simulator.txt' @@ -48,10 +51,8 @@ REASONS = ( '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'} +# This check never uses a realm or a credential: it drives the RawSimulator that +# ships inside b2sdk, in-process, with the network disabled. class Amber(Exception): @@ -128,42 +129,6 @@ def payload_of(size: int) -> bytes: 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]}' @@ -281,6 +246,10 @@ def run(target: str) -> None: def main() -> int: target = 'simulator' + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/client.sync b/.sdkharness/tests/conformance/client.sync index d5ba4269..126049e9 100755 --- a/.sdkharness/tests/conformance/client.sync +++ b/.sdkharness/tests/conformance/client.sync @@ -40,6 +40,9 @@ import tempfile import time import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'client.sync' REMOTE_PREFIX = 'st/sync' @@ -60,8 +63,6 @@ REASONS = ( # 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, } @@ -75,7 +76,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -357,7 +358,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/enc.sse_b2 b/.sdkharness/tests/conformance/enc.sse_b2 index 5f2a10fa..8450614a 100755 --- a/.sdkharness/tests/conformance/enc.sse_b2 +++ b/.sdkharness/tests/conformance/enc.sse_b2 @@ -36,6 +36,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'enc.sse_b2' OBJECT_NAME = 'st/sse-b2.txt' @@ -56,8 +59,6 @@ REASONS = ( # 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, } @@ -71,7 +72,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -284,7 +285,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/enc.sse_c b/.sdkharness/tests/conformance/enc.sse_c index 11a15409..faf10bf3 100755 --- a/.sdkharness/tests/conformance/enc.sse_c +++ b/.sdkharness/tests/conformance/enc.sse_c @@ -54,6 +54,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'enc.sse_c' OBJECT_NAME = 'st/sse-c.txt' @@ -75,8 +78,6 @@ REASONS = ( # 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, } @@ -90,7 +91,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -355,7 +356,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/files.delete_version b/.sdkharness/tests/conformance/files.delete_version index f9147a9d..17a9f536 100755 --- a/.sdkharness/tests/conformance/files.delete_version +++ b/.sdkharness/tests/conformance/files.delete_version @@ -27,6 +27,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'files.delete_version' PREFIX = 'st/' @@ -48,8 +51,6 @@ REASONS = ( # 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, } @@ -63,7 +64,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -270,7 +271,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/files.download_by_id b/.sdkharness/tests/conformance/files.download_by_id index 67b0aed9..ddc2ab60 100755 --- a/.sdkharness/tests/conformance/files.download_by_id +++ b/.sdkharness/tests/conformance/files.download_by_id @@ -27,6 +27,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'files.download_by_id' OBJECT_NAME = 'st/by-id.txt' @@ -47,8 +50,6 @@ REASONS = ( # 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, } @@ -62,7 +63,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -269,7 +270,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/files.download_content b/.sdkharness/tests/conformance/files.download_content index e77b7acd..eb98001a 100755 --- a/.sdkharness/tests/conformance/files.download_content +++ b/.sdkharness/tests/conformance/files.download_content @@ -27,6 +27,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'files.download_content' OBJECT_NAME = 'st/download.txt' @@ -47,8 +50,6 @@ REASONS = ( # 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, } @@ -62,7 +63,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -268,7 +269,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/files.hide b/.sdkharness/tests/conformance/files.hide index 542ded10..171cf366 100755 --- a/.sdkharness/tests/conformance/files.hide +++ b/.sdkharness/tests/conformance/files.hide @@ -33,6 +33,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'files.hide' PREFIX = 'st/' @@ -54,8 +57,6 @@ REASONS = ( # 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, } @@ -69,7 +70,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -294,7 +295,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/files.list b/.sdkharness/tests/conformance/files.list index 0fa637fe..2bcb8a32 100755 --- a/.sdkharness/tests/conformance/files.list +++ b/.sdkharness/tests/conformance/files.list @@ -29,6 +29,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'files.list' PREFIX = 'st/list/' @@ -51,8 +54,6 @@ REASONS = ( # 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, } @@ -66,7 +67,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -286,7 +287,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/files.metadata b/.sdkharness/tests/conformance/files.metadata index 5ac29460..050e00f7 100755 --- a/.sdkharness/tests/conformance/files.metadata +++ b/.sdkharness/tests/conformance/files.metadata @@ -33,6 +33,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'files.metadata' OBJECT_NAME = 'st/metadata.txt' @@ -56,8 +59,6 @@ REASONS = ( # 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, } @@ -71,7 +72,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -305,7 +306,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/files.server_side_copy b/.sdkharness/tests/conformance/files.server_side_copy index 48618d59..8e9d1509 100755 --- a/.sdkharness/tests/conformance/files.server_side_copy +++ b/.sdkharness/tests/conformance/files.server_side_copy @@ -28,6 +28,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'files.server_side_copy' SOURCE_NAME = 'st/copy-source.bin' @@ -54,8 +57,6 @@ REASONS = ( # 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, } @@ -69,7 +70,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -288,7 +289,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/files.upload b/.sdkharness/tests/conformance/files.upload index c6691e2a..574451bc 100755 --- a/.sdkharness/tests/conformance/files.upload +++ b/.sdkharness/tests/conformance/files.upload @@ -33,6 +33,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'files.upload' OBJECT_NAME = 'st/upload.txt' @@ -53,8 +56,6 @@ REASONS = ( # 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, } @@ -114,13 +115,7 @@ def run(target: str) -> None: ) 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') + key_id, key = SIM_CREDENTIAL try: from b2sdk.v3 import B2Api, InMemoryAccountInfo @@ -174,7 +169,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/keys.crud b/.sdkharness/tests/conformance/keys.crud index fd5299d9..69a510d9 100755 --- a/.sdkharness/tests/conformance/keys.crud +++ b/.sdkharness/tests/conformance/keys.crud @@ -30,6 +30,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'keys.crud' CAPABILITIES = ['listBuckets', 'listFiles', 'readFiles'] @@ -49,8 +52,6 @@ REASONS = ( # 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, } @@ -64,7 +65,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -280,7 +281,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/keys.multi_bucket b/.sdkharness/tests/conformance/keys.multi_bucket index 988d4018..fe34963c 100755 --- a/.sdkharness/tests/conformance/keys.multi_bucket +++ b/.sdkharness/tests/conformance/keys.multi_bucket @@ -29,6 +29,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'keys.multi_bucket' CAPABILITIES = ['listBuckets', 'listFiles', 'readFiles'] @@ -48,8 +51,6 @@ REASONS = ( # 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, } @@ -63,7 +64,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -309,7 +310,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/large.compose_mixed b/.sdkharness/tests/conformance/large.compose_mixed index cbd9eae9..e09fb88d 100755 --- a/.sdkharness/tests/conformance/large.compose_mixed +++ b/.sdkharness/tests/conformance/large.compose_mixed @@ -35,6 +35,9 @@ import sys import tempfile import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'large.compose_mixed' LOCAL_SOURCE = 'st/compose-local-source.bin' @@ -57,8 +60,6 @@ REASONS = ( # 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, } @@ -72,7 +73,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -318,7 +319,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/large.concurrent_parts b/.sdkharness/tests/conformance/large.concurrent_parts index 6151f73a..b0bce90d 100755 --- a/.sdkharness/tests/conformance/large.concurrent_parts +++ b/.sdkharness/tests/conformance/large.concurrent_parts @@ -38,6 +38,9 @@ import threading import time import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'large.concurrent_parts' OBJECT_SERIAL = 'st/concurrent-serial.bin' @@ -61,8 +64,6 @@ REASONS = ( # 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, } @@ -76,7 +77,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -354,7 +355,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/large.multipart b/.sdkharness/tests/conformance/large.multipart index 253dd948..2ee9bf3e 100755 --- a/.sdkharness/tests/conformance/large.multipart +++ b/.sdkharness/tests/conformance/large.multipart @@ -36,6 +36,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'large.multipart' OBJECT_NAME = 'st/multipart.bin' @@ -56,8 +59,6 @@ REASONS = ( # 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, } @@ -71,7 +72,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -342,7 +343,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/large.parallel_download b/.sdkharness/tests/conformance/large.parallel_download index 554050d7..1df48e88 100755 --- a/.sdkharness/tests/conformance/large.parallel_download +++ b/.sdkharness/tests/conformance/large.parallel_download @@ -43,6 +43,9 @@ import threading import time import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'large.parallel_download' OBJECT_NAME = 'st/parallel-download.bin' @@ -65,8 +68,6 @@ REASONS = ( # 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, } @@ -80,7 +81,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -361,7 +362,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/large.resume b/.sdkharness/tests/conformance/large.resume index aaba4f4b..3ea0559e 100755 --- a/.sdkharness/tests/conformance/large.resume +++ b/.sdkharness/tests/conformance/large.resume @@ -39,6 +39,9 @@ import sys import tempfile import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'large.resume' OBJECT_NAME = 'st/resume.bin' @@ -60,8 +63,6 @@ REASONS = ( # 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, } @@ -75,7 +76,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -415,7 +416,11 @@ def _caused_by_interruption(error) -> bool: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/large.unbound_incremental b/.sdkharness/tests/conformance/large.unbound_incremental index 30abbfd7..32fcaa5a 100755 --- a/.sdkharness/tests/conformance/large.unbound_incremental +++ b/.sdkharness/tests/conformance/large.unbound_incremental @@ -46,6 +46,9 @@ import sys import tempfile import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'large.unbound_incremental' UNBOUND_NAME = 'st/unbound.bin' @@ -68,8 +71,6 @@ REASONS = ( # 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, } @@ -83,7 +84,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -414,7 +415,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/lock.bucket_default b/.sdkharness/tests/conformance/lock.bucket_default index 8c6743d1..facb2590 100755 --- a/.sdkharness/tests/conformance/lock.bucket_default +++ b/.sdkharness/tests/conformance/lock.bucket_default @@ -29,6 +29,9 @@ import sys import time import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'lock.bucket_default' OBJECT_NAME = 'st/default-retention.txt' @@ -51,8 +54,6 @@ REASONS = ( # 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, } @@ -66,7 +67,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -293,7 +294,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/lock.bypass_governance b/.sdkharness/tests/conformance/lock.bypass_governance index d386ee37..257777a9 100755 --- a/.sdkharness/tests/conformance/lock.bypass_governance +++ b/.sdkharness/tests/conformance/lock.bypass_governance @@ -29,6 +29,9 @@ import sys import time import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'lock.bypass_governance' OBJECT_NAME = 'st/bypass-governance.txt' @@ -50,8 +53,6 @@ REASONS = ( # 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, } @@ -65,7 +66,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -290,7 +291,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/lock.legal_hold b/.sdkharness/tests/conformance/lock.legal_hold index 7f179e8c..af39e7fc 100755 --- a/.sdkharness/tests/conformance/lock.legal_hold +++ b/.sdkharness/tests/conformance/lock.legal_hold @@ -28,6 +28,9 @@ import os import sys import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'lock.legal_hold' OBJECT_NAME = 'st/legal-hold.txt' @@ -48,8 +51,6 @@ REASONS = ( # 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, } @@ -63,7 +64,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -296,7 +297,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/lock.per_file_retention b/.sdkharness/tests/conformance/lock.per_file_retention index fa973ff1..d310bbd6 100755 --- a/.sdkharness/tests/conformance/lock.per_file_retention +++ b/.sdkharness/tests/conformance/lock.per_file_retention @@ -31,6 +31,9 @@ import sys import time import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'lock.per_file_retention' OBJECT_NAME = 'st/per-file-retention.txt' @@ -52,8 +55,6 @@ REASONS = ( # 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, } @@ -67,7 +68,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -283,7 +284,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/conformance/urls.native_download b/.sdkharness/tests/conformance/urls.native_download index 8cfd373c..282d2a02 100755 --- a/.sdkharness/tests/conformance/urls.native_download +++ b/.sdkharness/tests/conformance/urls.native_download @@ -40,6 +40,9 @@ import urllib.error import urllib.request import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' CAPABILITY = 'urls.native_download' PREFIX = 'st/' @@ -63,8 +66,6 @@ REASONS = ( # 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, } @@ -78,7 +79,7 @@ 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') + raise AssertionError('conformance checks use only the fixed simulator credential') class Amber(Exception): @@ -327,7 +328,11 @@ def run(target: str) -> None: def main() -> int: - target = os.environ.get('CONFORMANCE_TARGET', 'staging') + target = os.environ.get('CONFORMANCE_TARGET', 'simulator') + refused = refusal('conformance') + if refused: + say(target, f'FAIL ({refused})') + return 1 try: run(target) except Amber as amber: diff --git a/.sdkharness/tests/lib/loopback_guard.py b/.sdkharness/tests/lib/loopback_guard.py new file mode 100644 index 00000000..2bd38bab --- /dev/null +++ b/.sdkharness/tests/lib/loopback_guard.py @@ -0,0 +1,55 @@ +"""Refuse to run a repository-owned leaf check outside a loopback simulator. + +The dispatchers (`run-conformance`, `run-resilience`) already validate their +invocation. The leaf checks are executable on their own, so each one calls +`refusal()` first: a leaf that is run directly can only ever talk to a loopback +simulator with the fixed test credential, never to staging or production with +ambient `B2_*` credentials. +""" + +from __future__ import annotations + +import os +from urllib.parse import urlsplit + + +def _is_loopback_origin(value: str, scheme: str) -> bool: + try: + parsed = urlsplit(value) + port = parsed.port + except ValueError: + return False + return ( + parsed.scheme == scheme + and parsed.hostname == '127.0.0.1' + and port is not None + and parsed.username is None + and parsed.password is None + and parsed.path in {'', '/'} + and not parsed.query + and not parsed.fragment + ) + + +def refusal(level: str) -> str | None: + """The reason this leaf must not run here, or None when it is safe to run. + + `level` is `conformance` or `resilience`. The environment is the one the + dispatcher builds: `_TARGET`, `_SIMULATOR_URL`, optionally + `_SIMULATOR_HTTPS_URL`, and `RESILIENCE_CONTROL_URL` for resilience. + """ + prefix = level.upper() + target = os.environ.get(f'{prefix}_TARGET', 'simulator') + if target != 'simulator': + return f'configuration -- only the simulator target is supported, got {target!r}' + hint = 'run it through .sdkharness/tests/run-' + level + ' with a loopback simulator' + if not _is_loopback_origin(os.environ.get(f'{prefix}_SIMULATOR_URL', ''), 'http'): + return f'configuration -- {prefix}_SIMULATOR_URL must be http://127.0.0.1:; {hint}' + https_url = os.environ.get(f'{prefix}_SIMULATOR_HTTPS_URL', '') + if https_url and not _is_loopback_origin(https_url, 'https'): + return f'configuration -- {prefix}_SIMULATOR_HTTPS_URL must be https://127.0.0.1:' + if level == 'resilience' and not _is_loopback_origin( + os.environ.get('RESILIENCE_CONTROL_URL', ''), 'http' + ): + return 'configuration -- RESILIENCE_CONTROL_URL must be http://127.0.0.1:' + return None diff --git a/.sdkharness/tests/resilience/api.backoff_503 b/.sdkharness/tests/resilience/api.backoff_503 index 40fc0e4e..680ae01f 100755 --- a/.sdkharness/tests/resilience/api.backoff_503 +++ b/.sdkharness/tests/resilience/api.backoff_503 @@ -28,6 +28,9 @@ import time import urllib.request import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' SCENARIO = 'api.backoff_503' OBJECT_NAME = 'res/backoff_503.txt' @@ -155,6 +158,10 @@ def run() -> None: def main() -> int: + refused = refusal('resilience') + if refused: + say(f'FAIL ({refused})') + return 1 try: run() except Amber as amber: diff --git a/.sdkharness/tests/resilience/api.retry_after_429 b/.sdkharness/tests/resilience/api.retry_after_429 index 612bad3e..2bad7d67 100755 --- a/.sdkharness/tests/resilience/api.retry_after_429 +++ b/.sdkharness/tests/resilience/api.retry_after_429 @@ -27,6 +27,9 @@ import time import urllib.request import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' SCENARIO = 'api.retry_after_429' OBJECT_NAME = 'res/retry_after_429.txt' @@ -159,6 +162,10 @@ def run() -> None: def main() -> int: + refused = refusal('resilience') + if refused: + say(f'FAIL ({refused})') + return 1 try: run() except Amber as amber: diff --git a/.sdkharness/tests/resilience/api.retry_after_503 b/.sdkharness/tests/resilience/api.retry_after_503 index fd1c9cc3..79e5037b 100755 --- a/.sdkharness/tests/resilience/api.retry_after_503 +++ b/.sdkharness/tests/resilience/api.retry_after_503 @@ -28,6 +28,9 @@ import time import urllib.request import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' SCENARIO = 'api.retry_after_503' OBJECT_NAME = 'res/retry_after_503.txt' @@ -160,6 +163,10 @@ def run() -> None: def main() -> int: + refused = refusal('resilience') + if refused: + say(f'FAIL ({refused})') + return 1 try: run() except Amber as amber: diff --git a/.sdkharness/tests/resilience/auth.clock_expiry b/.sdkharness/tests/resilience/auth.clock_expiry index fc25ec10..542035af 100755 --- a/.sdkharness/tests/resilience/auth.clock_expiry +++ b/.sdkharness/tests/resilience/auth.clock_expiry @@ -34,6 +34,9 @@ import sys import urllib.request import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' SCENARIO = 'auth.clock_expiry' OBJECT_NAME = 'res/clock_expiry.txt' @@ -160,6 +163,10 @@ def run() -> None: def main() -> int: + refused = refusal('resilience') + if refused: + say(f'FAIL ({refused})') + return 1 try: run() except Amber as amber: diff --git a/.sdkharness/tests/resilience/auth.expired_401 b/.sdkharness/tests/resilience/auth.expired_401 index 08470d54..a3af2919 100755 --- a/.sdkharness/tests/resilience/auth.expired_401 +++ b/.sdkharness/tests/resilience/auth.expired_401 @@ -29,6 +29,9 @@ import sys import urllib.request import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' SCENARIO = 'auth.expired_401' OBJECT_NAME = 'res/expired_401.txt' @@ -145,6 +148,10 @@ def run() -> None: def main() -> int: + refused = refusal('resilience') + if refused: + say(f'FAIL ({refused})') + return 1 try: run() except Amber as amber: diff --git a/.sdkharness/tests/resilience/download.retry_503 b/.sdkharness/tests/resilience/download.retry_503 index ca51ccb0..eac3982a 100755 --- a/.sdkharness/tests/resilience/download.retry_503 +++ b/.sdkharness/tests/resilience/download.retry_503 @@ -28,6 +28,9 @@ import sys import urllib.request import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' SCENARIO = 'download.retry_503' OBJECT_NAME = 'res/download_retry_503.bin' @@ -148,6 +151,10 @@ def run() -> None: def main() -> int: + refused = refusal('resilience') + if refused: + say(f'FAIL ({refused})') + return 1 try: run() except Amber as amber: diff --git a/.sdkharness/tests/resilience/part.retry_503 b/.sdkharness/tests/resilience/part.retry_503 index 855180c3..d8cdbe47 100755 --- a/.sdkharness/tests/resilience/part.retry_503 +++ b/.sdkharness/tests/resilience/part.retry_503 @@ -33,6 +33,9 @@ import sys import urllib.request import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' SCENARIO = 'part.retry_503' OBJECT_NAME = 'res/part_retry_503.bin' @@ -182,6 +185,10 @@ def run() -> None: def main() -> int: + refused = refusal('resilience') + if refused: + say(f'FAIL ({refused})') + return 1 try: run() except Amber as amber: diff --git a/.sdkharness/tests/resilience/upload.cap_exceeded_403 b/.sdkharness/tests/resilience/upload.cap_exceeded_403 index b7b62dce..2f0dd353 100755 --- a/.sdkharness/tests/resilience/upload.cap_exceeded_403 +++ b/.sdkharness/tests/resilience/upload.cap_exceeded_403 @@ -27,6 +27,9 @@ import sys import urllib.request import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' SCENARIO = 'upload.cap_exceeded_403' OBJECT_NAME = 'res/cap_exceeded.txt' @@ -154,6 +157,10 @@ def run() -> None: def main() -> int: + refused = refusal('resilience') + if refused: + say(f'FAIL ({refused})') + return 1 try: run() except Amber as amber: diff --git a/.sdkharness/tests/resilience/upload.expired_token_401 b/.sdkharness/tests/resilience/upload.expired_token_401 index 38dc1618..a5dc9bb7 100755 --- a/.sdkharness/tests/resilience/upload.expired_token_401 +++ b/.sdkharness/tests/resilience/upload.expired_token_401 @@ -31,6 +31,9 @@ import sys import urllib.request import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' SCENARIO = 'upload.expired_token_401' OBJECT_NAME = 'res/expired_token_401.txt' @@ -162,6 +165,10 @@ def run() -> None: def main() -> int: + refused = refusal('resilience') + if refused: + say(f'FAIL ({refused})') + return 1 try: run() except Amber as amber: diff --git a/.sdkharness/tests/resilience/upload.get_url_503 b/.sdkharness/tests/resilience/upload.get_url_503 index d5068ff0..abebbb3b 100755 --- a/.sdkharness/tests/resilience/upload.get_url_503 +++ b/.sdkharness/tests/resilience/upload.get_url_503 @@ -30,6 +30,9 @@ import sys import urllib.request import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' SCENARIO = 'upload.get_url_503' OBJECT_NAME = 'res/get_url_503.txt' @@ -153,6 +156,10 @@ def run() -> None: def main() -> int: + refused = refusal('resilience') + if refused: + say(f'FAIL ({refused})') + return 1 try: run() except Amber as amber: diff --git a/.sdkharness/tests/resilience/upload.reset_before_response b/.sdkharness/tests/resilience/upload.reset_before_response index 7505c5f2..1903e153 100755 --- a/.sdkharness/tests/resilience/upload.reset_before_response +++ b/.sdkharness/tests/resilience/upload.reset_before_response @@ -31,6 +31,9 @@ import sys import urllib.request import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' SCENARIO = 'upload.reset_before_response' OBJECT_NAME = 'res/reset_before_response.txt' @@ -175,6 +178,10 @@ def run() -> None: def main() -> int: + refused = refusal('resilience') + if refused: + say(f'FAIL ({refused})') + return 1 try: run() except Amber as amber: diff --git a/.sdkharness/tests/resilience/upload.reset_mid_request b/.sdkharness/tests/resilience/upload.reset_mid_request index 36210e16..a4e11642 100755 --- a/.sdkharness/tests/resilience/upload.reset_mid_request +++ b/.sdkharness/tests/resilience/upload.reset_mid_request @@ -30,6 +30,9 @@ import sys import urllib.request import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' SCENARIO = 'upload.reset_mid_request' OBJECT_NAME = 'res/reset_mid_request.txt' @@ -163,6 +166,10 @@ def run() -> None: def main() -> int: + refused = refusal('resilience') + if refused: + say(f'FAIL ({refused})') + return 1 try: run() except Amber as amber: diff --git a/.sdkharness/tests/resilience/upload.retry_408 b/.sdkharness/tests/resilience/upload.retry_408 index 3a9e5c71..2399a255 100755 --- a/.sdkharness/tests/resilience/upload.retry_408 +++ b/.sdkharness/tests/resilience/upload.retry_408 @@ -31,6 +31,9 @@ import sys import urllib.request import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' SCENARIO = 'upload.retry_408' OBJECT_NAME = 'res/retry_408.txt' @@ -160,6 +163,10 @@ def run() -> None: def main() -> int: + refused = refusal('resilience') + if refused: + say(f'FAIL ({refused})') + return 1 try: run() except Amber as amber: diff --git a/.sdkharness/tests/resilience/upload.retry_500 b/.sdkharness/tests/resilience/upload.retry_500 index 7af24478..84a476a7 100755 --- a/.sdkharness/tests/resilience/upload.retry_500 +++ b/.sdkharness/tests/resilience/upload.retry_500 @@ -30,6 +30,9 @@ import sys import urllib.request import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' SCENARIO = 'upload.retry_500' OBJECT_NAME = 'res/retry_500.txt' @@ -159,6 +162,10 @@ def run() -> None: def main() -> int: + refused = refusal('resilience') + if refused: + say(f'FAIL ({refused})') + return 1 try: run() except Amber as amber: diff --git a/.sdkharness/tests/resilience/upload.retry_503 b/.sdkharness/tests/resilience/upload.retry_503 index c6ed4332..65708c51 100755 --- a/.sdkharness/tests/resilience/upload.retry_503 +++ b/.sdkharness/tests/resilience/upload.retry_503 @@ -34,6 +34,9 @@ import sys import urllib.request import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' SCENARIO = 'upload.retry_503' OBJECT_NAME = 'res/retry_503.txt' @@ -130,6 +133,10 @@ def run() -> None: def main() -> int: + refused = refusal('resilience') + if refused: + say(f'FAIL ({refused})') + return 1 try: run() except Amber as amber: diff --git a/.sdkharness/tests/resilience/upload.stall b/.sdkharness/tests/resilience/upload.stall index a29f8ff7..7c1949a5 100755 --- a/.sdkharness/tests/resilience/upload.stall +++ b/.sdkharness/tests/resilience/upload.stall @@ -27,6 +27,9 @@ import sys import urllib.request import uuid +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lib')) +from loopback_guard import refusal # noqa: E402 + SLUG = 'b2-sdk-python' SCENARIO = 'upload.stall' OBJECT_NAME = 'res/stall.txt' @@ -146,6 +149,10 @@ def run() -> None: def main() -> int: + refused = refusal('resilience') + if refused: + say(f'FAIL ({refused})') + return 1 try: run() except Amber as amber: diff --git a/changelog.d/+sdkharness-loopback-guard.infrastructure.md b/changelog.d/+sdkharness-loopback-guard.infrastructure.md new file mode 100644 index 00000000..074913df --- /dev/null +++ b/changelog.d/+sdkharness-loopback-guard.infrastructure.md @@ -0,0 +1 @@ +Repository-owned sdkharness conformance and resilience leaf checks now refuse to run unless they are given a loopback simulator URL, and always use the fixed simulator credential, so running one directly can no longer reach staging or production with ambient `B2_*` credentials. diff --git a/test/unit/test_sdkharness_conformance_resilience_contract.py b/test/unit/test_sdkharness_conformance_resilience_contract.py index d3f7d1d1..e133e44f 100644 --- a/test/unit/test_sdkharness_conformance_resilience_contract.py +++ b/test/unit/test_sdkharness_conformance_resilience_contract.py @@ -106,3 +106,69 @@ def test_child_environment_scrubs_real_b2_credentials_and_pins_checkout(): assert child['B2_APPLICATION_KEY'] == 'test-key' assert 'B2_OPERATOR_EXTRA' not in child assert child['PYTHONPATH'] == str(contract.REPOSITORY_ROOT) + + +LEAF_ROOT = Path(__file__).parents[2] / '.sdkharness' / 'tests' +LEAVES = sorted( + path for level in ('conformance', 'resilience') for path in (LEAF_ROOT / level).iterdir() +) + + +def run_leaf(path, extra_environment): + import os + import subprocess + import sys + + env = { + name: value + for name, value in os.environ.items() + if not name.startswith(('B2_', 'CONFORMANCE_', 'RESILIENCE_', 'SDKHARNESS_')) + } + env.update(extra_environment) + return subprocess.run( + [sys.executable, str(path)], + env=env, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + +@pytest.mark.parametrize('leaf', LEAVES, ids=lambda path: f'{path.parent.name}/{path.name}') +def test_leaf_checks_refuse_ambient_staging_runs(leaf): + level = leaf.parent.name + prefix = level.upper() + ambient = { + 'B2_APPLICATION_KEY_ID': 'real-looking-key-id', + 'B2_APPLICATION_KEY': 'real-looking-key', + f'{prefix}_TARGET': 'staging', + } + refused = run_leaf(leaf, ambient) + assert refused.returncode == 1 + assert 'FAIL (configuration' in refused.stdout + assert 'real-looking' not in refused.stdout + refused.stderr + + +@pytest.mark.parametrize('leaf', LEAVES[:2] + LEAVES[-2:], ids=lambda path: path.name) +def test_leaf_checks_refuse_missing_or_non_loopback_simulator(leaf): + prefix = leaf.parent.name.upper() + for url in ('', 'http://192.0.2.10:8180', 'http://localhost:8180', 'https://127.0.0.1:8180'): + values = {f'{prefix}_SIMULATOR_URL': url} + if prefix == 'RESILIENCE': + values['RESILIENCE_CONTROL_URL'] = 'http://127.0.0.1:8181' + refused = run_leaf(leaf, values) + assert refused.returncode == 1, url + assert 'FAIL (configuration' in refused.stdout, url + + +def test_leaf_checks_name_no_external_realm_or_ambient_credentials(): + for leaf in LEAVES: + text = leaf.read_text() + assert "'staging'" not in text, leaf.name + # client.simulator names a realm only for the in-process RawSimulator (no network) + assert leaf.name == 'client.simulator' or "'production'" not in text, leaf.name + assert "os.environ.get('B2_APPLICATION_KEY" not in text, leaf.name + assert "os.environ['B2_APPLICATION_KEY" not in text, leaf.name + assert 'refusal(' in text, leaf.name From e6782724aa0c2d68279a1fc4f3b72dc9d72c47e8 Mon Sep 17 00:00:00 2001 From: scarreras Date: Thu, 1 Oct 2026 05:11:21 -0300 Subject: [PATCH 2/2] sdkharness: health leaf accepts only the fixed simulator credential and IPv4 loopback; readiness wait in README Review follow-up for the PR: the health golden path read any non-empty B2_TEST_APPLICATION_KEY_* pair and passed it to the SDK; a real key could be disclosed to a loopback listener. It now refuses anything but the fixed simulator pair before the SDK is touched, accepts only 127.0.0.1 (like the other leaves), and the README waits for the simulator to be ready. Co-Authored-By: Claude Sonnet 5.5 --- .sdkharness/README.md | 26 ++++++++++++++++++-------- .sdkharness/tests/health-golden-path | 10 ++++++++-- test/unit/test_sdkharness_contract.py | 27 +++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/.sdkharness/README.md b/.sdkharness/README.md index 1291a6cc..0bfe3de1 100644 --- a/.sdkharness/README.md +++ b/.sdkharness/README.md @@ -14,12 +14,14 @@ SDKHARNESS_RESULT health golden-path PASS - ``` 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. The -individual conformance and resilience check files refuse to run at all unless -they are given a loopback simulator URL (they use only the fixed simulator -credential `test-key-id` / `test-key`), so run them through the dispatchers -below, never with real `B2_*` values in the environment. +literal IPv4 loopback HTTP simulator URLs (`http://127.0.0.1:`; `localhost`, +`::1` and DNS names are refused). They import `b2sdk` from this exact checkout +and never use a production B2 endpoint or real credentials: every check uses +only the fixed simulator credential `test-key-id` / `test-key`, and the health +check refuses any other `B2_TEST_APPLICATION_KEY*` pair before it reaches the +SDK. The individual conformance and resilience check files refuse to run at all +unless they are given a loopback simulator URL, so run them through the +dispatchers below, never with real `B2_*` values in the environment. Conformance owns 33 capability checks and resilience owns 16 injected-fault checks. Their small dispatchers validate the invocation, run the selected @@ -38,10 +40,16 @@ python -m venv .venv && . .venv/bin/activate && pip install -e . # 2. A local simulator (any one of these; it needs access to backblaze-labs/b2-simulator) git clone https://github.com/backblaze-labs/b2-simulator /tmp/b2-simulator -node /tmp/b2-simulator/bin/simulator/serve.mjs --control > /tmp/sim.out & # prints the URLs +node /tmp/b2-simulator/bin/simulator/serve.mjs --control > /tmp/sim.out 2>&1 & # prints the URLs +SIM_PID=$! # ...or use the simulator embedded in the harness: sdkharness/bin/simulator/serve.mjs -# 3. Read the URLs it printed +# 3. Wait until it has printed all three listener lines (http, https, control), then read them +for _ in $(seq 100); do + [ "$(grep -c '^SIMULATOR-' /tmp/sim.out)" -ge 3 ] && break + kill -0 "$SIM_PID" 2>/dev/null || { echo 'simulator exited:'; cat /tmp/sim.out; break; } + sleep 0.1 +done export SDKHARNESS_SIMULATOR_URL=$(sed -n 's/^SIMULATOR-LISTENING \(http:.*\)/\1/p' /tmp/sim.out) export SDKHARNESS_SIMULATOR_HTTPS_URL=$(sed -n 's/^SIMULATOR-LISTENING \(https:.*\)/\1/p' /tmp/sim.out) export SDKHARNESS_SIMULATOR_CONTROL_URL=$(sed -n 's/^SIMULATOR-CONTROL \(.*\)/\1/p' /tmp/sim.out) @@ -72,6 +80,8 @@ HEALTHCHECK_REALM_URL=$SDKHARNESS_SIMULATOR_URL B2_TEST_APPLICATION_KEY_ID=test- .sdkharness/tests/health-golden-path ``` +When you are done, stop the simulator with `kill "$SIM_PID"`. + Each prints one `SDKHARNESS_RESULT` line. Scenario ids are in `tests.tsv`. A `FAIL` for `api.retry_after_503`, `upload.retry_408` is a known SDK finding, not a setup problem. diff --git a/.sdkharness/tests/health-golden-path b/.sdkharness/tests/health-golden-path index bc625a58..9d150599 100755 --- a/.sdkharness/tests/health-golden-path +++ b/.sdkharness/tests/health-golden-path @@ -13,6 +13,7 @@ from urllib.parse import urlsplit LEVEL = 'health' SCENARIO = 'golden-path' +SIMULATOR_CREDENTIAL = ('test-key-id', 'test-key') REPOSITORY_ROOT = Path(__file__).resolve().parents[2] @@ -51,7 +52,7 @@ def validate_environment(environment: Mapping[str, str]) -> tuple[str, str, str, raise CheckFailure('configuration', 'invalid simulator URL') from error if ( parsed.scheme != 'http' - or parsed.hostname not in {'127.0.0.1', '::1'} + or parsed.hostname != '127.0.0.1' or port is None or parsed.username is not None or parsed.password is not None @@ -66,7 +67,12 @@ def validate_environment(environment: Mapping[str, str]) -> tuple[str, str, str, bucket_name = environment.get('B2_BUCKET_NAME', '') if not key_id or not application_key or not bucket_name: raise CheckFailure('configuration', 'required simulator input is missing') - return simulator_url, key_id, application_key, bucket_name + # Only the simulator's fixed test credential is ever used. A real key in the + # environment is refused before it can reach the SDK (and so a loopback + # listener); the supplied values are never echoed. + if (key_id, application_key) != SIMULATOR_CREDENTIAL: + raise CheckFailure('configuration', 'only the fixed simulator credential is accepted') + return simulator_url, SIMULATOR_CREDENTIAL[0], SIMULATOR_CREDENTIAL[1], bucket_name def repository_api_factory(): diff --git a/test/unit/test_sdkharness_contract.py b/test/unit/test_sdkharness_contract.py index 16116b2e..89bde631 100644 --- a/test/unit/test_sdkharness_contract.py +++ b/test/unit/test_sdkharness_contract.py @@ -107,6 +107,7 @@ def test_customer_health_lifecycle(): 'http://127.0.0.1/', 'http://user@127.0.0.1:8180/', 'http://127.0.0.1:8180/not-root', + 'http://[::1]:8180/', ], ) def test_rejects_non_loopback_simulator_contract(url): @@ -132,3 +133,29 @@ def test_failure_result_is_one_credential_safe_record(monkeypatch, capsys): 'configuration: required simulator input is missing\n' ) assert 'test-key' not in output + + +@pytest.mark.parametrize( + 'key_id, key', + [ + ('005realkeyid0000000000000', 'K005realapplicationkey00000000000'), + ('test-key-id', 'K005realapplicationkey00000000000'), + ('005realkeyid0000000000000', 'test-key'), + ], +) +def test_ambient_real_looking_credentials_never_reach_the_sdk(key_id, key): + contract = load_contract() + environment = valid_environment() + environment['B2_TEST_APPLICATION_KEY_ID'] = key_id + environment['B2_TEST_APPLICATION_KEY'] = key + + class ForbiddenApi: + def authorize_account(self, *args, **kwargs): # pragma: no cover - must not run + raise AssertionError('a non-simulator credential reached the SDK') + + with pytest.raises(contract.CheckFailure) as raised: + contract.run_health(environment, api_factory=lambda: ForbiddenApi()) + assert raised.value.step == 'configuration' + assert raised.value.detail == 'only the fixed simulator credential is accepted' + assert 'K005' not in str(raised.value.detail) + assert '005real' not in str(raised.value.detail)