Skip to content

Fix/audit followups bundle - #162

Merged
TRO-Wolf merged 16 commits into
mainfrom
fix/audit-followups-bundle
Jul 18, 2026
Merged

TRO-Wolf merged 16 commits into
mainfrom
fix/audit-followups-bundle

Conversation

@TRO-Wolf

Copy link
Copy Markdown
Owner

Which issue does this PR close?

  • Closes #.

What changes are included in this PR?

Are these changes tested?

TRO-Wolf and others added 16 commits July 18, 2026 07:49
…, op-class guard, R160 cell text

G1a: convert `get_delete_vector_for_path` from `.read().ok()` (which swallowed
a poisoned state lock as `None`, silently DROPPING positional deletes and
resurrecting deleted rows) to the file's `recover_poison` policy, matching every
other lock site. Pin `test_get_delete_vector_survives_poisoned_lock` poisons the
lock and asserts the present delete vector is still returned; reverting to
`.read().ok()` fails it (RED). Lock-site sweep: the one remaining non-recover
site (`apply`'s per-file mutex `deletes.lock().map_err`) is fail-CLOSED — it
surfaces a typed error, not a silent drop — so it is justified, not converted.

G1b: add `test_operator_class_is_an_exact_partition`, an EXHAUSTIVE `match` over
every `PredicateOperator` variant (no wildcard arm) asserting each lands in
exactly one of is_unary/is_binary/is_set. A future variant breaks compilation
(proven: dropping one arm yields E0004 non-exhaustive).

G1c: correct R160 cell text — Java accessor is `credentials()` (camel
`storageCredentials` is the wire-key constant for `storage-credentials`), and
`newFileIO` is `newFileIO(SessionContext, Map, List<Credential>)`. Status and
anchor unchanged; check_matrix_anchors.sh green.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011wbhUQi8Ts3CAdgvbQm2r5
…S not-empty drop paths

Java throws NamespaceNotEmptyException when dropping a non-empty namespace; the
fork had no equivalent kind, so SQL drop_namespace and the HMS drop_database
InvalidOperationException arm both collapsed to Unexpected, losing the semantic.

PUBLIC-API ADDITION: adds ErrorKind::NamespaceNotEmpty to the core iceberg
crate. ErrorKind is #[non_exhaustive], so the new variant is additive and
non-breaking; downstream kind() matchers can adopt it at their own pace. This is
the bundle's one sanctioned core-error edit.

- core (crates/iceberg/src/error.rs): new NamespaceNotEmpty variant placed with
  the namespace kinds, its From<ErrorKind> for &'static str arm, house-style doc,
  and a Display/into_static pin.
- sql (crates/catalog/sql/src/catalog.rs): drop_namespace not-empty path flips
  Unexpected -> NamespaceNotEmpty (message preserved).
- hms (crates/catalog/hms/src/error.rs): from_drop_database_exception O2
  (InvalidOperationException) flips Unexpected -> NamespaceNotEmpty. Bytecode-
  grounded: javap -p -c of Java HiveCatalog.dropNamespace (iceberg-hive-metastore
  1.10.0) shows dropDatabase(name, false, false, false) with cascade=false, and
  the exception table maps the caught InvalidOperationException (target 40)
  directly to new NamespaceNotEmptyException(...) at offsets 41-60. With
  cascade=false, non-empty is the sole meaning of that arm, so the map is
  unambiguous. MetaException (O3) stays Unexpected.

Contract check re-run on the current tree: no callers conditionally match
Unexpected from these drop paths, and retryable() is kind-independent (the retry
gate keys on CommitStateUnknown + the independent flag). All three pins were
mutation-proven RED (revert each kind -> failing test).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011wbhUQi8Ts3CAdgvbQm2r5
… not-empty drop kind

G3 of fix/audit-followups-bundle. Closes the #159 unit-D residue: GlueCatalogConfig /
HmsCatalogConfig / S3TablesCatalogConfig / SqlCatalogConfig derived plain Debug over
secret-bearing property maps (AWS credentials) and, for SQL, a DSN that can embed a password.

- Promote `is_secret_prop_key` to `pub` in core io/storage/config so all four catalog crates
  redact against ONE authoritative needle superset (no per-crate drift). Public-surface
  addition: `iceberg::io::is_secret_prop_key`.
- Manual redacted Debug on each config type: secret prop VALUES to "***", keys and non-secret
  values stay visible. SqlCatalogConfig also masks the DSN userinfo password via
  `redact_dsn_password` (scheme/user/host/port/db stay visible).
- SqlCatalog: manual Debug redacting the raw `properties` map (verification found it as an extra
  leak vector beyond `fileio`; the `AnyPool` prints only pool sizing options, never the DSN).
- G3b: Glue drop_namespace non-empty error flips DataInvalid -> ErrorKind::NamespaceNotEmpty
  (Java GlueCatalog.dropNamespace parity), message preserved verbatim.
- HMS from_drop_database_exception doc: tighten to Java's UNCONDITIONAL exception mapping
  (the not-empty class also covers functions / materialized views).

Pins: per-config redaction + catalog-level composition, DSN password masking, and the not-empty
kind; each mutation-proven RED (revert redaction/kind) and restored byte-identical. Debug-format
only apart from the sanctioned G3b kind flip.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011wbhUQi8Ts3CAdgvbQm2r5
…-password

The cycle-1 implementation truncated the authority span at the first '/',
'?', or '#' BEFORE locating the userinfo/host '@' boundary. A password
containing an unencoded '/', '?', or '#' pushed the '@' outside the
truncated span, `rfind('@')` missed, and the ENTIRE DSN — full cleartext
password — returned unredacted and printed via Debug
(`postgres://user:pa/ss@host/db` and the '?'/'#' variants all leaked).

Replace the truncate-first parse with a left-to-right scan over the '@'
candidates in the post-scheme remainder: accept the FIRST '@' whose
userinfo carries a ':' password separator and whose host (up to the next
'/','?','#') holds no further '@'. This masks a password embedding path
delimiters or '@', keeps a non-userinfo query '@' intact, and
over-redacts (never leaks) on genuine ambiguity (bare host + port ':' +
later query '@'). Doc-comment corrected to describe the shipped rule.

New pins for '/'-, '?'-, '#'-in-password (each asserts no password byte
survives) plus query-'@' discrimination, IPv6, empty/multi-':' password,
and the port-ambiguity over-redact case. Mutation: restoring the
truncate-first order turns the delimiter-in-password pins RED.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011wbhUQi8Ts3CAdgvbQm2r5
…irst ':', last '@']

