the 0030dae rewrite dropped the try/except ValueError guard around urlsplit, so a
malformed url (an unbalanced ipv6 literal like http://[::1) raised ValueError out of a
display/logging masker instead of being returned unchanged - crashing the log call it
feeds. restore the guard: urlsplit failure returns the input verbatim, matching the
module's fail-safe display-only intent. a valid url still strips query+fragment.
Signed-off-by: disqualifier <dev@disqualifier.me>
stable_id() with no parts skipped the per-part loop and returned a constant
sha256(b''), colliding on every empty call - now raises ValueError like the other
weak-id guards. hosts(cidr, limit=0) checked the cap after appending, so limit=0
yielded one host - move the check before the append so limit=0 returns [].
Signed-off-by: disqualifier <dev@disqualifier.me>
The selective-key URL/proxy machinery (_mask_qs, SENSITIVE_QUERY_KEYS,
_split_host, _bullet_trailing_fields) tried to redact tokens inside arbitrary
URL structure and leaked on shapes it didn't anticipate (SPA-route fragments,
colon-passwords). Replace with purpose-honest functions that cannot leak or
over-mask:
- mask_url: strips query + fragment, keeps scheme/host/path. Does not parse or
hunt for sensitive params - a secret in a URL is the caller's bug.
- mask_proxy: partial user-reportable proxy id - drops any userinfo credentials
and masks an IPv4 host's middle octets (A.***.***.D, port kept); non-IPv4
host passes through; no parseable host raises ValueError.
- stable_id: deterministic sha256-derived id from ordered str parts (\x00-joined,
regenerable); empty/non-str part or non-positive length raises ValueError.
credit/cvv/phantom/provider unchanged. Export stable_id from the facade.
Signed-off-by: disqualifier <dev@disqualifier.me>
three credential-leak shapes where a redactor returned the secret verbatim (all 718c8a7):
- mask_proxy host:port:cred (3 parts) fell through to a verbatim return; and a >4-part spec
bulleted only the LAST field, leaking a colon-bearing password's earlier halves. now
everything after host:port is credential material and collapses to a single :**** for any
shape (3/4/n parts), so no credential field survives. plain host:port/bare host unchanged.
- mask_url's fragment used a lossless parse_qsl->urlencode round-trip gate that a =-padded
base64 token failed, passing the whole fragment (token included) through verbatim. _mask_qs
is now structure-preserving: it splits on & and the first =, replaces only a sensitive key's
value with *** (covering a =-padded/embedded-= token whole), and leaves every other segment
byte-identical - so a non-secret value is never corrupted and the round-trip gate (the root of
the leak) is gone. fragment and query mask identically now.
invariant: never emit a credential in the clear; over-mask when the shape is ambiguous.
Signed-off-by: disqualifier <dev@disqualifier.me>
deep_set stepping into or through a tuple used to fall through to the dict branch and
replace the tuple with {}, silently corrupting data on a deep_get/deep_set round-trip
(deep_get traverses tuples read-only). it now raises TypeError - tuples are immutable, so
an in-place update is impossible. also adds deep_gets/deep_sets: '*' path segments for
bulk get (always a list) and bulk set (plain value or fn(current)->new); a '*' passed to
the scalar deep_get/deep_set raises ValueError so the return type is never ambiguous.
Signed-off-by: disqualifier <dev@disqualifier.me>
the identity quote_via lambda disabled percent-encoding on re-emit, so a
non-sensitive value with a reserved character (e.g. x=%26%3D) came out
structurally corrupted (x=&=). re-encode with quote_plus (safe="*" so the
masked "***" stays literal) instead of the identity function - display
fidelity only, the secret is still always masked to "***" first.
Signed-off-by: disqualifier <dev@disqualifier.me>
provider() used str.isdigit(), which accepts unicode digit lookalikes (e.g. superscript
²) that int() can't parse, crashing on digit-only-looking input; _digits() now filters
on isdecimal()+isascii() so only real ASCII digits pass through. phantom() revealed the
entire value for len <= 10 while still looking masked (first6+last4 fully covers or
double-covers short strings); those now fully mask instead. Also compresses several
essay-length docstrings (retry, masking, paths) to a line plus the real nuance, with
zero behavior change, and bumps the README install pin.
Signed-off-by: disqualifier <dev@disqualifier.me>
mask_url/mask_proxy redact credentials from connection strings for logging
(display only, not a security control). deep_set previously destroyed a list
when a numeric path segment landed on it (isinstance(nxt, dict) check
replaced the list wholesale); it now mirrors deep_get's list indexing so a
get/set round-trip on the same dotted path never corrupts data, raising
IndexError on an out-of-range segment instead of silently mis-storing.
Signed-off-by: disqualifier <dev@disqualifier.me>
a malformed non-string 'state' reached unicodedata.normalize and raised TypeError out of
fetch_location, breaking the 'None on any parse failure' contract; str() it first.
Signed-off-by: disqualifier <dev@disqualifier.me>
- _parse_ipify/_parse_reverse return None on a truthy non-dict body instead of raising
AttributeError, honoring the documented 'None on any parse failure' contract (L9)
- build the geo request ClientTimeout once instead of twice (nit)
- drop the stale 'live proxy region- contract' wording from _state_slug's docstring (nit).
Signed-off-by: disqualifier <dev@disqualifier.me>
addr.geo.fetch_location produced a hyphen slug with no unicode fold (new-york / québec), but the live proxy region- token the original utils.py fed expects underscore + ascii-fold (new_york / quebec). a multi-word state silently routed to an unrecognized region with no error. added _state_slug (NFKD ascii-fold, lowercase, spaces->underscore) and routed _parse_reverse through it. bump to v0.2.2.
Signed-off-by: disqualifier <dev@disqualifier.me>
_get_json applied the timeout only when it created the session; when the caller passed their own session=, the timeout was silently dropped and the session default (aiohttp's 300s) governed. the per-request timeout is now passed to session.get(timeout=...) on both the owned and injected paths.
Signed-off-by: disqualifier <dev@disqualifier.me>
the retry warning logged index/last_index (attempts-1), so a 3-attempt retry showed 'retry 1/2'. now logs index+1 of attempts. both retry and aretry.
Signed-off-by: disqualifier <dev@disqualifier.me>
retry(fn, attempts=0) (or negative) silently returned None without ever calling fn,
looking like success. floor attempts at max(1, attempts) so the callable always runs
at least once; a failing call now fails loud after one try instead of no-op None.
verified: attempts=0/-5 -> 1 call (sync + async); failing fn raises after 1 try;
31/31 retry regression intact.
Signed-off-by: disqualifier <dev@disqualifier.me>
add commons.retry: exponential-backoff retry as sync `retry` and async `aretry`,
each usable as a call form or a decorator. backoff is min(backoff*factor**n,
max_backoff) with optional full jitter; the schedule is a pure generator so it
tests without real sleeps (sleep + rand injectable). `on=` narrows retryable
exception types, `give_up(exc)` stops early on a non-retryable error, and after
attempts are exhausted the LAST exception is re-raised (fail loud, never swallowed).
de-dups retry logic written 3x divergently (aiowebhooks 429/5xx, aioproxies
burn/rotate, aiomail reconnect).
Signed-off-by: disqualifier <dev@disqualifier.me>