1#[cfg(test)]
2use std::path::PathBuf;
3#[cfg(test)]
4use std::sync::{LazyLock, Mutex};
5use std::{
6 fs::File,
7 io::{BufReader, ErrorKind, Read},
8 path::Path,
9};
10
11use shardline_index::{
12 FileChunkRecord, FileRecord, FileRecordInvariantError, FileRecordStorageLayout,
13};
14use shardline_protocol::ByteRange;
15pub use shardline_server_core::ServerObjectStore;
16pub use shardline_server_core::ServerObjectStoreError;
17use shardline_storage::{ObjectKey, ObjectMetadata, ObjectPrefix, ObjectStore};
18
19use crate::error::{IndexError, ObjectStoreError};
20use crate::{
21 ObjectStorageAdapter, ServerConfig, ServerError, ServerFrontend, chunk_store::chunk_object_key,
22 server_frontend::append_referenced_term_bytes,
23};
24
25#[cfg(test)]
26type LocalObjectReadHook = Box<dyn FnOnce() + Send>;
27
28#[cfg(test)]
29struct LocalObjectReadHookRegistration {
30 path: PathBuf,
31 hook: LocalObjectReadHook,
32}
33
34#[cfg(test)]
35type LocalObjectReadHookSlot = Option<LocalObjectReadHookRegistration>;
36
37#[cfg(test)]
38static BEFORE_LOCAL_OBJECT_READ_HOOK: LazyLock<Mutex<LocalObjectReadHookSlot>> =
39 LazyLock::new(|| Mutex::new(None));
40
41impl From<ServerObjectStoreError> for ServerError {
42 fn from(value: ServerObjectStoreError) -> Self {
43 match value {
44 ServerObjectStoreError::NotFound => Self::NotFound,
45 ServerObjectStoreError::Overflow => Self::Overflow,
46 ServerObjectStoreError::InvalidContentHash => Self::InvalidContentHash,
47 ServerObjectStoreError::StoredObjectLengthMismatch => {
48 Self::ObjectStore(ObjectStoreError::StoredLengthMismatch)
49 }
50 ServerObjectStoreError::Local(e) => Self::ObjectStore(ObjectStoreError::Local(e)),
51 ServerObjectStoreError::S3(e) => Self::ObjectStore(ObjectStoreError::S3(e)),
52 ServerObjectStoreError::Io(e) => Self::Io(e),
53 ServerObjectStoreError::NumericConversion(e) => Self::NumericConversion(e),
54 }
55 }
56}
57
58pub(crate) fn visit_object_prefix(
59 object_store: &ServerObjectStore,
60 prefix: &ObjectPrefix,
61 mut visitor: impl FnMut(ObjectMetadata) -> Result<(), ServerError>,
62) -> Result<(), ServerError> {
63 object_store.visit_prefix(prefix, &mut visitor)
64}
65
66pub(crate) fn read_full_object(
67 object_store: &ServerObjectStore,
68 object_key: &ObjectKey,
69 length: u64,
70) -> Result<Vec<u8>, ServerError> {
71 const MAX_FULL_OBJECT_READ_BYTES: u64 = 1_073_741_824;
72
73 if length > MAX_FULL_OBJECT_READ_BYTES {
74 return Err(ServerError::RequestBodyTooLarge);
75 }
76 if length == 0 {
77 return Ok(Vec::new());
78 }
79
80 if let ServerObjectStore::Local(store) = object_store {
81 let path = store.path_for_key(object_key);
82 let file = store.open_object_file(object_key)?;
83 let actual_length = validate_local_object_length(&file, length)?;
84 return read_open_local_object(&path, file, actual_length);
85 }
86
87 let end = length.checked_sub(1).ok_or(ServerError::Overflow)?;
88 let range = ByteRange::new(0, end).map_err(|_error| ServerError::Overflow)?;
89 Ok(object_store.read_range(object_key, range)?)
90}
91
92pub(crate) fn reconstruct_local_file_bytes(
93 object_store: &ServerObjectStore,
94 chunks: &[FileChunkRecord],
95 capacity: usize,
96) -> Result<Vec<u8>, ServerError> {
97 let mut output = Vec::with_capacity(capacity);
98 for chunk in chunks {
99 let expected_offset = u64::try_from(output.len())?;
100 if chunk.offset != expected_offset {
101 return Err(ServerError::Index(IndexError::FileRecordInvariant(
102 FileRecordInvariantError::NonContiguousChunkOffsets,
103 )));
104 }
105 let object_key = chunk_object_key(&chunk.hash)?;
106 let ServerObjectStore::Local(store) = object_store else {
107 return Err(ServerError::NotFound);
108 };
109 let path = store.path_for_key(&object_key);
110 let file = store.open_object_file(&object_key)?;
111 read_open_local_object_append(&path, file, chunk.length, &mut output)?;
112 }
113 if output.len() != capacity {
114 return Err(ServerError::ObjectStore(
115 ObjectStoreError::StoredLengthMismatch,
116 ));
117 }
118 Ok(output)
119}
120
121pub(crate) fn reconstruct_file_record_bytes(
122 object_store: &ServerObjectStore,
123 frontends: &[ServerFrontend],
124 record: &FileRecord,
125) -> Result<Vec<u8>, ServerError> {
126 let capacity = usize::try_from(record.total_bytes)?;
127 match record.storage_layout() {
128 FileRecordStorageLayout::ReferencedObjectTerms => {
129 reconstruct_referenced_object_file_bytes(object_store, frontends, record, capacity)
130 }
131 FileRecordStorageLayout::StoredChunks => {
132 reconstruct_chunk_file_bytes(object_store, &record.chunks, capacity)
133 }
134 }
135}
136
137fn reconstruct_chunk_file_bytes(
138 object_store: &ServerObjectStore,
139 chunks: &[FileChunkRecord],
140 capacity: usize,
141) -> Result<Vec<u8>, ServerError> {
142 if object_store.local_root().is_some() {
143 return reconstruct_local_file_bytes(object_store, chunks, capacity);
144 }
145
146 let mut output = Vec::with_capacity(capacity);
147 for chunk in chunks {
148 let object_key = chunk_object_key(&chunk.hash)?;
149 let bytes = read_full_object(object_store, &object_key, chunk.length)?;
150 output.extend_from_slice(&bytes);
151 }
152 if output.len() != capacity {
153 return Err(ServerError::ObjectStore(
154 ObjectStoreError::StoredLengthMismatch,
155 ));
156 }
157
158 Ok(output)
159}
160
161fn reconstruct_referenced_object_file_bytes(
162 object_store: &ServerObjectStore,
163 frontends: &[ServerFrontend],
164 record: &FileRecord,
165 capacity: usize,
166) -> Result<Vec<u8>, ServerError> {
167 let mut output = Vec::with_capacity(capacity);
168 for term in &record.chunks {
169 let term_start = u64::try_from(output.len())?;
170 if term.offset != term_start {
171 return Err(ServerError::Index(IndexError::FileRecordInvariant(
172 FileRecordInvariantError::NonContiguousChunkOffsets,
173 )));
174 }
175 append_referenced_term_bytes(frontends, object_store, term, &mut output)?;
176 let term_end = term
177 .offset
178 .checked_add(term.length)
179 .ok_or(ServerError::Overflow)?;
180 if u64::try_from(output.len())? != term_end {
181 return Err(ServerError::ObjectStore(
182 ObjectStoreError::StoredLengthMismatch,
183 ));
184 }
185 }
186 if output.len() != capacity {
187 return Err(ServerError::ObjectStore(
188 ObjectStoreError::StoredLengthMismatch,
189 ));
190 }
191
192 Ok(output)
193}
194
195fn read_open_local_object(path: &Path, file: File, length: u64) -> Result<Vec<u8>, ServerError> {
196 let capacity = usize::try_from(length)?;
197 let mut output = Vec::with_capacity(capacity);
198 read_open_local_object_append(path, file, length, &mut output)?;
199 if output.len() != capacity {
200 return Err(ServerError::ObjectStore(
201 ObjectStoreError::StoredLengthMismatch,
202 ));
203 }
204 Ok(output)
205}
206
207fn read_open_local_object_append(
208 path: &Path,
209 file: File,
210 expected_length: u64,
211 output: &mut Vec<u8>,
212) -> Result<(), ServerError> {
213 validate_local_object_length(&file, expected_length)?;
214 run_before_local_object_read_hook(path);
215 let start_len = output.len();
216 let mut reader = BufReader::new(file);
217 let mut limited = reader.by_ref().take(expected_length);
218 if let Err(error) = limited.read_to_end(output) {
219 if error.kind() == ErrorKind::UnexpectedEof {
220 return Err(ServerError::ObjectStore(
221 ObjectStoreError::StoredLengthMismatch,
222 ));
223 }
224
225 return Err(ServerError::Io(error));
226 }
227 let read_len = output
228 .len()
229 .checked_sub(start_len)
230 .ok_or(ServerError::Overflow)?;
231 if u64::try_from(read_len)? != expected_length {
232 return Err(ServerError::ObjectStore(
233 ObjectStoreError::StoredLengthMismatch,
234 ));
235 }
236 let mut trailing_byte = [0_u8; 1];
237 match reader.read(&mut trailing_byte) {
238 Ok(0) => {}
239 Ok(_observed) => {
240 return Err(ServerError::ObjectStore(
241 ObjectStoreError::StoredLengthMismatch,
242 ));
243 }
244 Err(error) if error.kind() == ErrorKind::UnexpectedEof => {
245 return Err(ServerError::ObjectStore(
246 ObjectStoreError::StoredLengthMismatch,
247 ));
248 }
249 Err(error) => return Err(ServerError::Io(error)),
250 }
251 validate_local_object_length(&reader.into_inner(), expected_length)?;
252
253 Ok(())
254}
255
256fn validate_local_object_length(file: &File, expected_length: u64) -> Result<u64, ServerError> {
257 let actual_length = file.metadata()?.len();
258 if actual_length != expected_length {
259 return Err(ServerError::ObjectStore(
260 ObjectStoreError::StoredLengthMismatch,
261 ));
262 }
263
264 Ok(actual_length)
265}
266
267#[cfg(test)]
268pub(crate) fn run_before_local_object_read_hook(path: &Path) {
269 let hook = match BEFORE_LOCAL_OBJECT_READ_HOOK.lock() {
270 Ok(mut guard) => take_local_object_read_hook_for_path(&mut guard, path),
271 Err(poisoned) => take_local_object_read_hook_for_path(&mut poisoned.into_inner(), path),
272 };
273 if let Some(hook) = hook {
274 hook();
275 }
276}
277
278#[cfg(test)]
279fn take_local_object_read_hook_for_path(
280 slot: &mut LocalObjectReadHookSlot,
281 path: &Path,
282) -> Option<LocalObjectReadHook> {
283 if !matches!(slot, Some(registration) if registration.path == path) {
284 return None;
285 }
286 slot.take().map(|registration| registration.hook)
287}
288
289#[cfg(not(test))]
290pub(crate) const fn run_before_local_object_read_hook(_path: &Path) {}
291
292#[cfg(test)]
293pub(crate) fn set_before_local_object_read_hook(
294 path: PathBuf,
295 hook: impl FnOnce() + Send + 'static,
296) {
297 let mut slot = match BEFORE_LOCAL_OBJECT_READ_HOOK.lock() {
298 Ok(guard) => guard,
299 Err(poisoned) => poisoned.into_inner(),
300 };
301 *slot = Some(LocalObjectReadHookRegistration {
302 path,
303 hook: Box::new(hook),
304 });
305}
306
307pub(crate) fn object_store_from_config(
308 config: &ServerConfig,
309) -> Result<ServerObjectStore, ServerError> {
310 match config.object_storage_adapter() {
311 ObjectStorageAdapter::Local => {
312 Ok(ServerObjectStore::local(config.root_dir().join("chunks"))?)
313 }
314 ObjectStorageAdapter::S3 => {
315 let s3_config = config
316 .s3_object_store_config()
317 .ok_or(ServerError::ObjectStore(ObjectStoreError::MissingS3Config))?
318 .clone();
319 Ok(ServerObjectStore::s3(s3_config)?)
320 }
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use std::{
327 fs::{File, OpenOptions},
328 io::Write,
329 };
330
331 use shardline_index::{FileChunkRecord, FileRecord};
332 use shardline_storage::{ObjectKey, ObjectPrefix};
333
334 use super::{
335 read_full_object, read_open_local_object_append, reconstruct_chunk_file_bytes,
336 reconstruct_file_record_bytes, reconstruct_local_file_bytes,
337 set_before_local_object_read_hook, visit_object_prefix,
338 };
339 use crate::ServerConfig;
340 use crate::ServerError;
341 use crate::chunk_store::chunk_object_key;
342 use crate::error::ObjectStoreError;
343 use crate::object_store::ServerObjectStore;
344
345 #[test]
346 fn local_object_read_rejects_growth_after_length_validation_without_retaining_growth_bytes() {
347 let mut object = tempfile::NamedTempFile::new().unwrap();
348 object.write_all(b"abcd").unwrap();
349 object.as_file().sync_all().unwrap();
350
351 let reader = File::open(object.path()).unwrap();
352 let mut writer = OpenOptions::new().append(true).open(object.path()).unwrap();
353 set_before_local_object_read_hook(object.path().to_path_buf(), move || {
354 writer.write_all(b"efgh").unwrap();
355 writer.sync_all().unwrap();
356 });
357
358 let mut output = Vec::new();
359 let result = read_open_local_object_append(object.path(), reader, 4, &mut output);
360
361 assert!(matches!(
362 result,
363 Err(ServerError::ObjectStore(
364 ObjectStoreError::StoredLengthMismatch
365 ))
366 ));
367 assert_eq!(output, b"abcd");
368 }
369
370 #[test]
373 fn reconstruct_local_file_bytes_reassembles_chunks_correctly() {
374 let dir = tempfile::tempdir().unwrap();
375 let store = ServerObjectStore::local(dir.path()).unwrap();
376
377 let chunk1_hash = "aa".repeat(32); let chunk2_hash = "bb".repeat(32);
379 let chunk1_data = b"hello ";
380 let chunk2_data = b"world";
381
382 let key1 = chunk_object_key(&chunk1_hash).unwrap();
383 let key2 = chunk_object_key(&chunk2_hash).unwrap();
384
385 let path1 = dir.path().join(key1.as_str());
387 let path2 = dir.path().join(key2.as_str());
388 std::fs::create_dir_all(path1.parent().unwrap()).unwrap();
389 std::fs::create_dir_all(path2.parent().unwrap()).unwrap();
390 std::fs::write(&path1, chunk1_data).unwrap();
391 std::fs::write(&path2, chunk2_data).unwrap();
392
393 let chunks = vec![
394 FileChunkRecord {
395 hash: chunk1_hash,
396 offset: 0,
397 length: 6,
398 range_start: 0,
399 range_end: 1,
400 packed_start: 0,
401 packed_end: 6,
402 },
403 FileChunkRecord {
404 hash: chunk2_hash,
405 offset: 6,
406 length: 5,
407 range_start: 0,
408 range_end: 1,
409 packed_start: 0,
410 packed_end: 5,
411 },
412 ];
413
414 let result = reconstruct_local_file_bytes(&store, &chunks, 11);
415 assert!(result.is_ok());
416 assert_eq!(result.unwrap(), b"hello world");
417 }
418
419 #[test]
420 fn reconstruct_local_file_bytes_rejects_non_contiguous_offsets() {
421 let dir = tempfile::tempdir().unwrap();
422 let store = ServerObjectStore::local(dir.path()).unwrap();
423
424 let hash = "cc".repeat(32);
425 let key = chunk_object_key(&hash).unwrap();
426 let path = dir.path().join(key.as_str());
427 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
428 std::fs::write(&path, b"data").unwrap();
429
430 let chunks = vec![FileChunkRecord {
431 hash,
432 offset: 10, length: 4,
434 range_start: 0,
435 range_end: 1,
436 packed_start: 0,
437 packed_end: 4,
438 }];
439
440 let result = reconstruct_local_file_bytes(&store, &chunks, 4);
441 assert!(result.is_err());
442 }
443
444 #[test]
445 fn reconstruct_local_file_bytes_rejects_capacity_mismatch() {
446 let dir = tempfile::tempdir().unwrap();
447 let store = ServerObjectStore::local(dir.path()).unwrap();
448
449 let hash = "dd".repeat(32);
450 let key = chunk_object_key(&hash).unwrap();
451 let path = dir.path().join(key.as_str());
452 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
453 std::fs::write(&path, b"data").unwrap();
454
455 let chunks = vec![FileChunkRecord {
456 hash,
457 offset: 0,
458 length: 4,
459 range_start: 0,
460 range_end: 1,
461 packed_start: 0,
462 packed_end: 4,
463 }];
464
465 let result = reconstruct_local_file_bytes(&store, &chunks, 99);
467 assert!(matches!(
468 result,
469 Err(ServerError::ObjectStore(
470 ObjectStoreError::StoredLengthMismatch
471 ))
472 ));
473 }
474
475 #[test]
476 fn reconstruct_local_file_bytes_fails_with_blackhole_store() {
477 let store = ServerObjectStore::blackhole();
478 let hash = "ff".repeat(32);
481 let chunks = vec![FileChunkRecord {
482 hash,
483 offset: 0,
484 length: 4,
485 range_start: 0,
486 range_end: 1,
487 packed_start: 0,
488 packed_end: 4,
489 }];
490 let result = reconstruct_local_file_bytes(&store, &chunks, 4);
491 assert!(matches!(result, Err(ServerError::NotFound)));
492 }
493
494 #[test]
497 fn read_open_local_object_append_rejects_trailing_bytes() {
498 let dir = tempfile::tempdir().unwrap();
499 let path = dir.path().join("trailing.bin");
500 std::fs::write(&path, b"abcdef").unwrap();
502 let file = File::open(&path).unwrap();
503 let mut output = Vec::new();
504 let result = read_open_local_object_append(&path, file, 4, &mut output);
505 assert!(
506 matches!(
507 result,
508 Err(ServerError::ObjectStore(
509 ObjectStoreError::StoredLengthMismatch
510 ))
511 ),
512 "expected StoredLengthMismatch for trailing bytes, got {result:?}"
513 );
514 }
515
516 #[test]
517 fn read_open_local_object_append_rejects_shorter_file() {
518 let dir = tempfile::tempdir().unwrap();
519 let path = dir.path().join("short.bin");
520 std::fs::write(&path, b"abc").unwrap();
521 let file = File::open(&path).unwrap();
522 let mut output = Vec::new();
523 let result = read_open_local_object_append(&path, file, 5, &mut output);
524 assert!(matches!(
525 result,
526 Err(ServerError::ObjectStore(
527 ObjectStoreError::StoredLengthMismatch
528 ))
529 ));
530 }
531
532 #[test]
535 fn reconstruct_chunk_file_bytes_with_local_store() {
536 let dir = tempfile::tempdir().unwrap();
537 let store = ServerObjectStore::local(dir.path()).unwrap();
538
539 let hash = "ee".repeat(32);
540 let key = chunk_object_key(&hash).unwrap();
541 let path = dir.path().join(key.as_str());
542 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
543 std::fs::write(&path, b"chunk-data").unwrap();
544
545 let chunks = vec![FileChunkRecord {
546 hash,
547 offset: 0,
548 length: 10,
549 range_start: 0,
550 range_end: 1,
551 packed_start: 0,
552 packed_end: 10,
553 }];
554
555 let result = reconstruct_chunk_file_bytes(&store, &chunks, 10);
556 assert!(result.is_ok());
557 assert_eq!(result.unwrap(), b"chunk-data");
558 }
559
560 #[test]
561 fn reconstruct_chunk_file_bytes_empty_chunks_with_local_store() {
562 let dir = tempfile::tempdir().unwrap();
563 let store = ServerObjectStore::local(dir.path()).unwrap();
564
565 let result = reconstruct_chunk_file_bytes(&store, &[], 0);
566 assert!(result.is_ok());
567 assert_eq!(result.unwrap(), b"");
568 }
569
570 #[test]
571 fn reconstruct_chunk_file_bytes_blackhole_errors_on_nonzero_length() {
572 let store = ServerObjectStore::blackhole();
573 let hash = "ff".repeat(32);
574 let chunks = vec![FileChunkRecord {
575 hash,
576 offset: 0,
577 length: 4,
578 range_start: 0,
579 range_end: 1,
580 packed_start: 0,
581 packed_end: 4,
582 }];
583 let result = reconstruct_chunk_file_bytes(&store, &chunks, 4);
586 assert!(matches!(result, Err(ServerError::NotFound)));
587 }
588
589 #[test]
592 fn reconstruct_file_record_bytes_with_stored_chunks_layout() {
593 let dir = tempfile::tempdir().unwrap();
594 let store = ServerObjectStore::local(dir.path()).unwrap();
595
596 let data = b"stored-bytes";
597 let hash = "11".repeat(32);
598 let key = chunk_object_key(&hash).unwrap();
599 let path = dir.path().join(key.as_str());
600 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
601 std::fs::write(&path, data).unwrap();
602 let file_len = std::fs::metadata(&path).unwrap().len();
604 assert_eq!(file_len, data.len() as u64, "written file length mismatch");
605
606 let record = FileRecord {
607 file_id: "test.bin".to_owned(),
608 content_hash: "aa".repeat(32),
609 total_bytes: data.len() as u64,
610 chunk_size: data.len() as u64, repository_scope: None,
612 chunks: vec![FileChunkRecord {
613 hash,
614 offset: 0,
615 length: data.len() as u64,
616 range_start: 0,
617 range_end: 1,
618 packed_start: 0,
619 packed_end: data.len() as u64,
620 }],
621 };
622
623 let result = reconstruct_file_record_bytes(&store, &[], &record);
624 assert!(
625 result.is_ok(),
626 "reconstruct_file_record_bytes failed: {:?}",
627 result.as_ref().err()
628 );
629 assert_eq!(result.unwrap(), data);
630 }
631
632 #[test]
633 fn reconstruct_file_record_bytes_referenced_terms_rejects_empty_frontends() {
634 let dir = tempfile::tempdir().unwrap();
635 let store = ServerObjectStore::local(dir.path()).unwrap();
636
637 let record = FileRecord {
639 file_id: "ref.bin".to_owned(),
640 content_hash: "bb".repeat(32),
641 total_bytes: 4,
642 chunk_size: 0,
643 repository_scope: None,
644 chunks: vec![FileChunkRecord {
645 hash: "cc".repeat(32),
646 offset: 0,
647 length: 4,
648 range_start: 0,
649 range_end: 1,
650 packed_start: 0,
651 packed_end: 4,
652 }],
653 };
654
655 let result = reconstruct_file_record_bytes(&store, &[], &record);
657 assert!(result.is_err());
658 }
659
660 #[test]
663 fn visit_object_prefix_with_blackhole_returns_ok() {
664 let store = ServerObjectStore::blackhole();
665 let prefix = ObjectPrefix::parse("test-prefix").unwrap();
666 let result = visit_object_prefix(&store, &prefix, |_metadata| Ok(()));
667 assert!(result.is_ok());
668 }
669
670 #[test]
673 fn read_full_object_zero_length_returns_empty() {
674 let store = ServerObjectStore::blackhole();
675 let key = ObjectKey::parse("test/key").unwrap();
676 let result = read_full_object(&store, &key, 0);
677 assert!(result.is_ok());
678 assert_eq!(result.unwrap(), b"");
679 }
680
681 #[test]
682 fn read_full_object_blackhole_nonzero_returns_not_found() {
683 let store = ServerObjectStore::blackhole();
684 let key = ObjectKey::parse("test/key").unwrap();
685 let result = read_full_object(&store, &key, 10);
686 assert!(matches!(result, Err(ServerError::NotFound)));
687 }
688
689 #[test]
690 fn read_full_object_exceeds_max_returns_too_large() {
691 let store = ServerObjectStore::blackhole();
692 let key = ObjectKey::parse("test/key").unwrap();
693 let result = read_full_object(&store, &key, 2_000_000_000);
695 assert!(matches!(result, Err(ServerError::RequestBodyTooLarge)));
696 }
697
698 #[test]
699 fn read_full_object_local_store_reads_file() {
700 let dir = tempfile::tempdir().unwrap();
701 let store = ServerObjectStore::local(dir.path()).unwrap();
702
703 let key = ObjectKey::parse("my-object").unwrap();
705 let object_path = dir.path().join(key.as_str());
707 if let Some(parent) = object_path.parent() {
708 std::fs::create_dir_all(parent).unwrap();
709 }
710 let data = b"full-object-data";
711 std::fs::write(&object_path, data).unwrap();
712 let data_len = data.len() as u64;
713
714 let result = read_full_object(&store, &key, data_len);
715 assert!(
716 result.is_ok(),
717 "read_full_object failed: {:?}",
718 result.as_ref().err()
719 );
720 assert_eq!(result.unwrap(), data);
721 }
722
723 #[test]
726 fn server_object_store_error_not_found_converts_to_not_found() {
727 let err: ServerError = shardline_server_core::ServerObjectStoreError::NotFound.into();
728 assert!(matches!(err, ServerError::NotFound));
729 }
730
731 #[test]
732 fn server_object_store_error_overflow_converts_to_overflow() {
733 let err: ServerError = shardline_server_core::ServerObjectStoreError::Overflow.into();
734 assert!(matches!(err, ServerError::Overflow));
735 }
736
737 #[test]
738 fn server_object_store_error_invalid_content_hash_converts() {
739 let err: ServerError =
740 shardline_server_core::ServerObjectStoreError::InvalidContentHash.into();
741 assert!(matches!(err, ServerError::InvalidContentHash));
742 }
743
744 #[test]
747 fn object_store_from_config_local_adapter_returns_local_store() {
748 let tmp = tempfile::tempdir().unwrap();
749 let config = ServerConfig::new(
750 std::net::SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 8080),
751 "http://127.0.0.1:8080".to_owned(),
752 tmp.path().to_path_buf(),
753 std::num::NonZeroUsize::new(65536).unwrap_or(std::num::NonZeroUsize::MIN),
754 );
755 let store = super::object_store_from_config(&config);
756 assert!(store.is_ok());
757 assert!(store.unwrap().local_root().is_some());
758 }
759
760 #[test]
761 fn object_store_from_config_s3_without_config_returns_error() {
762 let tmp = tempfile::tempdir().unwrap();
763 let config = ServerConfig::new(
764 std::net::SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 8080),
765 "http://127.0.0.1:8080".to_owned(),
766 tmp.path().to_path_buf(),
767 std::num::NonZeroUsize::new(65536).unwrap_or(std::num::NonZeroUsize::MIN),
768 )
769 .with_object_storage(crate::ObjectStorageAdapter::S3, None);
770 let store = super::object_store_from_config(&config);
771 assert!(matches!(
772 store,
773 Err(ServerError::ObjectStore(ObjectStoreError::MissingS3Config))
774 ));
775 }
776
777 #[test]
780 fn server_object_store_error_stored_length_mismatch_converts() {
781 let err: ServerError =
782 shardline_server_core::ServerObjectStoreError::StoredObjectLengthMismatch.into();
783 assert!(matches!(
784 err,
785 ServerError::ObjectStore(ObjectStoreError::StoredLengthMismatch)
786 ));
787 }
788
789 #[test]
790 fn server_object_store_error_local_converts() {
791 let io_err = std::io::Error::other("test");
792 let err: ServerError =
793 shardline_server_core::ServerObjectStoreError::Local(io_err.into()).into();
794 assert!(matches!(
795 err,
796 ServerError::ObjectStore(ObjectStoreError::Local(_))
797 ));
798 }
799
800 #[test]
803 fn read_open_local_object_reads_exact_content() {
804 let dir = tempfile::tempdir().unwrap();
805 let path = dir.path().join("exact.bin");
806 let data = b"exact-length-content";
807 std::fs::write(&path, data).unwrap();
808 let file = std::fs::File::open(&path).unwrap();
809
810 let result = super::read_open_local_object(&path, file, data.len() as u64);
811 assert!(result.is_ok());
812 assert_eq!(result.unwrap(), data);
813 }
814
815 #[test]
816 fn read_open_local_object_rejects_trailing_bytes() {
817 let dir = tempfile::tempdir().unwrap();
818 let path = dir.path().join("trailing2.bin");
819 std::fs::write(&path, b"abcdefgh").unwrap();
820 let file = std::fs::File::open(&path).unwrap();
821
822 let result = super::read_open_local_object(&path, file, 5);
824 assert!(matches!(
825 result,
826 Err(ServerError::ObjectStore(
827 ObjectStoreError::StoredLengthMismatch
828 ))
829 ));
830 }
831
832 #[test]
833 fn read_open_local_object_rejects_shorter_file() {
834 let dir = tempfile::tempdir().unwrap();
835 let path = dir.path().join("short2.bin");
836 std::fs::write(&path, b"abc").unwrap();
837 let file = std::fs::File::open(&path).unwrap();
838
839 let result = super::read_open_local_object(&path, file, 10);
841 assert!(matches!(
842 result,
843 Err(ServerError::ObjectStore(
844 ObjectStoreError::StoredLengthMismatch
845 ))
846 ));
847 }
848
849 #[test]
852 fn validate_local_object_length_exact_match() {
853 let dir = tempfile::tempdir().unwrap();
854 let path = dir.path().join("exact_len.bin");
855 std::fs::write(&path, b"12345").unwrap();
856 let file = std::fs::File::open(&path).unwrap();
857 let result = super::validate_local_object_length(&file, 5);
858 assert!(result.is_ok());
859 assert_eq!(result.unwrap(), 5);
860 }
861
862 #[test]
863 fn validate_local_object_length_mismatch() {
864 let dir = tempfile::tempdir().unwrap();
865 let path = dir.path().join("mismatch_len.bin");
866 std::fs::write(&path, b"12345").unwrap();
867 let file = std::fs::File::open(&path).unwrap();
868 let result = super::validate_local_object_length(&file, 10);
869 assert!(matches!(
870 result,
871 Err(ServerError::ObjectStore(
872 ObjectStoreError::StoredLengthMismatch
873 ))
874 ));
875 }
876
877 #[test]
880 fn reconstruct_referenced_object_file_bytes_rejects_non_contiguous_offsets() {
881 let dir = tempfile::tempdir().unwrap();
882 let store = ServerObjectStore::local(dir.path()).unwrap();
883 let record = shardline_index::FileRecord {
884 file_id: "ref.bin".to_owned(),
885 content_hash: "aa".repeat(32),
886 total_bytes: 10,
887 chunk_size: 0, repository_scope: None,
889 chunks: vec![shardline_index::FileChunkRecord {
890 hash: "cc".repeat(32),
891 offset: 10, length: 4,
893 range_start: 0,
894 range_end: 1,
895 packed_start: 0,
896 packed_end: 4,
897 }],
898 };
899 let result = super::reconstruct_file_record_bytes(&store, &[], &record);
902 assert!(result.is_err());
903 }
904
905 #[test]
908 fn reconstruct_chunk_file_bytes_non_local_store_uses_read_full_object() {
909 let store = ServerObjectStore::blackhole();
911 let hash = "11".repeat(32);
912 let chunks = vec![shardline_index::FileChunkRecord {
913 hash,
914 offset: 0,
915 length: 4,
916 range_start: 0,
917 range_end: 1,
918 packed_start: 0,
919 packed_end: 4,
920 }];
921 let result = reconstruct_chunk_file_bytes(&store, &chunks, 4);
922 assert!(matches!(result, Err(ServerError::NotFound)));
923 }
924
925 #[test]
928 fn visit_object_prefix_with_local_store_lists_objects() {
929 let dir = tempfile::tempdir().unwrap();
930 let store = ServerObjectStore::local(dir.path()).unwrap();
931 let key = ObjectKey::parse("prefix/obj").unwrap();
932 let path = dir.path().join(key.as_str());
933 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
934 std::fs::write(&path, b"data").unwrap();
935
936 let prefix = ObjectPrefix::parse("prefix").unwrap();
937 let mut count = 0;
938 visit_object_prefix(&store, &prefix, |_meta| {
939 count += 1;
940 Ok(())
941 })
942 .unwrap();
943 assert_eq!(count, 1);
944 }
945
946 #[test]
949 fn object_store_from_config_s3_with_config_returns_s3_store() {
950 let tmp = tempfile::tempdir().unwrap();
951 let s3_config = shardline_storage::S3ObjectStoreConfig::new(
952 "bucket".to_owned(),
953 "us-east-1".to_owned(),
954 );
955 let config = ServerConfig::new(
956 std::net::SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 8080),
957 "http://127.0.0.1:8080".to_owned(),
958 tmp.path().to_path_buf(),
959 std::num::NonZeroUsize::new(65536).unwrap_or(std::num::NonZeroUsize::MIN),
960 )
961 .with_object_storage(crate::ObjectStorageAdapter::S3, Some(s3_config));
962 let store = super::object_store_from_config(&config);
963 assert!(store.is_ok());
964 assert!(store.unwrap().local_root().is_none());
965 }
966
967 #[test]
970 fn read_full_object_local_store_rejects_length_mismatch() {
971 let dir = tempfile::tempdir().unwrap();
972 let store = ServerObjectStore::local(dir.path()).unwrap();
973 let key = ObjectKey::parse("mismatch-obj").unwrap();
974 let object_path = dir.path().join(key.as_str());
975 if let Some(parent) = object_path.parent() {
976 std::fs::create_dir_all(parent).unwrap();
977 }
978 std::fs::write(&object_path, b"short").unwrap();
979 let result = read_full_object(&store, &key, 100);
981 assert!(matches!(
982 result,
983 Err(ServerError::ObjectStore(
984 ObjectStoreError::StoredLengthMismatch
985 ))
986 ));
987 }
988
989 #[test]
990 fn read_full_object_local_store_zero_length_returns_empty() {
991 let dir = tempfile::tempdir().unwrap();
992 let store = ServerObjectStore::local(dir.path()).unwrap();
993 let key = ObjectKey::parse("empty-obj").unwrap();
994 let object_path = dir.path().join(key.as_str());
995 if let Some(parent) = object_path.parent() {
996 std::fs::create_dir_all(parent).unwrap();
997 }
998 std::fs::write(&object_path, b"").unwrap();
999 let result = read_full_object(&store, &key, 0);
1000 assert!(result.is_ok());
1001 assert_eq!(result.unwrap(), b"");
1002 }
1003
1004 #[test]
1007 fn take_local_object_read_hook_for_path_returns_none_for_mismatched_path() {
1008 let result = super::take_local_object_read_hook_for_path(
1009 &mut None,
1010 std::path::Path::new("/nonexistent"),
1011 );
1012 assert!(result.is_none());
1013 }
1014
1015 #[test]
1018 fn read_open_local_object_append_read_to_end_eof_returns_length_mismatch() {
1019 let dir = tempfile::tempdir().unwrap();
1020 let path = dir.path().join("partial.bin");
1021 std::fs::write(&path, b"short").unwrap();
1023 let file = std::fs::File::open(&path).unwrap();
1024 let mut output = Vec::new();
1025
1026 let result = read_open_local_object_append(&path, file, 100, &mut output);
1028 assert!(matches!(
1029 result,
1030 Err(ServerError::ObjectStore(
1031 ObjectStoreError::StoredLengthMismatch
1032 ))
1033 ));
1034 }
1035
1036 #[test]
1037 fn read_open_local_object_append_eof_on_trailing_byte_check_returns_length_mismatch() {
1038 let dir = tempfile::tempdir().unwrap();
1039 let path = dir.path().join("eof-trailing.bin");
1040 std::fs::write(&path, b"exact").unwrap();
1044 let file = std::fs::File::open(&path).unwrap();
1045 let mut output = Vec::new();
1046 let result = read_open_local_object_append(&path, file, 5, &mut output);
1047 assert!(result.is_ok());
1048 assert_eq!(output, b"exact");
1049 }
1050
1051 #[test]
1054 fn reconstruct_chunk_file_bytes_local_store_rejects_capacity_mismatch() {
1055 let dir = tempfile::tempdir().unwrap();
1056 let store = ServerObjectStore::local(dir.path()).unwrap();
1057
1058 let hash = "ca".repeat(32);
1059 let key = chunk_object_key(&hash).unwrap();
1060 let path = dir.path().join(key.as_str());
1061 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1062 std::fs::write(&path, b"data").unwrap();
1063
1064 let chunks = vec![FileChunkRecord {
1065 hash,
1066 offset: 0,
1067 length: 4,
1068 range_start: 0,
1069 range_end: 1,
1070 packed_start: 0,
1071 packed_end: 4,
1072 }];
1073
1074 let result = reconstruct_chunk_file_bytes(&store, &chunks, 99);
1076 assert!(matches!(
1077 result,
1078 Err(ServerError::ObjectStore(
1079 ObjectStoreError::StoredLengthMismatch
1080 ))
1081 ));
1082 }
1083
1084 #[test]
1087 fn reconstruct_referenced_object_file_bytes_rejects_capacity_mismatch() {
1088 let dir = tempfile::tempdir().unwrap();
1089 let store = ServerObjectStore::local(dir.path()).unwrap();
1090
1091 let record = shardline_index::FileRecord {
1095 file_id: "cap.bin".to_owned(),
1096 content_hash: "cc".repeat(32),
1097 total_bytes: 99, chunk_size: 0,
1099 repository_scope: None,
1100 chunks: vec![shardline_index::FileChunkRecord {
1101 hash: "dd".repeat(32),
1102 offset: 0,
1103 length: 4,
1104 range_start: 0,
1105 range_end: 1,
1106 packed_start: 0,
1107 packed_end: 4,
1108 }],
1109 };
1110 let result = super::reconstruct_file_record_bytes(&store, &[], &record);
1111 assert!(result.is_err());
1114 }
1115
1116 #[test]
1119 fn read_open_local_object_append_requires_exact_read_length() {
1120 let dir = tempfile::tempdir().unwrap();
1121 let path = dir.path().join("read-exact.bin");
1122 std::fs::write(&path, b"toolong").unwrap();
1123 let file = std::fs::File::open(&path).unwrap();
1124 let mut output = Vec::new();
1125
1126 let result = read_open_local_object_append(&path, file, 4, &mut output);
1129 assert!(matches!(
1130 result,
1131 Err(ServerError::ObjectStore(
1132 ObjectStoreError::StoredLengthMismatch
1133 ))
1134 ));
1135 }
1136
1137 #[test]
1140 fn server_object_store_error_io_converts() {
1141 let io_err = std::io::Error::other("disk error");
1142 let err: ServerError = shardline_server_core::ServerObjectStoreError::Io(io_err).into();
1143 assert!(matches!(err, ServerError::Io(_)));
1144 }
1145
1146 #[test]
1147 fn server_object_store_error_numeric_conversion_converts() {
1148 let int_err = i32::try_from(3_000_000_000_i64).unwrap_err();
1150 let err: ServerError =
1151 shardline_server_core::ServerObjectStoreError::NumericConversion(int_err).into();
1152 assert!(matches!(err, ServerError::NumericConversion(_)));
1153 }
1154
1155 #[test]
1158 fn read_full_object_local_store_short_read_returns_length_mismatch() {
1159 let dir = tempfile::tempdir().unwrap();
1160 let store = ServerObjectStore::local(dir.path()).unwrap();
1161 let key = ObjectKey::parse("short-read").unwrap();
1162 let object_path = dir.path().join(key.as_str());
1163 if let Some(parent) = object_path.parent() {
1164 std::fs::create_dir_all(parent).unwrap();
1165 }
1166 std::fs::write(&object_path, b"abc").unwrap();
1168 let result = read_full_object(&store, &key, 10);
1170 assert!(matches!(
1171 result,
1172 Err(ServerError::ObjectStore(
1173 ObjectStoreError::StoredLengthMismatch
1174 ))
1175 ));
1176 }
1177
1178 #[test]
1181 fn read_open_local_object_append_returns_io_error_on_read_failure() {
1182 let dir = tempfile::tempdir().unwrap();
1187 let dir_path = dir.path().to_path_buf();
1188 let dir_file = std::fs::File::open(&dir_path).unwrap();
1189 let meta_len = dir_file.metadata().unwrap().len();
1190
1191 if meta_len == 0 {
1192 return;
1196 }
1197
1198 let mut output = Vec::new();
1199 let result = read_open_local_object_append(&dir_path, dir_file, meta_len, &mut output);
1200 assert!(
1201 matches!(result, Err(ServerError::Io(_))),
1202 "expected Io error when reading from a directory fd, got {result:?}"
1203 );
1204 }
1205
1206 #[test]
1222 fn server_object_store_error_s3_converts() {
1223 use shardline_storage::S3ObjectStoreError;
1224 let s3_err =
1225 shardline_server_core::ServerObjectStoreError::S3(S3ObjectStoreError::EmptyBucket);
1226 let err: ServerError = s3_err.into();
1227 assert!(matches!(
1228 err,
1229 ServerError::ObjectStore(ObjectStoreError::S3(_))
1230 ));
1231 }
1232
1233 #[test]
1236 fn read_full_object_length_one_overflow_edge() {
1237 let store = ServerObjectStore::blackhole();
1239 let key = ObjectKey::parse("test/edge").unwrap();
1240 let result = read_full_object(&store, &key, 1);
1241 assert!(matches!(result, Err(ServerError::NotFound)));
1242 }
1243
1244 #[test]
1247 fn reconstruct_chunk_file_bytes_non_local_rejects_capacity_mismatch() {
1248 let store = ServerObjectStore::blackhole();
1253 let hash = "11".repeat(32);
1254 let chunks = vec![FileChunkRecord {
1255 hash,
1256 offset: 0,
1257 length: 4,
1258 range_start: 0,
1259 range_end: 1,
1260 packed_start: 0,
1261 packed_end: 4,
1262 }];
1263 let result = reconstruct_chunk_file_bytes(&store, &chunks, 99);
1264 assert!(matches!(result, Err(ServerError::NotFound)));
1266 }
1267
1268 #[test]
1271 fn run_before_local_object_read_hook_without_registration_returns_immediately() {
1272 let path = std::path::Path::new("/nonexistent/path");
1275 crate::object_store::run_before_local_object_read_hook(path);
1277 }
1278
1279 #[test]
1280 fn set_before_local_object_read_hook_overwrites_previous_registration() {
1281 let path1 = std::path::PathBuf::from("/tmp/hook-overwrite-1");
1282 let path2 = std::path::PathBuf::from("/tmp/hook-overwrite-2");
1283 let called1 = std::sync::atomic::AtomicBool::new(false);
1284 let called2 = std::sync::atomic::AtomicBool::new(false);
1285
1286 set_before_local_object_read_hook(path1, move || {
1287 called1.store(true, std::sync::atomic::Ordering::SeqCst);
1288 });
1289 set_before_local_object_read_hook(path2.clone(), move || {
1290 called2.store(true, std::sync::atomic::Ordering::SeqCst);
1291 });
1292
1293 let slot = super::BEFORE_LOCAL_OBJECT_READ_HOOK.lock().unwrap();
1295 assert!(slot.is_some());
1296 let registration = slot.as_ref().unwrap();
1297 assert_eq!(registration.path, path2);
1298 }
1299
1300 #[test]
1303 fn reconstruct_chunk_file_bytes_local_store_exact_capacity() {
1304 let dir = tempfile::tempdir().unwrap();
1305 let store = ServerObjectStore::local(dir.path()).unwrap();
1306
1307 let hash = "aa".repeat(32);
1308 let data = b"exact-data";
1309 let key = chunk_object_key(&hash).unwrap();
1310 let path = dir.path().join(key.as_str());
1311 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1312 std::fs::write(&path, data).unwrap();
1313
1314 let chunks = vec![FileChunkRecord {
1315 hash,
1316 offset: 0,
1317 length: data.len() as u64,
1318 range_start: 0,
1319 range_end: 1,
1320 packed_start: 0,
1321 packed_end: data.len() as u64,
1322 }];
1323
1324 let result = reconstruct_chunk_file_bytes(&store, &chunks, data.len());
1325 assert!(result.is_ok());
1326 assert_eq!(result.unwrap(), data);
1327 }
1328
1329 #[test]
1332 fn read_open_local_object_append_read_len_mismatch_after_take() {
1333 let dir = tempfile::tempdir().unwrap();
1341 let path = dir.path().join("read-len-mismatch.bin");
1342 std::fs::write(&path, b"exact-content").unwrap();
1344 let file = std::fs::File::open(&path).unwrap();
1345
1346 let mut output = Vec::new();
1348 let result =
1349 crate::object_store::read_open_local_object_append(&path, file, 13, &mut output);
1350 assert!(result.is_ok());
1351 assert_eq!(output, b"exact-content");
1352 }
1353
1354 #[test]
1357 fn reconstruct_referenced_object_file_bytes_capacity_check() {
1358 let dir = tempfile::tempdir().unwrap();
1359 let store = ServerObjectStore::local(dir.path()).unwrap();
1360
1361 let record = shardline_index::FileRecord {
1364 file_id: "cap-ref.bin".to_owned(),
1365 content_hash: "dd".repeat(32),
1366 total_bytes: 99,
1367 chunk_size: 0,
1368 repository_scope: None,
1369 chunks: vec![shardline_index::FileChunkRecord {
1370 hash: "ee".repeat(32),
1371 offset: 0,
1372 length: 4,
1373 range_start: 0,
1374 range_end: 1,
1375 packed_start: 0,
1376 packed_end: 4,
1377 }],
1378 };
1379 let result = super::reconstruct_file_record_bytes(&store, &[], &record);
1380 assert!(result.is_err());
1381 }
1382
1383 #[test]
1386 fn reconstruct_chunk_file_bytes_multiple_chunks_exact_capacity() {
1387 let dir = tempfile::tempdir().unwrap();
1388 let store = ServerObjectStore::local(dir.path()).unwrap();
1389
1390 let hash1 = "b1".repeat(32);
1391 let hash2 = "b2".repeat(32);
1392 let data1 = b"hello ";
1393 let data2 = b"world";
1394
1395 let key1 = chunk_object_key(&hash1).unwrap();
1396 let key2 = chunk_object_key(&hash2).unwrap();
1397 let path1 = dir.path().join(key1.as_str());
1398 let path2 = dir.path().join(key2.as_str());
1399 std::fs::create_dir_all(path1.parent().unwrap()).unwrap();
1400 std::fs::create_dir_all(path2.parent().unwrap()).unwrap();
1401 std::fs::write(&path1, data1).unwrap();
1402 std::fs::write(&path2, data2).unwrap();
1403
1404 let chunks = vec![
1405 FileChunkRecord {
1406 hash: hash1,
1407 offset: 0,
1408 length: data1.len() as u64,
1409 range_start: 0,
1410 range_end: 1,
1411 packed_start: 0,
1412 packed_end: data1.len() as u64,
1413 },
1414 FileChunkRecord {
1415 hash: hash2,
1416 offset: data1.len() as u64,
1417 length: data2.len() as u64,
1418 range_start: 0,
1419 range_end: 1,
1420 packed_start: 0,
1421 packed_end: data2.len() as u64,
1422 },
1423 ];
1424 let total_capacity = data1.len() + data2.len();
1425
1426 let result = reconstruct_chunk_file_bytes(&store, &chunks, total_capacity);
1427 assert!(result.is_ok());
1428 assert_eq!(result.unwrap(), b"hello world");
1429 }
1430
1431 #[test]
1434 fn read_full_object_non_local_length_one_overflow_edge() {
1435 let store = ServerObjectStore::blackhole();
1437 let key = ObjectKey::parse("test/nonlocal-edge").unwrap();
1438 let result = read_full_object(&store, &key, 1);
1439 assert!(matches!(result, Err(ServerError::NotFound)));
1440 }
1441}