Two cycles of precise boundary-parsing each leaked one layer deeper
(`p@x/y@host`, `SECRET1@junk/SECRET2@host`). Replace the acceptance-scan
with the reviewer-prescribed coarse rule: after stripping `<scheme>://`,
if the remainder has a `:` AND an `@` after that first `:`, mask
`[first ':' , last '@']`. Any password lies between the first userinfo `:`
and its boundary `@`, and that boundary is <= the last `@`, so the span
always covers the whole password — under-redaction is impossible for ANY
input. The cost is over-redaction when a non-userinfo `:` precedes a
non-userinfo `@` (host/path masked, never leaked); documented honestly in
the fn's over-redaction envelope, replacing the previous false soundness
claim.

Tests: corpus pin iterating every leak input from both cycles asserting no
marker byte survives; unchanged-cases pin; exact per-case shapes with the
two deliberate over-redaction expectations. Mutation-proved RED against the
cycle-2 acceptance-scan rule.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011wbhUQi8Ts3CAdgvbQm2r5
…nside the password no longer leaks

The cycle-3 coarse mask stripped a leading scheme via `uri.find("://")` — the
FIRST `://` anywhere, not an anchored position-0 scheme. On a scheme-less DSN
whose password embeds `://` (`user:pass://x@host`), that pushed
`authority_start` past the userinfo `:` and the fn returned the input UNMASKED,
leaking `pass://x` (`p@ss://` and multibyte variants too).

Anchor the strip: treat `://` as a scheme separator ONLY when the bytes before
it, from position 0, form a valid URI scheme `[A-Za-z][A-Za-z0-9+.-]*` (which by
construction holds no `:`, `@`, or `/`); otherwise `authority_start = 0`. This
restores the soundness proof's premise (`rest` begins at or before the userinfo
`:`) for scheme-less inputs; schemeful behavior is byte-identical.

Corpus: adds the `://`-in-password leak class (`user:pass://x@host`,
`user:p@ss://host`, multibyte `usér:p+ø://x@höst`), a schemeful control
(`postgres://user:pa://ss@host/db`, already sound), and the `sqlite://file.db`
unchanged case. Mutation-proved: reverting to the bare `find("://")` turns the
new leak rows RED. Doc soundness claim updated to describe the anchored strip.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011wbhUQi8Ts3CAdgvbQm2r5
…o (R160 ❌→🟡)

The fork parsed LoadTableResult.storage-credentials but dropped it: load_file_io
built the FileIO from catalog + table config props only, ignoring the vended
scoped credentials returned by loadTable. Wire them in, mirroring Java 1.10.0
RESTSessionCatalog.newFileIO(SessionContext, Map, List<Credential>) →
CatalogUtil.loadFileIO → S3FileIO:

