1use std::io::Write;
46
47use icechunk_types::ICResultExt as _;
48
49use crate::{
50 IcechunkFormatError, IcechunkFormatErrorKind,
51 format_constants::{FileTypeBin, SpecVersionBin},
52 manifest::Manifest,
53 repo_info::RepoInfo,
54 snapshot::Snapshot,
55 transaction_log::TransactionLog,
56};
57
58pub fn serialize_snapshot(
59 snapshot: &Snapshot,
60 version: SpecVersionBin,
61 write: &mut impl Write,
62) -> Result<(), std::io::Error> {
63 match version {
64 SpecVersionBin::V1 | SpecVersionBin::V2 => write.write_all(snapshot.bytes()),
65 }
66}
67
68pub fn serialize_manifest(
69 manifest: &Manifest,
70 version: SpecVersionBin,
71 write: &mut impl Write,
72) -> Result<(), std::io::Error> {
73 match version {
74 SpecVersionBin::V1 | SpecVersionBin::V2 => write.write_all(manifest.bytes()),
75 }
76}
77
78pub fn serialize_transaction_log(
79 transaction_log: &TransactionLog,
80 version: SpecVersionBin,
81 write: &mut impl Write,
82) -> Result<(), std::io::Error> {
83 match version {
84 SpecVersionBin::V1 | SpecVersionBin::V2 => {
85 write.write_all(transaction_log.bytes())
86 }
87 }
88}
89
90pub fn serialize_repo_info(
91 info: &RepoInfo,
92 version: SpecVersionBin,
93 write: &mut impl Write,
94) -> Result<(), std::io::Error> {
95 match version {
96 SpecVersionBin::V2 => write.write_all(info.bytes()),
97 SpecVersionBin::V1 => Err(std::io::Error::new(
98 std::io::ErrorKind::Unsupported,
99 "Trying to write to an old Icechunk format version. Aborting.",
100 )),
101 }
102}
103
104pub fn deserialize_snapshot(
105 version: SpecVersionBin,
106 buffer: Vec<u8>,
107) -> Result<Snapshot, IcechunkFormatError> {
108 match version {
109 SpecVersionBin::V1 | SpecVersionBin::V2 => Snapshot::from_buffer(version, buffer),
110 }
111}
112
113pub fn deserialize_manifest(
114 version: SpecVersionBin,
115 buffer: Vec<u8>,
116) -> Result<Manifest, IcechunkFormatError> {
117 match version {
118 SpecVersionBin::V1 | SpecVersionBin::V2 => Manifest::from_buffer(buffer),
119 }
120}
121
122pub fn deserialize_transaction_log(
123 version: SpecVersionBin,
124 buffer: Vec<u8>,
125) -> Result<TransactionLog, IcechunkFormatError> {
126 match version {
127 SpecVersionBin::V1 | SpecVersionBin::V2 => TransactionLog::from_buffer(buffer),
128 }
129}
130
131pub fn deserialize_repo_info(
132 version: SpecVersionBin,
133 buffer: Vec<u8>,
134) -> Result<RepoInfo, IcechunkFormatError> {
135 match version {
136 SpecVersionBin::V2 => RepoInfo::from_buffer(buffer),
137 SpecVersionBin::V1 => {
138 Err(IcechunkFormatErrorKind::UnsupportedOperationForVersion {
139 version: SpecVersionBin::V1 as u8,
140 })
141 .capture()
142 }
143 }
144}
145
146pub const VERIFY_THRESHOLD_BYTES: usize = 128 * 1024 * 1024;
162
163pub const MIN_BYTES_PER_TABLE: usize = 8;
167
168pub const MAX_APPARENT_SIZE_INFLATION: usize = 2;
171
172pub fn verify_buffer(
178 file_type: FileTypeBin,
179 buffer: &[u8],
180) -> Result<(), IcechunkFormatError> {
181 match file_type {
182 FileTypeBin::Snapshot => Snapshot::verify_buffer(buffer),
183 FileTypeBin::Manifest => Manifest::verify_buffer(buffer),
184 FileTypeBin::TransactionLog => TransactionLog::verify_buffer(buffer),
185 FileTypeBin::RepoInfo => RepoInfo::verify_buffer(buffer),
186 FileTypeBin::Attributes | FileTypeBin::Chunk => Ok(()),
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use bytes::Bytes;
193 use flatbuffers::VerifierOptions;
194 use icechunk_types::error::ICError;
195
196 use super::*;
197 use crate::{
198 ChunkId, ChunkIndices, ManifestId, NodeId, SnapshotId,
199 format_constants::FileTypeBin,
200 manifest::{ChunkInfo, ChunkPayload, ChunkRef},
201 };
202
203 fn all_root_options() -> Vec<(&'static str, &'static VerifierOptions)> {
204 vec![
205 ("manifest", crate::manifest::root_options()),
206 ("snapshot", crate::snapshot::root_options()),
207 ("transaction log", crate::transaction_log::root_options()),
208 ("repo info", crate::repo_info::root_options()),
209 ]
210 }
211
212 fn native_payload(i: usize) -> ChunkPayload {
213 ChunkPayload::Ref(ChunkRef {
214 id: ChunkId::random(),
215 offset: i as u64,
216 length: 42,
217 })
218 }
219
220 fn virtual_payload(i: usize) -> ChunkPayload {
221 ChunkPayload::Virtual(crate::manifest::VirtualChunkRef {
222 location: crate::manifest::VirtualChunkLocation::from_url(&format!(
223 "s3://some-bucket/some/prefix/object-{i:012}"
224 ))
225 .expect("bad url"),
226 offset: i as u64,
227 length: 42,
228 checksum: None,
229 })
230 }
231
232 fn inline_payload(_i: usize) -> ChunkPayload {
233 ChunkPayload::Inline(Bytes::from(vec![42u8; 64]))
234 }
235
236 fn many_chunks_manifest(n: usize, payload: fn(usize) -> ChunkPayload) -> Vec<u8> {
237 let node = NodeId::random();
238 let chunks = (0..n)
239 .map(|i| ChunkInfo {
240 node: node.clone(),
241 coord: ChunkIndices(vec![i as u32, 0, 0]),
242 payload: payload(i),
243 })
244 .collect();
245 Manifest::from_sorted_vec(&ManifestId::random(), chunks, None)
246 .expect("cannot build manifest")
247 .expect("manifest is empty")
248 .bytes()
249 .to_vec()
250 }
251
252 fn many_nodes_snapshot(n: usize, arrays: bool) -> Vec<u8> {
253 use crate::snapshot::{ArrayShape, NodeData, NodeSnapshot};
254 let nodes: Vec<_> = (0..n)
255 .map(|i| {
256 let node_data = if arrays {
257 NodeData::Array {
258 shape: ArrayShape::new([(1000u64, 10u32); 3]).expect("bad shape"),
259 dimension_names: None,
260 manifests: vec![crate::manifest::ManifestRef {
261 object_id: ManifestId::random(),
262 extents: crate::manifest::ManifestExtents::new(
263 &[0, 0, 0],
264 &[10, 10, 10],
265 ),
266 }],
267 }
268 } else {
269 NodeData::Group
270 };
271 Ok(NodeSnapshot {
272 id: NodeId::random(),
273 path: crate::Path::new(&format!("/node{i:012}")).expect("bad path"),
274 user_data: Bytes::from(vec![b'u'; 100]),
275 node_data,
276 })
277 })
278 .collect();
279 Snapshot::from_iter(
280 None,
281 None,
282 SpecVersionBin::current(),
283 "a message",
284 None,
285 vec![],
286 None,
287 nodes,
288 )
289 .expect("cannot build snapshot")
290 .bytes()
291 .to_vec()
292 }
293
294 fn many_coords_transaction_log(n: usize) -> Vec<u8> {
295 let empty = Vec::<NodeId>::new();
296 let chunks =
297 vec![(NodeId::random(), (0..n).map(|i| ChunkIndices(vec![i as u32, 0, 0])))];
298 TransactionLog::new_from_parts(
299 &SnapshotId::random(),
300 empty.clone().into_iter(),
301 empty.clone().into_iter(),
302 empty.clone().into_iter(),
303 empty.clone().into_iter(),
304 empty.clone().into_iter(),
305 empty.into_iter(),
306 chunks.into_iter(),
307 std::iter::empty(),
308 )
309 .bytes()
310 .to_vec()
311 }
312
313 fn many_snapshots_repo_info(n: usize) -> Vec<u8> {
314 use crate::repo_info::{RepoAvailability, RepoStatus, UpdateInfo, UpdateType};
315 let snapshots: Vec<_> = (0..n)
316 .map(|i| crate::snapshot::SnapshotInfo {
317 id: SnapshotId::random(),
318 parent_id: None,
319 flushed_at: chrono::Utc::now(),
320 message: format!("commit number {i}"),
321 metadata: Default::default(),
322 pruned_ancestor_tx_logs: vec![],
323 })
324 .collect();
325 let branch_target = snapshots[0].id.clone();
326 let updates: Vec<crate::repo_info::UpdateTuple<'_>> = vec![];
327 RepoInfo::new(
328 SpecVersionBin::current(),
329 [],
330 [("main", branch_target)],
331 [],
332 snapshots,
333 &Default::default(),
334 UpdateInfo {
335 update_type: UpdateType::RepoInitializedUpdate,
336 update_time: chrono::Utc::now(),
337 previous_updates: updates,
338 },
339 None,
340 10,
341 None,
342 None,
343 None::<std::iter::Empty<u16>>,
344 None::<std::iter::Empty<u16>>,
345 &RepoStatus {
346 availability: RepoAvailability::Online,
347 set_at: chrono::Utc::now(),
348 limited_availability_reason: None,
349 },
350 )
351 .expect("cannot build repo info")
352 .bytes()
353 .to_vec()
354 }
355
356 fn a_manifest() -> Manifest {
357 Manifest::from_sorted_vec(
358 &ManifestId::random(),
359 vec![ChunkInfo {
360 node: NodeId::random(),
361 coord: ChunkIndices(vec![0, 0]),
362 payload: ChunkPayload::Ref(ChunkRef {
363 id: ChunkId::random(),
364 offset: 0,
365 length: 42,
366 }),
367 }],
368 None,
369 )
370 .expect("cannot build manifest")
371 .expect("manifest is empty")
372 }
373
374 fn a_transaction_log() -> TransactionLog {
375 let empty = Vec::<NodeId>::new();
376 let no_chunks = Vec::<(NodeId, std::vec::IntoIter<ChunkIndices>)>::new();
377 TransactionLog::new_from_parts(
378 &SnapshotId::random(),
379 empty.clone().into_iter(),
380 empty.clone().into_iter(),
381 empty.clone().into_iter(),
382 empty.clone().into_iter(),
383 empty.clone().into_iter(),
384 empty.into_iter(),
385 no_chunks.into_iter(),
386 std::iter::empty(),
387 )
388 }
389
390 fn a_repo_info() -> RepoInfo {
391 RepoInfo::initial(
392 SpecVersionBin::current(),
393 crate::snapshot::SnapshotInfo {
394 id: SnapshotId::random(),
395 parent_id: None,
396 flushed_at: chrono::Utc::now(),
397 message: "initial".to_string(),
398 metadata: Default::default(),
399 pruned_ancestor_tx_logs: vec![],
400 },
401 10,
402 None::<&()>,
403 None,
404 )
405 }
406
407 #[icechunk_macros::test]
408 fn verify_buffer_accepts_buffers_we_serialize()
409 -> Result<(), Box<dyn std::error::Error>> {
410 let snapshot = Snapshot::initial(SpecVersionBin::current())?;
411 verify_buffer(FileTypeBin::Snapshot, snapshot.bytes())?;
412 verify_buffer(FileTypeBin::Manifest, a_manifest().bytes())?;
413 verify_buffer(FileTypeBin::TransactionLog, a_transaction_log().bytes())?;
414 verify_buffer(FileTypeBin::RepoInfo, a_repo_info().bytes())?;
415 Ok(())
416 }
417
418 #[icechunk_macros::test]
419 fn verify_buffer_rejects_buffers_that_cannot_be_read_back() {
420 let snapshot_bytes = a_manifest().bytes().to_vec();
422 for file_type in [
423 FileTypeBin::Snapshot,
424 FileTypeBin::Manifest,
425 FileTypeBin::TransactionLog,
426 FileTypeBin::RepoInfo,
427 ] {
428 for bad in [vec![0u8; 64], vec![0xffu8; 7], snapshot_bytes[..32].to_vec()] {
429 assert!(
430 verify_buffer(file_type, bad.as_slice()).is_err(),
431 "{file_type:?} accepted a buffer it cannot read back"
432 );
433 }
434 }
435 }
436
437 #[icechunk_macros::test]
438 fn verify_buffer_ignores_file_types_that_are_not_flatbuffers() {
439 let chunk = Bytes::from_static(b"not a flatbuffer");
441 assert!(verify_buffer(FileTypeBin::Chunk, chunk.as_ref()).is_ok());
442 }
443
444 type VerifyFn = Box<dyn Fn(&[u8], &VerifierOptions) -> bool>;
446
447 type Sample = (&'static str, Vec<u8>, VerifyFn);
449
450 struct Counters {
453 apparent_size: usize,
454 tables: usize,
455 bytes: usize,
456 }
457
458 fn min_limit_that_passes(hi: usize, passes: impl Fn(usize) -> bool) -> usize {
459 assert!(passes(hi), "no limit in range lets the buffer through");
460 let (mut lo, mut hi) = (0usize, hi);
461 while lo < hi {
462 let mid = lo + (hi - lo) / 2;
463 if passes(mid) {
464 hi = mid;
465 } else {
466 lo = mid + 1;
467 }
468 }
469 lo
470 }
471
472 fn unlimited() -> VerifierOptions {
473 VerifierOptions {
474 max_depth: 1 << 20,
475 max_tables: 1 << 40,
476 max_apparent_size: 1 << 50,
477 ignore_missing_null_terminator: true,
478 }
479 }
480
481 fn with_apparent_size(limit: usize) -> VerifierOptions {
482 VerifierOptions { max_apparent_size: limit, ..unlimited() }
483 }
484
485 fn with_tables(limit: usize) -> VerifierOptions {
486 VerifierOptions { max_tables: limit, ..unlimited() }
487 }
488
489 fn count(verify: &VerifyFn, buffer: &[u8]) -> Counters {
490 assert!(verify(buffer, &unlimited()), "buffer does not verify at all");
491 Counters {
492 apparent_size: min_limit_that_passes(1 << 40, |limit| {
493 verify(buffer, &with_apparent_size(limit))
494 }),
495 tables: min_limit_that_passes(1 << 32, |limit| {
496 verify(buffer, &with_tables(limit))
497 }),
498 bytes: buffer.len(),
499 }
500 }
501
502 fn sample_buffers() -> Vec<Sample> {
505 let n = 5_000;
506 let manifest_verify = || {
507 Box::new(|b: &[u8], o: &VerifierOptions| {
508 flatbuffers::root_with_opts::<crate::flatbuffers::generated::Manifest<'_>>(
509 o, b,
510 )
511 .is_ok()
512 })
513 };
514 vec![
515 (
516 "manifest native refs",
517 many_chunks_manifest(n, native_payload),
518 manifest_verify(),
519 ),
520 (
521 "manifest virtual refs",
522 many_chunks_manifest(n, virtual_payload),
523 manifest_verify(),
524 ),
525 (
526 "manifest inline refs",
527 many_chunks_manifest(n, inline_payload),
528 manifest_verify(),
529 ),
530 (
531 "snapshot groups",
532 many_nodes_snapshot(n, false),
533 Box::new(|b: &[u8], o: &VerifierOptions| {
534 flatbuffers::root_with_opts::<
535 crate::flatbuffers::generated::Snapshot<'_>,
536 >(o, b)
537 .is_ok()
538 }),
539 ),
540 (
541 "snapshot arrays",
542 many_nodes_snapshot(n, true),
543 Box::new(|b: &[u8], o: &VerifierOptions| {
544 flatbuffers::root_with_opts::<
545 crate::flatbuffers::generated::Snapshot<'_>,
546 >(o, b)
547 .is_ok()
548 }),
549 ),
550 (
551 "transaction log chunk coords",
552 many_coords_transaction_log(n),
553 Box::new(|b: &[u8], o: &VerifierOptions| {
554 flatbuffers::root_with_opts::<
555 crate::flatbuffers::generated::TransactionLog<'_>,
556 >(o, b)
557 .is_ok()
558 }),
559 ),
560 (
561 "repo snapshot infos",
562 many_snapshots_repo_info(n),
563 Box::new(|b: &[u8], o: &VerifierOptions| {
564 flatbuffers::root_with_opts::<crate::flatbuffers::generated::Repo<'_>>(
565 o, b,
566 )
567 .is_ok()
568 }),
569 ),
570 ]
571 }
572
573 #[icechunk_macros::test]
574 fn apparent_size_stays_within_the_inflation_bound() {
575 for (name, buffer, verify) in sample_buffers() {
576 let counted = count(&verify, buffer.as_slice());
577 assert!(
578 counted.apparent_size <= MAX_APPARENT_SIZE_INFLATION * counted.bytes,
579 "{name}: apparent size {} is more than {MAX_APPARENT_SIZE_INFLATION}x the {} bytes it walks",
580 counted.apparent_size,
581 counted.bytes,
582 );
583 }
584 }
585
586 #[icechunk_macros::test]
587 fn tables_cost_at_least_the_assumed_minimum_bytes() {
588 for (name, buffer, verify) in sample_buffers() {
589 let counted = count(&verify, buffer.as_slice());
590 assert!(
591 counted.bytes >= MIN_BYTES_PER_TABLE * counted.tables,
592 "{name}: {} tables in only {} bytes, under {MIN_BYTES_PER_TABLE} bytes each",
593 counted.tables,
594 counted.bytes,
595 );
596 }
597 }
598
599 #[icechunk_macros::test]
600 fn verifier_limits_leave_room_below_the_write_threshold() {
601 for (name, options) in all_root_options() {
602 assert!(
605 MAX_APPARENT_SIZE_INFLATION * VERIFY_THRESHOLD_BYTES
606 <= options.max_apparent_size,
607 "{name}: a file just under the write threshold could exceed max_apparent_size",
608 );
609 assert!(
610 VERIFY_THRESHOLD_BYTES / MIN_BYTES_PER_TABLE <= options.max_tables,
611 "{name}: a file just under the write threshold could exceed max_tables",
612 );
613 }
614 }
615
616 #[icechunk_macros::test]
617 fn every_file_we_can_build_can_be_read_back() {
618 for (name, options) in all_root_options() {
621 assert!(
622 MAX_APPARENT_SIZE_INFLATION
623 .saturating_mul(flatbuffers::FLATBUFFERS_MAX_BUFFER_SIZE)
624 <= options.max_apparent_size,
625 "{name}: a file we can build could be rejected by max_apparent_size",
626 );
627 }
628 }
629
630 #[icechunk_macros::test]
631 fn verify_buffer_error_names_the_file_type() {
632 let err = verify_buffer(FileTypeBin::Manifest, &[0u8; 64])
633 .expect_err("garbage must not verify");
634 assert!(matches!(
635 err,
636 ICError { kind: IcechunkFormatErrorKind::InvalidFlatBuffer(_), .. }
637 ));
638 }
639}