Skip to content

Commit 954b0ed

Browse files
committed
Bound delivery delta metadata within poll envelopes
1 parent 4029c82 commit 954b0ed

5 files changed

Lines changed: 149 additions & 13 deletions

File tree

crates/locality-protocol/src/freshness_delivery.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ use crate::workspace_layout::LayoutDigest;
2121
pub const FRESHNESS_DELIVERY_READER_VERSION: u16 = 1;
2222
pub const GENERATION_DELTA_FORMAT_VERSION: u16 = 1;
2323
pub const MAX_GENERATION_DELTA_ENTRIES: usize = 100_000;
24+
/// Maximum compact JSON metadata for one complete delta. The transport reserves
25+
/// the remaining 1 MiB of its 64 MiB poll response for the envelope and terminal
26+
/// receipt, so V1 never requires metadata pagination.
27+
pub const MAX_GENERATION_DELTA_METADATA_BYTES: usize = 63 * 1024 * 1024;
2428
pub const MAX_GENERATION_FILE_BYTES: u64 = 64 * 1024 * 1024;
2529
pub const MAX_GENERATION_DELTA_CONTENT_BYTES: u64 = 512 * 1024 * 1024;
2630
pub const MAX_DELIVERY_ID_BYTES: usize = 128;
@@ -455,9 +459,24 @@ impl GenerationDelta {
455459
actual: changed_content_bytes,
456460
});
457461
}
462+
let metadata_bytes = self.serialized_metadata_len()?;
463+
if metadata_bytes > MAX_GENERATION_DELTA_METADATA_BYTES {
464+
return Err(FreshnessDeliveryError::DeltaMetadataTooLarge {
465+
actual: metadata_bytes,
466+
});
467+
}
458468
Ok(())
459469
}
460470