- select_vended_credential picks the longest vended `prefix` that prefixes the
  table's storage path (metadata_location), mirroring S3FileIO.clientForStoragePath's
  strictly-longest `startsWith` match; on a length tie the first in list order is
  kept (Java's `> length` replacement rule).
- load_file_io overlays that credential's config LAST, so a vended key beats the
  catalog/table props on collision — Java's clientByPrefix builds the per-prefix
  client with putAll(props).putAll(credential.config()).buildKeepingLast() (last
  put wins).
- No prefix match ⇒ silent skip (Java's base-client fallback raises no error);
  the zero-credentials path is byte-unchanged.

Security: the vended values flow into StorageConfig, whose redacted Debug already
covers the vended key names (s3.access-key-id→key, s3.secret-access-key→secret,
s3.session-token→token; needle list unchanged). No new logging on the path.

Pins (each RED under invert-selection / swap-overlay / drop-wiring mutations):
selection longest-prefix + tie-break, overlay + collision winner, no-match skip,
zero-credential regression, redaction composition end-to-end.

GAP_MATRIX R160 flipped ❌→🟡 (impl + offline behavioral pins; ✅ gated on a
live-cloud credentialed round-trip we cannot run offline) and corrected the
wire-key wording: the JSON field constant is STORAGE_CREDENTIALS (screaming-snake)
in LoadTableResponseParser, not a camelCase symbol. Residues named: expiry/refresh
(Java VendedCredentialsProvider) not built — the OpenDAL FileIO takes a static
props snapshot; Credential.validate (non-empty prefix/config) not enforced.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011wbhUQi8Ts3CAdgvbQm2r5
… for vended creds (R160)

The R160 cell and select_vended_credential call site claimed the fork
mirrors Java S3FileIO.clientForStoragePath. The SELECTION RULE does
(longest-prefix, strictly-longer, vended-wins overlay, silent no-match),
but the GRANULARITY is adapted: Java re-selects per accessed file path
from the full retained credential list; the fork selects ONCE at
metadata_location and overlays that single credential into the table's
static FileIO props. Name the divergence residue (multi-prefix tables;
data-only-prefix credential dropped) — per-path selection is not
expressible in the OpenDAL flat-props FileIO without core storage
changes. Documentation/comment only; no code behavior change.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011wbhUQi8Ts3CAdgvbQm2r5
…(R159 ❌→🟡)

Java 1.10.0 `OAuth2Manager`/`RefreshingAuthManager` schedule a background token
refresh ahead of `expires_in`; the fork exchanged the credential once and cached
the token forever (`HttpClient::authenticate`'s `TODO: Support automatic token
refreshing`). This ports the observable contract via the sanctioned lazy
refresh-before-use adaptation.

Decoded from iceberg-core-1.10.0 bytecode (javap -p -c, JDK 11):
- `OAuth2Util$AuthSession.scheduleTokenRefresh`: refreshWindow = min(ttl/10,
  MAX_REFRESH_WINDOW_MILLIS=300000ms); wait = max(ttl − refreshWindow,
  MIN_REFRESH_WAIT_MILLIS=10ms) — reproduced exactly in `TokenState::from_expires_in`.
- `OAuth2Properties`: TOKEN_REFRESH_ENABLED_DEFAULT=true, TOKEN_EXPIRES_IN_MS_DEFAULT
  =3600000, TOKEN_EXCHANGE_ENABLED_DEFAULT=true.
- Missing `expires_in` ⇒ no refresh scheduled (used indefinitely).
- Refresh failure ⇒ suppressed, prior token kept (`Tasks…suppressFailureWhenFinished`).

Adaptation (async-Rust, no background thread): `authenticate` refreshes when the
cached token has entered its window/expired, before use, so no known-expired token
is sent. A single-flight `refresh_gate` collapses a concurrent stampede to ONE
token-endpoint exchange. Refresh reuses `get_token_endpoint()` /
`exchange_credential_for_token` (`client_credentials`) exactly — no new endpoint,
no new credential-POST location. `token-refresh-enabled=false` restores the legacy
exchange-once-cache-forever behavior exactly. No token material in logs/Debug/error
contexts; monotonic saturating `Instant` clock math.

DEVIATIONS (module header + R159 residue): Java refreshes a still-valid token with
the token-exchange grant (subject_token) and can keep token-only (no-credential)
sessions alive; this fork always uses client_credentials re-exchange and does not
refresh token-only clients. Closing that needs the token-exchange grant (separate
item). Grade 🟡: offline mockito pins; live long-session soak is credential/docker-gated.

Pins (mockito, RED-under-mutation proven both directions): single-flight under 8
concurrent requests (drop gate → RED), fresh-token-no-refresh, disabled-zero-traffic
(invert flag → RED), missing-expires_in / schedule-rule numbers (default-apply → RED),
refresh-failure-keeps-old (propagate → RED), init-failure-propagates.

Edit surface: crates/catalog/rest/src/client.rs, catalog.rs (knob accessor only),
docs/parity/GAP_MATRIX.md R159. No Cargo.toml/Cargo.lock changes.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011wbhUQi8Ts3CAdgvbQm2r5
@TRO-Wolf
TRO-Wolf merged commit edb3370 into main Jul 18, 2026
27 checks passed
@TRO-Wolf
TRO-Wolf deleted the fix/audit-followups-bundle branch July 18, 2026 23:45
TRO-Wolf added a commit that referenced this pull request Jul 25, 2026
…-live-schema', 'fix/safety-joinhandle-channel' and 'fix/rest-secret-debug-ssrf' into fix/post-d1-block-2026-07-24 (#171)

* docs(task): G3 unit brief — interop-pin the adjacent-split merge (row R148)

The authoritative spec for this unit: the M-1..M-5 proposition ledger (both
directions, non-vacuity, adjacency, fail-closed sabotage), the fixture
determinism requirement, the in-scope file list, and the named
path-vs-reference-identity residue carried forward on row R148.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MRhAQnro5233xoVDLfib1X

* test(interop): pin the adjacent-split merge cross-engine (row R148)

The merge port (#169) landed with OFFLINE UNIT TESTS ONLY. The scan-plan oracle
could always REPRESENT a merge divergence — its member key is
`(basename,start,length)`, so `[(big,0,512),(big,512,512)]` and `[(big,0,1024)]`
compare unequal — but its FIXTURE never reached the branch: with big/mid/small +
one MoR delete at target=4096, no bin deterministically held two ADJACENT splits
of one file. Assertion granularity and fixture coverage are independent axes;
only the first was audited when R148 went green. MEASURED: with `merge_tasks`
deleted from `CombinedScanTask::new`, the pre-existing unfiltered comparison
still passes at 14 groups.

Two dedicated fixture files close it, each planned under an ISOLATING
metrics-prunable row filter (the fixture files own disjoint `id` ranges) at the
DELETE-FREE APPEND snapshot, so its splits meet an EMPTY bin-packer with weights
equal to their lengths — deterministic on any parquet build and any checkout
path, instead of tuned toward the 4096 knife edge that made the original failure
runner-only (the position delete attaches to every file of an unpartitioned
table and its size tracks the absolute path it embeds):

* merge.parquet — 120 rows / 2 row groups / whole file under TARGET, so both
  splits necessarily co-bin and merge into ONE spanning member
  (merge.parquet,4,fileLength-4); the key is independent of the row-group grid.
* gap.parquet — 210 rows / 3 row groups whose MIDDLE span alone exceeds TARGET
  while the outer two sum to under it, so first-fit co-bins splits 0 and 2
  NON-CONTIGUOUSLY: they must survive UNMERGED (adjacency, not group-by-file).
  Its wide rows use a mirrored 32-bit LCG over a 62-char alphabet — the engines'
  default parquet codecs differ (Rust uncompressed, Iceberg-Java zstd) and a
  low-entropy filler would compress the middle span back under TARGET on the
  Java side only.

Both plans are compared in BOTH directions against Java's REAL planTasks
(java_merge/gap_scan_plan.json <-> rust_merge/gap_scan_plan.json), and BOTH
engines assert NON-VACUITY — the merged member's length must STRICTLY exceed the
largest single field-132 row-group span — plus the sizing preconditions, so a
future fixture edit fails loudly instead of silently draining the pin.

Two new fail-closed legs: a Java sabotage leg (the UNMERGED split keys AND a
re-plan at a target too small to CO-BIN must both diverge from the canonical
merge-filtered plan; hard-fails if merge.parquet lacks multi-entry offsets), and
a PRODUCTION-SOURCE mutation leg that deletes the `merge_tasks(...)` call from
`CombinedScanTask::new`, requires the D1 leg to go RED, then restores the file
and md5-verifies the restore (hard-fails if the call site is absent or the mutant
passes). Chain [1/6] -> [1/7]. Also fixes the script's stale `row 146` citations
(R146 is conflict detection) and the same drift in tests/map.md.

ZERO production-code changes.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MRhAQnro5233xoVDLfib1X

* test(interop): Critic S3 remediation — name the real proof, add the adjacency mutation, gate the signal

Independent Critic CONVERGED (zero S1/S2) with S3 advisories. All applied:

S3-1/S3-2 — the pin's non-vacuity claim was MISNAMED. `Σspans > max(span)` and
the M-4 `assert_ne!` are, given >=2 ascending offsets, arithmetically always
true: both sides derive from the same manifest facts, so neither discriminates.
The assertions are unchanged (they still catch a degenerate fixture) but every
CLAIM around them now names the real mechanism — the exact `assert_eq` on the
plan SHAPE (ONE group / ONE whole-file-spanning member for merge.parquet; TWO
groups with the outer pair INTACT for gap.parquet) read against the
offsets-aware-split invariant: the splitter emits exactly one sub-task PER SPLIT
OFFSET and ignores the target, so >=2 offsets means >=2 splits and a single
spanning member is only producible by the merge. Reworded in the Rust section
banner + asserts, the Java javadoc + throws, the R148 cell, both map.md files,
the script header and the lessons entry; the two numeric checks are relabelled
DEGENERATE-FIXTURE guards. The Rust and Java merge-length guards now read the
length out of the EMITTED plan (`member_length` / `memberLength`) instead of
recomputing it from the manifest.

S3-3 — one mutation could not cover both claims: deleting the merge outright
reds the merge leg but NOT the adjacency leg (with no merge at all the
non-contiguous pair survives for the WRONG reason). Stage [7] now runs TWO
mutations through a shared `mutation_leg` helper. Measured this run:
  merge-removal     (task_group.rs `tasks: merge_tasks(tasks),` -> `tasks,`)
                    RED at interop_scan_plan.rs:946 (merge-filtered assert)
  adjacency-removal (task.rs `&& self.start.checked_add(self.length) ==
                    Some(other.start)` -> `&& true`, i.e. group-by-file)
                    RED at interop_scan_plan.rs:953 (gap-filtered assert)
Each restores byte-identically (md5 7ced67df… / 738a21b2…), `touch`es (cargo's
mtime staleness check would otherwise reuse the mutant lib) and re-runs D1 GREEN.

S3-4 — gating the leg on `rc != 0` alone was a compile-failure false-green:
`cargo test` exits 101 for a BUILD error too. The assertion-signal grep
(`panicked at|assertion .*failed|test result: FAILED`) is now GATING — no signal
means restore + hard-fail as INCONCLUSIVE. Verified by running the chain with
the adjacency replacement swapped for a non-compiling one: exit 101, no signal,
"MUTATION [adjacency-removal] INCONCLUSIVE … most likely did not COMPILE",
script exit 1, source restored.

S3-5 — `InteropOracle.java` still cited bare `row 146` (:1524, :4804) and `row
122` (x4). All now cite the permanent anchors R148 / R124, with a note that R146
is CONFLICT DETECTION, not this harness.

S3-7 — `sabotagePerturbedTarget`'s javadoc and the dispatcher comment still said
"TWO independent corruptions" / "BOTH legs"; there are three. Fixed, and the
third leg is now documented in the `<ul>`.

S3-6 left as-is (reverting the Arrays.asList churn adds churn). LOW-8/9/11 left
unfixed; LOW-10 recorded as a comment on the mutation window — it is not
signal-safe and assumes no concurrent cargo build, so the nightly driver must
stay sequential across this stage.

Still ZERO production-code changes.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MRhAQnro5233xoVDLfib1X

* docs(g3): close the three LOW framing residues from the Critic re-attestation

Comma splice in the R148 cell; the brief's M-3/done-gate lines now point at the
superseded framing note (the shipped discriminator is the plan-shape assert);
the todo tracker names both mutation legs and the degenerate-guard status of
the span inequalities.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MRhAQnro5233xoVDLfib1X

* fix(datafusion): live provider schema + scan emits what it advertises (BUG-005/BUG-011)

`IcebergTableProvider` froze its Arrow schema at construction while every operation
reloaded the table, so a long-lived provider (the catalog's `SchemaProvider` caches one
per table for the session) planned forever against a schema that no longer described the
table, and the scan reloaded the table but let the stream adapter keep advertising the
construction-time schema.

* BUG-005 — the cached schema is now republished by every operation that loads the table
  (`scan`/`insert_into`/`delete_from`/`update`) and by a new `IcebergTableProvider::refresh`.
  Java's analog is Spark's `SparkTable`, resolved per query by `SparkCatalog.loadTable`, so
  it never outlives a plan; a provider that does outlive one has to converge instead.
* BUG-011 — `IcebergTableScan` no longer asks for `select_all()` on a reloaded table. It
  selects the ADVERTISED columns by name (minus the ones the scanned snapshot's schema
  cannot resolve, using the same `field_by_name` test `TableScanBuilder::build` applies) and
  conforms every emitted batch to the advertised schema: absent-and-nullable becomes an
  all-NULL column (Java's reader behaviour), a changed type or an absent required column is
  a loud, named error. This also fixes `SELECT *` after `ADD COLUMN`, which failed outright
  ("Column extra not found in table") because `ADD COLUMN` creates no snapshot.
* DML — `delete_from`/`update` bind the row filter, the `SET` assignment indices and the
  exec's projection base to the freshly loaded table's schema, not the cached one.
* `IcebergWriteExec` now advertises its RESULT schema (the data-file column) instead of the
  table schema it never emits — the same skew, in the write path.

Tests: 7 new provider/scan regressions (out-of-band evolution via a second catalog handle)
plus 4 `conform_batch` unit tests; each fix mutation-proved RED individually.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MRhAQnro5233xoVDLfib1X

* fix(datafusion): bind the scan by FIELD ID, freeze the advertised schema, allow legal promotions

Critic remediation on top of e2a213f (2xS1 + S2 + S3 + LOW).

* S1-1 (silent wrong data on RENAME) — `resolve_projection` matched advertised columns
  against the scanned snapshot's schema BY NAME. A rename keeps the field id, rewrites the
  name and creates no snapshot, so `SELECT *` null-filled the renamed column over live
  data. Binding is now by FIELD ID (`PARQUET:field_id` on the advertised Arrow field ->
  `Schema::name_by_field_id`): the snapshot's name for that id is what gets selected, and
  `conform_batch` re-labels it to the advertised name. An advertised field with no id
  metadata is a loud error, never a name-based guess.
* S1-2 (DataFusion panic when the advertised schema shrank under a live plan) — the
  provider's schema is now IMMUTABLE for the life of the instance, because DataFusion
  stores projection ORDINALS resolved against it: a view or deferred DataFrame re-planned
  after the schema moved panics inside `datafusion-expr`'s `TableScan::try_new`. Freshness
  moves to a new INSTANCE, exactly as Java does it (`SparkCatalog.loadTable` per query):
  `IcebergSchemaProvider::table` now builds a provider from freshly loaded metadata on
  every resolution (it used to cache one per session — BUG-005 on the shipping path), and
  `IcebergTableProvider::refresh` becomes `refreshed()`, returning a NEW provider and
  leaving plans built on the old one valid.
* S2-3 (legal promotion rejected) — `conform_batch` applies Iceberg's legal type
  promotions (int->long, float->double, decimal precision widening) via an Arrow mirror of
  `iceberg::spec::is_promotion_allowed`, pinned against that authority over a 14x14
  primitive matrix. Illegal changes still error, naming the column and both types.
* S3-4 — the conform errors said "re-plan the query", which is a no-op for a
  snapshot-vs-current divergence; they now name the actual cause.
* LOW-5 — measured: in the steady state the reader's batch schema is IDENTICAL to the
  advertised one (fields AND `PARQUET:field_id` metadata), so the equality fast path hits
  and nothing is rebuilt. Kept, and pinned by a test so a reader change cannot silently
  move every scan onto the rebuild path.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MRhAQnro5233xoVDLfib1X

* fix(datafusion): rebind pushed filters by field id; conform nested types (S2-1/S2-2)

Critic cycle-2 remediation on top of 2364c5b.

* S2-1 — a pushed-down filter PRUNES, and `Inexact` pushdown only lets DataFusion drop
  false positives, never resurrect a row the scan discarded. The filter was still
  name-keyed, so once names and field ids disagreed it either failed to bind ("Field opt2
  not found in schema" after a rename) or bound to a DIFFERENT column and silently
  returned the wrong rows (0 instead of 2 after drop+re-add; 0 instead of 1 after a name
  swap). Filters are now rewritten onto the scanned snapshot's names through the same
  field-id bindings the projection uses (`resolve_bindings` now covers the FULL advertised
  schema, since a filter may reference an unprojected column), and a filter over a column
  with no counterpart there is not pushed at all — its column reads NULL and only
  DataFusion's own re-check can evaluate it.
* S2-2 — `conform_batch` was top-level only, so `ADD COLUMN s.b` (which creates no
  snapshot) hard-failed a whole `SELECT *` where Java reads `{a: 5, b: null}`. Conforming
  now recurses through struct, list and map types with the same field-id resolution at
  every level, preserving offsets and null buffers, and names the dotted PATH (`s.a`) when
  a nested change is genuinely illegal.
* S3-3 — the promotion-mirror test covered 12 of 17 `PrimitiveType` variants. It now covers
  all 17 and encodes the one place the Arrow mirror is NECESSARILY coarser than the Iceberg
  rule: `uuid` and `fixed[16]` share one Arrow type, so the identity arm accepts a pair
  `is_promotion_allowed` rejects. Inert — `ensure_promotion_allowed` blocks it at the DDL,
  and the representations are identical — and now asserted rather than hidden.
* S3-4 — the projection narrowing is pinned through `get_batch_stream` itself, so a revert
  to `select_all()` goes RED instead of being masked by `conform_batch` narrowing the batch
  back down afterwards.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MRhAQnro5233xoVDLfib1X

* fix(safety): honest join-failure propagation + close the DeleteFileIndex lost-wakeup hang (SAF-006/SAF-007)

Two hang/panic classes from the 2026-07-23 external audit, both on the shipping
read path.

SAF-006 - runtime/mod.rs `JoinHandle`
-------------------------------------
`Future::poll` collapsed EVERY join failure into `.expect("tokio spawned task
failed")`, so a panicking task's payload was destroyed and a *cancelled* task
(nothing failed) was reported with the same misleading string. There is exactly
one runtime arm in this crate (tokio) - no wasm/async-std/blocking alternative -
so this is the whole join-failure policy.

* Panic -> `std::panic::resume_unwind(join_error.into_panic())`. The waiter now
  observes the ORIGINAL payload, which is also the Java contract: `Future.get()`
  rethrows the task's own exception at the caller (`ExecutionException.getCause`
  is the original throwable; Iceberg's `Tasks`/`ExceptionUtil.castAndThrow`
  rethrows it unwrapped). The old `expect` was neither Java parity nor
  debuggable.
* Cancellation -> `Output = T` has no error channel, so a panic is unavoidable;
  it now says what actually happened instead of blaming the task.
* New `JoinHandle::try_join() -> Result<T>` maps BOTH cases to a typed
  `ErrorKind::Unexpected` (panic payload rendered into the message and logged via
  `tracing`; the `JoinError` kept as `source()` for a cancellation). Converted
  the two await sites whose signature already carries an error - the Avro and ORC
  data-file readers, where the panic source is third-party decode over untrusted
  file bytes. A malformed file now fails that read instead of unwinding into the
  awaiting scan.

This deliberately does NOT convert `Future::Output` from `T` to `Result<T, _>`;
that rewrite across every awaited AND detached spawn site stays deferred (as
#166 recorded), and `try_join` reaches the call sites that matter without it.

SAF-007 - delete_file_index.rs
------------------------------
The audit's stated mechanism (a full `channel(10)` hanging waiters) is STALE:
the populate task's `collect()` drains continuously, so a full buffer only
backpressures the senders, and the scan awaits the delete-manifest block, which
closes the channel. Two OTHER paths to a permanent hang were real, and are fixed:

* Lost wakeup. `get_deletes_for_data_file` observed `Populating`, released the
  read lock, and only then created its `Notified`. `notify_waiters()` stores no
  permit and `notified_owned()` snapshots the counter at CALL time, so a populate
  task that published in that window left the scan awaiting forever. The notifier
  is now armed while the read lock is still held (`lookup_or_arm`): the populate
  task cannot notify until it takes the write lock, so any notification follows
  the arming and is delivered. Same fix as upstream apache#2696 for
  this site (apache#2859 is the same class on the positional-delete path).
* Populate task death. The task is detached; if it unwinds or the runtime is torn
  down while it is parked on `collect()`, the state stayed `Populating` forever
  and no notification could ever be sent. A `PopulateGuard` armed before the
  task's first await now publishes a terminal `DeleteFileIndexState::Failed` on
  drop, so every waiter gets a typed error. Mirrors `EqDelState::Failed` in
  `arrow/delete_filter.rs` (#160).

Tests (+8, `-p iceberg --lib` 2855 -> 2863), each mutation-proved RED:
`test_await_re_raises_the_original_panic_payload`,
`test_try_join_maps_a_task_panic_to_a_typed_error`,
`test_try_join_maps_a_cancelled_task_to_a_typed_error`,
`test_try_join_describes_a_non_string_panic_payload`,
`test_try_join_returns_the_task_value`,
`test_waiter_is_armed_before_the_publisher_can_notify`,
`test_dead_populate_task_yields_a_typed_error_not_a_hang`,
`test_failed_index_state_is_terminal_for_later_callers`.
The two hang probes carry their own 5s timeout so a regression fails instead of
wedging CI. The panic-classification assertions read `Error::message()`, not
`to_string()`: `Display for Error` appends the source chain and a `JoinError`
renders the payload itself, which made a `to_string().contains(payload)` check
pass even with the conversion removed.

Known follow-up, NOT in this change: `arrow/delete_filter.rs` has the same
lost-wakeup pattern at three sites (`PosDelLoadAction::WaitFor(Arc<Notify>)` :112
awaited in `caching_delete_file_loader.rs` :276/:362, and the equality-delete
waits :232/:270). Upstream apache#2859 fixes the positional one and is a clean
cherry-pick; the equality-delete pair is not covered by it.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MRhAQnro5233xoVDLfib1X

* fix(safety): Critic remediation — arm the populate guard at spawn, correct the Java-parity claim

Independent-Critic NOT-CONVERGED remediation of 099d4f3 (SAF-006/SAF-007).

S2-1 — the populate guard was armed on FIRST POLL, not at spawn
---------------------------------------------------------------
`PopulateGuard::new` was called inside the `async move` block, so it was a LOCAL
of the future, created on the first poll. A future dropped before it is ever
polled — a runtime torn down between `spawn` and the first poll — runs no local
destructors, so the guard never existed and the state stranded at `Populating`:
the exact hang the guard was added to close, on a path the parked-at-`collect()`
test did not reach. Construction is hoisted into the `spawn` prelude so the
future CAPTURES an already-armed guard; `Drop` now covers all three deaths
(never polled, unwound, teardown of a parked future). The two doc sentences that
over-claimed ("on every exit path", "Armed BEFORE the first await") are rewritten
to the guarantee that is now actually true.

S2-2 — the Java-parity justification was wrong
----------------------------------------------
Verified against the Java 1.10.0 reference: `Tasks.waitFor` unwraps ONLY `Error`
causes; for everything else it collects the `ExecutionException` WRAPPER, and
`ExceptionUtil.castAndThrow` then re-wraps it as
`RuntimeException(ExecutionException(cause))` — doubly wrapped, the opposite of
the previous "goes further and rethrows the unchecked exception unwrapped".
`Tasks.waitFor` also explicitly IGNORES `CancellationException`, which our
`Future` impl cannot do. The justification block now keeps the one true anchor
(`Future.get` throws `ExecutionException` whose `getCause()` IS the original
throwable — `resume_unwind` is parity on that alone), states the
`Tasks`/`castAndThrow` behaviour correctly, and NAMES the cancellation panic as a
deliberate divergence forced by `Output = T`.

S3s taken in the same pass
--------------------------
* S3-1: the Avro and ORC reader `try_join` errors now carry the file path via
  `Error::with_context("path", ...)` — a join error cannot name the file that
  killed the decoder.
* S3-2: documented why the `JoinError` is attached as `source()` only on the
  cancellation path (`into_panic` consumes it by value on the panic path).
* S3-3: added the populate-task UNWIND pin.
* S3-4: the two cancellation wordings are now one `CANCELLED_MESSAGE` const
  shared by the `Future` panic and the `try_join` error, so they cannot drift.

Tests: +2 (`-p iceberg --lib` 2863 -> 2865).
`test_never_polled_populate_task_yields_a_typed_error_not_a_hang` (new, pins
S2-1; mutation: move `PopulateGuard::new` back inside the `async move` block ->
RED at the 5s timeout while the parked and unwind pins stay green),
`test_unwinding_populate_task_yields_a_typed_error_not_a_hang` (new, S3-3),
plus the earlier eight, all still mutation-proved.

Not in this change: `arrow/delete_filter.rs` carries the same lost-wakeup class
at three sites and is ruled the next unit — see the PR body.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MRhAQnro5233xoVDLfib1X

* fix(rest): redact secrets from REST wire-type Debug; SEC-011 is Java-parity, not a defect

SEC-010 — REST `Debug` impls printed live credential material.

`crates/catalog/rest/src/types.rs` derived `Debug` on every wire type, so any `{:?}`
or `tracing` site — including one in downstream user code this crate does not
control — printed the OAuth `access_token`, the server-vended `storage-credentials`,
and the `String -> String` config/property maps the REST server returns (which
`load_file_io` copies straight into the table's `FileIO` props). Ten types now carry
hand-written `Debug` impls following the house pattern established by #159/#162
(`StorageConfig`, `HttpClient::extra_headers`, the four catalog configs):

  TokenResponse (access_token -> "***"), CatalogConfig, LoadTableResult,
  StorageCredential, LoadViewResult, NamespaceResponse, CreateNamespaceRequest,
  UpdateNamespacePropertiesRequest, CreateTableRequest, CreateViewRequest.

Property maps are redacted PER KEY through the canonical needle test
`iceberg::io::is_secret_prop_key` (promoted `pub` in #162), so keys stay visible and
only secret VALUES are masked.

Same-family fix found during the sweep: `catalog.rs`'s REST-local `is_secret_prop_key`
was an EXACT match on `credential`/`token`/`client_secret` only, so `RestCatalogConfig`'s
own `Debug` printed `s3.secret-access-key`, `s3.session-token`, `gcs.oauth2.token` and
`adls.connection-string` in clear — the exact props operators hand this catalog for
FileIO. It now delegates to the canonical superset, removing the drift copy.

ASSESSED and deliberately left on `derive(Debug)` (anti-over-redaction), with the
rationale recorded in the module banner: `ErrorModel`/`ErrorResponse`, `OAuthError`
(RFC 6749 §5.2 diagnostic fields, already surfaced verbatim by their `From<_> for Error`),
and the identifier/pagination-only shapes. NAMED RESIDUE: core's derived
`TableMetadata`/`ViewMetadata` `.properties` and `TableUpdate::SetProperties` still print
in clear — a separate unit.

SEC-011 — REST `uri` / `oauth2-server-uri` SSRF: PARITY-SHARED, NOT A DEFECT.

Adjudicated against Java iceberg-core 1.10.0, which applies NO host, IP-range, or scheme
restriction: `HTTPClient.Builder.uri` only null-checks and `URI.create`s
(HTTPClient.java:483-491); `RESTSessionCatalog` passes the property straight through
(RESTSessionCatalog.java:175); `AuthConfig.fromProperties` takes `oauth2-server-uri`
verbatim (AuthConfig.java:85) and `OAuth2Manager` only WARNs when it is unset
(OAuth2Manager.java:274-291); the whole `core/.../rest/` tree contains no loopback /
`InetAddress` / site-local logic, and `rest.client.tls.configurer-impl` is an operator
EXTENSION point, not a restriction. A catalog URI is trusted operator configuration and
reaching a private endpoint is the normal deployment, so the audit's proposed private-IP
blocklist is rejected: it would break legitimate deployments and diverge from Java. The
trust model is documented on `REST_CATALOG_PROP_URI` / `get_token_endpoint`, and a
regression test pins loopback/RFC-1918/link-local acceptance so a future blocklist fails.

No behavioral change beyond `Debug` rendering; the wire format is untouched.

Tests: rest lib 82 -> 93 (+11). All 13 sabotage mutations (each Debug impl reverted to a
derive-equivalent raw render, both needle lists reverted to the old exact-match form, and
a private-IP blocklist added to `get_token_endpoint`) go RED against their guard test.
`cargo test -p iceberg --lib` 2855 green. The `--all-features` doctest failure
(`rt-multi-thread`) is pre-existing and reproduces identically on the base commit.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MRhAQnro5233xoVDLfib1X

* fix(rest): Critic remediation — stop leaking raw response bodies via error context (F1); correct trust-model provenance and residue scoping (F2-F7)

Remediates the NOT-CONVERGED review of 38802b7 (1 S1 + 2 S2 + 4 S3/S4).

F1 (S1) — the raw-body leak defeated the whole unit. `deserialize_catalog_response`
attached the RAW 200-OK body via `.with_context("json", …)` on any parse failure, and
`iceberg::Error` renders context VERBATIM in both `Display` and `Debug`. Every type
redacted in the parent commit routes through that function — `CatalogConfig`
(catalog.rs:592), `LoadTableResult` incl. `storage_credentials` (:988/:1059/:1208),
`LoadViewResult` (:1439/:1475), `NamespaceResponse` (:810/:838) — so a single parse
failure printed exactly what the `Debug` impls had just masked. The trigger is realistic:
a server returning a payload this parser rejects (unknown V3 field, version skew) hands
back the same body carrying the vended `s3.session-token`.

Fixed with the pattern already used on the token endpoint (client.rs:295-307): attach
`status`, `url` and `response_body_len` — never the body. Two mockito guard tests drive
the Critic's probe shape end-to-end (`/v1/config` with a sentinel in `defaults` and
`overrides` missing; and a real `load_table` body carrying vended credentials, corrupted
so it cannot parse) and assert the sentinel is absent from BOTH renderings while the safe
diagnostics are present. Both go RED when the raw-body attach is restored.

`with_source` is retained (AGENTS.md requires the chain); `serde_json`'s message can echo
the offending token at the failure POSITION, never the surrounding payload. The
load_table test pins that bound. Documented as residue.

SCOPED, not changed: the NON-2xx path (`deserialize_unexpected_catalog_error`) still
attaches its body. A non-2xx response means no table was loaded, so no `config` overlay
and no vended credentials were issued; the body is an `ErrorResponse` whose `message` is
the diagnostic value, and Java surfaces exactly this (`ErrorHandlers`). The one non-2xx
body that could echo credentials — the token endpoint's — already withholds it. Rationale
recorded on the function.

F2 (S2) — the trust-model doc was factually wrong about provenance. `merge_with_config`
lets the SERVER's /v1/config `overrides.uri` replace `self.uri`, and server
defaults/overrides merge into `props`, so `oauth2-server-uri` can be server-supplied: a
compromised server can redirect the token POST — carrying the operator's `client_secret` —
to a third-party host. The doc now names BOTH provenances, adjudicates the server-override
variant as parity-shared (Java builds both the client and `AuthConfig.fromProperties` from
`mergedProps`, RESTSessionCatalog.java:211-230), and reconciles with the
`disable-header-redaction` strip: that key is a client-only logging control with no wire
meaning (pure downside if server-set, so stripped), whereas `uri`/`oauth2-server-uri`
server override is a documented functional part of the config protocol. No behavior change.

F3 (S2) — `CommitTableResponse` carries `metadata: TableMetadata` (with `.properties`); it
was wrongly in the "identifier/pagination-only, no value map" all-clear bucket. Moved into
the named core-crate residue paragraph.

F4 (S3) — corrected the `OAuth2Manager` citation to :275-293 and the guard wording: the
WARN fires only when the key is absent AND (`initToken != null` OR non-empty `credential`).

F5 (S3) — added `ViewVersion.summary` (derived `Debug`, `String -> String`, reachable via
`CreateViewRequest.view_version` and both view-metadata paths) to the named residue.

F6 (S3) — reframed `ErrorModel`/`ErrorResponse`/`OAuthError` from all-clear to RESIDUE:
their fields are server-controlled free text, and `message`/`error_description` are now the
one channel by which response-body content still reaches logs after F1.

F7 (S4) — added the transport-layer nuance: Iceberg configures no redirect policy on either
side, so Java inherits HC5 defaults (max 50, no cross-origin header strip) while this fork
inherits reqwest's (max 10, strips Authorization/Cookie/Proxy-Authorization cross-origin).
We are STRICTER, not looser — "no restriction of any kind" was imprecise.

Tests: rest lib 93 -> 95 (+2); 15/15 mutations RED (13 prior + F1a/F1b).
`cargo test -p iceberg --lib` 2855 green.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MRhAQnro5233xoVDLfib1X

* fix(rest): Critic round-2 remediation — retract the refuted echo-bound, pin the double-encoded leak as known residue, correct F2 citations

Remediates the round-2 review of e985c5f (1 S2 + 2 S3, all doc/test level).

S2 — the echo-bound claim was REFUTED and is retracted. The previous residue note
asserted that serde_json's echo is "bounded to the single value at the parse-failure
position ... never the whole body", and concluded a secret was unreachable in practice.
That is wrong at a container boundary: a server or gateway that emits a nested object as
a JSON *string* (`writeValueAsString` over a sub-map, a proxy integration stringifying
the body — a real, well-known bug class) makes the value AT the failure position an entire
sub-document, which `Unexpected::Str` echoes in full. Reproduced through the public
`load_table` API with valid metadata and a double-encoded `config`: the whole vended
credential map prints verbatim in BOTH Display and Debug via `source:`.

Three fixes:
1. The RESIDUE block on `deserialize_catalog_response` is rewritten to state the true
   bound — the echo is the value at the failure position, and that value is an entire
   sub-document when the mismatch is at a container boundary, so a malformed
   credential-bearing body CAN still leak through `source`. The real fix is named as
   belonging to the core-crate unit that reworks `iceberg::Error`'s Display/Debug source
   rendering: AGENTS.md requires `source()` to SURVIVE, and it does — the chain obligation
   is satisfied; the leak is core's verbatim `source: {source}` interpolation.
2. New test `test_known_residue_double_encoded_body_leaks_through_error_source` pins the
   leaky shape AS KNOWN RESIDUE — it asserts the leak HAPPENS, with the flip condition
   spelled out (when the core rendering unit lands, invert the assertions and delete the
   doc block). A documented-leak pin, not a false-green.
3. The types.rs banner sentence claiming the REST credential channels are "covered here"
   is softened: covered at the `Debug` LAYER; the serde-source echo path remains.

Also corrected the load_table guard test's comment, which pinned the SAFE shape while
generalizing to "credentials elsewhere stay unreachable" — it now states that it pins the
safe scalar-mismatch shape only and points at the residue pin for the leaky one.

S3 — F2 citations were imprecise (same class as the F4 fixed last cycle).
`AuthConfig.fromProperties` is never called in RESTSessionCatalog.java and `.uri(` does not
appear at :211-230. Corrected to: RESTSessionCatalog.java:171-179 (default builder, `.uri`
at :175), :202-211 (init client + config fetch, then merge), :225-230 (rebuild + catalog
session); OAuth2Manager.java:104-109 (`catalogSession` → `AuthConfig.fromProperties`);
AuthConfig.java:84-85. Folded in the Critic's strengtheners: (a) server `overrides` OUTRANK
user properties — `merge` is defaults → clientProperties → overrides, last-write-wins, and
the only filter is `Maps.filterValues(merged, Objects::nonNull)` with no key allow/denylist
and an empty `validate()` (ConfigResponse.java:108-119, :68); (b) bootstrap parity — Java's
init client and config fetch use PRE-merge props and only then rebuild from mergedProps, and
this fork matches (`HttpClient::new(&self.user_config)` + `load_config(&client,
&self.user_config)` precede `merge_with_config`/`update_with`, catalog.rs:602-605), so the
config fetch can never be redirected by the response it is fetching; (c) Java has NO
`disable-header-redaction` analogue and filters nothing by key — the strip is fork-local
hardening of a fork-local knob, not a parity claim.

S3 — the non-2xx rationale was one-directional. `deserialize_unexpected_catalog_error` is
the fallthrough for create_table / create_namespace / update_namespace_properties /
create_view, whose request types were redacted precisely BECAUSE they carry credentials, so
a 400/409 echoing the offending request (a common validation pattern) puts those submitted
values into the error context. The note now states both directions explicitly — response
direction safe (a failed request vends nothing), request-echo direction NOT credential-free
— and keeps the body with the exposure named as parity-shared rather than implying the path
is credential-free.

No behavior change in this cycle.

Tests: rest lib 95 -> 96 (+1, the residue pin); 15/15 mutations RED.
`cargo test -p iceberg --lib` 2855 green.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MRhAQnro5233xoVDLfib1X

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant