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(store_dir: &Path, instance: &str) -> PathBuf {
store_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 = crate::contracts::read_optional_contract::<MapPublishManifestContract>(path)?
.map(|value| value.spec)
.unwrap_or_default();
for item in &mut items {
item.path = normalize_layer_path(&item.path).to_string();
}
Ok(items)
}
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", "store": "local",
"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}");
}
#[test]
fn a_layer_cannot_name_a_folder_outside_its_own() {
let dir = tempfile::tempdir().unwrap();
let path = layers_manifest_path(dir.path(), DEFAULT_INSTANCE);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
for (layer_id, artifact) in [
("roads", Some("../../../../home")),
("roads", Some("mapserver-artifacts/../../x/manifest.json")),
("roads", Some("mapserver/.artifacts/roads/manifest.json")),
("roads", Some("mapserver-artifacts/default-mapserver//manifest.json")),
("roads", Some("mapserver-artifacts/default-mapserver/roads/other.json")),
("../roads", None),
("ro ads", None),
] {
let mut item = record("roads");
item.layer_id = layer_id.to_string();
item.artifact_manifest_path = artifact.map(str::to_string);
assert!(write_layers(&path, DEFAULT_INSTANCE, &[item]).is_err(), "{layer_id} {artifact:?}");
}
let mut ok = record("roads");
ok.artifact_manifest_path = Some("mapserver-artifacts/default-mapserver/roads/manifest.json".into());
write_layers(&path, DEFAULT_INSTANCE, &[ok]).unwrap();
}
fn record(path: &str) -> MapserverLayerRecord {
MapserverLayerRecord {
layer_id: "roads".to_string(),
path: path.to_string(),
store: "local".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_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");
}
}