471+
/// Exact byte length of this delta's compact serde JSON representation.
472+
/// Counting writes avoids allocating a second metadata-sized buffer.
473+
pub fn serialized_metadata_len(&self) -> Result<usize, FreshnessDeliveryError> {
474+
let mut writer = JsonLengthWriter::default();
475+
serde_json::to_writer(&mut writer, self)
476+
.map_err(|_| FreshnessDeliveryError::CanonicalValueTooLarge)?;
477+
Ok(writer.len)
478+
}
479+
461480
pub fn canonical_preimage(&self) -> Result<Vec<u8>, FreshnessDeliveryError> {
462481
self.validate()?;
463482
let mut output = b"locality.generation-delta.v1\0".to_vec();
@@ -635,6 +654,7 @@ pub enum FreshnessDeliveryError {
635654
TooManyDeltaEntries { actual: usize },
636655
FileContentTooLarge { actual: u64 },
637656
DeltaContentTooLarge { actual: u64 },
657+
DeltaMetadataTooLarge { actual: usize },
638658
NonCanonicalDeltaOrder,
639659
NonCanonicalTargetInventoryOrder,
640660
CrossEntryPathReuse,
@@ -702,6 +722,10 @@ impl Display for FreshnessDeliveryError {
702722
formatter,
703723
"delta contains {actual} content bytes, exceeding {MAX_GENERATION_DELTA_CONTENT_BYTES}"
704724
),
725+
Self::DeltaMetadataTooLarge { actual } => write!(
726+
formatter,
727+
"delta metadata encoding is {actual} bytes, exceeding {MAX_GENERATION_DELTA_METADATA_BYTES}"
728+
),
705729
Self::NonCanonicalDeltaOrder => {
706730
formatter.write_str("delta entries are not in canonical order")
707731
}
@@ -728,6 +752,25 @@ impl Display for FreshnessDeliveryError {
728752

729753
impl std::error::Error for FreshnessDeliveryError {}
730754

755+
#[derive(Default)]
756+
struct JsonLengthWriter {
757+
len: usize,
758+
}
759+
760+
impl std::io::Write for JsonLengthWriter {
761+
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
762+
self.len = self
763+
.len
764+
.checked_add(bytes.len())
765+
.ok_or_else(|| std::io::Error::other("JSON length overflow"))?;
766+
Ok(bytes.len())
767+
}
768+
769+
fn flush(&mut self) -> std::io::Result<()> {
770+
Ok(())
771+
}
772+
}
773+
731774
fn validate_versions(
732775
format_version: u16,
733776
minimum_reader_version: u16,

crates/locality-protocol/src/freshness_delivery_transport.rs

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ use crate::FreshnessEpoch;
1616
use crate::freshness_delivery::{
1717
FreshnessReasonCode, FreshnessRetry, GenerationDelta, GenerationDeltaTerminalReceipt,
1818
GenerationFileIdentity, MAX_DELIVERY_ID_BYTES, MAX_DELIVERY_TIMESTAMP_BYTES,
19+
MAX_GENERATION_DELTA_METADATA_BYTES,
1920
};
2021

2122
pub const GENERATION_TRANSPORT_FORMAT_VERSION: u16 = 1;
2223
pub const GENERATION_TRANSPORT_READER_VERSION: u16 = 1;
2324
pub const MAX_GENERATION_TRANSPORT_CAPABILITIES_BYTES: usize = 4 * 1024;
2425
pub const MAX_GENERATION_TRANSPORT_REQUEST_BYTES: usize = 16 * 1024;
2526
pub const MAX_GENERATION_DELIVERY_POLL_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
27+
pub const GENERATION_DELIVERY_POLL_ENVELOPE_HEADROOM_BYTES: usize = 1024 * 1024;
2628
pub const MAX_GENERATION_BODY_WINDOW_METADATA_BYTES: usize = 16 * 1024;
2729
pub const MAX_GENERATION_BODY_WINDOW_BYTES: u64 = 4 * 1024 * 1024;
2830
pub const GENERATION_DELIVERY_POLL_CONTENT_TYPE: &str = "application/json";
@@ -37,6 +39,24 @@ pub const MAX_OPAQUE_DEVICE_SCOPE_ID_BYTES: usize = 128;
3739
pub const MAX_OPAQUE_PIN_LEASE_ID_BYTES: usize = 256;
3840
pub const MAX_GENERATION_PIN_OPERATION_ID_BYTES: usize = 128;
3941

42+
const MAX_ESCAPED_DELIVERY_ID_BYTES: usize = 6 * MAX_DELIVERY_ID_BYTES;
43+
const MAX_ESCAPED_DELIVERY_TIMESTAMP_BYTES: usize = 6 * MAX_DELIVERY_TIMESTAMP_BYTES;
44+
// Deliberately overcounts V1's identifiers, timestamps, fixed JSON syntax, and
45+
// numeric/enum fields in addition to the independently bounded capabilities.
46+
const MAX_GENERATION_DELIVERY_POLL_ENVELOPE_REQUIRED_BYTES: usize =
47+
MAX_GENERATION_TRANSPORT_CAPABILITIES_BYTES
48+
+ 16 * MAX_ESCAPED_DELIVERY_ID_BYTES
49+
+ 4 * MAX_ESCAPED_DELIVERY_TIMESTAMP_BYTES
50+
+ 64 * 1024;
51+
const _: () = assert!(
52+
MAX_GENERATION_DELTA_METADATA_BYTES + GENERATION_DELIVERY_POLL_ENVELOPE_HEADROOM_BYTES
53+
<= MAX_GENERATION_DELIVERY_POLL_RESPONSE_BYTES
54+
);
55+
const _: () = assert!(
56+
MAX_GENERATION_DELIVERY_POLL_ENVELOPE_REQUIRED_BYTES
57+
<= GENERATION_DELIVERY_POLL_ENVELOPE_HEADROOM_BYTES
58+
);
59+
4060
pub const GENERATION_TRANSPORT_CAPABILITIES_V1_GOLDEN_JSON: &[u8] =
4161
include_bytes!("../fixtures/generation-transport-capabilities-v1.json");
4262
pub const GENERATION_DELIVERY_REQUEST_V1_GOLDEN_JSON: &[u8] =
@@ -367,9 +387,10 @@ impl GenerationDeliveryPollResponse {
367387
_ => return Err(GenerationTransportContractError::AmbiguousPollResponse),
368388
}
369389

370-
let encoded = serde_json::to_vec(self)
371-
.expect("serializing a typed generation delivery poll response cannot fail");
372-
validate_encoding_length_against(encoded.len(), MAX_GENERATION_DELIVERY_POLL_RESPONSE_BYTES)
390+
validate_encoding_length_against(
391+
serialized_json_len(self),
392+
MAX_GENERATION_DELIVERY_POLL_RESPONSE_BYTES,
393+
)
373394
}
374395
}
375396

@@ -1461,6 +1482,32 @@ fn validate_encoding_length_against(
14611482
}
14621483
}
14631484

