1#[path = "cog_blocks.rs"]
4mod blocks;
5
6use blocks::{block_work_bytes, transport_error};
7#[cfg(test)]
8use blocks::{prefetch_blocks, ring_coords};
9
10use std::collections::HashMap;
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::Arc;
13
14use std::time::Duration;
15#[cfg(feature = "perf-tracing")]
16use std::time::Instant;
17
18use crate::blocking::{default_blocking_permits, CpuLimiter};
19use crate::bounds::{Bbox, GeoTransform};
20use crate::crs::Crs;
21use crate::error::{AgError, AgResult};
22use crate::io::block_cache::{
23 BlockCache, BlockKey, BlockKind, BlockResult, Claim, DecodedBlock, InflightGuard, PendingBlock,
24};
25use crate::io::cache::CacheConfig;
26use crate::io::scale_offset::ScaleOffset;
27use crate::io::window::{tile_range_for_window, validate_window_shape, IfdInfo, ValidatedIfdInfo};
28#[cfg(feature = "perf-tracing")]
29use crate::perf::duration_ms;
30use crate::pixel::{ImageData, PixelData};
31use async_tiff::metadata::{cache::ReadaheadMetadataCache, TiffMetadataReader};
32use async_tiff::reader::ObjectReader;
33use async_tiff::tags::PhotometricInterpretation;
34use async_tiff::{ImageFileDirectory, TIFF};
35use ndarray::Array2;
36use object_store::http::HttpBuilder;
37use object_store::path::Path;
38use object_store::{ClientOptions, ObjectStore};
39
40pub(crate) async fn build_http_transport<T: Send + 'static>(
43 build: impl FnOnce() -> AgResult<T> + Send + 'static,
44) -> AgResult<T> {
45 static LIMITER: std::sync::OnceLock<Arc<tokio::sync::Semaphore>> = std::sync::OnceLock::new();
46 let limiter = LIMITER
47 .get_or_init(|| Arc::new(tokio::sync::Semaphore::new(8)))
48 .clone();
49 let permit = limiter
50 .acquire_owned()
51 .await
52 .map_err(|_| AgError::Tiff("HTTP construction limiter closed".into()))?;
53 let request = crate::request::RequestContext::current();
54 tokio::task::spawn_blocking(move || {
55 let _permit = permit;
56 crate::request::RequestContext::run_blocking(request, build)
57 })
58 .await
59 .map_err(|_| AgError::Tiff("HTTP construction task failed".into()))?
60}
61
62static NEXT_SOURCE_ID: AtomicU64 = AtomicU64::new(0);
63
64#[cfg(test)]
65const TEST_OVERVIEW_PYRAMID: [f64; 4] = [10.0, 20.0, 40.0, 80.0];
66
67const INLINE_STITCH_MAX_BYTES: u64 = 1 << 20;
74
75const MAX_PREFETCH_TASKS: usize = 2;
79
80#[derive(Clone)]
90#[non_exhaustive]
91pub struct ReaderConfig {
92 pub max_ifds: usize,
94 pub max_ifd_entries: u64,
97 pub(crate) snapshot_size: Option<u64>,
98
99 pub snapshot_policy: crate::io::SnapshotPolicy,
102 pub max_metadata_bytes: u64,
104
105 pub work_budget: crate::resource::WorkBudget,
108 pub(crate) redact_transport_errors: bool,
109 pub max_window_pixels: u64,
111 pub max_window_bytes: u64,
114 pub max_window_blocks: usize,
116 pub max_decoded_block_bytes: u64,
118 pub max_concurrent_reads: usize,
121 pub read_limiter: Option<Arc<tokio::sync::Semaphore>>,
123 pub max_decode_tasks: usize,
127 pub source_id: String,
129 pub block_cache: Arc<BlockCache>,
131 pub cpu_limiter: Option<CpuLimiter>,
136 pub prefetch_ring: u8,
146}
147
148impl Default for ReaderConfig {
149 fn default() -> Self {
150 Self {
151 work_budget: crate::resource::WorkBudget::global(),
152 max_ifds: 256,
153 max_ifd_entries: 4096,
154 snapshot_size: None,
155 snapshot_policy: crate::io::SnapshotPolicy::RequireValidator,
156 max_metadata_bytes: 8 * 1024 * 1024,
157 redact_transport_errors: false,
158 max_window_pixels: 16 * 1024 * 1024,
159 max_window_bytes: 256 * 1024 * 1024,
160 max_window_blocks: 4096,
161 max_decoded_block_bytes: 64 * 1024 * 1024,
162 max_concurrent_reads: 8,
163 read_limiter: None,
164 max_decode_tasks: default_blocking_permits(),
165 source_id: format!("cog-{}", NEXT_SOURCE_ID.fetch_add(1, Ordering::Relaxed)),
166 block_cache: Arc::new(BlockCache::new(CacheConfig::default())),
167 cpu_limiter: None,
168 prefetch_ring: 0,
169 }
170 }
171}
172
173impl ReaderConfig {
174 fn decode_task_limit(&self) -> usize {
175 self.max_decode_tasks.max(1)
176 }
177}
178
179pub struct CogReader {
187 metadata_reservations: Arc<Vec<crate::resource::WorkReservation>>,
188 prefetch_tasks: std::sync::Mutex<tokio::task::JoinSet<()>>,
189 work_budget: crate::resource::WorkBudget,
190 redact_transport_errors: bool,
191 max_window_pixels: u64,
192 max_window_bytes: u64,
193 max_window_blocks: usize,
194 reads: Arc<tokio::sync::Semaphore>,
195 pub(crate) tiff: Arc<TIFF>,
196 pub(crate) reader: ObjectReader,
197 pub(crate) src_crs: Crs,
198 pub(crate) transform: GeoTransform,
199 data_ifds: Vec<usize>,
200 level_gsds: Vec<f64>,
203 mask_ifds: Vec<Option<usize>>,
204 alpha_bands: Vec<Option<usize>>,
208 band_scale_offset: Vec<ScaleOffset>,
210 source_id: Arc<str>,
213 statistics_id: u64,
215 block_cache: Arc<BlockCache>,
217 cpu: CpuLimiter,
220 prefetch_ring: u8,
222 prefetch_inflight: Arc<std::sync::atomic::AtomicUsize>,
224}
225
226pub struct CogReaderBuilder {
243 config: ReaderConfig,
244 client_options: Option<ClientOptions>,
245}
246
247impl CogReaderBuilder {
248 pub fn source_id(mut self, id: impl Into<String>) -> Self {
251 self.config.source_id = id.into();
252 self
253 }
254
255 pub fn block_cache(mut self, block_cache: Arc<BlockCache>) -> Self {
259 self.config.block_cache = block_cache;
260 self
261 }
262
263 pub fn cache_config(mut self, cache: CacheConfig) -> Self {
266 self.config.block_cache = Arc::new(BlockCache::new(cache));
267 self
268 }
269
270 pub fn cpu_limiter(mut self, cpu: CpuLimiter) -> Self {
272 self.config.cpu_limiter = Some(cpu);
273 self
274 }
275
276 pub fn max_decode_tasks(mut self, n: usize) -> Self {
280 self.config.max_decode_tasks = n;
281 self
282 }
283
284 pub fn prefetch_ring(mut self, ring: u8) -> Self {
286 self.config.prefetch_ring = ring;
287 self
288 }
289
290 pub fn reader_config(mut self, config: ReaderConfig) -> Self {
293 self.config = config;
294 self
295 }
296
297 pub fn snapshot_policy(mut self, policy: crate::io::SnapshotPolicy) -> Self {
299 self.config.snapshot_policy = policy;
300 self
301 }
302
303 pub fn client_options(mut self, options: ClientOptions) -> Self {
307 self.client_options = Some(options);
308 self
309 }
310
311 pub async fn open_http(mut self, url: &str) -> AgResult<CogReader> {
313 self.config.redact_transport_errors = true;
314 let (base, path) = split_http_url(url)?;
315 let options = self.client_options.unwrap_or_else(default_client_options);
316 let store: Arc<dyn ObjectStore> = build_http_transport(move || {
317 Ok(Arc::new(
318 HttpBuilder::new()
319 .with_url(base)
320 .with_client_options(options)
321 .build()
322 .map_err(|_| AgError::Tiff("HTTP store construction failed".into()))?,
323 ) as Arc<dyn ObjectStore>)
324 })
325 .await?;
326 let object_path = Path::from_url_path(&path)
331 .map_err(|e| AgError::Tiff(format!("invalid object path {path:?}: {e}")))?;
332 CogReader::open_managed(store, object_path, self.config)
333 .await
334 .map_err(|e| {
335 crate::io::snapshot::resource_error(&e)
336 .unwrap_or_else(|| AgError::Tiff("HTTP COG open failed".into()))
337 })
338 }
339
340 pub async fn open_object_store(
346 self,
347 store: Arc<dyn ObjectStore>,
348 key: &str,
349 ) -> AgResult<CogReader> {
350 CogReader::open_managed(store, Path::from(key), self.config).await
351 }
352
353 pub async fn open(self, reader: ObjectReader) -> AgResult<CogReader> {
357 if self.config.snapshot_policy != crate::io::SnapshotPolicy::AssumeImmutable {
358 return Err(AgError::Tiff("opaque ObjectReader cannot verify versions; use open_object_store or explicitly choose AssumeImmutable".into()));
359 }
360 CogReader::open_impl(reader, self.config).await
361 }
362}
363
364impl CogReader {
365 pub fn builder() -> CogReaderBuilder {
367 CogReaderBuilder {
368 config: ReaderConfig::default(),
369 client_options: None,
370 }
371 }
372
373 pub async fn open_http(url: &str) -> AgResult<Self> {
381 Self::builder().open_http(url).await
382 }
383
384 #[deprecated(
387 since = "0.0.9",
388 note = "use `CogReader::builder().client_options(options).open_http(url)`"
389 )]
390 pub async fn open_http_with_options(url: &str, options: ClientOptions) -> AgResult<Self> {
391 Self::builder().client_options(options).open_http(url).await
392 }
393
394 #[deprecated(
397 since = "0.0.9",
398 note = "use `CogReader::builder().client_options(options).cache_config(cache).open_http(url)`"
399 )]
400 pub async fn open_http_with_cache(
401 url: &str,
402 options: ClientOptions,
403 cache: CacheConfig,
404 ) -> AgResult<Self> {
405 Self::builder()
406 .client_options(options)
407 .cache_config(cache)
408 .open_http(url)
409 .await
410 }
411
412 #[deprecated(
414 since = "0.0.9",
415 note = "use `CogReader::builder().reader_config(config).open_http(url)`"
416 )]
417 pub async fn open_http_with_config_default(url: &str, config: ReaderConfig) -> AgResult<Self> {
418 Self::builder().reader_config(config).open_http(url).await
419 }
420
421 #[deprecated(
424 since = "0.0.9",
425 note = "use `CogReader::builder().reader_config(config).client_options(options).open_http(url)`"
426 )]
427 pub async fn open_http_with_config(
428 url: &str,
429 options: ClientOptions,
430 config: ReaderConfig,
431 ) -> AgResult<Self> {
432 Self::builder()
433 .reader_config(config)
434 .client_options(options)
435 .open_http(url)
436 .await
437 }
438
439 pub async fn open_with_reader(reader: ObjectReader) -> AgResult<Self> {
442 Self::builder().open(reader).await
443 }
444
445 #[deprecated(
447 since = "0.0.9",
448 note = "use `CogReader::builder().cache_config(cache).open(reader)`"
449 )]
450 pub async fn open_with_reader_and_cache(
451 reader: ObjectReader,
452 cache: CacheConfig,
453 ) -> AgResult<Self> {
454 Self::builder().cache_config(cache).open(reader).await
455 }
456
457 #[deprecated(
460 since = "0.0.9",
461 note = "use `CogReader::builder().reader_config(config).open(reader)`"
462 )]
463 pub async fn open_with_reader_and_config(
464 reader: ObjectReader,
465 config: ReaderConfig,
466 ) -> AgResult<Self> {
467 Self::builder().reader_config(config).open(reader).await
468 }
469
470 async fn open_managed(
471 store: Arc<dyn ObjectStore>,
472 path: Path,
473 mut config: ReaderConfig,
474 ) -> AgResult<Self> {
475 let snapshot = crate::io::snapshot::SnapshotStore::open(
476 store,
477 path.clone(),
478 config.snapshot_policy,
479 config.max_window_bytes,
480 config.max_metadata_bytes,
481 config.work_budget.clone(),
482 )
483 .await
484 .map_err(crate::io::snapshot::into_ag_error)?;
485 config.source_id = snapshot.cache_identity(&config.source_id);
486 config.snapshot_size = Some(snapshot.size());
487 let result = Self::open_impl(ObjectReader::new(snapshot.clone(), path), config).await;
488 snapshot.finish_metadata();
489 result
490 }
491
492 async fn open_impl(reader: ObjectReader, config: ReaderConfig) -> AgResult<Self> {
495 let md_cache = ReadaheadMetadataCache::new(reader.clone());
496 let mut md = TiffMetadataReader::try_open(&md_cache)
497 .await
498 .map_err(|e| transport_error(&e, config.redact_transport_errors))?;
499 let mut ifds = Vec::new();
500 let mut seen = std::collections::HashSet::new();
501 let mut remaining_metadata = config.max_metadata_bytes;
502 let mut metadata_reservations = Vec::new();
503 while let Some(offset) = md.next_ifd_offset() {
504 if ifds.len() >= config.max_ifds || !seen.insert(offset) {
505 return Err(AgError::Tiff(
506 "cyclic or excessive TIFF directory chain".into(),
507 ));
508 }
509 let allocation = crate::io::snapshot::preflight_ifd(
510 &md_cache,
511 offset,
512 md.bigtiff(),
513 md.endianness(),
514 &config,
515 remaining_metadata,
516 )
517 .await?;
518 remaining_metadata -= allocation;
519 metadata_reservations.push(config.work_budget.try_reserve(allocation)?);
520 use futures_util::FutureExt;
521 let ifd = std::panic::AssertUnwindSafe(md.read_next_ifd(&md_cache))
522 .catch_unwind()
523 .await
524 .map_err(|_| AgError::Tiff("malformed TIFF directory".into()))?
525 .map_err(|e| transport_error(&e, config.redact_transport_errors))?
526 .ok_or_else(|| AgError::Tiff("missing TIFF directory".into()))?;
527 ifds.push(ifd);
528 }
529 let tiff = TIFF::new(ifds, md.endianness());
530 for ifd in tiff.ifds() {
531 let tw = u64::from(ifd.tile_width().unwrap_or(ifd.image_width()));
532 let th = u64::from(ifd.tile_height().unwrap_or(ifd.image_height()));
533 let bytes_per_pixel: u64 = u64::from(ifd.samples_per_pixel())
534 * u64::from(ifd.bits_per_sample().iter().copied().max().unwrap_or(8)).div_ceil(8);
535 let decoded = tw
536 .checked_mul(th)
537 .and_then(|n| n.checked_mul(bytes_per_pixel));
538 if decoded.is_none_or(|n| n > config.max_decoded_block_bytes) {
539 return Err(AgError::Tiff(
540 "declared decoded block exceeds byte limit".into(),
541 ));
542 }
543 }
544
545 let (transform, src_crs) = derive_geo(&tiff)?;
546 let (data_ifds, mask_ifds) = classify_ifds(&tiff)?;
547 let alpha_bands: Vec<Option<usize>> = data_ifds
548 .iter()
549 .map(|&idx| tiff.ifds().get(idx).and_then(alpha_band_index))
550 .collect();
551 let band_scale_offset = {
552 let ifd = data_ifds.first().and_then(|&idx| tiff.ifds().get(idx));
553 let bands = ifd.map(|i| i.samples_per_pixel() as usize).unwrap_or(0);
554 let md = ifd.and_then(|i| i.gdal_metadata());
555 crate::io::scale_offset::parse_scale_offset(md, bands)
556 };
557 let mut cog = Self {
558 metadata_reservations: Arc::new(metadata_reservations),
559 prefetch_tasks: std::sync::Mutex::new(tokio::task::JoinSet::new()),
560 work_budget: config.work_budget.clone(),
561 redact_transport_errors: config.redact_transport_errors,
562 max_window_pixels: config.max_window_pixels,
563 max_window_bytes: config.max_window_bytes,
564 max_window_blocks: config.max_window_blocks,
565 reads: config.read_limiter.clone().unwrap_or_else(|| {
566 Arc::new(tokio::sync::Semaphore::new(
567 config
568 .max_concurrent_reads
569 .clamp(1, tokio::sync::Semaphore::MAX_PERMITS),
570 ))
571 }),
572 tiff: Arc::new(tiff),
573 reader,
574 src_crs,
575 transform,
576 data_ifds,
577 level_gsds: Vec::new(),
578 mask_ifds,
579 alpha_bands,
580 band_scale_offset,
581 source_id: Arc::from(config.source_id.as_str()),
582 statistics_id: NEXT_SOURCE_ID.fetch_add(1, Ordering::Relaxed),
583 block_cache: config.block_cache.clone(),
584 cpu: config
585 .cpu_limiter
586 .clone()
587 .unwrap_or_else(|| CpuLimiter::new(config.decode_task_limit())),
588 prefetch_ring: config.prefetch_ring,
589 prefetch_inflight: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
590 };
591 cog.level_gsds = (0..cog.level_count())
592 .map(|level| cog.compute_level_gsd(level).unwrap_or(f64::INFINITY))
593 .collect();
594 Ok(cog)
595 }
596
597 pub async fn open_object_store(store: Arc<dyn ObjectStore>, key: &str) -> AgResult<Self> {
604 Self::builder().open_object_store(store, key).await
605 }
606
607 #[deprecated(
610 since = "0.0.9",
611 note = "use `CogReader::builder().cache_config(cache).open_object_store(store, key)`"
612 )]
613 pub async fn open_object_store_with_cache(
614 store: Arc<dyn ObjectStore>,
615 key: &str,
616 cache: CacheConfig,
617 ) -> AgResult<Self> {
618 Self::builder()
619 .cache_config(cache)
620 .open_object_store(store, key)
621 .await
622 }
623
624 #[deprecated(
627 since = "0.0.9",
628 note = "use `CogReader::builder().reader_config(config).open_object_store(store, key)`"
629 )]
630 pub async fn open_object_store_with_config(
631 store: Arc<dyn ObjectStore>,
632 key: &str,
633 config: ReaderConfig,
634 ) -> AgResult<Self> {
635 Self::builder()
636 .reader_config(config)
637 .open_object_store(store, key)
638 .await
639 }
640
641 pub fn cpu_limiter(&self) -> CpuLimiter {
645 self.cpu.clone()
646 }
647
648 pub fn work_budget(&self) -> crate::resource::WorkBudget {
651 self.work_budget.clone()
652 }
653
654 pub fn src_crs(&self) -> &Crs {
656 &self.src_crs
657 }
658
659 pub(crate) fn statistics_identity(&self) -> u64 {
660 self.statistics_id
661 }
662
663 pub fn transform(&self) -> GeoTransform {
665 self.transform
666 }
667
668 pub fn wgs84_bounds(&self) -> AgResult<[f64; 4]> {
674 let (w, h) = self.level_dimensions(0)?;
675 let tr = self.transform;
676 let corners = [
677 tr.forward(0.0, 0.0),
678 tr.forward(w as f64, 0.0),
679 tr.forward(w as f64, h as f64),
680 tr.forward(0.0, h as f64),
681 ];
682 let bounds =
683 crate::warp::projection::reproject_boundary(corners, &self.src_crs, &Crs::epsg(4326))?;
684 Ok([bounds.xmin, bounds.ymin, bounds.xmax, bounds.ymax])
685 }
686
687 pub fn overview_count(&self) -> usize {
689 self.data_ifds.len().saturating_sub(1)
690 }
691
692 pub fn level_count(&self) -> usize {
694 self.data_ifds.len()
695 }
696
697 pub fn level_dimensions(&self, level: usize) -> AgResult<(u32, u32)> {
702 let ifd = self.data_ifd(level)?;
703 Ok((ifd.image_width(), ifd.image_height()))
704 }
705
706 pub fn internal_tile_dims(&self, level: usize) -> AgResult<(u32, u32)> {
710 let ifd = self.data_ifd(level)?;
711 Ok((
712 ifd.tile_width().unwrap_or_else(|| ifd.image_width()),
713 ifd.tile_height().unwrap_or_else(|| ifd.image_height()),
714 ))
715 }
716
717 pub fn level_transform(&self, level: usize) -> AgResult<GeoTransform> {
723 let (full_w, full_h) = self.level_dimensions(0)?;
724 let (level_w, level_h) = self.level_dimensions(level)?;
725 let scale_x = full_w as f64 / level_w as f64;
726 let scale_y = full_h as f64 / level_h as f64;
727 Ok(self.transform.scaled(scale_x, scale_y))
728 }
729
730 fn compute_level_gsd(&self, level: usize) -> AgResult<f64> {
731 let tr = self.level_transform(level)?.0;
732 let x_res = (tr.a.powi(2) + tr.d.powi(2)).sqrt();
733 let y_res = (tr.b.powi(2) + tr.e.powi(2)).sqrt();
734 Ok(x_res.max(y_res))
735 }
736
737 pub fn level_gsd(&self, level: usize) -> AgResult<f64> {
743 match self
744 .level_gsds
745 .get(level)
746 .copied()
747 .filter(|v| v.is_finite())
748 {
749 Some(gsd) => Ok(gsd),
750 None => self.compute_level_gsd(level),
753 }
754 }
755
756 pub fn pick_overview(&self, target_gsd: f64) -> usize {
771 pick_overview_from_gsds(&self.level_gsds, target_gsd)
772 }
773
774 #[tracing::instrument(skip(self), fields(level, width, height), err)]
785 pub async fn read_window(
786 &self,
787 level: usize,
788 col_off: i64,
789 row_off: i64,
790 width: u64,
791 height: u64,
792 ) -> AgResult<Arc<ImageData>> {
793 #[cfg(feature = "perf-tracing")]
794 let started = Instant::now();
795 validate_window_shape(width, height)?;
796 let _read_permit = self
797 .reads
798 .acquire()
799 .await
800 .map_err(|_| AgError::Tiff("read limiter closed".into()))?;
801 let pixels = width
802 .checked_mul(height)
803 .ok_or_else(|| AgError::Tiff("window pixel count overflow".into()))?;
804 let ifd = self.data_ifd(level)?;
805 let bytes_per_pixel: u64 = u64::from(ifd.samples_per_pixel())
806 * u64::from(ifd.bits_per_sample().iter().copied().max().unwrap_or(8)).div_ceil(8);
807 let bytes = pixels.checked_mul(bytes_per_pixel.saturating_add(1));
808 if pixels > self.max_window_pixels || bytes.is_none_or(|n| n > self.max_window_bytes) {
809 return Err(AgError::Tiff(
810 "source window exceeds pixel/byte limit".into(),
811 ));
812 }
813 checked_window_end(col_off, width, "column")?;
814 checked_window_end(row_off, height, "row")?;
815 let (lvl_w, lvl_h) = self.level_dimensions(level)?;
821 if col_off < 0 || row_off < 0 {
822 return Err(AgError::Tiff(format!(
823 "window origin ({col_off},{row_off}) is negative"
824 )));
825 }
826 if col_off as u64 + width > u64::from(lvl_w) || row_off as u64 + height > u64::from(lvl_h) {
827 return Err(AgError::Tiff(format!(
828 "window {width}x{height}+{col_off}+{row_off} exceeds level {level} extent {lvl_w}x{lvl_h}"
829 )));
830 }
831
832 let ifd = self.data_ifd(level)?;
833 let block_coords = |ifd: &ImageFileDirectory| -> AgResult<_> {
834 let ((tx0, tx1), (ty0, ty1)) =
835 tile_range_for_window(ifd, col_off, row_off, width, height)
836 .ok_or_else(|| AgError::Tiff("ifd lacks tile dims".into()))?;
837 let block_count = ((tx1 - tx0 + 1) as usize)
838 .checked_mul((ty1 - ty0 + 1) as usize)
839 .ok_or_else(|| AgError::Tiff("window block count overflow".into()))?;
840 if block_count > self.max_window_blocks {
841 return Err(AgError::Tiff("source window exceeds block limit".into()));
842 }
843 let mut coords = Vec::with_capacity(block_count);
844 for x in tx0..=tx1 {
845 for y in ty0..=ty1 {
846 coords.push((x as usize, y as usize));
847 }
848 }
849 Ok((coords, (tx0, tx1), (ty0, ty1)))
850 };
851 let (coords, tx_range, ty_range) = block_coords(ifd)?;
852 let data_info = IfdInfo::from(ifd).validate()?;
853
854 let mask_ifd = self.mask_ifd(level)?;
855 let mask_coords = mask_ifd
857 .map(|mask| block_coords(mask).map(|(coords, _, _)| coords))
858 .transpose()?;
859 let mut work_bytes = block_work_bytes(ifd, &coords, self.max_window_bytes)?;
860 if let Some((mask_ifd, mask_coords)) = mask_ifd.zip(mask_coords.as_deref()) {
861 work_bytes = work_bytes
862 .checked_add(block_work_bytes(
863 mask_ifd,
864 mask_coords,
865 self.max_window_bytes,
866 )?)
867 .ok_or_else(|| AgError::ResourceLimit("read work estimate overflow".into()))?;
868 }
869 let output_bytes = bytes.unwrap(); work_bytes = work_bytes
871 .checked_add(output_bytes.saturating_mul(2))
872 .ok_or_else(|| AgError::ResourceLimit("read work estimate overflow".into()))?;
873 let work = self.work_budget.try_reserve(work_bytes)?;
874 #[cfg(feature = "perf-tracing")]
875 let plan_done = started.elapsed();
876 let (data_blocks, mask_blocks) =
877 if let Some((mask_ifd, mask_coords)) = mask_ifd.zip(mask_coords) {
878 let mask_info = IfdInfo::from(mask_ifd).validate()?;
879 let data_blocks = self.blocks_for(level, BlockKind::Data, ifd, &coords, &work);
880 let mask_blocks =
881 self.blocks_for(level, BlockKind::Mask, mask_ifd, &mask_coords, &work);
882 let (data_blocks, mask_blocks) = tokio::try_join!(data_blocks, mask_blocks)?;
883 (data_blocks, Some((mask_blocks, mask_info, mask_coords)))
884 } else {
885 (
886 self.blocks_for(level, BlockKind::Data, ifd, &coords, &work)
887 .await?,
888 None,
889 )
890 };
891 self.maybe_spawn_prefetch(level, tx_range, ty_range);
895 #[cfg(feature = "perf-tracing")]
896 let blocks_done = started.elapsed();
897 #[cfg(feature = "perf-tracing")]
898 let data_stitch_started = Instant::now();
899 #[cfg(feature = "perf-tracing")]
900 let mut data_stitch_cpu_wait_ms = Duration::ZERO;
901 let alpha_idx = self.promoted_alpha_band(level)?;
908 let elem_bytes = data_blocks
909 .first()
910 .and_then(|b| b.array().data_type())
911 .map(|dt| dt.size() as u64)
912 .unwrap_or(8);
913 let est_out_bytes = (data_info.bands as u64)
914 .saturating_mul(width)
915 .saturating_mul(height)
916 .saturating_mul(elem_bytes)
917 .saturating_add(if mask_blocks.is_some() {
918 width.saturating_mul(height)
919 } else {
920 0
921 });
922 let est_out_bytes = if mask_blocks.is_none() && alpha_idx.is_some() {
926 est_out_bytes.saturating_mul(2)
927 } else {
928 est_out_bytes
929 };
930 let stitch_all = {
931 let work = work.clone();
932 let request = crate::request::RequestContext::current();
933 let coords2 = coords.clone();
934 let info = data_info;
935 let (w, h) = (width, height);
936 let (co, ro) = (col_off, row_off);
937 move || -> AgResult<(PixelData, Option<Array2<bool>>)> {
938 let _work = work;
939 crate::request::RequestContext::run_blocking(request, || {
940 let refs: Vec<&async_tiff::Array> =
941 data_blocks.iter().map(|b| b.array()).collect();
942 let stitched =
943 crate::io::window::stitch_with_info(&refs, &coords2, &info, co, ro, w, h)?;
944 let mask = match &mask_blocks {
945 Some((mask_blocks, mask_info, mask_coords)) => {
946 let refs: Vec<&async_tiff::Array> =
947 mask_blocks.iter().map(|b| b.array()).collect();
948 Some(crate::io::window::stitch_mask_with_info(
949 &refs,
950 mask_coords,
951 mask_info,
952 co,
953 ro,
954 w,
955 h,
956 )?)
957 }
958 None => None,
959 };
960 match (mask, alpha_idx) {
961 (None, Some(b)) if b < stitched.bands() => stitched
962 .split_off_band(b)
963 .map(|(pixels, alpha)| (pixels, Some(alpha)))
964 .ok_or_else(|| AgError::Tiff("alpha-band split failed".into())),
965 (mask, _) => Ok((stitched, mask)),
966 }
967 })
968 }
969 };
970 let (stitched, mask) = if est_out_bytes <= INLINE_STITCH_MAX_BYTES {
971 stitch_all()?
974 } else {
975 #[cfg(feature = "perf-tracing")]
976 let wait_started = Instant::now();
977 let permit = self.cpu.acquire().await?;
978 crate::perf_trace! {
979 data_stitch_cpu_wait_ms = wait_started.elapsed();
980 }
981 tokio::task::spawn_blocking(move || {
982 let _permit = permit;
983 stitch_all()
984 })
985 .await
986 .map_err(|e| AgError::Tiff(format!("join: {e}")))??
987 };
988 #[cfg(feature = "perf-tracing")]
989 let data_stitch_ms = data_stitch_started.elapsed();
990 #[cfg(feature = "perf-tracing")]
991 let data_stitch_job_ms = data_stitch_ms.saturating_sub(data_stitch_cpu_wait_ms);
992 #[cfg(feature = "perf-tracing")]
993 let mask_stitch_ms = Duration::ZERO;
994 #[cfg(feature = "perf-tracing")]
995 let mask_stitch_cpu_wait_ms = Duration::ZERO;
996 #[cfg(feature = "perf-tracing")]
997 let mask_stitch_job_ms = Duration::ZERO;
998
999 #[cfg(feature = "perf-tracing")]
1000 let assemble_started = Instant::now();
1001 let image = self.assemble_image(level, stitched, mask, col_off, row_off, width, height)?;
1002 #[cfg(feature = "perf-tracing")]
1003 let assemble_ms = assemble_started.elapsed();
1004 crate::perf_trace! {
1005 tracing::debug!(
1006 level,
1007 tiles = coords.len(),
1008 plan_ms = duration_ms(plan_done),
1009 blocks_ms = duration_ms(blocks_done - plan_done),
1010 data_stitch_ms = duration_ms(data_stitch_ms),
1011 data_stitch_cpu_wait_ms = duration_ms(data_stitch_cpu_wait_ms),
1012 data_stitch_job_ms = duration_ms(data_stitch_job_ms),
1013 mask_stitch_ms = duration_ms(mask_stitch_ms),
1014 mask_stitch_cpu_wait_ms = duration_ms(mask_stitch_cpu_wait_ms),
1015 mask_stitch_job_ms = duration_ms(mask_stitch_job_ms),
1016 assemble_ms = duration_ms(assemble_ms),
1017 total_ms = duration_ms(started.elapsed()),
1018 "read_window timing"
1019 );
1020 }
1021 Ok(Arc::new(image))
1022 }
1023}
1024
1025impl CogReader {
1026 #[allow(clippy::too_many_arguments)]
1029 fn assemble_image(
1030 &self,
1031 level: usize,
1032 pixels: PixelData,
1033 mask: Option<Array2<bool>>,
1034 col_off: i64,
1035 row_off: i64,
1036 width: u64,
1037 height: u64,
1038 ) -> AgResult<ImageData> {
1039 let level_transform = self.level_transform(level)?;
1040 let window_transform = level_transform.translated(col_off as f64, row_off as f64);
1041 let bbox_world = transform_bbox(level_transform, col_off, row_off, width, height)?;
1042 let nodata = self
1043 .data_ifd(level)?
1044 .gdal_nodata()
1045 .and_then(|s| s.parse::<f64>().ok());
1046
1047 Ok(ImageData {
1048 pixels,
1049 mask,
1050 bbox: bbox_world,
1051 crs: self.src_crs.clone(),
1052 transform: window_transform,
1053 nodata,
1054 })
1055 }
1056
1057 pub fn band_scale_offset(&self) -> &[ScaleOffset] {
1060 &self.band_scale_offset
1061 }
1062
1063 pub(crate) fn data_ifd(&self, level: usize) -> AgResult<&ImageFileDirectory> {
1064 let idx = self.data_ifd_index(level)?;
1065 self.tiff
1066 .ifds()
1067 .get(idx)
1068 .ok_or(AgError::MissingGeo("overview IFD"))
1069 }
1070
1071 pub(super) fn promoted_alpha_band(&self, level: usize) -> AgResult<Option<usize>> {
1073 Ok(if self.mask_ifd(level)?.is_none() {
1074 self.alpha_bands.get(level).copied().flatten()
1075 } else {
1076 None
1077 })
1078 }
1079
1080 fn data_ifd_index(&self, level: usize) -> AgResult<usize> {
1081 self.data_ifds
1082 .get(level)
1083 .copied()
1084 .ok_or(AgError::MissingGeo("overview level"))
1085 }
1086
1087 fn mask_ifd(&self, level: usize) -> AgResult<Option<&ImageFileDirectory>> {
1088 let Some(mask_idx) = self
1089 .mask_ifds
1090 .get(level)
1091 .ok_or(AgError::MissingGeo("overview level"))?
1092 else {
1093 return Ok(None);
1094 };
1095 Ok(self.tiff.ifds().get(*mask_idx))
1096 }
1097}
1098
1099fn checked_window_end(offset: i64, len: u64, axis: &'static str) -> AgResult<i64> {
1104 let len = i64::try_from(len)
1105 .map_err(|_| AgError::Tiff(format!("{axis} window length exceeds i64")))?;
1106 offset
1107 .checked_add(len)
1108 .ok_or_else(|| AgError::Tiff(format!("{axis} window end overflows i64")))
1109}
1110
1111fn transform_bbox(transform: GeoTransform, col: i64, row: i64, w: u64, h: u64) -> AgResult<Bbox> {
1112 let c0 = col as f64;
1113 let r0 = row as f64;
1114 let c1 = checked_window_end(col, w, "column")? as f64;
1115 let r1 = checked_window_end(row, h, "row")? as f64;
1116 let corners = [
1117 transform.forward(c0, r0),
1118 transform.forward(c1, r0),
1119 transform.forward(c1, r1),
1120 transform.forward(c0, r1),
1121 ];
1122 Ok(Bbox::from_corners(&corners))
1123}
1124
1125fn default_client_options() -> ClientOptions {
1135 ClientOptions::new()
1143 .with_allow_http(true)
1144 .with_allow_http2()
1145 .with_pool_max_idle_per_host(64)
1146 .with_pool_idle_timeout(Duration::from_secs(90))
1147 .with_timeout(Duration::from_secs(30))
1148 .with_connect_timeout(Duration::from_secs(10))
1149}
1150
1151fn split_http_url(url: &str) -> AgResult<(String, String)> {
1152 let u = url::Url::parse(url).map_err(|e| AgError::Tiff(format!("invalid url: {e}")))?;
1153 if u.query().is_some() {
1154 return Ok((u.to_string(), String::new()));
1157 }
1158 let scheme = u.scheme();
1159 let host = u
1160 .host_str()
1161 .ok_or_else(|| AgError::Tiff("url missing host".into()))?;
1162 let port = u.port().map(|p| format!(":{}", p)).unwrap_or_default();
1163 let base = format!("{}://{}{}", scheme, host, port);
1164 let path = u.path().trim_start_matches('/').to_string();
1165 Ok((base, path))
1166}
1167
1168fn derive_geo(tiff: &TIFF) -> AgResult<(GeoTransform, Crs)> {
1169 let ifd = tiff.ifds().first().ok_or(AgError::MissingGeo("no IFD"))?;
1170 derive_ifd_geo(ifd)
1171}
1172
1173fn derive_ifd_geo(ifd: &ImageFileDirectory) -> AgResult<(GeoTransform, Crs)> {
1174 let mut gt = if let Some(t) = ifd.model_transformation() {
1175 if t.len() < 16 {
1177 return Err(AgError::MissingGeo("model_transformation truncated"));
1178 }
1179 GeoTransform::from_gdal(&[t[3], t[0], t[1], t[7], t[4], t[5]])
1180 } else {
1181 let tp = ifd
1182 .model_tiepoint()
1183 .ok_or(AgError::MissingGeo("model_tiepoint"))?;
1184 let ps = ifd
1185 .model_pixel_scale()
1186 .ok_or(AgError::MissingGeo("model_pixel_scale"))?;
1187 if tp.len() < 6 || ps.len() < 2 {
1188 return Err(AgError::MissingGeo("tiepoint/scale truncated"));
1189 }
1190 let (i, j, x, y) = (tp[0], tp[1], tp[3], tp[4]);
1191 let (sx, sy) = (ps[0], ps[1]);
1192 GeoTransform::from_gdal(&[x - i * sx, sx, 0.0, y + j * sy, 0.0, -sy])
1194 };
1195
1196 if ifd.geo_key_directory().and_then(|keys| keys.raster_type) == Some(2) {
1197 gt = gt.translated(-0.5, -0.5);
1199 }
1200
1201 let crs = ifd
1202 .geo_key_directory()
1203 .and_then(|gk| gk.epsg_code())
1204 .map(|c| Crs::epsg(c as u32))
1205 .unwrap_or_else(|| Crs::new("UNKNOWN"));
1206
1207 Ok((gt, crs))
1208}
1209
1210fn classify_ifds(tiff: &TIFF) -> AgResult<(Vec<usize>, Vec<Option<usize>>)> {
1211 let first = tiff.ifds().first().ok_or(AgError::MissingGeo("no IFD"))?;
1212 let mut mask_by_dims: HashMap<(u32, u32), usize> = HashMap::new();
1213 let mut overviews: Vec<((u32, u32), usize)> = Vec::new();
1214 for (idx, ifd) in tiff.ifds().iter().enumerate().skip(1) {
1215 let dims = (ifd.image_width(), ifd.image_height());
1216 if is_mask_ifd(ifd) {
1217 mask_by_dims.insert(dims, idx);
1218 } else if ifd.new_subfile_type().is_some_and(|t| t & 1 != 0)
1219 && dims.0 > 0 && dims.1 > 0
1220 && dims.0 < first.image_width() && dims.1 < first.image_height()
1221 && ifd.samples_per_pixel() == first.samples_per_pixel()
1222 && ifd.bits_per_sample() == first.bits_per_sample()
1223 && ifd.sample_format() == first.sample_format()
1224 && ifd.photometric_interpretation() == first.photometric_interpretation()
1225 && (f64::from(first.image_width()) / f64::from(dims.0)
1228 - f64::from(first.image_height()) / f64::from(dims.1)).abs()
1229 <= f64::from(first.image_width()).max(f64::from(first.image_height()))
1230 / f64::from(dims.0) / f64::from(dims.1)
1231 {
1232 if ifd.model_transformation().is_some() || ifd.model_tiepoint().is_some() {
1235 let base = derive_geo(tiff)?.0;
1236 let candidate = derive_ifd_geo(ifd)?.0;
1237 let sx = f64::from(first.image_width()) / f64::from(dims.0);
1238 let sy = f64::from(first.image_height()) / f64::from(dims.1);
1239 let aligned = [(0., 0.), (1., 0.), (0., 1.)].iter().all(|&(x, y)| {
1240 let a = base.forward(x * sx, y * sy);
1241 let b = candidate.forward(x, y);
1242 (a.0 - b.0).abs() <= 1e-8 * a.0.abs().max(1.)
1243 && (a.1 - b.1).abs() <= 1e-8 * a.1.abs().max(1.)
1244 });
1245 if !aligned {
1246 continue;
1247 }
1248 }
1249 if let Some(epsg) = ifd.geo_key_directory().and_then(|g| g.epsg_code()) {
1250 if first.geo_key_directory().and_then(|g| g.epsg_code()) != Some(epsg) {
1251 continue;
1252 }
1253 }
1254 overviews.push((dims, idx));
1255 }
1256 }
1257
1258 overviews.sort_by_key(|((w, h), _)| std::cmp::Reverse(u64::from(*w) * u64::from(*h)));
1259
1260 let mut data_ifds = vec![0usize];
1261 let mut mask_ifds = vec![mask_by_dims
1262 .get(&(first.image_width(), first.image_height()))
1263 .copied()];
1264 for (dims, idx) in overviews {
1265 data_ifds.push(idx);
1266 mask_ifds.push(mask_by_dims.get(&dims).copied());
1267 }
1268 Ok((data_ifds, mask_ifds))
1269}
1270
1271fn pick_overview_from_gsds(gsds: &[f64], target_gsd: f64) -> usize {
1274 let n = gsds.len();
1275 if n <= 1 {
1276 return 0;
1277 }
1278 let src_res = gsds[0];
1279 if target_gsd <= src_res {
1280 return 0;
1281 }
1282 for level in 0..n - 1 {
1283 let cur = gsds[level];
1284 let next = gsds[level + 1];
1285 if cur < target_gsd && next > target_gsd {
1286 return level;
1287 }
1288 if cur == target_gsd {
1289 return level;
1290 }
1291 }
1292 n - 1
1293}
1294
1295fn is_mask_ifd(ifd: &ImageFileDirectory) -> bool {
1296 ifd.new_subfile_type().is_some_and(|t| t & 4 != 0)
1297 && ifd.photometric_interpretation() == PhotometricInterpretation::TransparencyMask
1298}
1299
1300fn alpha_band_index(ifd: &ImageFileDirectory) -> Option<usize> {
1306 use async_tiff::tags::ExtraSamples;
1307 let extras = ifd.extra_samples()?;
1308 let total_bands = ifd.samples_per_pixel() as usize;
1309 let base = total_bands.checked_sub(extras.len())?;
1310 extras.iter().enumerate().find_map(|(i, e)| {
1311 matches!(
1312 e,
1313 ExtraSamples::AssociatedAlpha | ExtraSamples::UnassociatedAlpha
1314 )
1315 .then_some(base + i)
1316 })
1317}
1318
1319#[cfg(test)]
1320mod tests {
1321 use super::*;
1324
1325 #[tokio::test]
1326 async fn read_concurrency_configuration_clamps_both_bounds() {
1327 for (configured, expected) in [(0, 1), (usize::MAX, tokio::sync::Semaphore::MAX_PERMITS)] {
1328 let config = ReaderConfig {
1329 max_concurrent_reads: configured,
1330 ..ReaderConfig::default()
1331 };
1332 let store =
1333 Arc::new(object_store::local::LocalFileSystem::new_with_prefix(".").unwrap());
1334 let reader = CogReader::builder()
1335 .reader_config(config)
1336 .open_object_store(store, "tests/fixtures/rgb_nodata.tif")
1337 .await
1338 .unwrap();
1339 assert_eq!(reader.reads.available_permits(), expected);
1340 assert!(reader.read_window(0, 0, 0, 1, 1).await.is_ok());
1341 }
1342 }
1343
1344 fn rgba_tiff() -> Vec<u8> {
1347 let shorts = |values: &[u16]| {
1348 values
1349 .iter()
1350 .flat_map(|v| v.to_le_bytes())
1351 .collect::<Vec<_>>()
1352 };
1353 let longs = |values: &[u32]| {
1354 values
1355 .iter()
1356 .flat_map(|v| v.to_le_bytes())
1357 .collect::<Vec<_>>()
1358 };
1359 let doubles = |values: &[f64]| {
1360 values
1361 .iter()
1362 .flat_map(|v| v.to_le_bytes())
1363 .collect::<Vec<_>>()
1364 };
1365 let block_bytes = 256 * 256 * 4;
1366 let mut tags = vec![
1367 (256u16, 4u16, 1u32, longs(&[512])),
1368 (257, 4, 1, longs(&[512])),
1369 (258, 3, 4, shorts(&[8; 4])),
1370 (259, 3, 1, shorts(&[1])),
1371 (262, 3, 1, shorts(&[2])),
1372 (277, 3, 1, shorts(&[4])),
1373 (284, 3, 1, shorts(&[1])),
1374 (322, 4, 1, longs(&[256])),
1375 (323, 4, 1, longs(&[256])),
1376 (324, 4, 4, longs(&[0; 4])),
1377 (325, 4, 4, longs(&[block_bytes; 4])),
1378 (338, 3, 1, shorts(&[2])),
1379 (33550, 12, 3, doubles(&[1., 1., 0.])),
1380 (33922, 12, 6, doubles(&[0., 0., 0., 0., 0., 0.])),
1381 ];
1382 let table_end = 8 + 2 + tags.len() * 12 + 4;
1383 let data_start = table_end
1384 + tags
1385 .iter()
1386 .filter(|tag| tag.3.len() > 4)
1387 .map(|tag| tag.3.len())
1388 .sum::<usize>();
1389 let offsets: Vec<u32> = (0..4)
1390 .map(|i| data_start as u32 + i * block_bytes)
1391 .collect();
1392 tags.iter_mut().find(|tag| tag.0 == 324).unwrap().3 = longs(&offsets);
1393 let mut bytes = b"II".to_vec();
1394 bytes.extend(42u16.to_le_bytes());
1395 bytes.extend(8u32.to_le_bytes());
1396 bytes.extend((tags.len() as u16).to_le_bytes());
1397 let mut payload = Vec::new();
1398 for (tag, kind, count, data) in tags {
1399 bytes.extend(tag.to_le_bytes());
1400 bytes.extend(kind.to_le_bytes());
1401 bytes.extend(count.to_le_bytes());
1402 if data.len() <= 4 {
1403 let mut inline = [0; 4];
1404 inline[..data.len()].copy_from_slice(&data);
1405 bytes.extend(inline);
1406 } else {
1407 bytes.extend(((table_end + payload.len()) as u32).to_le_bytes());
1408 payload.extend(data);
1409 }
1410 }
1411 bytes.extend(0u32.to_le_bytes());
1412 bytes.extend(payload);
1413 for _ in 0..4 * 256 * 256 {
1414 bytes.extend([3, 7, 11, 255]);
1415 }
1416 bytes[data_start + 3] = 0;
1417 bytes
1418 }
1419
1420 #[tokio::test]
1421 async fn alpha_assembly_obeys_cpu_budget_and_queued_cancellation() {
1422 use crate::request::{RequestLimiter, RequestOptions};
1423 use object_store::ObjectStoreExt;
1424 let store = Arc::new(object_store::memory::InMemory::new());
1425 store
1426 .put(&Path::from("rgba.tif"), rgba_tiff().into())
1427 .await
1428 .unwrap();
1429 let cpu = CpuLimiter::new(1);
1430 let budget = crate::resource::WorkBudget::new(128 * 1024 * 1024);
1431 let config = ReaderConfig {
1432 cpu_limiter: Some(cpu.clone()),
1433 work_budget: budget.clone(),
1434 ..ReaderConfig::default()
1435 };
1436 let reader = CogReader::builder()
1437 .reader_config(config)
1438 .open_object_store(store, "rgba.tif")
1439 .await
1440 .unwrap();
1441 let image = reader.read_window(0, 0, 0, 512, 512).await.unwrap();
1442 assert_eq!(image.pixels.bands(), 3);
1443 let pixels = image.pixels.to_f64();
1444 assert_eq!(
1445 [pixels[[0, 0, 1]], pixels[[1, 0, 1]], pixels[[2, 0, 1]]],
1446 [3., 7., 11.]
1447 );
1448 let mask = image.mask.as_ref().unwrap();
1449 assert!(!mask[[0, 0]]);
1450 assert!(mask[[0, 1]] && mask[[511, 511]]);
1451 let persistent = budget.used_bytes();
1454 let permit = cpu.acquire().await.unwrap();
1455 let requests = RequestLimiter::new(1);
1456 let result = requests
1457 .run(
1458 Duration::from_millis(30),
1459 &RequestOptions::default(),
1460 |_| reader.read_window(0, 0, 0, 512, 512),
1461 )
1462 .await;
1463 assert!(matches!(result, Err(AgError::DeadlineExceeded)));
1464 assert_eq!(requests.available_permits(), 1);
1465 assert_eq!(reader.reads.available_permits(), 8);
1466 assert_eq!(budget.used_bytes(), persistent);
1467 drop(permit);
1468 let again = reader.read_window(0, 0, 0, 512, 512).await.unwrap();
1469 assert_eq!(again.pixels.to_f64(), pixels);
1470 assert_eq!(again.mask, image.mask);
1471 }
1472
1473 #[cfg(test)]
1474 mod scale_offset_reader_tests {
1475 #[tokio::test]
1476 async fn band_scale_offset_defaults_to_identity() {
1477 use crate::io::{CogReader, ObjectReader};
1478 use object_store::local::LocalFileSystem;
1479 use std::sync::Arc;
1480 let fs = Arc::new(LocalFileSystem::new_with_prefix(".").unwrap());
1481 let r = CogReader::builder()
1482 .snapshot_policy(crate::io::SnapshotPolicy::AssumeImmutable)
1483 .open(ObjectReader::new(
1484 fs,
1485 object_store::path::Path::from("tests/fixtures/rgb_nodata.tif"),
1486 ))
1487 .await
1488 .unwrap();
1489 let so = r.band_scale_offset();
1491 assert!(so.iter().all(|s| s.is_identity()));
1492 assert_eq!(so.len(), 3);
1493 }
1494 }
1495
1496 #[cfg(test)]
1497 mod open_object_store_tests {
1498 use crate::io::{CogReader, ObjectReader};
1499 use object_store::local::LocalFileSystem;
1500 use std::sync::Arc;
1501
1502 #[tokio::test]
1503 async fn open_object_store_matches_open_with_reader() {
1504 let fs = Arc::new(LocalFileSystem::new_with_prefix(".").unwrap());
1505 let key = "tests/fixtures/rgb_nodata.tif";
1506
1507 let via_helper = CogReader::open_object_store(fs.clone(), key)
1509 .await
1510 .expect("open_object_store");
1511
1512 let reader = ObjectReader::new(fs, object_store::path::Path::from(key));
1514 let via_reader = CogReader::builder()
1515 .snapshot_policy(crate::io::SnapshotPolicy::AssumeImmutable)
1516 .open(reader)
1517 .await
1518 .expect("open_with_reader");
1519
1520 assert_eq!(
1523 via_helper.wgs84_bounds().unwrap(),
1524 via_reader.wgs84_bounds().unwrap()
1525 );
1526 assert_eq!(via_helper.src_crs(), via_reader.src_crs());
1527 }
1528 }
1529
1530 #[cfg(test)]
1531 mod block_cache_tests {
1532 use super::*;
1533
1534 async fn open_local_for_test(
1535 tif: &std::path::Path,
1536 source_id: &str,
1537 cache: std::sync::Arc<crate::io::block_cache::BlockCache>,
1538 ) -> CogReader {
1539 use async_tiff::reader::ObjectReader;
1540 use object_store::local::LocalFileSystem;
1541 use object_store::path::Path as OsPath;
1542 let os_path = OsPath::from_filesystem_path(tif).unwrap();
1543 let reader = ObjectReader::new(std::sync::Arc::new(LocalFileSystem::new()), os_path);
1544 CogReader::builder()
1545 .source_id(source_id)
1546 .block_cache(cache)
1547 .snapshot_policy(crate::io::SnapshotPolicy::AssumeImmutable)
1548 .open(reader)
1549 .await
1550 .unwrap()
1551 }
1552
1553 #[test]
1554 fn ring_coords_clamps_to_grid_and_excludes_inner_rect() {
1555 use crate::io::window::IfdInfo;
1556 use async_tiff::tags::PlanarConfiguration;
1557 let info = IfdInfo::from_parts(256, 256, 3, PlanarConfiguration::Planar, 1024, 1024)
1559 .validate()
1560 .unwrap();
1561 let mut got = super::ring_coords(&info, (0, 0), (0, 0), 1);
1562 got.sort_unstable();
1563 assert_eq!(got, vec![(0, 1), (1, 0), (1, 1)]);
1565
1566 let mut got = super::ring_coords(&info, (1, 1), (1, 1), 1);
1568 got.sort_unstable();
1569 assert_eq!(got.len(), 8);
1570 assert!(!got.contains(&(1, 1)), "inner rect must be excluded");
1571
1572 assert!(super::ring_coords(&info, (0, 3), (0, 3), 1).is_empty());
1574 }
1575
1576 #[tokio::test]
1577 async fn prefetch_ring_warms_neighbor_blocks() {
1578 use crate::io::block_cache::BlockCache;
1579 use crate::io::cache::CacheConfig;
1580 use crate::io::window::IfdInfo;
1581 use async_tiff::reader::ObjectReader;
1582 use object_store::local::LocalFileSystem;
1583 use object_store::path::Path as OsPath;
1584
1585 let tif = std::env::current_dir()
1586 .unwrap()
1587 .join("tests/fixtures/rgb_nodata.tif");
1588 let cache = std::sync::Arc::new(BlockCache::new(CacheConfig::default()));
1589 let os_path = OsPath::from_filesystem_path(&tif).unwrap();
1590 let reader = ObjectReader::new(std::sync::Arc::new(LocalFileSystem::new()), os_path);
1591 let cog = CogReader::builder()
1592 .source_id("prefetch-test")
1593 .block_cache(std::sync::Arc::clone(&cache))
1594 .prefetch_ring(1)
1595 .snapshot_policy(crate::io::SnapshotPolicy::AssumeImmutable)
1596 .open(reader)
1597 .await
1598 .unwrap();
1599
1600 let info = IfdInfo::from(cog.data_ifd(0).unwrap()).validate().unwrap();
1602 let ring = super::ring_coords(&info, (0, 0), (0, 0), 1);
1603 assert!(!ring.is_empty(), "fixture must have neighboring blocks");
1604
1605 cog.read_window(0, 0, 0, 16, 16).await.unwrap();
1607
1608 let want = 1 + ring.len() as u64; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1613 loop {
1614 let stats = cache.stats();
1615 if stats.decoded >= want {
1616 break;
1617 }
1618 assert!(
1619 std::time::Instant::now() < deadline,
1620 "prefetch did not warm ring blocks in time: decoded={} want>={want}",
1621 stats.decoded
1622 );
1623 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1624 }
1625 }
1626
1627 #[tokio::test]
1628 async fn read_window_reuses_blocks_on_identical_window() {
1629 use crate::io::block_cache::BlockCache;
1630 use crate::io::cache::CacheConfig;
1631 let tif = std::env::current_dir()
1632 .unwrap()
1633 .join("tests/fixtures/rgb_nodata.tif");
1634 let cache = std::sync::Arc::new(BlockCache::new(CacheConfig::default()));
1635 let r = open_local_for_test(&tif, "src-A", cache.clone()).await;
1636 let a = r.read_window(0, 0, 0, 64, 64).await.unwrap();
1637 let n1 = cache.sync_count().await;
1638 let b = r.read_window(0, 0, 0, 64, 64).await.unwrap();
1639 let n2 = cache.sync_count().await;
1640 assert_eq!(
1641 a.pixels.to_f64(),
1642 b.pixels.to_f64(),
1643 "identical window → identical pixels"
1644 );
1645 assert!(n1 > 0, "blocks must be cached");
1646 assert_eq!(n1, n2, "identical window must not add blocks");
1647 }
1648
1649 #[tokio::test]
1650 async fn read_window_rejects_out_of_bounds() {
1651 use crate::io::block_cache::BlockCache;
1652 use crate::io::cache::CacheConfig;
1653 let tif = std::env::current_dir()
1654 .unwrap()
1655 .join("tests/fixtures/rgb_nodata.tif");
1656 let cache = std::sync::Arc::new(BlockCache::new(CacheConfig::default()));
1657 let r = open_local_for_test(&tif, "src-A", cache.clone()).await;
1658 let (w, h) = r.level_dimensions(0).unwrap();
1659 assert!(
1662 r.read_window(0, 0, 0, u64::from(w) + 100_000, u64::from(h) + 100_000)
1663 .await
1664 .is_err(),
1665 "oversized window must be rejected"
1666 );
1667 assert!(r
1669 .read_window(0, 0, 0, u64::from(w), u64::from(h))
1670 .await
1671 .is_ok());
1672 }
1673
1674 #[tokio::test]
1675 async fn distinct_sources_add_separate_blocks() {
1676 use crate::io::block_cache::BlockCache;
1677 use crate::io::cache::CacheConfig;
1678 let tif = std::env::current_dir()
1679 .unwrap()
1680 .join("tests/fixtures/rgb_nodata.tif");
1681 let cache = std::sync::Arc::new(BlockCache::new(CacheConfig::default()));
1682 let a = open_local_for_test(&tif, "src-A", cache.clone()).await;
1683 let b = open_local_for_test(&tif, "src-B", cache.clone()).await;
1684 let _ = a.read_window(0, 0, 0, 32, 32).await.unwrap();
1685 let na = cache.sync_count().await;
1686 let _ = b.read_window(0, 0, 0, 32, 32).await.unwrap();
1687 let nb = cache.sync_count().await;
1688 assert!(
1689 nb > na,
1690 "different source ids must add separate block entries"
1691 );
1692 }
1693
1694 #[tokio::test]
1695 async fn concurrent_identical_cold_reads_single_flight_the_fetch() {
1696 use crate::io::block_cache::BlockCache;
1697 use crate::io::cache::CacheConfig;
1698 let tif = std::env::current_dir()
1699 .unwrap()
1700 .join("tests/fixtures/rgb_nodata.tif");
1701 let cache = std::sync::Arc::new(BlockCache::new(CacheConfig::default()));
1702 let r = std::sync::Arc::new(open_local_for_test(&tif, "src-sf", cache.clone()).await);
1703
1704 let r1 = std::sync::Arc::clone(&r);
1708 let r2 = std::sync::Arc::clone(&r);
1709 let (a, b) = tokio::join!(
1710 async move { r1.read_window(0, 0, 0, 64, 64).await },
1711 async move { r2.read_window(0, 0, 0, 64, 64).await },
1712 );
1713 let a = a.unwrap();
1714 let b = b.unwrap();
1715 assert_eq!(
1716 a.pixels.to_f64(),
1717 b.pixels.to_f64(),
1718 "both racing readers must observe identical pixels"
1719 );
1720
1721 let blocks = cache.sync_count().await;
1722 let fetched = cache.stats().fetched;
1723 assert!(blocks > 0, "cold reads must populate the cache");
1724 assert_eq!(
1728 fetched, blocks,
1729 "each cold block must be fetched exactly once despite the race"
1730 );
1731 }
1732
1733 #[tokio::test]
1734 async fn level_gsds_are_cached_from_the_existing_formula() {
1735 use crate::io::block_cache::BlockCache;
1736 use crate::io::cache::CacheConfig;
1737
1738 let tif = std::env::current_dir()
1739 .unwrap()
1740 .join("tests/fixtures/rgb_nodata.tif");
1741 let cache = std::sync::Arc::new(BlockCache::new(CacheConfig::default()));
1742 let reader = open_local_for_test(&tif, "level-gsd-cache", cache).await;
1743
1744 assert!(reader.level_count() > 1, "fixture must contain overviews");
1745 assert_eq!(reader.level_gsds.len(), reader.level_count());
1746 for level in 0..reader.level_count() {
1747 let tr = reader.level_transform(level).unwrap().0;
1749 let x_res = (tr.a.powi(2) + tr.d.powi(2)).sqrt();
1750 let y_res = (tr.b.powi(2) + tr.e.powi(2)).sqrt();
1751 let expected = x_res.max(y_res);
1752
1753 assert_eq!(reader.level_gsds[level], expected);
1754 assert_eq!(reader.level_gsd(level).unwrap(), expected);
1755 }
1756 assert!(matches!(
1757 reader.level_gsd(reader.level_count()),
1758 Err(AgError::MissingGeo("overview level"))
1759 ));
1760 }
1761
1762 #[test]
1763 fn overview_brackets_target_from_below_downsample() {
1764 assert_eq!(pick_overview_from_gsds(&TEST_OVERVIEW_PYRAMID, 30.0), 1);
1767 }
1768
1769 #[test]
1770 fn overview_zoomed_in_uses_full_res() {
1771 assert_eq!(pick_overview_from_gsds(&TEST_OVERVIEW_PYRAMID, 5.0), 0);
1773 assert_eq!(pick_overview_from_gsds(&TEST_OVERVIEW_PYRAMID, 10.0), 0);
1774 }
1775
1776 #[test]
1777 fn overview_snaps_within_tolerance() {
1778 assert_eq!(pick_overview_from_gsds(&TEST_OVERVIEW_PYRAMID, 40.05), 2);
1780 }
1781
1782 #[test]
1783 fn overview_coarser_than_all_uses_coarsest() {
1784 assert_eq!(pick_overview_from_gsds(&TEST_OVERVIEW_PYRAMID, 200.0), 3);
1785 }
1786
1787 #[test]
1788 fn overview_single_level_is_zero() {
1789 assert_eq!(pick_overview_from_gsds(&[10.0], 100.0), 0);
1790 }
1791
1792 #[test]
1793 fn overview_selection_is_independent_of_coordinate_units() {
1794 assert_eq!(
1795 pick_overview_from_gsds(&[0.0001, 0.0002, 0.0004, 0.0008], 0.0005),
1796 2
1797 );
1798 assert_eq!(pick_overview_from_gsds(&[1.0, 2.0, 4.0, 8.0], 5.0), 2);
1799 }
1800
1801 #[test]
1802 fn reader_config_clamps_decode_limit_to_one() {
1803 let cfg = ReaderConfig {
1804 max_decode_tasks: 0,
1805 ..ReaderConfig::default()
1806 };
1807 assert_eq!(cfg.decode_task_limit(), 1);
1808 }
1809
1810 #[test]
1811 fn split_http_url_extracts_parts() {
1812 let (base, path) = split_http_url("https://example.com/foo/bar.tif").unwrap();
1813 assert_eq!(base, "https://example.com");
1814 assert_eq!(path, "foo/bar.tif");
1815 }
1816
1817 #[test]
1818 fn split_http_url_keeps_port() {
1819 let (base, _path) = split_http_url("http://localhost:8080/data/scene.tif").unwrap();
1820 assert_eq!(base, "http://localhost:8080");
1821 }
1822
1823 #[test]
1824 fn non_ascii_path_round_trips_via_from_url_path() {
1825 use object_store::path::Path;
1826
1827 let mut u = url::Url::parse("http://127.0.0.1:8080").unwrap();
1830 u.path_segments_mut()
1831 .unwrap()
1832 .push("data")
1833 .push("测试-中文.tif");
1834 let (_base, path) = split_http_url(u.as_str()).unwrap();
1835 assert!(path.contains('%'), "split keeps the URL-encoded path");
1836
1837 assert_ne!(Path::from(path.as_str()).as_ref(), "data/测试-中文.tif");
1840
1841 assert_eq!(
1845 Path::from_url_path(&path).unwrap().as_ref(),
1846 "data/测试-中文.tif"
1847 );
1848 }
1849 }
1850
1851 #[cfg(test)]
1852 mod audit_metadata_tests {
1853 use super::*;
1854 use async_tiff::{reader::Endianness, tags::Tag, TagValue};
1855
1856 fn ifd(
1857 w: u32,
1858 h: u32,
1859 reduced: bool,
1860 bands: u16,
1861 point: bool,
1862 matrix: bool,
1863 ) -> ImageFileDirectory {
1864 let mut tags = HashMap::from([
1865 (Tag::ImageWidth, TagValue::Unsigned(w)),
1866 (Tag::ImageLength, TagValue::Unsigned(h)),
1867 (Tag::SamplesPerPixel, TagValue::Short(bands)),
1868 (
1869 Tag::BitsPerSample,
1870 TagValue::List(vec![TagValue::Short(8); bands as usize]),
1871 ),
1872 (Tag::Compression, TagValue::Short(1)),
1873 (Tag::PhotometricInterpretation, TagValue::Short(1)),
1874 (Tag::NewSubfileType, TagValue::Unsigned(u32::from(reduced))),
1875 (Tag::TileWidth, TagValue::Unsigned(16)),
1876 (Tag::TileLength, TagValue::Unsigned(16)),
1877 (
1878 Tag::GeoKeyDirectory,
1879 TagValue::List(
1880 [1, 1, 0, 1, 1025, 0, 1, if point { 2 } else { 1 }]
1881 .into_iter()
1882 .map(TagValue::Short)
1883 .collect(),
1884 ),
1885 ),
1886 ]);
1887 let doubles =
1888 |v: &[f64]| TagValue::List(v.iter().copied().map(TagValue::Double).collect());
1889 if matrix {
1890 tags.insert(
1891 Tag::ModelTransformation,
1892 doubles(&[
1893 2., 0.5, 0., 100., 0.25, -3., 0., 200., 0., 0., 1., 0., 0., 0., 0., 1.,
1894 ]),
1895 );
1896 } else {
1897 tags.insert(Tag::ModelTiepoint, doubles(&[0., 0., 0., 100., 200., 0.]));
1898 tags.insert(
1899 Tag::ModelPixelScale,
1900 doubles(&[128. / f64::from(w), 192. / f64::from(h), 0.]),
1901 );
1902 }
1903 ImageFileDirectory::from_tags(tags, Endianness::LittleEndian).unwrap()
1904 }
1905
1906 #[test]
1907 fn audit_pixel_is_point_normalizes_both_georeferencing_forms() {
1908 for matrix in [false, true] {
1909 let area = TIFF::new(
1910 vec![ifd(64, 64, false, 1, false, matrix)],
1911 Endianness::LittleEndian,
1912 );
1913 let point = TIFF::new(
1914 vec![ifd(64, 64, false, 1, true, matrix)],
1915 Endianness::LittleEndian,
1916 );
1917 let area_gt = derive_geo(&area).unwrap().0;
1918 let point_gt = derive_geo(&point).unwrap().0;
1919 assert_eq!(point_gt.forward(0.5, 0.5), area_gt.forward(0., 0.));
1920 }
1921 }
1922
1923 #[test]
1924 fn audit_unrelated_pages_are_not_overviews() {
1925 let tiff = TIFF::new(
1926 vec![
1927 ifd(64, 64, false, 1, false, false),
1928 ifd(64, 64, false, 1, false, false),
1929 ifd(128, 128, true, 1, false, false),
1930 ifd(32, 32, true, 3, false, false),
1931 ifd(32, 16, true, 1, false, false),
1932 ifd(32, 32, false, 1, false, false),
1933 ifd(32, 32, true, 1, false, false),
1934 ],
1935 Endianness::LittleEndian,
1936 );
1937 assert_eq!(classify_ifds(&tiff).unwrap().0, vec![0, 6]);
1938 }
1939 }
1940
1941 #[cfg(test)]
1942 mod audit_http_tests {
1943 use super::*;
1944 #[tokio::test]
1945 async fn audit_signed_url_reaches_range_server_unchanged() {
1946 use axum::{
1947 http::{HeaderMap, StatusCode},
1948 routing::get,
1949 Router,
1950 };
1951 let fixture = include_bytes!("../../tests/fixtures/rgb_nodata.tif");
1952 let app = Router::new().route(
1953 "/signed.tif",
1954 get(move |uri: axum::http::Uri, headers: HeaderMap| async move {
1955 assert_eq!(uri.query(), Some("signature=a%2Fb&versionId=v1"));
1956 let range = headers
1957 .get("range")
1958 .unwrap()
1959 .to_str()
1960 .unwrap()
1961 .strip_prefix("bytes=")
1962 .unwrap();
1963 let (start, end) = range.split_once('-').unwrap();
1964 let start: usize = start.parse().unwrap();
1965 let end: usize = end.parse::<usize>().unwrap().min(fixture.len() - 1);
1966 (
1967 StatusCode::PARTIAL_CONTENT,
1968 [
1969 ("etag", "\"fixture-v1\"".to_string()),
1970 (
1971 "content-range",
1972 format!("bytes {start}-{end}/{}", fixture.len()),
1973 ),
1974 ],
1975 fixture[start..=end].to_vec(),
1976 )
1977 }),
1978 );
1979 let socket = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1980 let addr = socket.local_addr().unwrap();
1981 let server = tokio::spawn(async move { axum::serve(socket, app).await.unwrap() });
1982 let result = CogReader::builder()
1983 .open_http(&format!(
1984 "http://{addr}/signed.tif?signature=a%2Fb&versionId=v1"
1985 ))
1986 .await;
1987 server.abort();
1988 assert!(result.is_ok(), "signed URL open failed: {:?}", result.err());
1989 }
1990 }
1991
1992 #[cfg(test)]
1993 mod audit_limit_tests {
1994 use super::*;
1995 async fn configured(config: ReaderConfig) -> AgResult<CogReader> {
1996 let store =
1997 Arc::new(object_store::local::LocalFileSystem::new_with_prefix(".").unwrap());
1998 CogReader::builder()
1999 .reader_config(config)
2000 .open_object_store(store, "tests/fixtures/rgb_nodata.tif")
2001 .await
2002 }
2003 #[tokio::test]
2004 async fn audit_compressed_ranges_are_bounded_before_fetch() {
2005 let mut bytes = include_bytes!("../../tests/fixtures/rgb_nodata.tif").to_vec();
2006 let ifd = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize;
2007 let entries = u16::from_le_bytes(bytes[ifd..ifd + 2].try_into().unwrap()) as usize;
2008 let entry = (0..entries)
2009 .map(|i| ifd + 2 + i * 12)
2010 .find(|&i| u16::from_le_bytes(bytes[i..i + 2].try_into().unwrap()) == 325)
2011 .unwrap();
2012 let counts =
2013 u32::from_le_bytes(bytes[entry + 8..entry + 12].try_into().unwrap()) as usize;
2014 bytes[counts..counts + 4].copy_from_slice(&9000u32.to_le_bytes());
2015 let file = tempfile::NamedTempFile::new().unwrap();
2016 std::fs::write(file.path(), bytes).unwrap();
2017 let cfg = ReaderConfig {
2018 max_window_bytes: 8000,
2019 ..ReaderConfig::default()
2020 };
2021 let cache = cfg.block_cache.clone();
2022 let source = ObjectReader::new(
2023 Arc::new(object_store::local::LocalFileSystem::new()),
2024 Path::from_filesystem_path(file.path()).unwrap(),
2025 );
2026 let reader = CogReader::builder()
2027 .reader_config(cfg)
2028 .snapshot_policy(crate::io::SnapshotPolicy::AssumeImmutable)
2029 .open(source)
2030 .await
2031 .unwrap();
2032 let result = reader.read_window(0, 0, 0, 1, 1).await;
2033 assert_eq!(
2034 cache.stats().fetched,
2035 0,
2036 "oversized declared compressed range reached the store"
2037 );
2038 assert!(result.is_err());
2039
2040 let cfg = ReaderConfig {
2041 max_window_bytes: 5000,
2042 ..ReaderConfig::default()
2043 };
2044 let cache = cfg.block_cache.clone();
2045 let reader = configured(cfg).await.unwrap();
2046 let result = reader.read_window(0, 31, 0, 2, 1).await;
2047 assert_eq!(
2048 cache.stats().fetched,
2049 0,
2050 "batch sum exceeded compressed byte limit"
2051 );
2052 assert!(result.is_err());
2053 }
2054 #[tokio::test]
2055 async fn audit_window_limits_reject_before_fetch() {
2056 for kind in 0..3 {
2057 let mut cfg = ReaderConfig::default();
2058 match kind {
2059 0 => cfg.max_window_pixels = 100,
2060 1 => cfg.max_window_bytes = 100,
2061 _ => cfg.max_window_blocks = 1,
2062 };
2063 let cache = cfg.block_cache.clone();
2064 let reader = configured(cfg).await.unwrap();
2065 assert!(reader.read_window(0, 0, 0, 64, 64).await.is_err());
2066 assert_eq!(cache.stats().fetched, 0);
2067 }
2068 }
2069 #[tokio::test]
2070 async fn audit_declared_decoded_block_limit_rejects_on_open() {
2071 let cfg = ReaderConfig {
2072 max_decoded_block_bytes: 100,
2073 ..ReaderConfig::default()
2074 };
2075 assert!(configured(cfg).await.is_err());
2076 }
2077 #[tokio::test]
2078 async fn audit_prefetch_does_not_queue_behind_foreground() {
2079 let cfg = ReaderConfig {
2080 max_concurrent_reads: 1,
2081 prefetch_ring: 1,
2082 ..ReaderConfig::default()
2083 };
2084 let cache = cfg.block_cache.clone();
2085 let reader = configured(cfg).await.unwrap();
2086 reader.read_window(0, 0, 0, 16, 16).await.unwrap();
2087 tokio::time::sleep(Duration::from_millis(50)).await;
2088 assert_eq!(
2089 cache.stats().fetched,
2090 1,
2091 "speculation must skip when foreground owns the budget"
2092 );
2093 }
2094 #[tokio::test]
2095 async fn audit_shared_read_limiter_applies_backpressure() {
2096 let limiter = Arc::new(tokio::sync::Semaphore::new(0));
2097 let cfg = ReaderConfig {
2098 read_limiter: Some(limiter.clone()),
2099 ..ReaderConfig::default()
2100 };
2101 let reader = configured(cfg).await.unwrap();
2102 assert!(tokio::time::timeout(
2103 Duration::from_millis(20),
2104 reader.read_window(0, 0, 0, 16, 16)
2105 )
2106 .await
2107 .is_err());
2108 limiter.add_permits(1);
2109 assert!(reader.read_window(0, 0, 0, 16, 16).await.is_ok());
2110 }
2111 }
2112
2113 #[cfg(test)]
2114 mod audit_http_error_tests {
2115 use super::*;
2116 #[derive(Clone)]
2117 struct LogBuffer(Arc<std::sync::Mutex<Vec<u8>>>);
2118 impl std::io::Write for LogBuffer {
2119 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
2120 self.0.lock().unwrap().extend_from_slice(bytes);
2121 Ok(bytes.len())
2122 }
2123 fn flush(&mut self) -> std::io::Result<()> {
2124 Ok(())
2125 }
2126 }
2127 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogBuffer {
2128 type Writer = Self;
2129 fn make_writer(&'a self) -> Self {
2130 self.clone()
2131 }
2132 }
2133 #[tokio::test]
2134 async fn audit_http_range_errors_and_prefetch_logs_hide_signed_query() {
2135 use axum::{
2136 http::{HeaderMap, StatusCode},
2137 routing::get,
2138 Router,
2139 };
2140 use std::sync::atomic::{AtomicBool, Ordering};
2141 let failing = Arc::new(AtomicBool::new(false));
2142 let state = failing.clone();
2143 let fixture = include_bytes!("../../tests/fixtures/rgb_nodata.tif");
2144 let app = Router::new().route(
2145 "/signed.tif",
2146 get(move |headers: HeaderMap| {
2147 let state = state.clone();
2148 async move {
2149 if state.load(Ordering::SeqCst) {
2150 return (StatusCode::FORBIDDEN, HeaderMap::new(), Vec::new());
2151 }
2152 let range = headers["range"]
2153 .to_str()
2154 .unwrap()
2155 .strip_prefix("bytes=")
2156 .unwrap();
2157 let (start, end) = range.split_once('-').unwrap();
2158 let start: usize = start.parse().unwrap();
2159 let end = end.parse::<usize>().unwrap().min(fixture.len() - 1);
2160 let mut out = HeaderMap::new();
2161 out.insert("etag", "\"fixture-v1\"".parse().unwrap());
2162 out.insert(
2163 "content-range",
2164 format!("bytes {start}-{end}/{}", fixture.len())
2165 .parse()
2166 .unwrap(),
2167 );
2168 (
2169 StatusCode::PARTIAL_CONTENT,
2170 out,
2171 fixture[start..=end].to_vec(),
2172 )
2173 }
2174 }),
2175 );
2176 let socket = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2177 let addr = socket.local_addr().unwrap();
2178 let server = tokio::spawn(async move { axum::serve(socket, app).await.unwrap() });
2179 let url = format!("http://{addr}/signed.tif?signature=SECRET_RANGE_TOKEN");
2180 let direct = Arc::new(CogReader::builder().open_http(&url).await.unwrap());
2181 #[allow(unused_mut)]
2182 let mut readers = vec![direct];
2183 failing.store(true, Ordering::SeqCst);
2184 let logs = LogBuffer(Arc::new(std::sync::Mutex::new(Vec::new())));
2185 let subscriber = tracing_subscriber::fmt()
2186 .with_writer(logs.clone())
2187 .with_max_level(tracing::Level::DEBUG)
2188 .without_time()
2189 .with_ansi(false)
2190 .finish();
2191 let _subscriber = tracing::subscriber::set_default(subscriber);
2192 let mut errors = Vec::new();
2193 errors.push(
2194 CogReader::builder()
2195 .open_http(&url)
2196 .await
2197 .err()
2198 .expect("HTTP open should now fail")
2199 .to_string(),
2200 );
2201 for reader in readers {
2202 errors.push(
2203 reader
2204 .read_window(0, 0, 0, 16, 16)
2205 .await
2206 .unwrap_err()
2207 .to_string(),
2208 );
2209 prefetch_blocks(
2210 reader.data_ifd(0).unwrap(),
2211 BlockKind::Data,
2212 0,
2213 &[(1, 1)],
2214 &reader.source_id,
2215 &reader.block_cache,
2216 &reader.cpu,
2217 &reader.reader,
2218 reader.redact_transport_errors,
2219 reader.max_window_bytes,
2220 &reader.work_budget,
2221 &reader.metadata_reservations,
2222 )
2223 .await;
2224 }
2225 server.abort();
2226 let log_text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
2227 assert!(
2228 log_text.contains("block prefetch fetch failed"),
2229 "prefetch failure was not captured"
2230 );
2231 assert!(
2232 !errors.join("\n").contains("SECRET_RANGE_TOKEN"),
2233 "range error leaked query: {errors:?}"
2234 );
2235 assert!(
2236 !log_text.contains("SECRET_RANGE_TOKEN"),
2237 "tracing leaked signed query: {log_text}"
2238 );
2239 }
2240 }
2241
2242 #[cfg(test)]
2243 mod snapshot_regression_tests {
2244 use super::*;
2245 use object_store::ObjectStoreExt;
2246 #[tokio::test]
2247 async fn snapshot_open_preserves_resource_limit_classification() {
2248 let store = Arc::new(object_store::memory::InMemory::new());
2249 let path = Path::from("x.tif");
2250 store
2251 .put(
2252 &path,
2253 include_bytes!("../../tests/fixtures/rgb_nodata.tif")
2254 .as_slice()
2255 .into(),
2256 )
2257 .await
2258 .unwrap();
2259 let config = ReaderConfig {
2260 work_budget: crate::resource::WorkBudget::new(0),
2261 ..ReaderConfig::default()
2262 };
2263 assert!(matches!(
2264 CogReader::builder()
2265 .reader_config(config)
2266 .open_object_store(store, path.as_ref())
2267 .await,
2268 Err(AgError::ResourceLimit(_))
2269 ));
2270 }
2271 #[tokio::test]
2272 async fn snapshot_rejects_object_mutation_between_metadata_and_pixels() {
2273 let store = Arc::new(object_store::memory::InMemory::new());
2274 let path = Path::from("mutable.tif");
2275 let bytes = include_bytes!("../../tests/fixtures/rgb_nodata.tif");
2276 store.put(&path, bytes.as_slice().into()).await.unwrap();
2277 let reader = CogReader::builder()
2278 .open_object_store(store.clone(), path.as_ref())
2279 .await
2280 .unwrap();
2281 let mut changed = bytes.to_vec();
2282 let last = changed.len() - 1;
2283 changed[last] ^= 1;
2284 store.put(&path, changed.into()).await.unwrap();
2285 assert!(
2286 reader.read_window(0, 0, 0, 16, 16).await.is_err(),
2287 "must not combine old metadata with a new object version"
2288 );
2289 }
2290 }
2291 #[tokio::test]
2292 async fn working_budget_rejects_read_before_fetch_and_releases_on_return() {
2293 let budget = crate::resource::WorkBudget::new(256 * 1024);
2294 let cfg = ReaderConfig {
2295 work_budget: budget.clone(),
2296 ..ReaderConfig::default()
2297 };
2298 let cache = cfg.block_cache.clone();
2299 let store = Arc::new(object_store::local::LocalFileSystem::new_with_prefix(".").unwrap());
2300 let reader = CogReader::builder()
2301 .reader_config(cfg)
2302 .open_object_store(store, "tests/fixtures/rgb_nodata.tif")
2303 .await
2304 .unwrap();
2305 let persistent = budget.used_bytes();
2306 let busy = budget
2307 .try_reserve(budget.limit_bytes() - persistent)
2308 .unwrap();
2309 assert!(reader.read_window(0, 0, 0, 16, 16).await.is_err());
2310 assert_eq!(cache.stats().fetched, 0);
2311 drop(busy);
2312 assert!(reader.read_window(0, 0, 0, 16, 16).await.is_ok());
2313 assert_eq!(budget.used_bytes(), persistent);
2314 drop(reader);
2315 assert_eq!(budget.used_bytes(), 0);
2316 assert!(budget.peak_bytes() <= budget.limit_bytes());
2317 }
2318
2319 #[cfg(test)]
2320 mod prefetch_shutdown_tests {
2321 use super::*;
2322 #[tokio::test]
2323 async fn dropping_reader_cancels_inflight_prefetch() {
2324 use axum::{
2325 http::{HeaderMap, StatusCode},
2326 routing::get,
2327 Router,
2328 };
2329 use std::sync::atomic::{AtomicBool, Ordering};
2330 let pixels_started = Arc::new(AtomicBool::new(false));
2331 let flag = pixels_started.clone();
2332 let (notify, mut started) = tokio::sync::mpsc::unbounded_channel();
2333 let fixture = include_bytes!("../../tests/fixtures/rgb_nodata.tif");
2334 let app = Router::new().route(
2335 "/image",
2336 get(move |headers: HeaderMap| {
2337 let flag = flag.clone();
2338 let notify = notify.clone();
2339 async move {
2340 let range = headers["range"]
2341 .to_str()
2342 .unwrap()
2343 .strip_prefix("bytes=")
2344 .unwrap();
2345 let (start, end) = range.split_once('-').unwrap();
2346 let start: usize = start.parse().unwrap();
2347 let end = end.parse::<usize>().unwrap().min(fixture.len() - 1);
2348 if flag.load(Ordering::SeqCst) {
2349 let _ = notify.send(());
2350 std::future::pending::<()>().await;
2351 }
2352 let mut out = HeaderMap::new();
2353 out.insert("etag", "\"fixed\"".parse().unwrap());
2354 out.insert(
2355 "content-range",
2356 format!("bytes {start}-{end}/{}", fixture.len())
2357 .parse()
2358 .unwrap(),
2359 );
2360 (
2361 StatusCode::PARTIAL_CONTENT,
2362 out,
2363 fixture[start..=end].to_vec(),
2364 )
2365 }
2366 }),
2367 );
2368 let socket = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2369 let addr = socket.local_addr().unwrap();
2370 let server = tokio::spawn(async move { axum::serve(socket, app).await.unwrap() });
2371 let reader = CogReader::builder()
2372 .open_http(&format!("http://{addr}/image"))
2373 .await
2374 .unwrap();
2375 let reads = reader.reads.clone();
2376 pixels_started.store(true, Ordering::SeqCst);
2377 let mut reader = reader;
2380 reader.prefetch_ring = 1;
2381 reader.maybe_spawn_prefetch(0, (0, 0), (0, 0));
2382 tokio::time::timeout(Duration::from_secs(1), started.recv())
2383 .await
2384 .unwrap()
2385 .unwrap();
2386 drop(reader);
2387 let stopped = tokio::time::timeout(Duration::from_millis(200), async {
2388 while reads.available_permits() != 8 {
2389 tokio::time::sleep(Duration::from_millis(5)).await;
2390 }
2391 })
2392 .await;
2393 server.abort();
2394 assert!(
2395 stopped.is_ok(),
2396 "reader drop must abort its speculative fetch and release its permit"
2397 );
2398 }
2399 }
2400
2401 #[cfg(test)]
2402 mod metadata_limit_tests {
2403 use super::*;
2404 #[tokio::test]
2405 async fn malformed_directory_cycles_and_huge_counts_are_rejected() {
2406 use object_store::ObjectStoreExt;
2407 let original = include_bytes!("../../tests/fixtures/rgb_nodata.tif").to_vec();
2408 let offset = u32::from_le_bytes(original[4..8].try_into().unwrap()) as usize;
2409 let count =
2410 u16::from_le_bytes(original[offset..offset + 2].try_into().unwrap()) as usize;
2411 let mut cyclic = original.clone();
2412 let next = offset + 2 + count * 12;
2413 cyclic[next..next + 4].copy_from_slice(&(offset as u32).to_le_bytes());
2414 let mut values = original;
2415 values[offset + 2 + 4..offset + 2 + 8].copy_from_slice(&u32::MAX.to_le_bytes());
2416 let mut big = b"II".to_vec();
2417 big.extend(43u16.to_le_bytes());
2418 big.extend(8u16.to_le_bytes());
2419 big.extend(0u16.to_le_bytes());
2420 big.extend(16u64.to_le_bytes());
2421 big.extend(u64::MAX.to_le_bytes());
2422 for bytes in [cyclic, values, big] {
2423 let store = Arc::new(object_store::memory::InMemory::new());
2424 let path = Path::from("invalid.tif");
2425 store.put(&path, bytes.into()).await.unwrap();
2426 assert!(CogReader::builder()
2427 .open_object_store(store, path.as_ref())
2428 .await
2429 .is_err());
2430 }
2431 }
2432 #[tokio::test]
2433 async fn parser_limits_apply_before_ifd_materialization() {
2434 for entries in [false, true] {
2435 let mut config = ReaderConfig::default();
2436 if entries {
2437 config.max_ifd_entries = 1;
2438 } else {
2439 config.max_ifds = 1;
2440 }
2441 let store =
2442 Arc::new(object_store::local::LocalFileSystem::new_with_prefix(".").unwrap());
2443 assert!(CogReader::builder()
2444 .reader_config(config)
2445 .open_object_store(store, "tests/fixtures/rgb_nodata.tif")
2446 .await
2447 .is_err());
2448 }
2449 }
2450 }
2451}