9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
493 lines
19 KiB
Python
493 lines
19 KiB
Python
"""Bounded exhaustive model comparing two NIP-RS manual-unread encodings.
|
|
|
|
Candidate A: lexicographic operation register
|
|
Per context: {counter, client_tiebreak, op in {SET,CLEAR}, baseline}
|
|
in a NEW top-level field beside `contexts`.
|
|
Merge = max tuple (counter, tiebreak, op-rule on full tie).
|
|
|
|
Candidate B: two grow-only counters + baseline
|
|
Per context: S (set counter), C (clear counter), B (frontier-at-set-time)
|
|
as sibling keys under `contexts` (ov_s:, ov_c:, ov_b: prefixes).
|
|
Action: own counter := max(S,C)+1; set also writes B := effective frontier.
|
|
Merge = componentwise max. Tie policy on S == C is a parameter.
|
|
|
|
Both share:
|
|
- Frontier: grow-only max() per NIP-RS v1 (unchanged).
|
|
- Verdict: unread(ctx) = latest > effective_frontier(ctx) OR override_set(ctx).
|
|
- Mark-read = advance frontier + clear override.
|
|
- Mark-unread = set override with baseline B = current effective frontier.
|
|
- Natural frontier advance strictly past B dominates a stale set.
|
|
|
|
Device simulators use overridable methods (_override_set, _compact, _merge_reg,
|
|
_bump, _sanitize_value) so the mutation harness can inject weakened rules via
|
|
subclassing without monkeypatching.
|
|
"""
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
|
|
SET = "SET"
|
|
CLEAR = "CLEAR"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reserved key namespace + escaping
|
|
#
|
|
# NIP-RS v1 context IDs are arbitrary UTF-8 (spec :89, :113-114), so a
|
|
# pre-existing opaque context could legitimately begin with `ov_s:`,
|
|
# `ov_c:`, or `ov_b:` and collide with a control key for a DIFFERENT
|
|
# context in the same flattened `contexts` map. `ov_` (the shared
|
|
# 3-byte stem) and the escape marker itself are reserved; any raw
|
|
# context ID that would collide is escaped before being used as a
|
|
# plain frontier key. Escaping is a no-op for every context ID Buzz
|
|
# actually generates (channel UUID, `msg:<hex64>`, `thread:<hex64>`
|
|
# — none start with `ov_` or `esc:`), so the common case pays zero
|
|
# bytes. Only a pathological ID pays the 4-byte `esc:` cost.
|
|
#
|
|
# This protects context IDs generated by amendment-aware clients.
|
|
# It does NOT retroactively protect a context that a PRE-EXISTING
|
|
# legacy client already published unescaped before the amendment
|
|
# shipped — that residual hazard is documented, not solved (see
|
|
# NOTE.md "Reserved key namespace").
|
|
# ---------------------------------------------------------------------------
|
|
|
|
ESCAPE_PREFIX = "esc:"
|
|
_RESERVED_STEM = "ov_"
|
|
|
|
|
|
def _needs_escape(raw_key: str) -> bool:
|
|
return raw_key.startswith(_RESERVED_STEM) or raw_key.startswith(ESCAPE_PREFIX)
|
|
|
|
|
|
def escape_context_key(raw_key: str) -> str:
|
|
return ESCAPE_PREFIX + raw_key if _needs_escape(raw_key) else raw_key
|
|
|
|
|
|
def unescape_context_key(wire_key: str) -> str:
|
|
if wire_key.startswith(ESCAPE_PREFIX):
|
|
return wire_key[len(ESCAPE_PREFIX):]
|
|
return wire_key
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Candidate B — two grow-only counters + baseline
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True)
|
|
class RegB:
|
|
s: int = 0
|
|
c: int = 0
|
|
b: int = 0
|
|
|
|
|
|
def merge_reg_b(a: Optional[RegB], b: Optional[RegB]) -> Optional[RegB]:
|
|
if a is None:
|
|
return b
|
|
if b is None:
|
|
return a
|
|
return RegB(s=max(a.s, b.s), c=max(a.c, b.c), b=max(a.b, b.b))
|
|
|
|
|
|
def override_set_b(reg: Optional[RegB], frontier_val: int, tie_policy=CLEAR) -> bool:
|
|
if reg is None:
|
|
return False
|
|
if frontier_val > reg.b and reg.s > 0:
|
|
return False
|
|
if reg.s > reg.c:
|
|
return True
|
|
if reg.s == reg.c and reg.s > 0:
|
|
return tie_policy == SET
|
|
return False
|
|
|
|
|
|
def compact_b(reg: RegB, frontier_val: int, tie_policy=CLEAR) -> Optional[RegB]:
|
|
"""Compact override state.
|
|
|
|
Tombstone-floor design: a register with any recorded counter
|
|
activity (S>0 or C>0) is never fully deleted. Its counter
|
|
high-water-mark is exactly what prevents a stale replica —
|
|
any (S,C) pair below that ceiling — from dominating a freshly
|
|
created register after compaction (delete-on-dominance made
|
|
counters reusable: a dead register dropped entirely, then a new
|
|
local set/clear pair restarted from S=0/C=0, so a delayed stale
|
|
peer snapshot with S>0 could out-rank the new state on replay).
|
|
Only a virgin register (S==0, C==0, no activity ever recorded)
|
|
has no ceiling to protect and compacts to None.
|
|
|
|
A live override (per `override_set_b`, which is already
|
|
policy-aware) is returned unchanged — compaction only touches dead
|
|
state. Dead overrides — whether dominated by C>S, tied under
|
|
clear-wins, or baseline-dominated by frontier advance — compact to
|
|
the clear-tombstone floor `RegB(s=0, c=max(S,C), b=0)`: S is
|
|
zeroed (no longer overriding), but C retains the ceiling so both a
|
|
future local bump (`max(S,C)+1`) and a componentwise-max merge with
|
|
any pre-compaction stale snapshot start strictly above the
|
|
historical maximum, never below it.
|
|
"""
|
|
if reg.s == 0 and reg.c == 0:
|
|
return None
|
|
if override_set_b(reg, frontier_val, tie_policy):
|
|
return reg
|
|
return RegB(s=0, c=max(reg.s, reg.c), b=0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Candidate A — lexicographic operation register
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True)
|
|
class RegA:
|
|
counter: int = 0
|
|
tiebreak: str = ""
|
|
op: str = CLEAR
|
|
baseline: int = 0
|
|
|
|
def as_tuple(self, op_wins):
|
|
op_val = 1 if self.op == op_wins else 0
|
|
return (self.counter, self.tiebreak, op_val)
|
|
|
|
|
|
def merge_reg_a(a: Optional[RegA], b: Optional[RegA], tie_op=CLEAR) -> Optional[RegA]:
|
|
if a is None:
|
|
return b
|
|
if b is None:
|
|
return a
|
|
at = a.as_tuple(tie_op)
|
|
bt = b.as_tuple(tie_op)
|
|
if at == bt:
|
|
return RegA(
|
|
counter=a.counter, tiebreak=a.tiebreak, op=a.op,
|
|
baseline=max(a.baseline, b.baseline),
|
|
)
|
|
return a if at > bt else b
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Device simulation — Candidate B
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class DeviceB:
|
|
"""Simulates one device's NIP-RS read-state blob with manual-unread
|
|
override layer (candidate B encoding).
|
|
|
|
All model operations go through overridable _methods so the mutation
|
|
harness can inject weakened rules via subclassing.
|
|
"""
|
|
|
|
def __init__(self, client_id, is_legacy=False):
|
|
self.client_id = client_id
|
|
self.is_legacy = is_legacy
|
|
self.frontier = {}
|
|
self.overrides = {}
|
|
|
|
def effective_frontier(self, ctx):
|
|
return self.frontier.get(ctx, 0)
|
|
|
|
def _override_set(self, reg, frontier_val, tie_policy):
|
|
return override_set_b(reg, frontier_val, tie_policy)
|
|
|
|
def _compact(self, reg, frontier_val, tie_policy):
|
|
return compact_b(reg, frontier_val, tie_policy)
|
|
|
|
def _merge_reg(self, a, b):
|
|
return merge_reg_b(a, b)
|
|
|
|
def _bump(self, s, c):
|
|
return max(s, c) + 1
|
|
|
|
def _sanitize_value(self, v):
|
|
return isinstance(v, int) and 0 <= v <= 4294967295
|
|
|
|
def override_is_set(self, ctx, tie_policy=CLEAR):
|
|
return self._override_set(
|
|
self.overrides.get(ctx), self.effective_frontier(ctx), tie_policy
|
|
)
|
|
|
|
def verdict(self, ctx, latest_ts, tie_policy=CLEAR):
|
|
return (latest_ts > self.effective_frontier(ctx)
|
|
or self.override_is_set(ctx, tie_policy))
|
|
|
|
def do_mark_unread(self, ctx):
|
|
if self.is_legacy:
|
|
return
|
|
cur = self.overrides.get(ctx, RegB())
|
|
new_s = self._bump(cur.s, cur.c)
|
|
self.overrides[ctx] = RegB(s=new_s, c=cur.c, b=self.effective_frontier(ctx))
|
|
|
|
def do_mark_read(self, ctx, frontier_ts):
|
|
self.frontier[ctx] = max(self.frontier.get(ctx, 0), frontier_ts)
|
|
if not self.is_legacy:
|
|
cur = self.overrides.get(ctx, RegB())
|
|
new_c = self._bump(cur.s, cur.c)
|
|
self.overrides[ctx] = RegB(s=cur.s, c=new_c, b=cur.b)
|
|
|
|
def do_advance_frontier(self, ctx, ts):
|
|
self.frontier[ctx] = max(self.frontier.get(ctx, 0), ts)
|
|
|
|
def do_compact(self, ctx, tie_policy=CLEAR):
|
|
reg = self.overrides.get(ctx)
|
|
if reg is None:
|
|
return
|
|
result = self._compact(reg, self.effective_frontier(ctx), tie_policy)
|
|
if result is None:
|
|
if ctx in self.overrides:
|
|
del self.overrides[ctx]
|
|
else:
|
|
self.overrides[ctx] = result
|
|
|
|
def do_reinstall(self):
|
|
self.client_id = self.client_id + "_r"
|
|
self.frontier = {}
|
|
self.overrides = {}
|
|
|
|
def _canonicalize_for_publish(self, ctx, tie_policy):
|
|
"""Canonical published form of `ctx`'s override register,
|
|
computed fresh against the current effective frontier —
|
|
independent of whether `do_compact` was ever called locally.
|
|
Returns `(is_live, canonical_reg)`; `canonical_reg is None`
|
|
means virgin (omit from the wire entirely). Reuses the same
|
|
overridable `_compact`/`_override_set` hooks `do_compact` uses,
|
|
so a mutation-harness subclass that weakens one weakens both
|
|
the storage-GC path and the publish path identically.
|
|
"""
|
|
reg = self.overrides.get(ctx)
|
|
if reg is None:
|
|
return False, None
|
|
front = self.effective_frontier(ctx)
|
|
canonical = self._compact(reg, front, tie_policy)
|
|
if canonical is None:
|
|
return False, None
|
|
return self._override_set(canonical, front, tie_policy), canonical
|
|
|
|
def publish_blob(self, tie_policy=CLEAR):
|
|
"""Serialize this device's read-state blob.
|
|
|
|
Every override is canonicalized at serialization time: live ->
|
|
unchanged (3 keys), dead -> tombstone floor (1 key, `ov_c:`
|
|
only), virgin -> omitted (0 keys). Canonical publication is a
|
|
protocol requirement, not an optimization — noncanonical wire
|
|
output is structurally impossible here, not merely avoided by
|
|
convention. `do_compact` remains a separate storage-GC
|
|
transition that mutates `self.overrides`; publication no
|
|
longer depends on it having been called first.
|
|
|
|
**Atomic slot-grouping rule (spec-amendment requirement):**
|
|
A context's frontier entry and ALL of its `ov_*` sibling entries
|
|
MUST travel in the same slot. `split_blob_into_slots` below
|
|
enforces this by round-robining per-context groups, never
|
|
per-entry. A receiving client that only holds part of a context
|
|
group and attempts to reconstruct a `RegB` from it would see
|
|
partial zeroes and might canonically re-publish a false
|
|
tombstone. Group atomicity makes partial reconstruction
|
|
structurally impossible from a compliant publisher's output.
|
|
"""
|
|
blob_ctx = {escape_context_key(k): v for k, v in self.frontier.items()}
|
|
if not self.is_legacy:
|
|
for k in self.overrides:
|
|
is_live, canonical = self._canonicalize_for_publish(k, tie_policy)
|
|
if canonical is None:
|
|
continue # virgin: omitted from the wire entirely
|
|
if is_live:
|
|
blob_ctx[f"ov_s:{k}"] = canonical.s
|
|
blob_ctx[f"ov_c:{k}"] = canonical.c
|
|
blob_ctx[f"ov_b:{k}"] = canonical.b
|
|
else:
|
|
blob_ctx[f"ov_c:{k}"] = canonical.c # tombstone: ceiling only
|
|
return {"v": 1, "client_id": self.client_id, "contexts": blob_ctx}
|
|
|
|
def split_blob_into_slots(self, tie_policy=CLEAR, n_slots=2):
|
|
"""Split this device's blob into `n_slots` compliant slots.
|
|
|
|
**Atomic grouping rule:** a context's frontier entry and ALL of
|
|
its `ov_*` sibling entries travel together in the same slot.
|
|
Round-robin assignment is per-context group, never per-entry.
|
|
This matches production `splitContextsIntoBudgetedSlots` when
|
|
it is amended to group by context instead of by individual entry.
|
|
|
|
Returns a list of `n_slots` blobs, each with the same `v` and
|
|
`client_id` but a disjoint subset of context groups.
|
|
"""
|
|
blob = self.publish_blob(tie_policy)
|
|
contexts = blob["contexts"]
|
|
|
|
# Gather per-context groups: each group is a list of (key, value) pairs.
|
|
# A "group" is: the frontier key (escaped ctx) + any ov_* siblings.
|
|
# Contexts that appear only as ov_* keys (no frontier entry) are
|
|
# also grouped together.
|
|
groups = {} # logical_ctx -> list of (wire_key, value)
|
|
for wire_key, value in contexts.items():
|
|
if wire_key.startswith("ov_s:"):
|
|
ctx = wire_key[5:]
|
|
elif wire_key.startswith("ov_c:"):
|
|
ctx = wire_key[5:]
|
|
elif wire_key.startswith("ov_b:"):
|
|
ctx = wire_key[5:]
|
|
else:
|
|
# Frontier key: may be escaped (e.g. "esc:ov_s:evil").
|
|
# Derive the logical context ID by unescaping so this
|
|
# entry joins the same group as its ov_* siblings, which
|
|
# are keyed by the RAW context ID (e.g. "ov_s:evil" ->
|
|
# ctx = "evil", but "esc:ov_s:evil" frontier -> ctx =
|
|
# "ov_s:evil" after unescape). Without this step an
|
|
# escaped frontier key and its ov_* siblings would be
|
|
# treated as two different groups, splitting the register
|
|
# across slots — reproducing the round-1 partial-
|
|
# reconstruction poison for escaped context IDs.
|
|
ctx = unescape_context_key(wire_key)
|
|
groups.setdefault(ctx, []).append((wire_key, value))
|
|
|
|
slots = [{"v": blob["v"], "client_id": blob["client_id"], "contexts": {}}
|
|
for _ in range(n_slots)]
|
|
for i, (_ctx, pairs) in enumerate(sorted(groups.items())):
|
|
slot = slots[i % n_slots]
|
|
for wire_key, value in pairs:
|
|
slot["contexts"][wire_key] = value
|
|
return slots
|
|
|
|
def receive_merge(self, blob):
|
|
incoming_overrides = {}
|
|
for k, v in blob.get("contexts", {}).items():
|
|
if k.startswith("ov_s:"):
|
|
ctx = k[5:]
|
|
incoming_overrides.setdefault(ctx, [0, 0, 0])[0] = v
|
|
elif k.startswith("ov_c:"):
|
|
ctx = k[5:]
|
|
incoming_overrides.setdefault(ctx, [0, 0, 0])[1] = v
|
|
elif k.startswith("ov_b:"):
|
|
ctx = k[5:]
|
|
incoming_overrides.setdefault(ctx, [0, 0, 0])[2] = v
|
|
else:
|
|
ctx = unescape_context_key(k)
|
|
self.frontier[ctx] = max(self.frontier.get(ctx, 0), v)
|
|
|
|
if not self.is_legacy:
|
|
for ctx, (s, c, b) in incoming_overrides.items():
|
|
incoming_reg = RegB(s=s, c=c, b=b)
|
|
self.overrides[ctx] = self._merge_reg(
|
|
self.overrides.get(ctx), incoming_reg
|
|
)
|
|
|
|
def legacy_sanitize_and_publish(self, tie_policy=CLEAR):
|
|
blob = self.publish_blob(tie_policy)
|
|
sanitized = {}
|
|
for k, v in blob["contexts"].items():
|
|
if len(k.encode("utf-8")) <= 256 and self._sanitize_value(v):
|
|
sanitized[k] = v
|
|
return {"v": 1, "client_id": self.client_id, "contexts": sanitized}
|
|
|
|
def state_key(self, contexts, tie_policy=CLEAR):
|
|
parts = []
|
|
for ctx in sorted(contexts):
|
|
f = self.effective_frontier(ctx)
|
|
reg = self.overrides.get(ctx, RegB())
|
|
ov = self.override_is_set(ctx, tie_policy)
|
|
parts.append((ctx, f, reg.s, reg.c, reg.b, ov))
|
|
return (self.client_id, self.is_legacy, tuple(parts))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Device simulation — Candidate A
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class DeviceA:
|
|
def __init__(self, client_id, is_legacy=False):
|
|
self.client_id = client_id
|
|
self.is_legacy = is_legacy
|
|
self.frontier = {}
|
|
self.overrides = {}
|
|
self.counter = 0
|
|
|
|
def effective_frontier(self, ctx):
|
|
return self.frontier.get(ctx, 0)
|
|
|
|
def override_is_set(self, ctx):
|
|
reg = self.overrides.get(ctx)
|
|
if reg is None or reg.op == CLEAR:
|
|
return False
|
|
if self.effective_frontier(ctx) > reg.baseline:
|
|
return False
|
|
return True
|
|
|
|
def verdict(self, ctx, latest_ts):
|
|
return latest_ts > self.effective_frontier(ctx) or self.override_is_set(ctx)
|
|
|
|
def do_mark_unread(self, ctx):
|
|
if self.is_legacy:
|
|
return
|
|
self.counter += 1
|
|
self.overrides[ctx] = RegA(
|
|
counter=self.counter, tiebreak=self.client_id,
|
|
op=SET, baseline=self.effective_frontier(ctx),
|
|
)
|
|
|
|
def do_mark_read(self, ctx, frontier_ts):
|
|
self.frontier[ctx] = max(self.frontier.get(ctx, 0), frontier_ts)
|
|
if not self.is_legacy:
|
|
self.counter += 1
|
|
self.overrides[ctx] = RegA(
|
|
counter=self.counter, tiebreak=self.client_id,
|
|
op=CLEAR, baseline=0,
|
|
)
|
|
|
|
def do_advance_frontier(self, ctx, ts):
|
|
self.frontier[ctx] = max(self.frontier.get(ctx, 0), ts)
|
|
|
|
def receive_merge(self, blob, tie_op=CLEAR):
|
|
for ctx, ts in blob.get("contexts", {}).items():
|
|
self.frontier[ctx] = max(self.frontier.get(ctx, 0), ts)
|
|
if not self.is_legacy:
|
|
for ctx, reg in blob.get("overrides", {}).items():
|
|
self.overrides[ctx] = merge_reg_a(
|
|
self.overrides.get(ctx), reg, tie_op
|
|
)
|
|
if reg.counter > self.counter:
|
|
self.counter = reg.counter
|
|
|
|
def publish_blob(self):
|
|
blob = {"v": 1, "client_id": self.client_id, "contexts": dict(self.frontier)}
|
|
if not self.is_legacy:
|
|
blob["overrides"] = dict(self.overrides)
|
|
return blob
|
|
|
|
def legacy_rewrite_and_publish(self):
|
|
return {"v": 1, "client_id": self.client_id, "contexts": dict(self.frontier)}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Legacy pruning/trim model
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def legacy_prune(contexts, horizon):
|
|
return {k: v for k, v in contexts.items()
|
|
if not (k.startswith("msg:") or k.startswith("thread:")) or v >= horizon}
|
|
|
|
|
|
def legacy_trim(contexts, client_id, max_bytes=32768):
|
|
import json
|
|
|
|
def size(ctx):
|
|
return len(json.dumps({"v": 1, "client_id": client_id, "contexts": ctx}).encode())
|
|
|
|
if size(contexts) <= max_bytes:
|
|
return contexts, True
|
|
evictable = sorted(
|
|
((k, v) for k, v in contexts.items()
|
|
if k.startswith("msg:") or k.startswith("thread:")),
|
|
key=lambda kv: kv[1],
|
|
)
|
|
out = dict(contexts)
|
|
for k, _ in evictable:
|
|
del out[k]
|
|
if size(out) <= max_bytes:
|
|
return out, True
|
|
return out, size(out) <= max_bytes
|
|
|
|
|
|
def legacy_sanitize_blob(blob):
|
|
sanitized = {}
|
|
for k, v in blob.get("contexts", {}).items():
|
|
if (len(k.encode("utf-8")) <= 256
|
|
and isinstance(v, int) and 0 <= v <= 4294967295):
|
|
sanitized[k] = v
|
|
return {"v": 1, "client_id": blob.get("client_id", ""), "contexts": sanitized}
|