use std::path::{Path, PathBuf};
use crate::contracts::kinds::{MapPublishManifestContract, MapserverLayerRecord};
use crate::contracts::{ContractError, ContractMetadata};
use crate::mapserver::publish::manifest::{PublishedLayerManifest, SourceKind};
pub const DEFAULT_INSTANCE: &str = "default-mapserver";
pub fn layers_manifest_path(files_dir: &Path, instance: &str) -> PathBuf {
files_dir
.join("mapserver")
.join(format!("{instance}.layers.json"))
}
pub fn normalize_layer_path(path: &str) -> &str {
path.trim().trim_start_matches('/').trim_end_matches('/')
}
pub fn read_layers(path: &Path) -> Result<Vec<MapserverLayerRecord>, ContractError> {
let mut items =
match crate::contracts::read_optional_contract::<MapPublishManifestContract>(path) {
Ok(document) => document.map(|value| value.spec).unwrap_or_default(),
Err(err) => match read_pre_contract_array(path) {
Some(items) => items,
None => return Err(err),
},
};
for item in &mut items {
item.path = normalize_layer_path(&item.path).to_string();
if crate::infra::io::path::rel_path_escapes_root(&item.source_path) {
return Err(ContractError::invalid(format!(
"map layer '{}' source_path '{}' escapes the project",
item.layer_id, item.source_path
)));
}
}
Ok(items)
}
fn read_pre_contract_array(path: &Path) -> Option<Vec<MapserverLayerRecord>> {
let bytes = std::fs::read(path).ok()?;
let mut entries: Vec<serde_json::Value> = serde_json::from_slice(&bytes).ok()?;
for entry in &mut entries {
if let Some(object) = entry.as_object_mut() {
object.remove("column_stats_path");
}
}
serde_json::from_value(serde_json::Value::Array(entries)).ok()
}
pub fn write_layers(
path: &Path,
instance: &str,
items: &[MapserverLayerRecord],
) -> Result<(), ContractError> {
let mut items = items.to_vec();
for item in &mut items {
item.path = normalize_layer_path(&item.path).to_string();
}
crate::contracts::write_contract::<MapPublishManifestContract>(
path,
ContractMetadata::named(instance),
items,
)
}
#[allow(clippy::too_many_arguments)]
pub fn manifest_from_runtime(
layer_id: String,
path: String,
source_kind: SourceKind,
source_ref: String,
mode: String,
min_zoom: Option<u8>,
max_zoom: Option<u8>,
bbox_required: bool,
max_features: usize,
allowed_properties: Vec<String>,
style: Option<serde_json::Value>,
filter: Option<String>,
function_slug: Option<String>,
cache_ttl_secs: Option<u64>,
) -> PublishedLayerManifest {
PublishedLayerManifest {
layer_id,
path,
source_kind,
source_ref,
mode,
min_zoom,
max_zoom,
bbox_required,
max_features,
allowed_properties,
style,
filter,
function_slug,
cache_ttl_secs,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_layer_reaching_outside_the_project_is_refused_on_read() {
let dir = tempfile::tempdir().unwrap();
let enveloped = dir.path().join("enveloped.layers.json");
std::fs::write(
&enveloped,
serde_json::json!({
"apiVersion": "zebflow.com/v1",
"kind": "MapPublishManifest",
"metadata": { "name": "alice/demo" },
"spec": [{
"layer_id": "roads", "path": "roads",
"source_path": "../../../../etc/passwd",
"source_kind": "geojson_file", "mode": "features",
"min_zoom": 0, "max_zoom": 14, "bbox_required": true,
"max_features": 1000, "allowed_properties": []
}]
})
.to_string(),
)
.unwrap();
let err = read_layers(&enveloped).expect_err("an escaping source_path is refused");
assert!(err.to_string().contains("escapes the project"), "{err}");
let legacy = dir.path().join("legacy.layers.json");
std::fs::write(
&legacy,
serde_json::json!([{
"layer_id": "roads", "path": "roads",
"source_path": "../../../../etc/passwd",
"source_kind": "geojson_file", "mode": "features",
"min_zoom": 0, "max_zoom": 14, "bbox_required": true,
"max_features": 1000, "allowed_properties": []
}])
.to_string(),
)
.unwrap();
let err = read_layers(&legacy).expect_err("the legacy array is refused too");
assert!(err.to_string().contains("escapes the project"), "{err}");
}
fn record(path: &str) -> MapserverLayerRecord {
MapserverLayerRecord {
layer_id: "roads".to_string(),
path: path.to_string(),
source_path: "mapserver/roads.geojson".to_string(),
source_kind: "geojson_file".to_string(),
artifact_manifest_path: None,
mode: "features".to_string(),
min_zoom: None,
max_zoom: None,
bbox_required: false,
max_features: 1000,
allowed_properties: vec!["name".to_string()],
feature_count: None,
chunk_count: None,
style: None,
filter: None,
function_slug: None,
cache_ttl_secs: None,
}
}
#[test]
fn the_registry_holds_one_path_shape_and_one_document_format() {
let tmp = tempfile::tempdir().expect("tmp");
let path = layers_manifest_path(tmp.path(), DEFAULT_INSTANCE);
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
write_layers(&path, DEFAULT_INSTANCE, &[record("/roads")]).expect("write");
let raw = std::fs::read_to_string(&path).expect("read raw");
assert!(
!raw.contains("\"/roads\""),
"stored with a leading slash: {raw}"
);
let value: serde_json::Value = serde_json::from_str(&raw).expect("json");
assert_eq!(value["kind"], serde_json::json!("MapPublishManifest"));
assert_eq!(value["spec"][0]["path"], serde_json::json!("roads"));
let items = read_layers(&path).expect("read");
assert_eq!(items.len(), 1);
assert_eq!(items[0].path, "roads");
crate::contracts::write_contract::<MapPublishManifestContract>(
&path,
ContractMetadata::named(DEFAULT_INSTANCE),
vec![record("/roads")],
)
.expect("legacy write");
let items = read_layers(&path).expect("read legacy");
assert_eq!(items[0].path, "roads");
}
#[test]
fn a_registry_written_before_the_publishers_shared_this_module_is_repaired() {
let tmp = tempfile::tempdir().expect("tmp");
let path = layers_manifest_path(tmp.path(), DEFAULT_INSTANCE);
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
let mut legacy = serde_json::to_value(record("roads")).expect("record json");
legacy["column_stats_path"] =
serde_json::json!("mapserver/.optimized/roads.spatial.stats.json");
std::fs::write(
&path,
serde_json::to_string(&serde_json::json!([legacy])).expect("legacy json"),
)
.expect("legacy write");
let items = read_layers(&path).expect("legacy read");
assert_eq!(items.len(), 1);
assert_eq!(items[0].layer_id, "roads");
write_layers(&path, DEFAULT_INSTANCE, &items).expect("rewrite");
let raw = std::fs::read_to_string(&path).expect("read raw");
assert!(raw.contains("MapPublishManifest"));
assert!(!raw.contains("column_stats_path"));
std::fs::write(&path, b"{\"apiVersion\":\"zebflow.com/v1\"}").expect("bad write");
assert!(read_layers(&path).is_err());
}
#[test]
fn a_request_path_and_a_stored_path_meet_in_one_shape() {
assert_eq!(normalize_layer_path("/roads/"), "roads");
assert_eq!(normalize_layer_path("roads"), "roads");
assert_eq!(normalize_layer_path(" /layers/roads "), "layers/roads");
}
}