1485+
#[derive(Default)]
1486+
struct JsonLengthWriter {
1487+
len: usize,
1488+
}
1489+
1490+
impl std::io::Write for JsonLengthWriter {
1491+
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
1492+
self.len = self
1493+
.len
1494+
.checked_add(bytes.len())
1495+
.ok_or_else(|| std::io::Error::other("JSON length overflow"))?;
1496+
Ok(bytes.len())
1497+
}
1498+
1499+
fn flush(&mut self) -> std::io::Result<()> {
1500+
Ok(())
1501+
}
1502+
}
1503+
1504+
fn serialized_json_len(value: &impl Serialize) -> usize {
1505+
let mut writer = JsonLengthWriter::default();
1506+
serde_json::to_writer(&mut writer, value)
1507+
.expect("serializing a typed generation transport value cannot fail");
1508+
writer.len
1509+
}
1510+
14641511
fn validate_body_frame_length(actual: usize) -> Result<(), GenerationTransportContractError> {
14651512
let maximum = GENERATION_BODY_WINDOW_METADATA_LENGTH_BYTES
14661513
+ MAX_GENERATION_BODY_WINDOW_METADATA_BYTES

crates/locality-protocol/tests/freshness_delivery.rs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ use locality_protocol::freshness_delivery::{
99
GENERATION_DELTA_PREIMAGE_V1_GOLDEN_JSON, GENERATION_DELTA_RECEIPT_V1_GOLDEN_JSON,
1010
GENERATION_DELTA_V1_GOLDEN_JSON, GENERATION_TARGET_INVENTORY_V1_VECTORS_JSON, GenerationDelta,
1111
GenerationDeltaEntry, GenerationDeltaTerminalReceipt, GenerationFileIdentity,
12-
MAX_GENERATION_DELTA_CONTENT_BYTES, MAX_GENERATION_FILE_BYTES, ProviderHealth,
13-
ProviderHealthState, ProviderWorkerProgress, PublicationGenerationHealth,
14-
PublicationGenerationState, canonical_target_inventory_preimage,
12+
MAX_GENERATION_DELTA_CONTENT_BYTES, MAX_GENERATION_DELTA_METADATA_BYTES,
13+
MAX_GENERATION_FILE_BYTES, ProviderHealth, ProviderHealthState, ProviderWorkerProgress,
14+
PublicationGenerationHealth, PublicationGenerationState, canonical_target_inventory_preimage,
1515
canonical_target_inventory_sha256,
1616
};
1717
use locality_protocol::workspace_layout::LayoutDigest;
@@ -476,3 +476,29 @@ fn empty_delta_advances_generation_and_content_limits_are_bounded() {
476476
Err(FreshnessDeliveryError::DeltaContentTooLarge { .. })
477477
));
478478
}
479+
480+
#[test]
481+
fn structurally_valid_delta_rejects_oversized_exact_json_metadata() {
482+
let mut oversized = delta();
483+
let escaped_id_tail = "\0".repeat(123);
484+
let escaped_content_id = "\0".repeat(128);
485+
oversized.entries = (0..42_000)
486+
.map(|index| GenerationDeltaEntry {
487+
old: None,
488+
new: Some(identity(
489+
&format!("{index:05}{escaped_id_tail}"),
490+
&format!("file-{index:05}"),
491+
&escaped_content_id,
492+
'3',
493+
1,
494+
)),
495+
})
496+
.collect();
497+
498+
let actual = oversized.serialized_metadata_len().unwrap();
499+
assert!(actual > MAX_GENERATION_DELTA_METADATA_BYTES);
500+
assert_eq!(
501+
oversized.validate(),
502+
Err(FreshnessDeliveryError::DeltaMetadataTooLarge { actual })
503+
);
504+
}

