Skip to content

Commit 0ffb7fa

Browse files
Merge pull request #684 from openpacketcore/feat/663-restore-ledger-ig4xo5fa
fix(session-store): enforce protected payload limits
2 parents 9b85ae1 + 485be7d commit 0ffb7fa

32 files changed

Lines changed: 3947 additions & 552 deletions

‎.github/workflows/ci.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,15 @@ jobs:
357357
- name: Run opc-gtpu-dataplane unit tests on macOS
358358
run: cargo test --locked -p opc-gtpu-dataplane --lib
359359

360+
# Dynamic and fixed durable consensus deliberately rely on Linux
361+
# descriptor-pinned SQLite snapshot handling. This native lane proves
362+
# the public and core constructors fail closed before durable setup.
363+
- name: Prove unsupported consensus initialization is side-effect free on macOS
364+
run: cargo test --locked -p opc-session-store --all-features unsupported_platform_before_durable_initialization
365+
366+
- name: Lint opc-session-store on macOS
367+
run: cargo clippy --locked -p opc-session-store --all-targets --all-features -- -D warnings
368+
360369
rust:
361370
name: Rust workspace
362371
runs-on: ubuntu-latest

‎crates/opc-session-net/README.md‎

Lines changed: 53 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -313,14 +313,21 @@ mutation or rebuild authority beside Openraft.
313313
scope, cursor shape, and the server's claimed progress; it cannot prove that
314314
an authenticated server did not omit records or falsely report completion.
315315
Production completeness is the local Openraft-applied scan after a
316-
linearizable barrier, not this compatibility RPC. Backends may return fewer records than requested
317-
(including an empty advancing sparse page) to stay within the fixed 4 MiB
318-
payload, 8 MiB retained-page, 8 MiB examined-metadata, and 4,096
319-
examined-candidate budgets; callers continue from the
320-
confidential authenticated `next_cursor` until `complete`. A server does not
321-
rewrite a backend cursor to fit a smaller wire frame: it returns
322-
`RestoreScanResponseTooLarge`, allowing the caller to retry from the same
323-
cursor with a smaller record limit. The wire omits redundant `loaded_count`
316+
linearizable barrier, not this compatibility RPC. The server conservatively
317+
narrows the dispatched record limit from the effective negotiated response
318+
frame using the `effective_max / 8192` record-count heuristic (with a
319+
minimum of one); it does not derive that count from the fixed 2,096,128-byte
320+
wire-payload cap. Backends may independently return fewer records than that
321+
narrowed request (including an empty advancing sparse page) to stay within
322+
their retained-page, aggregate-payload, examined-metadata, and 4,096
323+
examined-candidate budgets; callers continue from the confidential
324+
authenticated `next_cursor` until `complete`. The transport validates the
325+
entire backend page against the fixed wire-payload cap and effective frame;
326+
it never trims or rewrites records or the cursor. An oversize page returns
327+
`RestoreScanResponseTooLarge` when that error is representable, or closes
328+
the connection. The SDK does not automatically retry or change the request
329+
limit; a caller may retry the same cursor with a smaller record limit. The
330+
wire omits redundant `loaded_count`
324331
and `complete` values and recomputes both from records and cursor.
325332
- `SessionBackend::probe_replication_head` performs a fresh, deadline-bounded
326333
wire request. It does not consult the client's capability cache and reports
@@ -337,7 +344,7 @@ mutation or rebuild authority beside Openraft.
337344
`MAX_REPLICATION_OPERATIONS_PER_ENTRY` (256). The root is depth 1 and every
338345
operation node, including `Batch`, counts toward the per-entry total.
339346
- Independent protocol work limits admit at most 256 batch operations, 1,024
340-
restore records and 4 MiB of restore payload, 65,536 replication-log
347+
restore records and 2,096,128 bytes of restore payload, 65,536 replication-log
341348
entries, and 65,536 rebuild entries.
342349
These limits apply in addition to the configured encoded-frame bound.
343350
- Every post-bootstrap server response and watch item is fully bounded-encoded
@@ -357,7 +364,8 @@ mutation or rebuild authority beside Openraft.
357364
also enforces the finite 365-day horizon; production OpenRaft binds it to the
358365
leader-authored command time, never the client's or follower's wall clock.
359366
- If one record cannot fit, the call returns
360-
`StoreError::RestoreScanResponseTooLarge` instead of retrying indefinitely.
367+
`StoreError::RestoreScanResponseTooLarge`; the SDK does not automatically
368+
retry it.
361369
- `listen(bind_addr).await` starts the listener and returns a server handle and
362370
bound address.
363371
- `ServerHandle::abort()` schedules non-blocking listener/connection
@@ -397,13 +405,14 @@ build.
397405

