Repository navigation
Fix/audit followups bundle - #162
Merged
Merged
Conversation
…ss guard, R160 text) Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_011wbhUQi8Ts3CAdgvbQm2r5
…, 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
…p paths 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
…flip (G2 spillover) 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
… pending Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_011wbhUQi8Ts3CAdgvbQm2r5
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_011wbhUQi8Ts3CAdgvbQm2r5
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]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Which issue does this PR close?
What changes are included in this PR?
Are these changes tested?