crates/locality-protocol/tests/freshness_delivery_transport.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ use locality_core::portable::{
33
};
44
use locality_core::workspace_layout::PortableMountId;
55
use locality_protocol::freshness_delivery::{
6-
GENERATION_DELTA_RECEIPT_V1_GOLDEN_JSON, GenerationDeltaTerminalReceipt, GenerationFileIdentity,
6+
GENERATION_DELTA_RECEIPT_V1_GOLDEN_JSON, GenerationDeltaTerminalReceipt,
7+
GenerationFileIdentity, MAX_GENERATION_DELTA_METADATA_BYTES,
78
};
89
use locality_protocol::freshness_delivery_transport::{
910
GENERATION_BODY_WINDOW_CONTENT_TYPE, GENERATION_BODY_WINDOW_FRAME_V1_GOLDEN_HEX,
1011
GENERATION_BODY_WINDOW_METADATA_V1_GOLDEN_JSON, GENERATION_BODY_WINDOW_REQUEST_V1_GOLDEN_JSON,
11-
GENERATION_DELIVERY_ACKNOWLEDGMENT_V1_GOLDEN_JSON, GENERATION_DELIVERY_POLL_V1_GOLDEN_JSON,
12+
GENERATION_DELIVERY_ACKNOWLEDGMENT_V1_GOLDEN_JSON,
13+
GENERATION_DELIVERY_POLL_ENVELOPE_HEADROOM_BYTES, GENERATION_DELIVERY_POLL_V1_GOLDEN_JSON,
1214
GENERATION_DELIVERY_REQUEST_V1_GOLDEN_JSON, GENERATION_PIN_LEASE_V1_GOLDEN_JSON,
1315
GENERATION_TRANSPORT_CAPABILITIES_V1_GOLDEN_JSON, GENERATION_TRANSPORT_FORMAT_VERSION,
1416
GENERATION_TRANSPORT_READER_VERSION, GenerationBodyRange, GenerationBodyWindowCapability,
@@ -247,11 +249,22 @@ fn poll_envelope_statuses_are_bounded_and_bound_to_the_request() {
247249
})
248250
);
249251

252+
let normal_delivery = serde_json::to_vec(&polls.delivery).unwrap();
253+
assert!(normal_delivery.len() < MAX_GENERATION_DELIVERY_POLL_RESPONSE_BYTES);
254+
GenerationDeliveryPollResponse::decode_json(&normal_delivery, &request)
255+
.expect("normal delivery poll");
256+
257+
assert_eq!(
258+
MAX_GENERATION_DELTA_METADATA_BYTES + GENERATION_DELIVERY_POLL_ENVELOPE_HEADROOM_BYTES,
259+
MAX_GENERATION_DELIVERY_POLL_RESPONSE_BYTES
260+
);
261+
let mut boundary = serde_json::to_vec(&polls.no_delivery).unwrap();
262+
boundary.resize(MAX_GENERATION_DELIVERY_POLL_RESPONSE_BYTES, b' ');
263+
GenerationDeliveryPollResponse::decode_json(&boundary, &request)
264+
.expect("poll at exact response boundary");
265+
boundary.push(b' ');
250266
assert!(matches!(
251-
GenerationDeliveryPollResponse::decode_json(
252-
&vec![b' '; MAX_GENERATION_DELIVERY_POLL_RESPONSE_BYTES + 1],
253-
&request,
254-
),
267+
GenerationDeliveryPollResponse::decode_json(&boundary, &request),
255268
Err(GenerationTransportContractError::EncodingTooLarge { .. })
256269
));
257270
}

docs/freshness-delivery-transport.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ adapter maps that portable value to its local daemon result after authentication
2727
the protocol value contains no reader or stream type.
2828

2929
The poll response uses `Content-Type: application/json`, is capped at 64 MiB,
30-
and has exactly one of these status/payload combinations:
30+
and reserves 1 MiB for its bounded envelope and terminal receipt. A complete
31+
delta's exact compact serde JSON is capped at 63 MiB, so every delta accepted by
32+
the public contract fits in one V1 poll response without metadata pagination.
33+
The entry-count ceiling remains 100,000. Delta validation counts serializer
34+
output without allocating a second metadata-sized buffer. The response has
35+
exactly one of these status/payload combinations:
3136

3237
- `delivery` has one delta and terminal receipt and no error.
3338
- `no_delivery` has neither a delivery nor an error.
@@ -187,6 +192,8 @@ is integrated with generation selection.
187192
| --- | ---: |
188193
| Capability JSON | 4 KiB |
189194
| Request/metadata JSON | 16 KiB |
195+
| Complete delta metadata JSON | 63 MiB |
196+
| Poll envelope and terminal-receipt headroom | 1 MiB |
190197
| Poll response JSON | 64 MiB |
191198
| Body window | 4 MiB |
192199
| Body-window frame | 4-byte prefix + 16 KiB metadata + 4 MiB body |

0 commit comments

Comments
 (0)