const MAX_DATASET_BYTES: u64 = 512 * 1024 * 1024;
const MAX_LISTED_NOTICES: usize = 3;
fn read_dataset(path: &std::path::Path, name: &str) -> Result<Vec<u8>, String> {
read_capped(path, name, MAX_DATASET_BYTES)
}
pub(crate) fn read_capped(path: &std::path::Path, name: &str, cap: u64) -> Result<Vec<u8>, String> {
use std::io::Read as _;
let file =
std::fs::File::open(path).map_err(|error| format!("Could not read {name}: {error}"))?;
let length = file.metadata().map_or(0, |metadata| metadata.len());
if length > cap {
return Err(too_large(name, length, cap));
}
let mut bytes = Vec::new();
file.take(cap.saturating_add(1))
.read_to_end(&mut bytes)
.map_err(|error| format!("Could not read {name}: {error}"))?;
let read = bytes.len() as u64;
if read > cap {
return Err(too_large(name, read, cap));
}
Ok(bytes)
}
fn too_large(name: &str, length: u64, cap: u64) -> String {
format!(
"{name} is {} MB; OxiGIS reads at most {} MB into memory. \
Convert it to a .pmtiles/.mbtiles archive, which streams.",
length / (1024 * 1024),
cap / (1024 * 1024),
)
}
fn drop_name(path: &std::path::Path) -> String {
path.file_name().map_or_else(
|| path.display().to_string(),
|name| name.to_string_lossy().into_owned(),
)
}
fn join_notices(notices: &[String]) -> Option<String> {
let listed = notices.len().min(MAX_LISTED_NOTICES);
let (head, rest) = notices.split_at(listed);
if head.is_empty() {
return None;
}
let mut message = head.join(" ");
if !rest.is_empty() {
message.push_str(&format!(" (and {} more.)", rest.len()));
}
Some(message)
}
pub(crate) fn drain_dropped_paths(app: &mut oxigis_ui::OxigisApp) {
let pending = app.take_pending_dropped_paths();
let notices = read_pending_paths(app, pending);
if let Some(message) = join_notices(¬ices) {
app.set_status(message);
}
}
fn read_pending_paths(
app: &mut oxigis_ui::OxigisApp,
pending: Vec<oxigis_ui::PendingPath>,
) -> Vec<String> {
let mut notices = Vec::new();
for pending in pending {
let path_text = pending.path.display().to_string();
let name = drop_name(&pending.path);
let notice = match oxigis_ui::classify_drop(&name) {
oxigis_ui::DropKind::Shapefile(_) => {
read_shapefile_set(app, &pending, &path_text, &name)
}
oxigis_ui::DropKind::GeoPackage => read_geopackage(app, &pending, &path_text, &name),
oxigis_ui::DropKind::GeoParquet => read_geoparquet(app, &pending, &path_text, &name),
oxigis_ui::DropKind::GeoLibreProject if pending.layer.is_none() => {
read_geolibre_project(app, &pending, &path_text, &name)
}
oxigis_ui::DropKind::TileArchive(_) => continue,
oxigis_ui::DropKind::GeoJson
| oxigis_ui::DropKind::GeoLibreProject
| oxigis_ui::DropKind::Unsupported => read_geojson(app, &pending, &path_text, &name),
};
notices.extend(notice);
}
notices
}
fn read_geojson(
app: &mut oxigis_ui::OxigisApp,
pending: &oxigis_ui::PendingPath,
path_text: &str,
name: &str,
) -> Option<String> {
match read_dataset(&pending.path, name) {
Ok(bytes) => {
tracing::info!(
file = path_text,
bytes = bytes.len(),
rebuild = pending.layer.is_some(),
"OxiGIS desktop: read GeoJSON",
);
match pending.layer {
Some(id) => {
app.hydrate_geojson_layer_from_bytes(id, name, &bytes);
}
None => {
app.add_geojson_layer_from_bytes(name, &bytes, Some(path_text));
}
}
None
}
Err(notice) => {
tracing::error!(file = path_text, %notice, "OxiGIS desktop: could not read the file");
Some(notice)
}
}
}
fn read_shapefile_set(
app: &mut oxigis_ui::OxigisApp,
pending: &oxigis_ui::PendingPath,
path_text: &str,
name: &str,
) -> Option<String> {
let shp = match read_dataset(&pending.path, name) {
Ok(bytes) => bytes,
Err(notice) => {
tracing::error!(file = path_text, %notice, "OxiGIS desktop: could not read the .shp");
return Some(notice);
}
};
let dbf = read_sibling(&pending.path, "dbf");
let prj = read_sibling(&pending.path, "prj").and_then(|bytes| String::from_utf8(bytes).ok());
let cpg = read_sibling(&pending.path, "cpg").and_then(|bytes| String::from_utf8(bytes).ok());
tracing::info!(
file = path_text,
bytes = shp.len(),
dbf = dbf.is_some(),
prj = prj.is_some(),
rebuild = pending.layer.is_some(),
"OxiGIS desktop: read Shapefile",
);
let bytes = oxigis_ui::ShapefileBytes::new(&shp)
.with_dbf(dbf.as_deref())
.with_sidecars(prj.as_deref(), cpg.as_deref());
match pending.layer {
Some(id) => {
app.hydrate_shapefile_layer_from_bytes(id, name, bytes);
}
None => {
app.add_shapefile_layer_from_bytes(name, bytes, Some(path_text));
}
}
None
}
fn read_geopackage(
app: &mut oxigis_ui::OxigisApp,
pending: &oxigis_ui::PendingPath,
path_text: &str,
name: &str,
) -> Option<String> {
let bytes = match read_dataset(&pending.path, name) {
Ok(bytes) => bytes,
Err(notice) => {
tracing::error!(file = path_text, %notice, "OxiGIS desktop: could not read the .gpkg");
return Some(notice);
}
};
tracing::info!(
file = path_text,
bytes = bytes.len(),
table = pending.table.as_deref().unwrap_or("*"),
rebuild = pending.layer.is_some(),
"OxiGIS desktop: read GeoPackage",
);
match (pending.layer, pending.table.as_deref()) {
(Some(id), Some(table)) => {
app.hydrate_gpkg_layer_from_bytes(id, name, &bytes, table);
None
}
(Some(_), None) => Some(format!(
"{name} does not say which of its tables this layer came from; re-drop the file.",
)),
(None, _) => {
app.add_gpkg_layer_from_bytes(name, &bytes, Some(path_text));
None
}
}
}
fn read_geoparquet(
app: &mut oxigis_ui::OxigisApp,
pending: &oxigis_ui::PendingPath,
path_text: &str,
name: &str,
) -> Option<String> {
let bytes = match read_dataset(&pending.path, name) {
Ok(bytes) => bytes,
Err(notice) => {
tracing::error!(file = path_text, %notice, "OxiGIS desktop: could not read the .parquet");
return Some(notice);
}
};
tracing::info!(
file = path_text,
bytes = bytes.len(),
rebuild = pending.layer.is_some(),
"OxiGIS desktop: read GeoParquet",
);
match pending.layer {
Some(id) => {
app.hydrate_geoparquet_layer_from_bytes(id, name, &bytes);
}
None => {
app.add_geoparquet_layer_from_bytes(name, &bytes, Some(path_text));
}
}
None
}
fn read_geolibre_project(
app: &mut oxigis_ui::OxigisApp,
pending: &oxigis_ui::PendingPath,
path_text: &str,
name: &str,
) -> Option<String> {
match read_dataset(&pending.path, name) {
Ok(bytes) => {
tracing::info!(
file = path_text,
bytes = bytes.len(),
"OxiGIS desktop: read GeoLibre project",
);
app.load_geolibre_project_from_bytes(name, &bytes);
None
}
Err(notice) => {
tracing::error!(file = path_text, %notice, "OxiGIS desktop: could not read the file");
Some(notice)
}
}
}
fn read_sibling(shp_path: &std::path::Path, extension: &str) -> Option<Vec<u8>> {
for candidate in [
shp_path.with_extension(extension),
shp_path.with_extension(extension.to_ascii_uppercase()),
] {
if !candidate.is_file() {
continue;
}
match read_dataset(&candidate, &drop_name(&candidate)) {
Ok(bytes) => return Some(bytes),
Err(notice) => {
tracing::warn!(%notice, "OxiGIS desktop: a shapefile sibling was skipped")
}
}
}
None
}
pub(crate) fn open_startup_paths(app: &mut oxigis_ui::OxigisApp, paths: Vec<std::path::PathBuf>) {
let mut pending = Vec::new();
let mut notices = Vec::new();
let mut archive = None;
for path in paths {
let name = drop_name(&path);
match std::fs::metadata(&path) {
Ok(metadata) if metadata.is_file() => {}
Ok(_) => {
notices.push(format!("{name} is not a file."));
continue;
}
Err(error) => {
notices.push(format!("Could not open {name}: {error}"));
continue;
}
}
match oxigis_ui::classify_drop(&name) {
oxigis_ui::DropKind::TileArchive(format) if archive.is_none() => {
archive = Some((path.display().to_string(), format));
}
oxigis_ui::DropKind::TileArchive(_) => notices.push(format!(
"Only one tile archive can be opened at a time; {name} was not opened.",
)),
oxigis_ui::DropKind::Unsupported => {
notices.push(format!("{name} is not a file type OxiGIS can open."));
}
oxigis_ui::DropKind::GeoJson
| oxigis_ui::DropKind::GeoLibreProject
| oxigis_ui::DropKind::GeoPackage
| oxigis_ui::DropKind::GeoParquet
| oxigis_ui::DropKind::Shapefile(_) => pending.push(oxigis_ui::PendingPath {
layer: None,
path,
table: None,
}),
}
}
notices.extend(read_pending_paths(app, pending));
if let Some((path, format)) = archive {
if !app.request_archive_probe(oxigis_core::ArchiveRef::Path { path }, format) {
notices.push(
app.status()
.map_or_else(|| "The archive was refused.".to_owned(), str::to_owned),
);
}
}
if let Some(message) = join_notices(¬ices) {
app.set_status(message);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(label: &str) -> std::path::PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_nanos());
std::env::temp_dir().join(format!("oxigis-desktop-{label}-{stamp}"))
}
#[test]
fn a_dataset_past_the_cap_is_refused_and_one_under_it_is_read() {
let path = scratch("cap");
std::fs::write(&path, vec![b'x'; 64]).expect("the fixture is writable");
let read = read_capped(&path, "cities.geojson", 64).expect("64 bytes fit a 64-byte cap");
assert_eq!(read.len(), 64);
let refusal = read_capped(&path, "cities.geojson", 32).expect_err("65 bytes do not fit");
assert!(refusal.contains("cities.geojson"), "{refusal}");
assert!(refusal.contains("at most"), "{refusal}");
let _removed = std::fs::remove_file(&path);
}
#[test]
fn a_missing_dataset_reports_the_name_rather_than_panicking() {
let error = read_dataset(&scratch("absent"), "absent.gpkg").expect_err("no such file");
assert!(error.contains("absent.gpkg"), "{error}");
}
#[test]
fn notices_are_folded_into_one_bounded_status_line() {
assert_eq!(join_notices(&[]), None);
let two = [String::from("A failed."), String::from("B failed.")];
assert_eq!(join_notices(&two).as_deref(), Some("A failed. B failed."));
let many: Vec<String> = (0..7).map(|index| format!("{index} failed.")).collect();
let message = join_notices(&many).expect("seven notices report");
assert!(message.contains("0 failed."), "{message}");
assert!(message.ends_with("(and 4 more.)"), "{message}");
assert!(!message.contains("6 failed."), "{message}");
}
#[test]
fn a_file_is_reported_under_its_last_component() {
assert_eq!(
drop_name(std::path::Path::new("/data/tokyo.gpkg")),
"tokyo.gpkg"
);
assert_eq!(drop_name(std::path::Path::new("tokyo.gpkg")), "tokyo.gpkg");
}
#[test]
fn a_dataset_exactly_the_size_of_the_cap_is_read() {
let path = scratch("exact");
std::fs::write(&path, vec![b'x'; 4096]).expect("the fixture is writable");
let read = read_capped(&path, "cities.geojson", 4096).expect("an exact fit is not over");
assert_eq!(read.len(), 4096);
let _removed = std::fs::remove_file(&path);
}
#[test]
#[cfg(unix)]
fn a_stream_whose_length_the_os_will_not_answer_for_is_still_bounded() {
let zero = std::path::Path::new("/dev/zero");
let Ok(metadata) = std::fs::metadata(zero) else {
return;
};
assert_eq!(
metadata.len(),
0,
"the premise: an endless stream that stats as empty",
);
let refusal = read_capped(zero, "cities.geojson", 4096).expect_err("it never ends");
assert!(refusal.contains("cities.geojson"), "{refusal}");
assert!(refusal.contains("at most"), "{refusal}");
}
}