Skip to main content

ridge_core/
srtm.rs

1//! SRTM elevation tiles: parsing, downloading, caching.
2//!
3//! Replicates the pieces of `SRTM.py` that upstream ridge_map relies on:
4//!
5//! * `.hgt` files are big-endian int16 grids, north-up, with `side` = 3601
6//!   (SRTM 1-arc-second) or 1201 (SRTM 3-arc-second).
7//! * Tiles are named `{N|S}{lat:02}{E|W}{lon:03}.hgt`, e.g. `N44W072.hgt`,
8//!   and cover exactly one degree. `row = floor((lat_lo + 1 - lat) * (side-1))`,
9//!   `col = floor((lon - lon_lo) * (side-1))`.
10//! * Void / invalid samples (outside `[-1000, 10000]`) become NaN.
11//! * Downloads come from a directory mirror (`srtm.kurviger.de` by default)
12//!   as `.hgt.zip`, split across region subdirectories; the region index is
13//!   scraped once and cached. Downloaded tiles are cached on disk unzipped.
14
15use std::collections::HashMap;
16use std::io::Read;
17use std::path::{Path, PathBuf};
18use std::sync::{Arc, Mutex, RwLock};
19
20use crate::{Error, Result};
21
22/// Memoized tile map shared by the cached sources.
23type TileMap = HashMap<(i32, i32), Option<Arc<Tile>>>;
24
25/// A parsed single-degree SRTM tile.
26#[derive(Debug, Clone)]
27pub struct Tile {
28    /// Lower-left latitude of the tile, e.g. `44.0` for N44.
29    pub lat_lo: f64,
30    /// Lower-left longitude, e.g. `-72.0` for W072.
31    pub lon_lo: f64,
32    /// Grid side length (3601 or 1201).
33    pub side: usize,
34    /// Row-major, north-up elevation samples in meters.
35    pub data: Vec<i16>,
36}
37
38impl Tile {
39    /// Parse raw `.hgt` contents. `file_name` supplies the tile origin, e.g.
40    /// `N44W072.hgt`.
41    pub fn parse(file_name: &str, data: &[u8]) -> Result<Tile> {
42        let (lat_lo, lon_lo) = parse_tile_name(file_name)
43            .ok_or_else(|| Error::Srtm(format!("bad tile name {file_name:?}")))?;
44        if !data.len().is_multiple_of(2) {
45            return Err(Error::Srtm(format!("tile {file_name:?} has odd length")));
46        }
47        let n = data.len() / 2;
48        let side = (n as f64).sqrt();
49        if side.fract() != 0.0 {
50            return Err(Error::Srtm(format!(
51                "tile {file_name:?} has non-square size {n}"
52            )));
53        }
54        let side = side as usize;
55        let values: Vec<i16> = data
56            .as_chunks::<2>()
57            .0
58            .iter()
59            .map(|&pair| i16::from_be_bytes(pair))
60            .collect();
61        Ok(Tile {
62            lat_lo,
63            lon_lo,
64            side,
65            data: values,
66        })
67    }
68
69    pub fn resolution(&self) -> f64 {
70        1.0 / (self.side - 1) as f64
71    }
72
73    /// Nearest-neighbor elevation at `(lat, lon)` in meters, or NaN for
74    /// voids / out-of-tile points (mirrors `SRTM.py`'s `get_elevation`).
75    pub fn elevation(&self, lat: f64, lon: f64) -> f64 {
76        let s = (self.side - 1) as f64;
77        let row = ((self.lat_lo + 1.0 - lat) * s).floor();
78        let col = ((lon - self.lon_lo) * s).floor();
79        if row < 0.0 || col < 0.0 || row as usize >= self.side || col as usize >= self.side {
80            return f64::NAN;
81        }
82        let v = self.data[row as usize * self.side + col as usize] as f64;
83        if !(-1000.0..=10000.0).contains(&v) {
84            f64::NAN
85        } else {
86            v
87        }
88    }
89}
90
91/// `N44W072.hgt` -> `(44.0, -72.0)`.
92pub fn parse_tile_name(name: &str) -> Option<(f64, f64)> {
93    let base = name.rsplit('/').next()?;
94    let stem = base.strip_suffix(".hgt")?;
95    let bytes = stem.as_bytes();
96    if bytes.len() != 7 {
97        return None;
98    }
99    let ns = bytes[0];
100    let lat: i32 = stem[1..3].parse().ok()?;
101    let ew = bytes[3];
102    let lon: i32 = stem[4..7].parse().ok()?;
103    let lat_lo = match ns {
104        b'N' => lat as f64,
105        b'S' => -(lat as f64),
106        _ => return None,
107    };
108    let lon_lo = match ew {
109        b'E' => lon as f64,
110        b'W' => -(lon as f64),
111        _ => return None,
112    };
113    Some((lat_lo, lon_lo))
114}
115
116/// `lat=44.3, lon=-71.9` -> `"N44W072.hgt"` (mirrors `SRTM.py` `get_file_name`).
117pub fn tile_name(lat: f64, lon: f64) -> String {
118    let (ns, lat) = if lat >= 0.0 { ('N', lat) } else { ('S', -lat) };
119    let (ew, lon) = if lon >= 0.0 { ('E', lon) } else { ('W', -lon) };
120    // srtm.py: str(int(abs(floor(x)))).zfill(...)
121    format!(
122        "{ns}{:02}{ew}{:03}.hgt",
123        lat.floor() as i32,
124        lon.floor() as i32
125    )
126}
127
128/// Where tiles come from.
129pub trait TileSource: Send + Sync {
130    fn tile(&self, lat_lo: i32, lon_lo: i32) -> Option<Arc<Tile>>;
131}
132
133/// Tiles read from a local directory (tests, offline demos, pre-seeded caches).
134/// Parsed tiles are memoized, so repeated lookups don't re-read the file.
135pub struct DirSource {
136    dir: PathBuf,
137    tiles: Mutex<TileMap>,
138}
139
140impl DirSource {
141    pub fn new(dir: impl Into<PathBuf>) -> Self {
142        Self {
143            dir: dir.into(),
144            tiles: Mutex::new(HashMap::new()),
145        }
146    }
147}
148
149impl TileSource for DirSource {
150    fn tile(&self, lat_lo: i32, lon_lo: i32) -> Option<Arc<Tile>> {
151        if let Some(hit) = self.tiles.lock().unwrap().get(&(lat_lo, lon_lo)) {
152            return hit.clone();
153        }
154        let name = tile_name(lat_lo as f64, lon_lo as f64);
155        let loaded = std::fs::read(self.dir.join(&name))
156            .ok()
157            .and_then(|bytes| Tile::parse(&name, &bytes).ok())
158            .map(Arc::new);
159        self.tiles
160            .lock()
161            .unwrap()
162            .insert((lat_lo, lon_lo), loaded.clone());
163        loaded
164    }
165}
166
167/// In-memory source for unit tests: synthesizes tiles on demand.
168pub struct SyntheticSource {
169    pub side: usize,
170}
171
172impl SyntheticSource {
173    /// A smooth valley-and-ridge terrain, in meters, over global lat/lon.
174    pub fn value(lat: f64, lon: f64) -> f64 {
175        let r = ((lat - 44.0).powi(2) + 1.5 * (lon + 71.5).powi(2)).sqrt();
176        900.0 * (-0.15 * r).exp() + 300.0 * (0.05 * lon).sin() * (0.07 * lat).cos() + 400.0
177    }
178}
179
180impl TileSource for SyntheticSource {
181    fn tile(&self, lat_lo: i32, lon_lo: i32) -> Option<Arc<Tile>> {
182        let n = self.side * self.side;
183        let mut data = Vec::with_capacity(n);
184        for row in 0..self.side {
185            let lat = lat_lo as f64 + 1.0 - row as f64 / (self.side - 1) as f64;
186            for col in 0..self.side {
187                let lon = lon_lo as f64 + col as f64 / (self.side - 1) as f64;
188                data.push(Self::value(lat, lon).round() as i16);
189            }
190        }
191        Some(Arc::new(Tile {
192            lat_lo: lat_lo as f64,
193            lon_lo: lon_lo as f64,
194            side: self.side,
195            data,
196        }))
197    }
198}
199
200/// Remote mirror + on-disk cache, replicating `SRTM.py`'s default behavior.
201///
202/// The mirror lists `.hgt.zip` files under region subdirectories; we scrape
203/// the whole index once (8-ish requests) and memoize it. Tiles land in
204/// `cache_dir` as plain `.hgt` files, so a `DirSource` pointed at the same
205/// directory shares the cache.
206pub struct RemoteSource {
207    base_urls: Vec<String>,
208    cache_dir: PathBuf,
209    agent: ureq::Agent,
210    index: RwLock<Option<HashMap<String, String>>>,
211    tiles: Mutex<TileMap>,
212}
213
214impl RemoteSource {
215    pub fn new(base_urls: &[&str], cache_dir: impl Into<PathBuf>) -> Result<Self> {
216        let cache_dir = cache_dir.into();
217        std::fs::create_dir_all(&cache_dir)?;
218        Ok(Self {
219            base_urls: base_urls
220                .iter()
221                .map(|u| u.trim_end_matches('/').to_string())
222                .collect(),
223            cache_dir,
224            agent: ureq::AgentBuilder::new()
225                .timeout_connect(std::time::Duration::from_secs(20))
226                .timeout(std::time::Duration::from_secs(180))
227                .user_agent("ridge-redux/0.1")
228                .build(),
229            index: RwLock::new(None),
230            tiles: Mutex::new(HashMap::new()),
231        })
232    }
233
234    /// Default mirror and [`default_cache_dir`].
235    pub fn default_paths() -> Result<Self> {
236        let cache = default_cache_dir();
237        Self::new(
238            &[
239                "https://srtm.kurviger.de/SRTM1/",
240                "https://srtm.kurviger.de/SRTM3/",
241            ],
242            cache,
243        )
244    }
245
246    fn fetch_url(&self, url: &str) -> Result<Vec<u8>> {
247        let resp = self
248            .agent
249            .get(url)
250            .call()
251            .map_err(|e| Error::Srtm(format!("GET {url}: {e}")))?;
252        let mut buf = Vec::new();
253        resp.into_reader()
254            .take(512 * 1024 * 1024)
255            .read_to_end(&mut buf)
256            .map_err(|e| Error::Srtm(format!("reading {url}: {e}")))?;
257        Ok(buf)
258    }
259
260    /// Scrape each mirror's subdirectories for `NAME.hgt.zip -> url`.
261    /// Earlier bases win, so pass higher-resolution mirrors first.
262    fn build_index(&self) -> Result<HashMap<String, String>> {
263        let mut index = HashMap::new();
264        for base in &self.base_urls {
265            let page = self.fetch_url(&format!("{base}/"))?;
266            let text = String::from_utf8_lossy(&page);
267            // Subdirectories are linked as `Region_01/index.html` (SRTM1) or
268            // `Eurasia/index.html` (SRTM3) — or with a trailing slash.
269            let dirs = scan_hrefs(&text)
270                .into_iter()
271                .filter_map(|h| {
272                    if h.starts_with("..") {
273                        return None;
274                    }
275                    if let Some(dir) = h.strip_suffix("/index.html") {
276                        return Some(dir.to_string());
277                    }
278                    if h.ends_with('/') {
279                        return Some(h.trim_end_matches('/').to_string());
280                    }
281                    None
282                })
283                .collect::<Vec<_>>();
284            for dir in dirs {
285                let dir_url = format!("{base}/{}", dir.trim_end_matches('/'));
286                let sub = match self.fetch_url(&dir_url) {
287                    Ok(sub) => sub,
288                    Err(_) => continue,
289                };
290                let sub_text = String::from_utf8_lossy(&sub);
291                for href in scan_hrefs(&sub_text) {
292                    if let Some(name) = href.rsplit('/').next() {
293                        if name.ends_with(".hgt.zip") {
294                            let file = name.trim_end_matches(".zip");
295                            index.entry(file.to_string()).or_insert_with(|| {
296                                if href.starts_with("http") {
297                                    href.clone()
298                                } else {
299                                    format!("{dir_url}/{href}")
300                                }
301                            });
302                        }
303                    }
304                }
305            }
306        }
307        if index.is_empty() {
308            return Err(Error::Srtm("mirror index came back empty".into()));
309        }
310        Ok(index)
311    }
312
313    pub fn index(&self) -> Result<HashMap<String, String>> {
314        if let Some(idx) = self.index.read().unwrap().as_ref() {
315            return Ok(idx.clone());
316        }
317        let idx = self.build_index()?;
318        *self.index.write().unwrap() = Some(idx.clone());
319        Ok(idx)
320    }
321
322    pub fn load_or_download(&self, lat_lo: i32, lon_lo: i32) -> Result<Arc<Tile>> {
323        let name = tile_name(lat_lo as f64, lon_lo as f64);
324        let path = self.cache_dir.join(&name);
325        let bytes = match std::fs::read(&path) {
326            Ok(bytes) => bytes,
327            Err(_) => {
328                let index = self.index()?;
329                let url = index.get(&name).ok_or_else(|| {
330                    Error::Srtm(format!(
331                        "no tile {name} on the mirror (ocean or out of range?)"
332                    ))
333                })?;
334                let zipped = self.fetch_url(url)?;
335                let raw = unzip_single(&zipped, &name)
336                    .ok_or_else(|| Error::Srtm(format!("could not unzip tile {name}")))?;
337                std::fs::write(&path, &raw)?;
338                raw
339            }
340        };
341        Ok(Arc::new(Tile::parse(&name, &bytes)?))
342    }
343}
344
345impl TileSource for RemoteSource {
346    fn tile(&self, lat_lo: i32, lon_lo: i32) -> Option<Arc<Tile>> {
347        if let Some(cached) = self.tiles.lock().unwrap().get(&(lat_lo, lon_lo)) {
348            return cached.clone();
349        }
350        let loaded = self.load_or_download(lat_lo, lon_lo).ok();
351        self.tiles
352            .lock()
353            .unwrap()
354            .insert((lat_lo, lon_lo), loaded.clone());
355        loaded
356    }
357}
358
359/// Extract every `href="..."` from an HTML page.
360fn scan_hrefs(page: &str) -> Vec<String> {
361    let mut out = Vec::new();
362    let mut rest = page;
363    while let Some(pos) = rest.find("href=\"") {
364        rest = &rest[pos + 6..];
365        if let Some(end) = rest.find('"') {
366            out.push(rest[..end].to_string());
367            rest = &rest[end..];
368        } else {
369            break;
370        }
371    }
372    out
373}
374
375/// Where downloaded tiles are kept by default: `ridge-redux/srtm` in the
376/// platform's cache directory. That's `$XDG_CACHE_HOME` or `~/.cache` on
377/// Linux, `~/Library/Caches` on macOS and `%LOCALAPPDATA%` on Windows; the
378/// system temp directory if there's none.
379pub fn default_cache_dir() -> PathBuf {
380    dirs::cache_dir()
381        .unwrap_or_else(std::env::temp_dir)
382        .join("ridge-redux")
383        .join("srtm")
384}
385
386/// The default cache before 0.1.1, on every platform: `$XDG_CACHE_HOME`,
387/// else `$HOME/.cache`, else `/tmp/.cache`. Only Linux matches
388/// [`default_cache_dir`]; [`migrate_legacy_cache`] moves the others.
389pub fn legacy_cache_dir() -> PathBuf {
390    std::env::var("XDG_CACHE_HOME")
391        .map(PathBuf::from)
392        .unwrap_or_else(|_| {
393            std::env::var("HOME")
394                .map(PathBuf::from)
395                .unwrap_or_else(|_| PathBuf::from("/tmp"))
396                .join(".cache")
397        })
398        .join("ridge-redux")
399        .join("srtm")
400}
401
402/// Move tiles from [`legacy_cache_dir`] into `to`, then remove the legacy
403/// directory if that empties it. Tiles `to` already has are dropped, not
404/// copied over. Returns how many tiles moved; 0 when there's nothing to do.
405pub fn migrate_legacy_cache(to: &Path) -> std::io::Result<usize> {
406    migrate_cache(&legacy_cache_dir(), to)
407}
408
409fn migrate_cache(from: &Path, to: &Path) -> std::io::Result<usize> {
410    if !from.is_dir() || same_dir(from, to) {
411        return Ok(0);
412    }
413    std::fs::create_dir_all(to)?;
414    let mut moved = 0;
415    for entry in std::fs::read_dir(from)? {
416        let entry = entry?;
417        if !entry.file_type()?.is_file() {
418            continue;
419        }
420        let src = entry.path();
421        let dest = to.join(entry.file_name());
422        if dest.exists() {
423            std::fs::remove_file(&src)?;
424            continue;
425        }
426        // A rename can't cross filesystems (a different drive, say).
427        if std::fs::rename(&src, &dest).is_err() {
428            std::fs::copy(&src, &dest)?;
429            std::fs::remove_file(&src)?;
430        }
431        moved += 1;
432    }
433    remove_cache_dirs(from);
434    Ok(moved)
435}
436
437/// Delete a tile cache directory, and its `ridge-redux` parent if that's
438/// left empty. Returns the number of files and bytes freed.
439pub fn remove_cache(dir: &Path) -> std::io::Result<(usize, u64)> {
440    let (files, bytes) = cache_size(dir)?;
441    std::fs::remove_dir_all(dir)?;
442    remove_cache_dirs(dir);
443    Ok((files, bytes))
444}
445
446/// Files and bytes in a tile cache directory (not recursive: tiles are flat).
447pub fn cache_size(dir: &Path) -> std::io::Result<(usize, u64)> {
448    let mut files = 0;
449    let mut bytes = 0;
450    for entry in std::fs::read_dir(dir)? {
451        let meta = entry?.metadata()?;
452        if meta.is_file() {
453            files += 1;
454            bytes += meta.len();
455        }
456    }
457    Ok((files, bytes))
458}
459
460/// Remove `dir` and then its `ridge-redux` parent, each only if empty.
461fn remove_cache_dirs(dir: &Path) {
462    let _ = std::fs::remove_dir(dir);
463    if let Some(parent) = dir.parent().filter(|p| p.ends_with("ridge-redux")) {
464        let _ = std::fs::remove_dir(parent);
465    }
466}
467
468fn same_dir(a: &Path, b: &Path) -> bool {
469    match (a.canonicalize(), b.canonicalize()) {
470        (Ok(a), Ok(b)) => a == b,
471        _ => a == b,
472    }
473}
474
475/// Minimal zip extractor: find the entry whose name ends with `want_suffix`
476/// (or the first entry) and decompress it. Handles stored + deflate entries.
477pub fn unzip_single(zip: &[u8], want_suffix: &str) -> Option<Vec<u8>> {
478    // End of central directory record.
479    let eocd = zip
480        .windows(22)
481        .rev()
482        .find(|w| w[..4] == [0x50, 0x4b, 0x05, 0x06])?;
483    let eocd_pos = zip.len() - eocd.len();
484    let entries = u16::from_le_bytes([eocd[10], eocd[11]]) as usize;
485    let cd_size = u32::from_le_bytes([eocd[12], eocd[13], eocd[14], eocd[15]]) as usize;
486    let cd_offset = u32::from_le_bytes([eocd[16], eocd[17], eocd[18], eocd[19]]) as usize;
487    if cd_offset == 0xFFFF_FFFF {
488        return None; // zip64: not needed for 25 MB tiles
489    }
490    let _ = eocd_pos;
491    let mut cd = &zip[cd_offset.min(zip.len())..(cd_offset + cd_size).min(zip.len())];
492
493    for _ in 0..entries {
494        if cd.len() < 46 || cd[..4] != [0x50, 0x4b, 0x01, 0x02] {
495            return None;
496        }
497        let method = u16::from_le_bytes([cd[10], cd[11]]);
498        let comp_size = u32::from_le_bytes([cd[20], cd[21], cd[22], cd[23]]) as usize;
499        let name_len = u16::from_le_bytes([cd[28], cd[29]]) as usize;
500        let extra_len = u16::from_le_bytes([cd[30], cd[31]]) as usize;
501        let comment_len = u16::from_le_bytes([cd[32], cd[33]]) as usize;
502        let local_off = u32::from_le_bytes([cd[42], cd[43], cd[44], cd[45]]) as usize;
503        let name = String::from_utf8_lossy(&cd[46..46 + name_len]).to_string();
504        cd = &cd[46 + name_len + extra_len + comment_len..];
505
506        if !want_suffix.is_empty() && !name.ends_with(want_suffix) {
507            continue;
508        }
509        // Local file header: skip name + extra to find data.
510        if local_off + 30 > zip.len() || zip[local_off..local_off + 4] != [0x50, 0x4b, 0x03, 0x04] {
511            return None;
512        }
513        let l_name = u16::from_le_bytes([zip[local_off + 26], zip[local_off + 27]]) as usize;
514        let l_extra = u16::from_le_bytes([zip[local_off + 28], zip[local_off + 29]]) as usize;
515        let data_start = local_off + 30 + l_name + l_extra;
516        let data_end = (data_start + comp_size).min(zip.len());
517        let data = &zip[data_start..data_end];
518        return match method {
519            0 => Some(data.to_vec()),
520            8 => {
521                let mut out = Vec::with_capacity(comp_size * 4);
522                let mut decoder = flate2::read::DeflateDecoder::new(data);
523                decoder.read_to_end(&mut out).ok()?;
524                Some(out)
525            }
526            _ => None,
527        };
528    }
529    None
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    fn scratch(name: &str) -> PathBuf {
537        let dir = std::env::temp_dir()
538            .join(format!("ridge-srtm-test-{}", std::process::id()))
539            .join(name);
540        let _ = std::fs::remove_dir_all(&dir);
541        dir
542    }
543
544    #[test]
545    fn migrate_moves_new_tiles_and_drops_duplicates() {
546        let root = scratch("migrate");
547        let from = root.join("old").join("ridge-redux").join("srtm");
548        let to = root.join("new").join("ridge-redux").join("srtm");
549        std::fs::create_dir_all(&from).unwrap();
550        std::fs::create_dir_all(&to).unwrap();
551        std::fs::write(from.join("N44W072.hgt"), b"old").unwrap();
552        std::fs::write(from.join("N43W071.hgt"), b"old").unwrap();
553        std::fs::write(to.join("N43W071.hgt"), b"new").unwrap();
554
555        assert_eq!(migrate_cache(&from, &to).unwrap(), 1);
556        assert_eq!(std::fs::read(to.join("N44W072.hgt")).unwrap(), b"old");
557        assert_eq!(std::fs::read(to.join("N43W071.hgt")).unwrap(), b"new");
558        assert!(!from.parent().unwrap().exists(), "emptied legacy dirs go");
559        // Nothing left to move the second time.
560        assert_eq!(migrate_cache(&from, &to).unwrap(), 0);
561        std::fs::remove_dir_all(&root).unwrap();
562    }
563
564    #[test]
565    fn migrate_into_itself_is_a_no_op() {
566        let dir = scratch("same").join("ridge-redux").join("srtm");
567        std::fs::create_dir_all(&dir).unwrap();
568        std::fs::write(dir.join("N44W072.hgt"), b"tile").unwrap();
569        assert_eq!(migrate_cache(&dir, &dir).unwrap(), 0);
570        assert!(dir.join("N44W072.hgt").exists());
571        std::fs::remove_dir_all(dir.parent().unwrap().parent().unwrap()).unwrap();
572    }
573
574    #[test]
575    fn remove_cache_reports_and_tidies() {
576        let root = scratch("remove");
577        let dir = root.join("ridge-redux").join("srtm");
578        std::fs::create_dir_all(&dir).unwrap();
579        std::fs::write(dir.join("N44W072.hgt"), [0u8; 10]).unwrap();
580        std::fs::write(dir.join("N43W071.hgt"), [0u8; 5]).unwrap();
581        assert_eq!(remove_cache(&dir).unwrap(), (2, 15));
582        assert!(!root.join("ridge-redux").exists());
583        std::fs::remove_dir_all(&root).unwrap();
584    }
585
586    #[test]
587    fn tile_names_roundtrip() {
588        assert_eq!(tile_name(44.0, -72.0), "N44W072.hgt");
589        assert_eq!(tile_name(-33.5, 151.2), "S33E151.hgt");
590        assert_eq!(parse_tile_name("N44W072.hgt"), Some((44.0, -72.0)));
591        assert_eq!(parse_tile_name("S33E151.hgt"), Some((-33.0, 151.0)));
592        assert_eq!(parse_tile_name("junk.hgt"), None);
593    }
594
595    #[test]
596    fn synthetic_tile_lookup() {
597        let src = SyntheticSource { side: 1201 };
598        let t = src.tile(44, -72).unwrap();
599        let v = t.elevation(44.5, -71.5);
600        let expected = SyntheticSource::value(44.5, -71.5).round();
601        assert!((v - expected).abs() < 1e-9);
602        assert!(t.elevation(45.5, -71.5).is_nan()); // out of tile
603    }
604
605    #[test]
606    fn zip_roundtrip() {
607        // Build a stored (method 0) zip by hand: header + data + central dir + eocd.
608        let name = b"N44W072.hgt";
609        let payload = b"hello-tile-data".to_vec();
610        let mut zip = Vec::new();
611        // local header
612        zip.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
613        zip.extend_from_slice(&0u32.to_le_bytes()); // crc
614        zip.extend_from_slice(&(payload.len() as u32).to_le_bytes());
615        zip.extend_from_slice(&(payload.len() as u32).to_le_bytes());
616        zip.extend_from_slice(&(name.len() as u16).to_le_bytes());
617        zip.extend_from_slice(&0u16.to_le_bytes());
618        zip.extend_from_slice(name);
619        zip.extend_from_slice(&payload);
620        // central directory
621        let cd_start = zip.len();
622        // sig(4), ver_made(2), ver_needed(2), flags(2), method(2), time(2), date(2) = 16 bytes
623        zip.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02, 20, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
624        zip.extend_from_slice(&0u32.to_le_bytes());
625        zip.extend_from_slice(&(payload.len() as u32).to_le_bytes());
626        zip.extend_from_slice(&(payload.len() as u32).to_le_bytes());
627        zip.extend_from_slice(&(name.len() as u16).to_le_bytes());
628        zip.extend_from_slice(&0u16.to_le_bytes()); // extra len
629        zip.extend_from_slice(&0u16.to_le_bytes()); // comment len
630        zip.extend_from_slice(&0u16.to_le_bytes()); // disk number start
631        zip.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
632        zip.extend_from_slice(&0u32.to_le_bytes()); // external attrs
633        zip.extend_from_slice(&0u32.to_le_bytes()); // local header offset (header starts at 0)
634        zip.extend_from_slice(name);
635        let cd_end = zip.len();
636        // eocd
637        zip.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0, 1, 0, 1, 0]);
638        zip.extend_from_slice(&((cd_end - cd_start) as u32).to_le_bytes());
639        zip.extend_from_slice(&(cd_start as u32).to_le_bytes());
640        zip.extend_from_slice(&0u16.to_le_bytes());
641
642        let out = unzip_single(&zip, ".hgt").expect("extract");
643        assert_eq!(out, payload);
644    }
645}