398406
### Outbound response contract
399407

400-
Protocol v5 contract-profile wire-schema revision 6 retains revision 5's
401-
confidential authenticated snapshot-bound
402-
restore cursor, explicit durable-page profile, fixed 4 MiB restore payload and
403-
8 MiB retained-page, 8 MiB examined-metadata, and 4,096 examined-candidate
404-
budgets and exact configuration/process epoch binding for direct CAS. Revision
405-
5 adds the bounded, payload-free `RecordExpiryPreflight` authority exchange.
406-
Revision 6 adds the fixed `ConnectionRetiring` no-dispatch proof used for safe
408+
Protocol v5 contract-profile wire-schema revision 7 retains revision 6's
409+
confidential authenticated snapshot-bound restore cursor, explicit
410+
durable-page profile, 8 MiB retained-page, 8 MiB examined-metadata, and 4,096
411+
examined-candidate budgets and exact configuration/process epoch binding for
412+
direct CAS. Revision 7 corrects the restore payload fence from a nominal 4 MiB
413+
that worst-case JSON could not carry to the frame-safe 2,096,128-byte budget.
414+
Revision 5 adds the bounded, payload-free `RecordExpiryPreflight` authority
415+
exchange. Revision 6 adds the fixed `ConnectionRetiring` no-dispatch proof used for safe
407416
reconnection during authentication-material rotation. When lifecycle
408417
retirement is observed after mutual TLS and before any `HelloAck` bytes are
409418
written, the server returns the same complete control as
@@ -419,7 +428,7 @@ handshake. `requested_response_frame_size`,
419428
`Option<u32>` bootstrap fields so an older decoder can classify an otherwise
420429
decodable legacy minimal bootstrap. This is not bidirectional mismatch
421430
negotiation: an older decoder may reject unknown fields by simply closing.
422-
Exact revision-6 v5 admission requires each to be `Some`, at least
431+
Exact revision-7 v5 admission requires each to be `Some`, at least
423432
`MIN_NEGOTIATED_FRAME_SIZE` (8 KiB, or 8,192 bytes), and at most
424433
`MAX_NEGOTIATED_FRAME_SIZE` (16 MiB, or 16,777,216 bytes). The profile pins
425434
both as `min_frame_size = 8192` and `max_frame_size = 16777216`.
@@ -428,8 +437,18 @@ second independently configurable limit. The accepted response size is the
428437
smaller of the client's receive limit and the server's configured frame limit;
429438
the server request size independently bounds frames sent by that client. This
430439
supports unequal client/server settings without assuming either configured
431-
limit applies in both directions. A revision-4/error-revision-7 or older peer
432-
is incompatible; the ALPN is `opc-session-net/5`.
440+
limit applies in both directions. The exact direct profile is wire-schema
441+
revision 7/error-set revision 9; every non-current direct profile combination
442+
is incompatible. The ALPN is `opc-session-net/5`, and this profile requires a
443+
coordinated drained stop/upgrade/start.
444+
445+
The 2,096,128-byte restore payload is a v5 wire-only contract value. It does
446+
not derive from a local backend capability or local scan budget: standalone
447+
SQLite may restore one 4 MiB + 64 KiB stored envelope locally. The fixed value
448+
reserves worst-case JSON expansion and metadata headroom below the 16 MiB
449+
frame. Server serialization and client response decoding both reject a page
450+
one byte over the fixed wire cap without emitting or accepting a partial page;
451+
the exact contract profile is v5/revision 7.
433452

