use crate::cog::{LazyRaster, Window, WindowOptions, WindowSource};
use crate::crs::SpatialRef;
use crate::formats::{RasterDriver, VectorDriver};
use crate::info::{Info, InfoOptions, Subdataset};
use crate::ops::vrt::SourceRead;
use crate::{Dataset, Error, RasterDataset, Result, VectorDataset};
use serde_json::{Value, json};
use std::collections::BTreeMap;
use wbraster::DataType;
use wbraster::raster::RasterData;
pub const DEFAULT_MAX_ITEMS: usize = 1000;
fn bad(msg: impl std::fmt::Display) -> Error {
Error::InvalidData(format!("STAC: {msg}"))
}
fn is_url(s: &str) -> bool {
let l = s.to_ascii_lowercase();
l.starts_with("http://") || l.starts_with("https://")
}
pub(crate) fn fetch(loc: &str) -> Result<Vec<u8>> {
use crate::vfs::Location;
match Location::parse(loc) {
Location::Local(p) => Ok(std::fs::read(p)?),
#[cfg(remote)]
l @ (Location::Url(_) | Location::S3 { .. }) => {
let remote = l.remote().ok_or_else(|| bad(format!("'{loc}' is not remote")))?;
crate::http::get(&remote)
}
_ => {
let local = crate::vfs::resolve(loc)?;
Ok(std::fs::read(local.path())?)
}
}
}
fn fetch_json(loc: &str) -> Result<Value> {
serde_json::from_slice(&fetch(loc)?).map_err(|e| bad(format!("{loc}: {e}")))
}
#[derive(Debug, Clone, PartialEq)]
struct Request {
url: String,
body: Option<Value>,
headers: Vec<(String, String)>,
}
impl Request {
fn get(url: &str) -> Self {
Self { url: url.to_string(), body: None, headers: Vec::new() }
}
fn fetch(&self) -> Result<Value> {
let bytes = match &self.body {
None if self.headers.is_empty() => fetch(&self.url)?,
#[cfg(remote)]
None => crate::http::get_with_headers(&self.url, &self.headers)?,
#[cfg(remote)]
Some(body) => crate::http::post_json(&self.url, &serde_json::to_vec(body).map_err(bad)?, &self.headers)?,
#[cfg(not(remote))]
_ => return Err(bad(format!("{}: POST requests and request headers need the `remote` feature", self.url))),
};
serde_json::from_slice(&bytes).map_err(|e| bad(format!("{}: {e}", self.url)))
}
}
fn next_request(doc: &Value, cur: &Request) -> Option<Request> {
let links: Vec<&Value> = doc["links"]
.as_array()
.into_iter()
.flatten()
.filter(|l| {
l["rel"] == "next" && l["type"].as_str().is_none_or(|t| t == "application/geo+json" || t == "application/json" || t.is_empty())
})
.collect();
let method = |l: &Value| l["method"].as_str().unwrap_or("GET").to_ascii_uppercase();
let link = links.iter().find(|l| method(l) == "GET").or_else(|| links.iter().find(|l| method(l) == "POST"))?;
let url = resolve_href(&cur.url, link["href"].as_str()?);
let link_headers: Vec<(String, String)> = link["headers"]
.as_object()
.into_iter()
.flatten()
.flat_map(|(k, v)| match v {
Value::Array(values) => values.iter().filter_map(|x| Some((k.clone(), x.as_str()?.to_string()))).collect::<Vec<_>>(),
other => other.as_str().map(|x| vec![(k.clone(), x.to_string())]).unwrap_or_default(),
})
.collect();
let same_origin = crate::vfs::url_origin(&url).is_some() && crate::vfs::url_origin(&url) == crate::vfs::url_origin(&cur.url);
let headers = match (same_origin, link_headers.is_empty(), link["merge"] == true) {
(true, true, _) => cur.headers.clone(),
(true, false, true) => {
let mut h: Vec<(String, String)> =
cur.headers.iter().filter(|(k, _)| !link_headers.iter().any(|(l, _)| l.eq_ignore_ascii_case(k))).cloned().collect();
h.extend(link_headers);
h
}
_ => link_headers,
};
if method(link) == "GET" {
return Some(Request { url, body: None, headers });
}
let body = match (&cur.body, &link["body"]) {
(Some(Value::Object(prev)), Value::Object(b)) if link["merge"] == true && same_origin => {
let mut m = prev.clone();
m.extend(b.iter().map(|(k, v)| (k.clone(), v.clone())));
Value::Object(m)
}
(_, Value::Object(b)) => Value::Object(b.clone()),
(Some(prev), _) if same_origin => prev.clone(),
_ => json!({}),
};
Some(Request { url, body: Some(body), headers })
}
fn url_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
let hex = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
match (b[i], hex) {
(b'+', _) => out.push(b' '),
(b'%', Some(v)) => {
out.push(v);
i += 2;
}
(c, _) => out.push(c),
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn rfc3339_interval(v: &str) -> String {
let parts: Vec<&str> = v.split('/').map(str::trim).collect();
let days_in = |y: i64, m: i64| match m {
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
2 => 28,
4 | 6 | 9 | 11 => 30,
_ => 31,
};
let expand = |p: &str| -> Option<(String, String)> {
let n: Vec<i64> = p.split('-').map(|x| x.parse().ok()).collect::<Option<_>>()?;
let ok = p.len() == [4, 7, 10][n.len().clamp(1, 3) - 1];
match n[..] {
[y] if ok => Some((format!("{y:04}-01-01T00:00:00Z"), format!("{y:04}-12-31T23:59:59Z"))),
[y, m] if ok && (1..=12).contains(&m) => {
Some((format!("{y:04}-{m:02}-01T00:00:00Z"), format!("{y:04}-{m:02}-{:02}T23:59:59Z", days_in(y, m))))
}
[y, m, d] if ok && (1..=12).contains(&m) && (1..=days_in(y, m)).contains(&d) => {
Some((format!("{y:04}-{m:02}-{d:02}T00:00:00Z"), format!("{y:04}-{m:02}-{d:02}T23:59:59Z")))
}
_ => None,
}
};
match parts[..] {
[one] => match expand(one) {
Some((a, b)) => format!("{a}/{b}"),
None => one.to_string(),
},
[a, b] => {
let a = expand(a).map_or_else(|| a.to_string(), |(s, _)| s);
let b = expand(b).map_or_else(|| b.to_string(), |(_, e)| e);
format!("{a}/{b}")
}
_ => v.to_string(),
}
}
fn post_request(source: &str) -> Request {
let (url, query) = source.split_once('?').unwrap_or((source, ""));
let mut body = serde_json::Map::new();
for kv in query.split('&').filter(|kv| !kv.is_empty()) {
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
let (k, v) = (url_decode(k), url_decode(v));
let list = || Value::Array(v.split(',').map(|s| Value::String(s.trim().to_string())).collect());
let value = match k.as_str() {
"bbox" => Value::Array(v.split(',').filter_map(|n| n.trim().parse::<f64>().ok()).map(Value::from).collect()),
"collections" | "ids" => list(),
"limit" => v.parse::<u64>().map(Value::from).unwrap_or(Value::String(v)),
"datetime" => Value::String(rfc3339_interval(&v)),
"sortby" => Value::Array(
v.split(',')
.map(|f| {
let f = f.trim();
let (dir, field) = match f.strip_prefix('-') {
Some(r) => ("desc", r),
None => ("asc", f.strip_prefix('+').unwrap_or(f)),
};
json!({"field": field, "direction": dir})
})
.collect(),
),
"fields" => {
let (mut include, mut exclude) = (Vec::new(), Vec::new());
for f in v.split(',').map(str::trim).filter(|f| !f.is_empty()) {
match f.strip_prefix('-') {
Some(r) => exclude.push(r.to_string()),
None => include.push(f.strip_prefix('+').unwrap_or(f).to_string()),
}
}
json!({"include": include, "exclude": exclude})
}
_ => match serde_json::from_str::<Value>(&v) {
Ok(j @ (Value::Object(_) | Value::Array(_))) => j,
_ => Value::String(v),
},
};
body.insert(k, value);
}
if body.get("filter").is_some_and(Value::is_string) && !body.contains_key("filter-lang") {
body.insert("filter-lang".into(), json!("cql2-text"));
}
Request { url: url.to_string(), body: Some(Value::Object(body)), headers: Vec::new() }
}
pub(crate) fn resolve_href(base: &str, href: &str) -> String {
if href.contains("://") || href.starts_with("/vsi") {
return href.to_string();
}
if is_url(base) {
if href.starts_with('/') {
let (scheme, rest) = base.split_once("://").unwrap_or(("https", base));
let host = rest.split(['/', '?', '#']).next().unwrap_or(rest);
return format!("{scheme}://{host}{href}");
}
} else if href.starts_with('/') || std::path::Path::new(href).is_absolute() {
return href.to_string();
}
let dir = base.split(['?', '#']).next().unwrap_or(base);
let dir = match dir.rfind(['/', '\\']) {
Some(i) => &dir[..i],
None => "",
};
let mut parts: Vec<&str> = if dir.is_empty() { Vec::new() } else { dir.split('/').collect() };
for seg in href.split('/') {
match seg {
"." | "" => {}
".." if parts.len() > 1 && parts.last() != Some(&"..") => {
parts.pop();
}
s => parts.push(s),
}
}
parts.join("/")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
Item,
ItemCollection,
Collection,
Catalog,
}
fn kind(v: &Value) -> Option<Kind> {
let is_item = |f: &Value| f["type"] == "Feature" && f.get("stac_version").is_some();
match v["type"].as_str()? {
"Feature" if is_item(v) => Some(Kind::Item),
"FeatureCollection" if v.get("stac_version").is_some() || v["features"].as_array().is_some_and(|f| f.iter().any(is_item)) => {
Some(Kind::ItemCollection)
}
"Collection" if v.get("stac_version").is_some() => Some(Kind::Collection),
"Catalog" if v.get("stac_version").is_some() => Some(Kind::Catalog),
_ => None,
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SignMode {
#[default]
Auto,
PlanetaryComputer,
None,
}
impl SignMode {
fn parse(v: &str) -> Option<Self> {
match v.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"auto" | "" => Some(Self::Auto),
"planetary_computer" | "planetarycomputer" | "pc" | "yes" | "on" | "true" => Some(Self::PlanetaryComputer),
"none" | "no" | "off" | "false" => Some(Self::None),
_ => None,
}
}
fn from_env() -> Self {
std::env::var("TERRIO_STAC_SIGN").ok().and_then(|v| Self::parse(&v)).unwrap_or_default()
}
}
const PC_HOST: &str = "planetarycomputer.microsoft.com";
fn blob_container(href: &str) -> Option<(&str, &str)> {
let rest = href.strip_prefix("https://")?;
let (host, path) = rest.split_once('/')?;
let account = host
.strip_suffix(".blob.core.windows.net")
.filter(|a| !a.is_empty() && a.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()))?;
let container = path
.split(['/', '?', '#'])
.next()
.filter(|c| !c.is_empty() && c.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'))?;
Some((account, container))
}
fn from_planetary_computer(item: &Value, base: &str) -> bool {
let host = |u: &str| crate::vfs::url_origin(u).map(|(_, h)| h);
host(base).is_some_and(|h| h == PC_HOST)
|| item["links"].as_array().into_iter().flatten().any(|l| l["href"].as_str().and_then(host).is_some_and(|h| h == PC_HOST))
}
fn sign_href(href: String, item: &Value, base: &str, mode: SignMode) -> Result<String> {
if needs_signing(&href, item, base, mode) { signed(href) } else { Ok(href) }
}
fn needs_signing(href: &str, item: &Value, base: &str, mode: SignMode) -> bool {
let sign = match mode {
SignMode::None => false,
SignMode::PlanetaryComputer => true,
SignMode::Auto => from_planetary_computer(item, base),
};
sign && blob_container(href).is_some() && !href.split_once('?').is_some_and(|(_, q)| q.split('&').any(|kv| kv.starts_with("sig=")))
}
fn signed(href: String) -> Result<String> {
let Some((account, container)) = blob_container(&href) else { return Ok(href) };
let token = pc_token(account, container)?;
Ok(format!("{href}{}{token}", if href.contains('?') { '&' } else { '?' }))
}
#[cfg(remote)]
fn pc_token(account: &str, container: &str) -> Result<String> {
use std::sync::Mutex;
use std::time::{Duration, Instant};
static TOKENS: Mutex<BTreeMap<(String, String), (String, Instant)>> = Mutex::new(BTreeMap::new());
let key = (account.to_string(), container.to_string());
if let Ok(tokens) = TOKENS.lock()
&& let Some((token, until)) = tokens.get(&key)
&& Instant::now() < *until
{
return Ok(token.clone());
}
let base = std::env::var("TERRIO_PC_SAS_URL").unwrap_or_else(|_| format!("https://{PC_HOST}/api/sas/v1/token"));
let url = format!("{}/{account}/{container}", base.trim_end_matches('/'));
let headers: Vec<(String, String)> = std::env::var("PC_SDK_SUBSCRIPTION_KEY")
.ok()
.filter(|k| !k.trim().is_empty() && url.starts_with("https://"))
.map(|k| ("Ocp-Apim-Subscription-Key".to_string(), k.trim().to_string()))
.into_iter()
.collect();
let v: Value = serde_json::from_slice(&crate::http::get_with_headers(&url, &headers)?).map_err(|e| bad(format!("{url}: {e}")))?;
let token = v["token"].as_str().filter(|t| !t.is_empty()).ok_or_else(|| bad(format!("{url}: no token in the response")))?.to_string();
let lifetime = match v["msft:expiry"].as_str() {
None => Some(Duration::from_secs(1800)),
Some(t) => seconds_until(t).filter(|s| *s > 300).map(|s| Duration::from_secs(s - 300)),
};
if let Some(lifetime) = lifetime
&& let Ok(mut tokens) = TOKENS.lock()
{
tokens.insert(key, (token.clone(), Instant::now() + lifetime));
}
Ok(token)
}
#[cfg(not(remote))]
fn pc_token(account: &str, container: &str) -> Result<String> {
Err(bad(format!("signing {account}/{container} assets needs the `remote` feature")))
}
#[cfg(remote)]
fn seconds_until(t: &str) -> Option<u64> {
let n = |r: std::ops::Range<usize>| t.get(r)?.parse::<i64>().ok();
crate::http::seconds_until_utc(n(0..4)?, n(5..7)?, n(8..10)?, n(11..13)?, n(14..16)?, n(17..19)?)
}
struct Items {
items: Vec<(Value, String)>,
truncated: bool,
}
fn load_items(doc: Value, loc: &str, max_items: usize) -> Result<Items> {
load_items_from(doc, &Request::get(loc), max_items)
}
fn load_items_from(doc: Value, request: &Request, max_items: usize) -> Result<Items> {
let loc = request.url.as_str();
let mut out = Items { items: Vec::new(), truncated: false };
let full = |out: &Items| max_items > 0 && out.items.len() >= max_items;
match kind(&doc) {
Some(Kind::Item) => out.items.push((doc, loc.to_string())),
Some(Kind::ItemCollection) => read_pages(doc, request.clone(), max_items, &mut out)?,
Some(Kind::Collection | Kind::Catalog) => {
let mut queue = vec![(doc, loc.to_string(), 0usize)];
while let Some((doc, base, depth)) = queue.pop() {
for link in doc["links"].as_array().into_iter().flatten() {
if full(&out) {
out.truncated = true;
return Ok(out);
}
let Some(href) = link["href"].as_str() else { continue };
let href = resolve_href(&base, href);
match link["rel"].as_str() {
Some("item") => {
let item = fetch_json(&href)?;
if kind(&item) == Some(Kind::Item) {
out.items.push((item, href));
}
}
Some("items") => read_pages(fetch_json(&href)?, Request::get(&href), max_items, &mut out)?,
Some("child") if depth < 8 => queue.push((fetch_json(&href)?, href, depth + 1)),
_ => {}
}
}
}
}
None => return Err(bad(format!("'{loc}' is not a STAC item, item collection, collection or catalog"))),
}
Ok(out)
}
fn read_pages(mut doc: Value, mut cur: Request, max_items: usize, out: &mut Items) -> Result<()> {
let mut pages = 0;
loop {
pages += 1;
for f in doc["features"].as_array().into_iter().flatten() {
if max_items > 0 && out.items.len() >= max_items {
out.truncated = true;
return Ok(());
}
out.items.push((f.clone(), cur.url.clone()));
}
let Some(next) = next_request(&doc, &cur) else { return Ok(()) };
if next == cur || pages > 10_000 {
return Ok(());
}
doc = next.fetch()?;
cur = next;
}
}
fn footprints(items: &[(Value, String)], name: &str) -> Result<wbvector::Layer> {
let features: Vec<&Value> = items.iter().map(|(v, _)| v).collect();
let fc = json!({"type": "FeatureCollection", "features": features});
let bytes = serde_json::to_vec(&fc).map_err(bad)?;
crate::drivers::vector::geojson::parse(&bytes, name)
}
fn layer_name(doc: &Value, loc: &str) -> String {
if matches!(kind(doc), Some(Kind::Collection | Kind::Catalog))
&& let Some(id) = doc["id"].as_str()
{
return id.to_string();
}
let path = loc.split(['?', '#']).next().unwrap_or(loc);
let file = path.trim_end_matches('/').rsplit(['/', '\\']).next().unwrap_or(path);
let stem = file.split('.').next().unwrap_or(file);
if stem.is_empty() { "items".into() } else { stem.to_string() }
}
fn is_data_asset(asset: &Value) -> bool {
let t = asset["type"].as_str().unwrap_or_default();
if matches!(t, "application/json" | "application/xml" | "text/plain" | "text/html") {
return false;
}
!asset["roles"].as_array().into_iter().flatten().any(|r| matches!(r.as_str(), Some("thumbnail" | "info" | "metadata" | "overview")))
}
#[derive(Debug, Clone, PartialEq)]
enum Name {
Asset { item: String, asset: String },
Mosaic { source: String, filters: Filters },
}
#[derive(Debug, Clone, Default, PartialEq)]
struct Filters {
collection: Option<String>,
asset: Option<String>,
crs: Option<String>,
resolution: Option<String>,
max_items: Option<usize>,
post: bool,
sign: Option<SignMode>,
invalid: Option<String>,
}
impl Filters {
fn kept_options(&self) -> String {
let mut out = String::new();
if self.post {
out.push_str(",method=POST");
}
match self.sign {
Some(SignMode::Auto) => out.push_str(",sign=auto"),
Some(SignMode::PlanetaryComputer) => out.push_str(",sign=planetary_computer"),
Some(SignMode::None) => out.push_str(",sign=none"),
None => {}
}
out
}
fn check(&self) -> Result<()> {
match &self.invalid {
Some(o) => Err(Error::InvalidArgument(format!("STACIT: invalid option '{o}'"))),
None => Ok(()),
}
}
}
fn split_source(rest: &str, options_have_equals: bool) -> (String, Option<String>) {
if let Some(q) = rest.strip_prefix('"')
&& let Some(end) = q.find('"')
{
let tail = q[end + 1..].strip_prefix(':').map(str::to_string).filter(|s| !s.is_empty());
return (q[..end].to_string(), tail);
}
if let Some((src, tail)) = rest.rsplit_once(':')
&& !tail.contains('/')
&& !tail.is_empty()
&& (!options_have_equals || tail.contains('='))
&& !src.is_empty()
&& !src.ends_with(":/")
&& src.len() > 1
{
return (src.to_string(), Some(tail.to_string()));
}
(rest.to_string(), None)
}
fn parse_name(path: &str) -> Option<Name> {
let p = path.trim();
if let Some(rest) = p.strip_prefix("STACIT:").or_else(|| p.strip_prefix("stacit:")) {
let (source, tail) = split_source(rest, true);
let mut filters = Filters::default();
for kv in tail.iter().flat_map(|t| t.split(',')) {
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
match k.trim().to_ascii_lowercase().as_str() {
"collection" => filters.collection = Some(v.to_string()),
"asset" => filters.asset = Some(v.to_string()),
"crs" => filters.crs = Some(v.to_string()),
"resolution" => filters.resolution = Some(v.to_ascii_uppercase()),
"max_items" => filters.max_items = v.parse().ok(),
"method" if v.eq_ignore_ascii_case("POST") || v.eq_ignore_ascii_case("GET") => {
filters.post = v.eq_ignore_ascii_case("POST")
}
"sign" if SignMode::parse(v).is_some() => filters.sign = SignMode::parse(v),
"method" | "sign" => filters.invalid = Some(kv.to_string()),
_ => {}
}
}
return Some(Name::Mosaic { source, filters });
}
if let Some(rest) = p.strip_prefix("STAC:").or_else(|| p.strip_prefix("stac:")) {
let (item, asset) = split_source(rest, false);
return Some(Name::Asset { item, asset: asset? });
}
let lower = p.to_ascii_lowercase();
for ext in [".json!", ".geojson!"] {
if let Some(i) = lower.find(ext) {
let (item, asset) = (&p[..i + ext.len() - 1], &p[i + ext.len()..]);
if !asset.is_empty() && !asset.contains('/') {
return Some(Name::Asset { item: item.to_string(), asset: asset.to_string() });
}
}
}
None
}
fn asset_href(item_loc: &str, asset: &str) -> Result<String> {
let doc = fetch_json(item_loc)?;
let item = match kind(&doc) {
Some(Kind::Item) => doc,
_ => return Err(bad(format!("'{item_loc}' is not a STAC item"))),
};
let a = &item["assets"][asset];
let href = a["href"].as_str().ok_or_else(|| {
let names: Vec<&String> = item["assets"].as_object().map(|o| o.keys().collect()).unwrap_or_default();
let names: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
bad(format!("'{item_loc}' has no asset '{asset}' (assets: {})", names.join(", ")))
})?;
sign_href(resolve_href(item_loc, href), &item, item_loc, SignMode::from_env())
}
#[derive(Debug, Clone)]
struct AssetItem {
href: String,
sign: bool,
datetime: String,
width: usize,
height: usize,
bounds: [f64; 4],
}
impl AssetItem {
fn open(&self) -> Result<LazyRaster> {
if self.sign { LazyRaster::open(&signed(self.href.clone())?) } else { LazyRaster::open(&self.href) }
}
}
#[derive(Debug, Default)]
struct AssetGroup {
bands: Value,
by_crs: BTreeMap<String, Vec<AssetItem>>,
}
type Groups = BTreeMap<String, BTreeMap<String, AssetGroup>>;
fn sanitize_crs(v: &str) -> String {
let mut out = String::new();
let mut last_alnum = true;
for ch in v.chars() {
if !ch.is_ascii_alphanumeric() {
if last_alnum {
out.push('_');
}
last_alnum = false;
} else {
out.push(ch);
last_alnum = true;
}
}
if out.ends_with('_') {
out.pop();
}
out
}
fn group_assets(items: &[(Value, String)], f: &Filters) -> Groups {
let mut groups: Groups = BTreeMap::new();
let sign = f.sign.unwrap_or_else(SignMode::from_env);
let crs_filter = f.crs.as_deref().map(sanitize_crs);
for (item, base) in items {
let has_proj = item["stac_extensions"]
.as_array()
.into_iter()
.flatten()
.any(|e| e.as_str().is_some_and(|s| s == "proj" || s.starts_with("https://stac-extensions.github.io/projection/")));
let props = &item["properties"];
if !has_proj || !props.is_object() || !item["assets"].is_object() {
continue;
}
let collection = match &item["collection"] {
Value::String(s) => s.clone(),
Value::Null => String::new(),
v => v.to_string(),
};
if f.collection.as_ref().is_some_and(|c| *c != collection) {
continue;
}
for (name, asset) in item["assets"].as_object().into_iter().flatten() {
if f.asset.as_ref().is_some_and(|a| a != name) {
continue;
}
let t = asset["type"].as_str().unwrap_or_default();
if matches!(t, "application/json" | "application/xml" | "text/plain")
|| asset["roles"].as_array().into_iter().flatten().any(|r| matches!(r.as_str(), Some("thumbnail" | "info" | "metadata")))
{
continue;
}
let Some(href) = asset["href"].as_str().filter(|h| !h.is_empty()) else { continue };
let get = |k: &str| if asset.get(k).is_some() { &asset[k] } else { &props[k] };
let crs = if !get("proj:code").is_null() && get("proj:code").as_str().is_some() {
get("proj:code").as_str().unwrap_or_default().to_string()
} else if !get("proj:epsg").is_null() {
let code = get("proj:epsg");
let esri = code.as_u64().and_then(|c| u32::try_from(c).ok()).is_some_and(crate::crs::is_esri_code);
format!("{}:{code}", if esri { "ESRI" } else { "EPSG" })
} else if let Some(w) = get("proj:wkt2").as_str() {
w.to_string()
} else if get("proj:projjson").is_object() {
get("proj:projjson").to_string()
} else {
continue;
};
if crs_filter.as_ref().is_some_and(|c| *c != sanitize_crs(&crs)) {
continue;
}
let nums = |k: &str| -> Option<Vec<f64>> { get(k).as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()) };
let bbox = nums("proj:bbox").filter(|b| b.len() == 4);
let shape = nums("proj:shape").filter(|s| s.len() == 2);
let transform = nums("proj:transform").filter(|t| t.len() == 6 || t.len() == 9);
if let Some(t) = &transform
&& (t[0] <= 0.0
|| t[1] != 0.0
|| t[3] != 0.0
|| t[4] >= 0.0
|| (t.len() == 9 && (t[6] != 0.0 || t[7] != 0.0 || t[8] != 1.0)))
{
continue;
}
let (width, height, bounds) = match (&bbox, &shape, &transform) {
(Some(b), Some(s), _) => (s[1] as i64, s[0] as i64, [b[0], b[1], b[2], b[3]]),
(Some(b), None, Some(t)) => {
if b[0] != t[2] || b[3] != t[5] {
continue;
}
(((b[2] - b[0]) / t[0]) as i64, ((b[3] - b[1]) / -t[4]) as i64, [b[0], b[1], b[2], b[3]])
}
(None, Some(s), Some(t)) => {
let (w, h) = (s[1] as i64, s[0] as i64);
(w, h, [t[2], t[5] + h as f64 * t[4], t[2] + w as f64 * t[0], t[5]])
}
_ => continue,
};
if width <= 0 || height <= 0 {
continue;
}
let entry = groups.entry(collection.clone()).or_default().entry(name.clone()).or_insert_with(|| AssetGroup {
bands: if asset["bands"].is_array() { asset["bands"].clone() } else { asset["eo:bands"].clone() },
by_crs: BTreeMap::new(),
});
let href = resolve_href(base, href);
entry.by_crs.entry(crs).or_default().push(AssetItem {
sign: needs_signing(&href, item, base, sign),
href,
datetime: props["datetime"].as_str().unwrap_or_default().to_string(),
width: width as usize,
height: height as usize,
bounds,
});
}
}
groups
}
fn stacit_subdatasets(source: &str, groups: &Groups, kept: &str) -> Vec<Subdataset> {
let mut out = Vec::new();
for (collection, assets) in groups {
for (asset, group) in assets {
let (arg, text) = if groups.len() > 1 {
(format!("collection={collection},asset={asset}"), format!("Collection {collection}, Asset {asset}"))
} else {
(format!("asset={asset}"), format!("Asset {asset}"))
};
if group.by_crs.len() == 1 {
out.push(Subdataset { name: format!("STACIT:\"{source}\":{arg}{kept}"), description: format!("{text} of {source}") });
} else {
for crs in group.by_crs.keys() {
out.push(Subdataset {
name: format!("STACIT:\"{source}\":{arg},crs={}{kept}", sanitize_crs(crs)),
description: format!("{text} of {source} in CRS {crs}"),
});
}
}
}
}
out
}
fn crs_of(s: &str) -> SpatialRef {
if s.trim_start().starts_with('{')
&& let Ok(v) = serde_json::from_str::<Value>(s)
{
if let Some(code) = crate::drivers::vector::projjson::epsg(&v)
&& let Ok(r) = SpatialRef::from_epsg(code)
{
return r;
}
if let Some(w) = crate::drivers::vector::projjson::to_wkt1(&v) {
return SpatialRef::from_wkt(&w);
}
}
SpatialRef::parse(s).unwrap_or_default()
}
struct Mosaic {
items: Vec<(AssetItem, Option<LazyRaster>)>,
geo_transform: [f64; 6],
data_type: DataType,
nodata: Option<f64>,
}
impl WindowSource for Mosaic {
fn overviews(&self) -> Vec<(usize, usize)> {
Vec::new()
}
fn read(&mut self, _level: usize, w: Window, bands: &[usize]) -> Result<RasterData> {
let (dw, dh) = (w.width, w.height);
let gt = self.geo_transform;
let (rx, ry) = (gt[1], -gt[5]);
let mut out: Vec<Vec<f64>> = vec![vec![self.nodata.unwrap_or(0.0); dw * dh]; bands.len()];
for (item, raster) in &mut self.items {
let [xmin, ymin, xmax, ymax] = item.bounds;
let (dxo, dxs) = ((xmin - gt[0]) / rx, (xmax - xmin) / rx);
let (dyo, dys) = ((gt[3] - ymax) / ry, (ymax - ymin) / ry);
let (x0, x1) = (dxo.max(w.x as f64), (dxo + dxs).min((w.x + dw) as f64));
let (y0, y1) = (dyo.max(w.y as f64), (dyo + dys).min((w.y + dh) as f64));
if x1 <= x0 || y1 <= y0 {
continue;
}
let (scx, scy) = (item.width as f64 / dxs, item.height as f64 / dys);
let clamp = |v: f64, max: usize| (v.max(0.0) as usize).min(max);
let sx0 = clamp(((x0 - dxo) * scx).floor() - 1.0, item.width);
let sx1 = clamp(((x1 - dxo) * scx).ceil() + 1.0, item.width);
let sy0 = clamp(((y0 - dyo) * scy).floor() - 1.0, item.height);
let sy1 = clamp(((y1 - dyo) * scy).ceil() + 1.0, item.height);
if sx1 <= sx0 || sy1 <= sy0 {
continue;
}
if raster.is_none() {
*raster = Some(item.open()?);
}
let r = raster.as_mut().ok_or_else(|| bad("unreachable"))?;
let (sw, sh) = (sx1 - sx0, sy1 - sy0);
if sx1 > r.width() || sy1 > r.height() {
return Err(bad(format!("{} is {}x{}, smaller than its STAC metadata says", item.href, r.width(), r.height())));
}
let (window, options) = (Window::new(sx0, sy0, sw, sh), WindowOptions { bands: bands.to_vec(), overview: None });
let sub = match r.read_window(window, &options) {
Err(Error::Http(_)) if item.sign => {
*r = item.open()?;
r.read_window(window, &options)?
}
result => result?,
};
for (bi, buf) in out.iter_mut().enumerate() {
let values = crate::ops::BandView::of(&sub.raster, bi);
let valid: Option<Vec<bool>> = self.nodata.map(|nd| values.valid(nd));
let req = SourceRead {
values,
valid: valid.as_deref(),
width: sw,
height: sh,
src_type: sub.raster.data_type,
src_rect: [-(sx0 as f64), -(sy0 as f64), item.width as f64, item.height as f64],
dst_rect: [dxo - w.x as f64, dyo - w.y as f64, dxs, dys],
resampling: crate::ops::Resampling::Nearest,
working_float: self.nodata.is_some(),
invalid_value: None,
};
req.read(dw, dh, buf, |v| v);
}
}
Ok(RasterData::from_f64_vec(self.data_type, out.concat()))
}
}
fn open_mosaic(source: &str, groups: Groups, resolution: Option<&str>, kept: &str) -> Result<LazyRaster> {
let group_count: usize = groups.values().flat_map(|a| a.values()).map(|g| g.by_crs.len()).sum();
if group_count == 0 {
return Err(bad(format!("no compatible asset found in '{source}'")));
}
if group_count > 1 {
let subs = stacit_subdatasets(source, &groups, kept);
let names: Vec<String> = subs.iter().take(8).map(|s| s.name.clone()).collect();
return Err(Error::InvalidArgument(format!(
"'{source}' holds {group_count} mosaics (assets or CRSs): choose one, such as {}{}",
names.join(", "),
if subs.len() > 8 { ", ..." } else { "" }
)));
}
let (_, assets) = groups.into_iter().next().ok_or_else(|| bad("no collection"))?;
let (_, group) = assets.into_iter().next().ok_or_else(|| bad("no asset"))?;
let (crs, mut items) = group.by_crs.into_iter().next().ok_or_else(|| bad("no CRS"))?;
let (mut xmin, mut ymin, mut xmax, mut ymax) = (f64::MAX, f64::MAX, -f64::MAX, -f64::MAX);
let (mut rx, mut ry) = (0.0, 0.0);
let strategy = resolution.unwrap_or("AVERAGE");
for it in &items {
xmin = xmin.min(it.bounds[0]);
ymin = ymin.min(it.bounds[1]);
xmax = xmax.max(it.bounds[2]);
ymax = ymax.max(it.bounds[3]);
let (tx, ty) = ((it.bounds[2] - it.bounds[0]) / it.width as f64, (it.bounds[3] - it.bounds[1]) / it.height as f64);
if rx != 0.0 && strategy.eq_ignore_ascii_case("HIGHEST") {
rx = f64::min(rx, tx);
ry = f64::min(ry, ty);
} else if rx != 0.0 && strategy.eq_ignore_ascii_case("LOWEST") {
rx = f64::max(rx, tx);
ry = f64::max(ry, ty);
} else {
rx += tx;
ry += ty;
}
}
if strategy.eq_ignore_ascii_case("AVERAGE") {
rx /= items.len() as f64;
ry /= items.len() as f64;
}
let (w, h) = (((xmax - xmin) / rx).round(), ((ymax - ymin) / ry).round());
if !(rx > 0.0 && ry > 0.0 && w >= 1.0 && h >= 1.0 && w < i32::MAX as f64 && h < i32::MAX as f64) {
return Err(bad("invalid computed dataset dimensions"));
}
let (w, h) = (w as usize, h as usize);
let first_href = items[0].href.clone();
let first = items[0].open()?;
let (bands, data_type, nodata) = (first.band_count(), first.data_type(), first.nodata());
items.sort_by(|a, b| a.datetime.cmp(&b.datetime));
let srs = crs_of(&crs);
let mut metadata = Vec::new();
for (i, b) in group.bands.as_array().into_iter().flatten().enumerate().take(bands) {
if let Some(name) = b["name"].as_str() {
metadata.push((format!("BAND_{}_NAME", i + 1), name.to_string()));
}
}
let raster = wbraster::Raster {
cols: w,
rows: h,
bands,
x_min: xmin,
y_min: ymax - h as f64 * ry,
cell_size_x: rx,
cell_size_y: ry,
nodata: nodata.unwrap_or(-9999.0),
data_type,
crs: srs.to_crs_info(),
metadata,
data: RasterData::new_filled(data_type, 0, 0.0),
};
let header = RasterDataset::with_driver(raster, RasterDriver::STACIT, nodata.is_some());
let mut items: Vec<(AssetItem, Option<LazyRaster>)> = items.into_iter().map(|i| (i, None)).collect();
if let Some(slot) = items.iter_mut().find(|(i, _)| i.href == first_href) {
slot.1 = Some(first);
}
let mosaic = Mosaic { items, geo_transform: [xmin, rx, 0.0, ymax, 0.0, -ry], data_type, nodata };
Ok(LazyRaster::from_source(header, Box::new(mosaic)))
}
fn may_be_stac(path: &str) -> bool {
let base = path.split(['?', '#']).next().unwrap_or(path).to_ascii_lowercase();
if base.ends_with(".json") || base.ends_with(".geojson") {
return true;
}
if base.split('/').any(|s| s.ends_with(".zarr")) {
return false;
}
is_url(path) && (base.contains("/search") || base.ends_with("/items") || base.contains("/collections/"))
}
enum Probe {
Stac(Value),
Stacta(Box<crate::stacta::Item>),
Other(Box<Dataset>),
}
fn probe(path: &str) -> Option<Result<Probe>> {
if !may_be_stac(path) {
return None;
}
let loc = crate::vfs::Location::parse(path);
if let crate::vfs::Location::Local(p) = &loc {
let mut head = vec![0u8; 65536];
let n = std::fs::File::open(p).and_then(|mut f| std::io::Read::read(&mut f, &mut head)).ok()?;
if !String::from_utf8_lossy(&head[..n]).contains("\"stac_version\"") {
return None;
}
} else if !matches!(loc, crate::vfs::Location::Url(_) | crate::vfs::Location::S3 { .. }) {
return None;
}
let bytes = match fetch(path) {
Ok(b) => b,
Err(e) => return Some(Err(e)),
};
match serde_json::from_slice::<Value>(&bytes) {
Ok(v) if crate::stacta::is_stacta(&v) && kind(&v) == Some(Kind::Item) => {
crate::stacta::Item::parse(&bytes, path).map(|item| Ok(Probe::Stacta(Box::new(item))))
}
Ok(v) if kind(&v).is_some() => Some(Ok(Probe::Stac(v))),
_ if loc.is_local() => None,
_ => Some(crate::memory::open_bytes(bytes, &loc.file_name()).map(|ds| Probe::Other(Box::new(ds)))),
}
}
fn footprint_dataset(doc: Value, path: &str, max_items: usize) -> Result<(VectorDataset, Groups)> {
let name = layer_name(&doc, path);
let items = load_items(doc, path, max_items)?;
let groups = group_assets(&items.items, &Filters { sign: Some(SignMode::None), ..Filters::default() });
let layer = footprints(&items.items, &name)?;
Ok((crate::dataset::finish_vector(vec![layer], path, VectorDriver::GeoJSON), groups))
}
pub(crate) fn open(path: &str) -> Option<Result<Dataset>> {
if let Some(r) = crate::stacta::open_named_or_local(path) {
return Some(r.and_then(|mut r| r.read()).map(Dataset::Raster));
}
match parse_name(path) {
Some(Name::Asset { item, asset }) => return Some(asset_href(&item, &asset).and_then(|href| crate::open(&href))),
Some(Name::Mosaic { .. }) => return Some(open_lazy(path)?.and_then(|mut r| r.read()).map(Dataset::Raster)),
None => {}
}
Some(match probe(path)? {
Ok(Probe::Stac(doc)) => footprint_dataset(doc, path, DEFAULT_MAX_ITEMS).map(|(v, _)| Dataset::Vector(v)),
Ok(Probe::Stacta(item)) => crate::stacta::open_first(*item, None, None).and_then(|mut r| r.read()).map(Dataset::Raster),
Ok(Probe::Other(ds)) => Ok(*ds),
Err(e) => Err(e),
})
}
fn search_request(source: &str, filters: &Filters) -> Request {
if filters.post && is_url(source) { post_request(source) } else { Request::get(source) }
}
pub(crate) fn open_lazy(path: &str) -> Option<Result<LazyRaster>> {
if let Some(r) = crate::stacta::open_lazy(path) {
return Some(r);
}
Some(match parse_name(path)? {
Name::Asset { item, asset } => asset_href(&item, &asset).and_then(|href| LazyRaster::open(&href)),
Name::Mosaic { source, filters } => (|| {
filters.check()?;
let request = search_request(&source, &filters);
let doc = request.fetch()?;
let mut max = filters.max_items.unwrap_or(DEFAULT_MAX_ITEMS);
if filters.max_items.is_none()
&& let Some(i) = source.to_ascii_lowercase().find("&limit=")
{
let digits: String = source[i + 7..].chars().take_while(char::is_ascii_digit).collect();
max = max.max(digits.parse().unwrap_or(0));
}
let items = load_items_from(doc, &request, max)?;
let groups = group_assets(&items.items, &filters);
open_mosaic(&source, groups, filters.resolution.as_deref(), &filters.kept_options())
})(),
})
}
pub(crate) fn info(path: &str, options: &InfoOptions) -> Option<Result<Info>> {
if let Some(r) = crate::stacta::info(path, options) {
return Some(r);
}
match parse_name(path) {
Some(Name::Asset { item, asset }) => {
return Some(asset_href(&item, &asset).and_then(|href| crate::info::info_path(&href, options)));
}
Some(Name::Mosaic { source, filters }) => {
let several = (|| -> Result<Option<Info>> {
filters.check()?;
let request = search_request(&source, &filters);
let doc = request.fetch()?;
let max = filters.max_items.unwrap_or(DEFAULT_MAX_ITEMS);
let name = layer_name(&doc, &source);
let items = load_items_from(doc, &request, max)?;
let groups = group_assets(&items.items, &Filters { sign: Some(SignMode::None), ..filters.clone() });
let count: usize = groups.values().flat_map(|a| a.values()).map(|g| g.by_crs.len()).sum();
if count <= 1 {
return Ok(None);
}
let layer = footprints(&items.items, &name)?;
let ds = crate::dataset::finish_vector(vec![layer], &source, VectorDriver::GeoJSON);
let mut v = crate::info::vector_info(&ds);
v.subdatasets = stacit_subdatasets(&source, &groups, &filters.kept_options());
Ok(Some(Info::Vector(v)))
})();
return Some(match several {
Ok(Some(i)) => Ok(i),
Ok(None) => {
open_lazy(path)?.and_then(|mut r| crate::info::lazy_raster_info(&mut r, options)).map(|r| Info::Raster(Box::new(r)))
}
Err(e) => Err(e),
});
}
None => {}
}
let doc = match probe(path)? {
Ok(Probe::Stac(doc)) => doc,
Ok(Probe::Stacta(item)) => return Some(crate::stacta::info_item(*item, None, None, options)),
Ok(Probe::Other(ds)) => return Some(Ok(crate::info::info(&ds, options))),
Err(e) => return Some(Err(e)),
};
let is_item = kind(&doc) == Some(Kind::Item);
let assets: Vec<(String, Value)> =
if is_item { doc["assets"].as_object().into_iter().flatten().map(|(k, v)| (k.clone(), v.clone())).collect() } else { Vec::new() };
Some(footprint_dataset(doc, path, DEFAULT_MAX_ITEMS).map(|(ds, groups)| {
let mut v = crate::info::vector_info(&ds);
v.subdatasets = if is_item {
assets
.iter()
.filter(|(_, a)| is_data_asset(a))
.map(|(k, a)| Subdataset {
name: format!("STAC:\"{path}\":{k}"),
description: match a["title"].as_str() {
Some(t) => format!("Asset {k}: {t}"),
None => format!("Asset {k}"),
},
})
.collect()
} else {
stacit_subdatasets(path, &groups, "")
};
Info::Vector(v)
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_names() {
assert_eq!(
parse_name(r#"STACIT:"https://x.org/search?a=1&b=2":asset=red,crs=EPSG_32613"#),
Some(Name::Mosaic {
source: "https://x.org/search?a=1&b=2".into(),
filters: Filters { asset: Some("red".into()), crs: Some("EPSG_32613".into()), ..Default::default() }
})
);
assert_eq!(
parse_name("STACIT:items.json:asset=red"),
Some(Name::Mosaic { source: "items.json".into(), filters: Filters { asset: Some("red".into()), ..Default::default() } })
);
assert_eq!(
parse_name("STACIT:https://x.org/items.json"),
Some(Name::Mosaic { source: "https://x.org/items.json".into(), filters: Filters::default() })
);
assert_eq!(parse_name(r#"STAC:"C:\d\item.json":B04"#), Some(Name::Asset { item: r"C:\d\item.json".into(), asset: "B04".into() }));
assert_eq!(
parse_name("STAC:https://x.org/item.json:B04"),
Some(Name::Asset { item: "https://x.org/item.json".into(), asset: "B04".into() })
);
assert_eq!(parse_name("dir/item.json!visual"), Some(Name::Asset { item: "dir/item.json".into(), asset: "visual".into() }));
assert_eq!(parse_name("dir/item.json"), None);
assert_eq!(parse_name("a.zip!b.json"), None);
}
#[test]
fn resolves_hrefs() {
assert_eq!(resolve_href("https://x.org/a/b/item.json?q=1", "./B04.tif"), "https://x.org/a/b/B04.tif");
assert_eq!(resolve_href("https://x.org/a/b/item.json", "../c/B04.tif"), "https://x.org/a/c/B04.tif");
assert_eq!(resolve_href("https://x.org/a/item.json", "/c/B04.tif"), "https://x.org/c/B04.tif");
assert_eq!(resolve_href("dir/item.json", "B04.tif"), "dir/B04.tif");
assert_eq!(resolve_href("item.json", "B04.tif"), "B04.tif");
assert_eq!(resolve_href("/data/item.json", "s3://b/k.tif"), "s3://b/k.tif");
}
#[test]
fn builds_post_searches_as_pystac_client() {
let r = post_request(
"https://x.org/v1/search?collections=a,b&bbox=-105.3,39.9,-105.1,40.1&limit=2&datetime=2023-07&sortby=-datetime&query=%7B%22eo%3Acloud_cover%22%3A%7B%22lt%22%3A10%7D%7D&filter=cloud%20%3C%2010",
);
assert_eq!(r.url, "https://x.org/v1/search");
assert_eq!(
r.body,
Some(json!({
"collections": ["a", "b"],
"bbox": [-105.3, 39.9, -105.1, 40.1],
"limit": 2,
"datetime": "2023-07-01T00:00:00Z/2023-07-31T23:59:59Z",
"sortby": [{"field": "datetime", "direction": "desc"}],
"query": {"eo:cloud_cover": {"lt": 10}},
"filter": "cloud < 10",
"filter-lang": "cql2-text",
}))
);
assert_eq!(rfc3339_interval("2024-02"), "2024-02-01T00:00:00Z/2024-02-29T23:59:59Z");
assert_eq!(rfc3339_interval("2023-07-05"), "2023-07-05T00:00:00Z/2023-07-05T23:59:59Z");
assert_eq!(rfc3339_interval("2023/.."), "2023-01-01T00:00:00Z/..");
assert_eq!(rfc3339_interval("2023-07-05T10:00:00Z"), "2023-07-05T10:00:00Z");
}
#[test]
fn follows_post_next_links() {
let cur =
Request { url: "https://x.org/search".into(), body: Some(json!({"limit": 2, "bbox": [0, 0, 1, 1]})), headers: Vec::new() };
let page = json!({"links": [{"rel": "next", "method": "POST", "href": "https://x.org/search", "merge": false, "body": {"limit": 2, "next": "t"}}]});
assert_eq!(next_request(&page, &cur).and_then(|r| r.body), Some(json!({"limit": 2, "next": "t"})));
let page = json!({"links": [{"rel": "next", "method": "POST", "href": "/search", "merge": true, "body": {"token": "t"}, "headers": {"X-A": "1"}}]});
let next = next_request(&page, &cur).expect("a next page");
assert_eq!(next.body, Some(json!({"limit": 2, "bbox": [0, 0, 1, 1], "token": "t"})));
assert_eq!(next.headers, vec![("X-A".to_string(), "1".to_string())]);
let page = json!({"links": [{"rel": "next", "method": "POST", "href": "/a"}, {"rel": "next", "href": "/b?page=2"}]});
assert_eq!(next_request(&page, &cur).map(|r| (r.url, r.body)), Some(("https://x.org/b?page=2".into(), None)));
assert_eq!(next_request(&json!({"links": []}), &cur), None);
let keyed = Request { headers: vec![("X-Key".into(), "k".into())], ..cur.clone() };
let page = json!({"links": [{"rel": "next", "href": "https://x.org/search?page=2"}]});
assert_eq!(next_request(&page, &keyed).map(|r| r.headers), Some(keyed.headers.clone()));
let page = json!({"links": [{"rel": "next", "href": "https://elsewhere.org/search?page=2"}]});
assert_eq!(next_request(&page, &keyed).map(|r| r.headers), Some(Vec::new()));
let page = json!({"links": [{"rel": "next", "href": "/search?page=2", "headers": {"X-Cursor": ["a", "b"]}}]});
assert_eq!(
next_request(&page, &cur).map(|r| r.headers),
Some(vec![("X-Cursor".to_string(), "a".to_string()), ("X-Cursor".to_string(), "b".to_string())])
);
let page =
json!({"links": [{"rel": "next", "method": "POST", "href": "/search", "merge": true, "headers": {"Search-After": "t"}}]});
assert_eq!(
next_request(&page, &keyed).map(|r| r.headers),
Some(vec![("X-Key".to_string(), "k".to_string()), ("Search-After".to_string(), "t".to_string())])
);
}
#[test]
fn signs_planetary_computer_assets_only() {
let href = "https://sentinel2l2a01.blob.core.windows.net/sentinel2-l2/13/T/B04.tif";
assert_eq!(blob_container(href), Some(("sentinel2l2a01", "sentinel2-l2")));
assert_eq!(blob_container("https://example.com/a/b.tif"), None);
assert_eq!(blob_container("https://evil.example#@a.blob.core.windows.net/c/x.tif"), None);
assert_eq!(blob_container("https://u:p@a.blob.core.windows.net/c/x.tif"), None);
let pc = "https://planetarycomputer.microsoft.com/api/stac/v1/search?collections=x";
assert!(from_planetary_computer(&json!({}), pc));
assert!(!from_planetary_computer(&json!({}), "https://earth-search.aws.element84.com/v1/search"));
let linked =
json!({"links": [{"rel": "self", "href": "https://planetarycomputer.microsoft.com/api/stac/v1/collections/x/items/y"}]});
assert!(from_planetary_computer(&linked, "items.json"));
let other = "https://earth-search.aws.element84.com/v1/search";
assert_eq!(sign_href(href.into(), &json!({}), other, SignMode::Auto).ok().as_deref(), Some(href));
assert_eq!(
sign_href("https://e.com/a.tif".into(), &json!({}), pc, SignMode::PlanetaryComputer).ok().as_deref(),
Some("https://e.com/a.tif")
);
let signed = format!("{href}?st=1&sig=abc");
assert_eq!(sign_href(signed.clone(), &json!({}), pc, SignMode::Auto).ok(), Some(signed));
assert_eq!(sign_href(href.into(), &json!({}), pc, SignMode::None).ok().as_deref(), Some(href));
assert_eq!(SignMode::parse("planetary-computer"), Some(SignMode::PlanetaryComputer));
assert_eq!(SignMode::parse("planetarycomputer"), Some(SignMode::PlanetaryComputer));
let Some(Name::Mosaic { filters, .. }) = parse_name(r#"STACIT:"https://x.org/search":asset=a,sign=nope"#) else { panic!() };
assert!(filters.check().is_err());
let Some(Name::Mosaic { filters, .. }) = parse_name(r#"STACIT:"https://x.org/search":asset=a,method=POST,sign=none"#) else {
panic!()
};
assert!(filters.check().is_ok());
assert_eq!(filters.kept_options(), ",method=POST,sign=none");
}
#[cfg(remote)]
#[test]
fn counts_seconds_to_expiry() {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
assert_eq!(seconds_until("2000-01-01T00:00:00Z"), None);
let s = seconds_until("2100-03-01T12:30:15Z").expect("a future time");
assert!((4_107_587_415 - now).abs_diff(s) <= 1, "{s}");
assert_eq!(seconds_until("not a time"), None);
}
#[test]
fn sanitizes_crs_like_gdal() {
assert_eq!(sanitize_crs("EPSG:32613"), "EPSG_32613");
assert_eq!(sanitize_crs("{\"a\": 1}"), "_a_1");
}
}