Skip to main content

async_geotiff/io/
cog.rs

1//! `CogReader` — async-tiff reader orchestration with GeoTransform/CRS derivation.
2
3#[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
40/// Client/TLS initialization is synchronous and may read platform trust stores.
41/// Keep it off async workers, with capacity held until a cancelled job exits.
42pub(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
67/// Stitched outputs at or below this size are assembled inline on the async
68/// thread instead of through `spawn_blocking`. Stitching is memcpy-bound and
69/// output-size-proportional; for tile-size windows (a 256×256×3 u16 window +
70/// mask is ~460 KiB) the copy costs tens of microseconds — less than the
71/// dispatch + CPU-permit round-trip it would otherwise pay. 1 MiB keeps the
72/// worst inline stall around ~100 µs, well under Tokio's blocking guidance.
73const INLINE_STITCH_MAX_BYTES: u64 = 1 << 20;
74
75/// Max concurrent background prefetch tasks per reader. When the budget is
76/// exhausted, new windows simply skip prefetching — the foreground path is
77/// never delayed by speculation.
78const MAX_PREFETCH_TASKS: usize = 2;
79
80/// Runtime limits for a [`CogReader`].
81///
82/// `max_decode_tasks` caps concurrent CPU-heavy decode/stitch jobs submitted
83/// through `spawn_blocking`. Waiting for a permit is async, so excess requests
84/// yield the Tokio worker instead of flooding the blocking pool.
85///
86/// `#[non_exhaustive]`: construct with [`ReaderConfig::default()`] and assign
87/// fields (or use struct-update syntax inside this crate). This lets new fields
88/// be added without breaking external callers.
89#[derive(Clone)]
90#[non_exhaustive]
91pub struct ReaderConfig {
92    /// Maximum IFDs accepted before upstream allocations; defaults to 256.
93    pub max_ifds: usize,
94    /// Maximum directory entries per IFD before upstream allocations;
95    /// defaults to 4096. A zero limit rejects nonempty directories.
96    pub max_ifd_entries: u64,
97    pub(crate) snapshot_size: Option<u64>,
98
99    /// Strong validator required for managed sources by default. Opaque
100    /// ObjectReader handles require explicit AssumeImmutable.
101    pub snapshot_policy: crate::io::SnapshotPolicy,
102    /// Maximum cumulative metadata bytes fetched while opening (default: 8 MiB).
103    pub max_metadata_bytes: u64,
104
105    /// Shared active-work byte allowance; completed caller-owned images and
106    /// resident caches have separate ownership/budgets.
107    pub work_budget: crate::resource::WorkBudget,
108    pub(crate) redact_transport_errors: bool,
109    /// Maximum pixels in a single source window (default: 16 million).
110    pub max_window_pixels: u64,
111    /// Maximum declared window data plus mask bytes, and compressed bytes
112    /// per fetch batch (default: 256 MiB).
113    pub max_window_bytes: u64,
114    /// Maximum blocks requested by one window (default: 4096).
115    pub max_window_blocks: usize,
116    /// Maximum declared uncompressed bytes in any TIFF block (default: 64 MiB).
117    pub max_decoded_block_bytes: u64,
118    /// Concurrent foreground windows per reader (default: 8), clamped to
119    /// `1..=tokio::sync::Semaphore::MAX_PERMITS`.
120    pub max_concurrent_reads: usize,
121    /// Optional shared foreground read limiter, e.g. across a mosaic pool.
122    pub read_limiter: Option<Arc<tokio::sync::Semaphore>>,
123    /// Capacity of the reader's CPU limiter when `cpu_limiter` is `None`.
124    /// Defaults to available parallelism (four if unavailable), clamped to
125    /// `1..=tokio::sync::Semaphore::MAX_PERMITS`. Ignored for an injected limiter.
126    pub max_decode_tasks: usize,
127    /// Caller-supplied id for this logical source (same id ⇒ shared blocks).
128    pub source_id: String,
129    /// Shared decoded-block cache injected by the caller.
130    pub block_cache: Arc<BlockCache>,
131    /// Shared CPU-job limiter. When `None`, the reader builds its own sized to
132    /// `max_decode_tasks`. Inject the same [`CpuLimiter`] into several readers
133    /// (and their [`Tiler`](crate::tiler::Tiler)s) to bound the whole process/mosaic
134    /// to one CPU budget — see [`crate::blocking::CpuLimiter`].
135    pub cpu_limiter: Option<CpuLimiter>,
136    /// Speculative block prefetch radius, in native COG blocks. After each
137    /// `read_window`, the blocks in a ring of this width around the window
138    /// are fetched + decoded into the shared block cache in the background —
139    /// map-browsing patterns make those exactly the blocks the neighbouring
140    /// XYZ tiles need next, turning their cold misses into warm hits.
141    ///
142    /// `0` (the default) disables prefetch. Each ring step issues extra
143    /// object-store GETs, so leave it off for pay-per-request storage unless
144    /// the latency win is worth it; `1` is the sweet spot for tile serving.
145    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
179/// Asynchronous window and point reader over an immutable TIFF pyramid.
180///
181/// Construct through [`Self::builder`] to configure snapshot verification,
182/// shared caches and resource limits. Level zero is full resolution; subsequent
183/// data levels are overviews. Independent mask IFDs do not count as data levels.
184/// Metadata reservations remain charged for this reader's lifetime, and decoded
185/// blocks may outlive it in a shared cache.
186pub 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    /// Ground-sample distance for each data level, computed once when the
201    /// immutable IFD pyramid is opened.
202    level_gsds: Vec<f64>,
203    mask_ifds: Vec<Option<usize>>,
204    /// Per data-level, the band index (0-based) whose `ExtraSamples` is
205    /// Associated/UnassociatedAlpha. Mirrors rio-tiler's
206    /// "alpha band > nodata > none" mask priority.
207    alpha_bands: Vec<Option<usize>>,
208    /// Per-band SCALE/OFFSET from GDAL metadata (identity where absent).
209    band_scale_offset: Vec<ScaleOffset>,
210    /// Logical source id; combined with level/kind/tile to key the shared
211    /// block cache so identical tiles from the same source are reused.
212    source_id: Arc<str>,
213    // Instance identity also isolates opaque readers with independently scoped source IDs.
214    statistics_id: u64,
215    /// Shared decoded-block cache (may be shared across readers).
216    block_cache: Arc<BlockCache>,
217    /// Shared CPU-job limiter, covering decode + stitch here and warp + render
218    /// in the [`Tiler`](crate::tiler::Tiler) built from this reader.
219    cpu: CpuLimiter,
220    /// Ring width (in blocks) for speculative neighbour prefetch; 0 = off.
221    prefetch_ring: u8,
222    /// Number of live background prefetch tasks; bounds prefetch fan-out.
223    prefetch_inflight: Arc<std::sync::atomic::AtomicUsize>,
224}
225
226/// Builds a [`CogReader`]. Setters mirror [`ReaderConfig`] fields (plus HTTP
227/// [`ClientOptions`]); the async terminal methods pick the source:
228/// [`Self::open_http`], [`Self::open_object_store`], or [`Self::open`] for an
229/// existing [`ObjectReader`].
230///
231/// ```no_run
232/// # async fn demo(cache: std::sync::Arc<async_geotiff::io::BlockCache>) -> async_geotiff::error::AgResult<()> {
233/// use async_geotiff::io::CogReader;
234/// let reader = CogReader::builder()
235///     .source_id("scene-42")
236///     .block_cache(cache)
237///     .prefetch_ring(1)
238///     .open_http("https://example.com/scene.tif")
239///     .await?;
240/// # Ok(()) }
241/// ```
242pub struct CogReaderBuilder {
243    config: ReaderConfig,
244    client_options: Option<ClientOptions>,
245}
246
247impl CogReaderBuilder {
248    /// Caller-supplied id for this logical source — same id ⇒ shared decoded
249    /// blocks in the block cache (see [`ReaderConfig::source_id`]).
250    pub fn source_id(mut self, id: impl Into<String>) -> Self {
251        self.config.source_id = id.into();
252        self
253    }
254
255    /// Shared decoded-block cache injected by the caller. Inject the same
256    /// `Arc<BlockCache>` into several readers (with distinct `source_id`s) to
257    /// bound them to one memory budget.
258    pub fn block_cache(mut self, block_cache: Arc<BlockCache>) -> Self {
259        self.config.block_cache = block_cache;
260        self
261    }
262
263    /// Size a fresh private decoded-block cache — shorthand for
264    /// [`Self::block_cache`] with a new [`BlockCache`] built from `cache`.
265    pub fn cache_config(mut self, cache: CacheConfig) -> Self {
266        self.config.block_cache = Arc::new(BlockCache::new(cache));
267        self
268    }
269
270    /// Shared CPU-job limiter — see [`ReaderConfig::cpu_limiter`].
271    pub fn cpu_limiter(mut self, cpu: CpuLimiter) -> Self {
272        self.config.cpu_limiter = Some(cpu);
273        self
274    }
275
276    /// Cap on concurrent CPU-heavy decode/stitch jobs — see
277    /// [`ReaderConfig::max_decode_tasks`]. Ignored when a shared
278    /// [`Self::cpu_limiter`] is injected.
279    pub fn max_decode_tasks(mut self, n: usize) -> Self {
280        self.config.max_decode_tasks = n;
281        self
282    }
283
284    /// Speculative block prefetch radius — see [`ReaderConfig::prefetch_ring`].
285    pub fn prefetch_ring(mut self, ring: u8) -> Self {
286        self.config.prefetch_ring = ring;
287        self
288    }
289
290    /// Replace the whole [`ReaderConfig`] at once. Setters called afterwards
291    /// still apply on top of it.
292    pub fn reader_config(mut self, config: ReaderConfig) -> Self {
293        self.config = config;
294        self
295    }
296
297    /// Choose whether source immutability is verified or explicitly assumed.
298    pub fn snapshot_policy(mut self, policy: crate::io::SnapshotPolicy) -> Self {
299        self.config.snapshot_policy = policy;
300        self
301    }
302
303    /// Override HTTP [`ClientOptions`] (timeouts, proxy, custom headers).
304    /// Only consulted by [`Self::open_http`]; the default is tuned for
305    /// tile-server workloads (large per-host idle pool, 30s timeout).
306    pub fn client_options(mut self, options: ClientOptions) -> Self {
307        self.client_options = Some(options);
308        self
309    }
310
311    /// Open a remote COG over HTTPS (or any URL `object_store::http` supports).
312    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        // `split_http_url` returns the percent-ENCODED URL path, so use
327        // the decoding constructor. `Path::from` would treat `%` as a
328        // literal and double-encode non-ASCII names (e.g. CJK filenames),
329        // making the loopback GET 404 → tile 500.
330        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    /// Open a COG through any `object_store` backend (S3/MinIO, GCS, local, …).
341    ///
342    /// `key` must be a **decoded** object key (e.g. `"data/my file.tif"`),
343    /// not a URL-encoded string — it is passed verbatim to `Path::from`.
344    /// The library never holds credentials — wire them into `store` yourself.
345    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    /// Open an opaque reader with an explicit AssumeImmutable policy. Its
354    /// underlying store cannot be inspected for conditional reads; prefer
355    /// open_object_store for managed snapshots and bounded response bodies.
356    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    /// Start building a reader — see [`CogReaderBuilder`].
366    pub fn builder() -> CogReaderBuilder {
367        CogReaderBuilder {
368            config: ReaderConfig::default(),
369            client_options: None,
370        }
371    }
372
373    /// Open a remote COG over HTTPS (or any URL `object_store::http` supports)
374    /// with default configuration — shorthand for
375    /// `CogReader::builder().open_http(url)`.
376    ///
377    /// Uses tuned `ClientOptions` for tile-server workloads: large per-host
378    /// idle pool so concurrent tile requests reuse connections instead of
379    /// paying TLS handshake per fetch, and a 30s timeout.
380    pub async fn open_http(url: &str) -> AgResult<Self> {
381        Self::builder().open_http(url).await
382    }
383
384    /// Like [`Self::open_http`] but lets the caller override [`ClientOptions`]
385    /// (e.g. timeouts, proxy, custom headers).
386    #[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    /// Like [`Self::open_http_with_options`] but also lets the caller size the
395    /// decoded-block cache.
396    #[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    /// Like [`Self::open_http`] but lets the caller inject a full [`ReaderConfig`].
413    #[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    /// Like [`Self::open_http_with_cache`] but lets callers configure both cache
422    /// capacity and CPU decode concurrency.
423    #[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    /// Open from an existing `ObjectReader` with default configuration —
440    /// shorthand for `CogReader::builder().open(reader)`.
441    pub async fn open_with_reader(reader: ObjectReader) -> AgResult<Self> {
442        Self::builder().open(reader).await
443    }
444
445    /// Open from an existing `ObjectReader` with an explicit cache config.
446    #[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    /// Open from an existing `ObjectReader` with explicit cache and decode
458    /// concurrency settings.
459    #[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    /// Shared open path: every builder terminal method and deprecated
493    /// constructor funnels through here.
494    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    /// Open a COG from an existing object store and key, building the
598    /// `ObjectReader` internally, with default configuration — shorthand for
599    /// `CogReader::builder().open_object_store(store, key)`.
600    ///
601    /// `key` must be a **decoded** object key (e.g. `"data/my file.tif"`),
602    /// not a URL-encoded string — it is passed verbatim to `Path::from`.
603    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    /// Like [`Self::open_object_store`] but lets the caller size the decoded-block
608    /// cache.
609    #[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    /// Like [`Self::open_object_store`] but lets callers configure cache capacity
625    /// and CPU decode concurrency.
626    #[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    /// The shared CPU-job limiter backing this reader. A [`Tiler`](crate::tiler::Tiler) built from
642    /// this reader reuses it so decode/stitch/warp/render all draw from one CPU
643    /// budget.
644    pub fn cpu_limiter(&self) -> CpuLimiter {
645        self.cpu.clone()
646    }
647
648    /// Clone the shared byte allowance used for metadata and active read buffers.
649    /// The clone joins the same allowance; it does not create a new budget.
650    pub fn work_budget(&self) -> crate::resource::WorkBudget {
651        self.work_budget.clone()
652    }
653
654    /// Native coordinate reference system parsed from the full-resolution IFD.
655    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    /// Map full-resolution pixel-corner coordinates into the source CRS.
664    pub fn transform(&self) -> GeoTransform {
665        self.transform
666    }
667
668    /// Full-resolution image extent in WGS84 lon/lat as
669    /// `[west, south, east, north]`. Densifies the raster boundary from the
670    /// source CRS (skipped when already 4326). A projected footprint containing
671    /// a pole includes that latitude and the full `[-180, 180]` longitude range.
672    /// This envelope may include locations outside the actual raster footprint.
673    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    /// Number of reduced-resolution data levels, excluding full resolution and masks.
688    pub fn overview_count(&self) -> usize {
689        self.data_ifds.len().saturating_sub(1)
690    }
691
692    /// Number of data levels, including full resolution at index zero and excluding masks.
693    pub fn level_count(&self) -> usize {
694        self.data_ifds.len()
695    }
696
697    /// Return `(width, height)` in pixels for data `level` (zero is full resolution).
698    ///
699    /// # Errors
700    /// Returns [`AgError::Tiff`] when `level` is outside [`Self::level_count`].
701    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    /// Internal tile dimensions of the chosen overview, or the full image
707    /// dims if the IFD is strip-based. Used by the `is_aligned` padding
708    /// optimisation (rio-tiler `_requested_tile_aligned_with_internal_tile`).
709    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    /// Map the chosen level's pixel corners into the source CRS, scaling the
718    /// full-resolution transform by the ratio of image dimensions on each axis.
719    ///
720    /// # Errors
721    /// Returns [`AgError::Tiff`] when `level` is outside [`Self::level_count`].
722    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    /// Return the larger of the level's two pixel-axis lengths in source CRS units.
738    /// This includes rotation or shear and is not necessarily a distance in metres.
739    ///
740    /// # Errors
741    /// Returns [`AgError::Tiff`] when `level` is outside [`Self::level_count`].
742    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            // Preserve the previous error/non-finite behavior for malformed or
751            // out-of-range levels; normal levels stay on the cached fast path.
752            None => self.compute_level_gsd(level),
753        }
754    }
755
756    /// Select an overview without a tolerance tied to CRS units.
757    ///
758    /// Level 0 is full resolution; 1.. are progressively coarser overviews
759    /// (gsd strictly increasing). The chosen level is the **coarsest one that
760    /// is still no coarser than the target** — i.e. it brackets the target
761    /// from below and the subsequent warp *downsamples* to the exact tile
762    /// resolution. Reading a finer level and downsampling preserves detail;
763    /// reading a coarser level and upsampling would blur the tile. This is
764    /// the single most important quality/bandwidth decision in the pipeline.
765    ///
766    /// Rules:
767    /// - `target ≤ src_res` (zoomed in past native): use full resolution.
768    /// - otherwise pick `L` where `gsd(L) ≤ target < gsd(L+1)`.
769    /// - target coarser than every overview: use the coarsest level.
770    pub fn pick_overview(&self, target_gsd: f64) -> usize {
771        pick_overview_from_gsds(&self.level_gsds, target_gsd)
772    }
773
774    /// Read a pixel window. Each native tile in the window is resolved through
775    /// the shared block cache (single-flight decode on a miss), and the decoded
776    /// blocks are stitched into the requested window. Blocks are cached and may
777    /// be shared across readers with the same `source_id`.
778    ///
779    /// Without an independent mask IFD, the first tagged Alpha band is removed
780    /// from the pixels and converted to a validity mask; other bands keep their
781    /// relative order. An independent mask takes precedence and preserves all
782    /// native bands, including Alpha. Windows must be nonempty and fully inside
783    /// the selected level. Configured window and working-memory limits also apply.
784    #[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        // Bound the window to the level's pixel extent so a caller-supplied
816        // width/height cannot drive an allocation larger than the image itself
817        // (the output buffers below are sized `bands × height × width`). Every
818        // in-crate caller already clamps to the extent; this rejects the
819        // out-of-bounds windows an external caller could otherwise pass.
820        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        // A mask shares the image extent, not necessarily the data block grid.
856        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(); // checked against window limits above
870        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        // Foreground blocks are resolved — speculatively warm the surrounding
892        // block ring in the background. It overlaps the stitch/warp/render
893        // below and turns the neighbouring XYZ tiles' cold misses into hits.
894        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        // Fused stitch: ONE pass builds both the data and the mask windows.
902        // Previously this was two sequential spawn_blocking jobs, each paying
903        // its own dispatch latency and CPU-permit wait. Additionally, tile-size
904        // windows are memcpy-bound and cost less than the spawn_blocking
905        // dispatch itself, so small outputs stitch inline on the async thread.
906        // Estimated stitched output footprint: planar data + 1-byte mask cells.
907        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        // Alpha promotion builds another planar buffer and a mask. Include
923        // that pass in the inline ceiling rather than splitting it afterwards
924        // on the async worker, outside the CPU permit.
925        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            // Small window (typical 256/512 px tile): the copy is cheaper than
972            // a spawn_blocking round-trip + permit wait — run it inline.
973            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    /// Attach georeferencing and nodata to a window already stitched and
1027    /// alpha-promoted by the same CPU job.
1028    #[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    /// Per-band [`ScaleOffset`] from GDAL metadata; [`ScaleOffset::IDENTITY`]
1058    /// where absent. Apply as `physical = raw * scale + offset` when unscaling.
1059    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    // Keep point selection consistent with the band's actual removal during stitching.
1072    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
1099/// World-space AABB of a pixel rectangle under any (possibly rotated/sheared)
1100/// GeoTransform. Previously only sampled two opposite corners, which produced
1101/// the wrong bbox for rotated COGs (where the world-space pixel grid is not
1102/// axis-aligned).
1103fn 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
1125/// Defaults tuned for COG tile servers: keep many idle keep-alive sockets per
1126/// origin so back-to-back tile requests skip the TLS handshake. Mirrors the
1127/// `obstore` connection-pool tuning recommended for async-geotiff.
1128///
1129/// `allow_http(true)`: object_store refuses plain `http://` URLs by default
1130/// (it fails at request-build time with a "builder error", before any
1131/// network). A COG tile server commonly fronts an internal object store
1132/// (MinIO, on-prem S3) reached over `http://host:port`, so plain HTTP must
1133/// be permitted. HTTPS endpoints are unaffected.
1134fn default_client_options() -> ClientOptions {
1135    // `with_allow_http2`: object_store defaults to HTTP/1.1-only
1136    // (`http1_only: true`), so even HTTPS object stores get no
1137    // multiplexing — every concurrent tile range request needs its own
1138    // TCP/TLS handshake, which dominates cold-start latency when a viewer
1139    // fans out a full screen of tiles. Allowing HTTP/2 lets HTTPS
1140    // endpoints negotiate h2 via ALPN (one connection, many streams);
1141    // plaintext endpoints transparently stay on HTTP/1.1.
1142    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        // Keep the complete signed URL as the store base. Appending an empty
1155        // object path preserves the encoded path and signature query verbatim.
1156        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        // 4x4 row-major; for a north-up image use indices [3, 0, 1, 7, 4, 5].
1176        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        // World origin = (x - i*sx, y + j*sy) for north-up images.
1193        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        // GeoTIFF point coordinates locate sample centres; ImageData uses corners.
1198        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            // Allow one overview pixel of rounding when the base dimensions
1226            // are not divisible by the reduction factor.
1227            && (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            // Tagged overviews must describe the same grid/footprint. Untagged
1233            // reduced IFDs inherit the base transform, as ordinary COGs do.
1234            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
1271/// Pure overview-selection logic, independent of coordinate units.
1272/// `gsds[0]` is full-resolution; entries are non-decreasing (coarser).
1273fn 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
1300/// 0-based index of the band tagged as Associated/UnassociatedAlpha via
1301/// `ExtraSamples`. The alpha band is positioned after the
1302/// PhotometricInterpretation-defined bands (3 for RGB, 1 for greyscale),
1303/// so the alpha index is `(bands - extra_samples.len()) + offset_within_extra`.
1304/// Returns None when there are no extra samples or none is tagged alpha.
1305fn 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    //! CogReader regression tests.
1322
1323    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    // A small uncompressed TIFF writer keeps the alpha regression independent of
1345    // optional local datasets. Four 256x256 blocks form a 512x512 RGBA window.
1346    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        // All four blocks are warm: an occupied CPU permit can only block the
1452        // alpha/stitch job, not decoding. Before the fix this window ran inline.
1453        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            // RGB fixture without SCALE/OFFSET metadata → identity per band.
1490            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            // New convenience path.
1508            let via_helper = CogReader::open_object_store(fs.clone(), key)
1509                .await
1510                .expect("open_object_store");
1511
1512            // Old explicit path (must still produce an equivalent reader).
1513            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            // GeoTransform does not impl PartialEq (wraps raffine::Affine);
1521            // compare wgs84_bounds() which returns [f64; 4] and does.
1522            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            // 4×4 block grid (1024/256), window = block (0,0), ring = 1.
1558            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            // Corner window: only the 3 in-grid neighbours; (−1, *) clamped away.
1564            assert_eq!(got, vec![(0, 1), (1, 0), (1, 1)]);
1565
1566            // Interior window gets the full 8-neighbour ring.
1567            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            // Window already covering the whole grid → nothing to prefetch.
1573            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            // Need at least a 2-block grid at level 0 for a ring to exist.
1601            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            // Read a window inside block (0, 0) only.
1606            cog.read_window(0, 0, 0, 16, 16).await.unwrap();
1607
1608            // The detached prefetch task should eventually decode the ring blocks
1609            // (data kind at minimum). Poll with a deadline; no decode budget
1610            // contention exists in this test so it resolves in milliseconds.
1611            let want = 1 + ring.len() as u64; // foreground block + its ring
1612            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            // A window far larger than the level must be rejected before it can
1660            // drive a giant allocation — not clamped, not read.
1661            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            // Exactly-in-bounds still works.
1668            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            // Two requests race the SAME cold window. `claim` is synchronous, so the
1705            // first to enter the miss path owns every block's fetch; the second sees
1706            // the in-flight entries and awaits them instead of re-fetching.
1707            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            // Single-flight: each cold block is fetched exactly once across the two
1725            // concurrent requests. Without dedup the loser would re-fetch every
1726            // block, driving `fetched` up to 2× the number of cached blocks.
1727            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                // This is the formula `level_gsd` used before the eager cache.
1748                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            // target 30 sits between L1(20) and L2(40): rio-tiler picks the
1765            // FINER bracket (L1) and downsamples 20→30. Not L2 (upsample).
1766            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            // target finer than native → full res.
1772            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            // target ≈ L2(40) within 0.1 → snap to L2.
1779            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            // Mirror server::dataset_url: the `url` crate percent-encodes a
1828            // non-ASCII filename into the path segment.
1829            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            // The trap: `Path::from` treats input as a DECODED logical path,
1838            // so it re-encodes the `%`, double-encoding the filename.
1839            assert_ne!(Path::from(path.as_str()).as_ref(), "data/测试-中文.tif");
1840
1841            // The fix: `Path::from_url_path` decodes the URL-encoded path, so
1842            // object_store re-encodes it exactly once for the loopback GET and
1843            // the server's `/data/{name}` handler sees the original filename.
1844            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            // Start exactly the reader-owned speculative operation; no foreground
2378            // request owns the reader or its read permit when it is dropped below.
2379            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}