434453
Error-set revision 4 adds typed replication-log range overflow, page-limit,
435454
and compacted-cursor outcomes. A log request normalizes `start = 0` to one;
@@ -564,9 +583,11 @@ After bootstrap, the negotiated response budget applies to every response, not
564583
only restore pages. A non-pageable response, or a complete restore/log page
565584
that fits, takes the common single-encode path: it is bounded-encoded once and
566585
then emitted without a separate sizing serialization. If a complete pageable
567-
response is too large, that failed bounded encode emits no prefix; restore/log
568-
shaping may then use bounded logarithmic sizing probes and one final bounded
569-
encode. The direct attempt, every probe, final encode, prefix, payload, and
586+
response is too large, that failed bounded encode emits no prefix;
587+
replication-log shaping may then use bounded logarithmic sizing probes and one
588+
final bounded encode. Restore pages are validated as whole backend results and
589+
are never transport-shaped. The direct attempt, every probe, final encode,
590+
prefix, payload, and
570591
flush all share one absolute deadline established before the first encode or
571592
probe. Sizing counters and encoded storage check that deadline and
572593
`ServerHandle::abort` cancellation cooperatively between serializer
@@ -584,7 +605,7 @@ Response families use these fail-closed rules:
584605
| Fixed/scalar store or lease results | Replace an oversized backend-provided result/error with the operation's fixed SDK-owned, redaction-safe fallback when it fits; otherwise close. |
585606
| Get and CAS conflict records | Never truncate a record. Replace the record-bearing result with the fixed fallback, or close if even that cannot fit. |
586607
| Batch | Never truncate or reorder the positional result vector. Replace the complete batch response with its fixed fallback, or close. Earlier backend effects may already exist. |
587-
| Restore scan | Return a complete record prefix that fits and preserve `next_cursor`/excluded-count semantics. If the first record cannot fit, return the fixed restore-size error; never split a record. |
608+
| Restore scan | Return the complete backend page when it fits, preserving its `next_cursor`/excluded-count semantics. If the whole page exceeds the wire cap or effective frame, return the fixed restore-size error when representable or close; never trim, split, or rewrite records/cursors. |
588609
| Replication log | Return the largest complete contiguous entry prefix that fits. Never split an entry or skip a sequence; use the fixed fallback when no entry can fit. |
589610
| Watch | Bound the stream acknowledgement and every item independently. An item that cannot fit is not skipped; emit a fixed error item when representable and terminate the stream/connection so the client resumes from its last delivered sequence. |
590611

@@ -770,8 +791,8 @@ Retirement has these invariants:
770791
explicitly.
771792

772793
This is credential continuity, not protocol negotiation. The move to direct
773-
wire-schema revision 6 is still a coordinated drained stop/upgrade/start of
774-
every participant. After the fleet is uniformly on revision 6, leaf and trust
794+
wire-schema revision 7 is still a coordinated drained stop/upgrade/start of
795+
every participant. After the fleet is uniformly on revision 7, leaf and trust
775796
rotation uses the lifecycle above without a protocol downgrade or plaintext
776797
fallback. The bootstrap retirement control does not advance the direct or
777798
consensus profile revision and does not change the public API, but an older
@@ -898,8 +919,8 @@ endpoint, SPIFFE ID, certificate, key, transaction, or payload text.
898919
adds public `ContractProfile::max_frame_size`, so external profile struct
899920
literals and exhaustive destructuring must be updated in the same
900921
coordinated change.
901-
- The v5 profile pins wire-schema revision 6 and error-set revision 9;
902-
`max_restore_scan_page_payload_bytes = 4194304`;
922+
- The v5 profile pins wire-schema revision 7 and error-set revision 9;
923+
`max_restore_scan_page_payload_bytes = 2096128`;
903924
`max_restore_scan_examined_rows = 4096`;
904925
`min_frame_size = 8192`; `max_frame_size = 16777216`; the 128-byte
905926
owner/custom-key/state-type rules;
@@ -921,7 +942,7 @@ endpoint, SPIFFE ID, certificate, key, transaction, or payload text.
921942
pre-v4 peer built before #135 can still send an empty or oversized value
922943
that a new peer rejects before dispatch, so unchanged valid JSON shape is not
923944
a rolling-compatibility claim.
924-
- Treat every v5 exact-profile migration through wire-schema revision 6 and
945+
- Treat every v5 exact-profile migration through wire-schema revision 7 and
925946
error-set revision 9 as a
926947
coordinated stop/upgrade/start boundary. Drain
927948
traffic and writers, audit every persisted SQLite replica with the count-only
@@ -1002,8 +1023,9 @@ endpoint, SPIFFE ID, certificate, key, transaction, or payload text.
10021023
maximum is accepted and zero means immediate expiry. The TTL request shape is
10031024
unchanged for entries within the operation-tree contract. The new serialized
10041025
error variants require external exhaustive matches. Their wire representation
1005-
was introduced by v4 error revision 1 and is retained by current error
1006-
revision 8; an error-revision-7 or older peer fails exact negotiation.
1026+
was introduced by v4 error revision 1 and is retained by current v5 error
1027+
revision 9. Every non-current direct profile combination fails exact
1028+
negotiation.
10071029
Legacy persisted replication logs must be
10081030
audited before upgrade because an entry carrying a larger TTL now fails
10091031
closed during replay or rebuild rather than being clamped. Cross-field

