diff --git a/.sdkharness/README.md b/.sdkharness/README.md index 7f0a0bbf..0bfe3de1 100644 --- a/.sdkharness/README.md +++ b/.sdkharness/README.md @@ -14,8 +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. +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 @@ -23,3 +29,59 @@ 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 2>&1 & # prints the URLs +SIM_PID=$! +# ...or use the simulator embedded in the harness: sdkharness/bin/simulator/serve.mjs + +# 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) +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 +``` + +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/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/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/.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 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)