‎crates/opc-session-net/src/client.rs‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,9 @@ use crate::protocol::{
4343
bounded_session_op_expectations, checked_frame_size, checked_wire_frame_size,
4444
compare_and_set_result_matches_key, conservative_payload_budget, get_result_matches_key,
4545
read_frame, read_response_frame, session_op_results_match_expectations,
46-
validate_request_payload_limit, validate_request_profile, write_frame_bounded_until,
47-
BootstrapHello, BootstrapRequest, BootstrapResponse, ContractProfile, Request, Response,
46+
validate_request_payload_limit, validate_request_profile,
47+
validate_restore_scan_wire_payload_bytes, write_frame_bounded_until, BootstrapHello,
48+
BootstrapRequest, BootstrapResponse, ContractProfile, Request, Response,
4849
RestoreScanWireRequest, CONTRACT_VERSION, CURRENT_CONTRACT_PROFILE, DEFAULT_MAX_FRAME_SIZE,
4950
MAX_HANDSHAKE_FRAME_SIZE, MAX_SESSION_NET_BATCH_OPERATIONS, MAX_SESSION_NET_REBUILD_ENTRIES,
5051
MIN_RESTORE_SCAN_RESPONSE_FRAME_SIZE, SESSION_NET_ALPN,
@@ -2324,6 +2325,16 @@ impl SessionBackend for RemoteSessionBackend {
23242325
"legacy_remote_restore_scan".to_string(),
23252326
));
23262327
}
2328+
if let Err(error) = validate_restore_scan_wire_payload_bytes(&page.records) {
2329+
self.discard_connection().await;
2330+
self.clear_cached_capabilities();
2331+
tracing::warn!(
2332+
target = %self.target,
2333+
failure = store_error_kind(&error),
2334+
"remote restore scan response exceeded the wire payload limit"
2335+
);
2336+
return Err(error);
2337+
}
23272338
if let Err(error) = page.validate_for_request(&request) {
23282339
self.discard_connection().await;
23292340
self.clear_cached_capabilities();

‎crates/opc-session-net/src/lib.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ pub use protocol::{
9999
};
100100
pub use protocol::{
101101
SessionConsensusContractProfile, CURRENT_SESSION_CONSENSUS_CONTRACT_PROFILE,
102-
MAX_NEGOTIATED_FRAME_SIZE, MIN_SESSION_CONSENSUS_FRAME_SIZE, SESSION_CONSENSUS_ALPN,
102+
MAX_NEGOTIATED_FRAME_SIZE, MIN_SESSION_CONSENSUS_FRAME_SIZE,
103+
RESTORE_SCAN_MAX_WIRE_PAGE_PAYLOAD_BYTES, SESSION_CONSENSUS_ALPN,
103104
SESSION_CONSENSUS_TRANSPORT_REVISION,
104105
};
105106
#[cfg(feature = "legacy-session-net-compat")]

0 commit comments

Comments
 (0)