diff --git a/tp-webapp/src/edit.rs b/tp-webapp/src/edit.rs index 8ac50a1..d5b572f 100644 --- a/tp-webapp/src/edit.rs +++ b/tp-webapp/src/edit.rs @@ -17,20 +17,25 @@ //! All manually-added segments are created with: //! - `probability = 1.0` //! - `origin = PathOrigin::Manual` -//! - `gnss_start_index = gnss_end_index = 0` +//! - `gnss_start_index = gnss_end_index` = the adjacent segment's end index (append) or +//! start index (prepend), so GNSS ordering invariants are preserved //! - `start_intrinsic = 0.0`, `end_intrinsic = 1.0` use tp_lib_core::{AssociatedNetElement, PathOrigin, RailwayNetwork, TrainPath}; /// Build a manual [`AssociatedNetElement`] with fixed invariants. -fn manual_segment(netelement_id: String) -> AssociatedNetElement { +fn manual_segment( + netelement_id: String, + gnss_start_index: usize, + gnss_end_index: usize, +) -> AssociatedNetElement { let mut seg = AssociatedNetElement::new( netelement_id, 1.0, // probability 0.0, // start_intrinsic 1.0, // end_intrinsic - 0, // gnss_start_index - 0, // gnss_end_index + gnss_start_index, + gnss_end_index, ) .expect("invariants guarantee valid construction"); seg.origin = PathOrigin::Manual; @@ -86,14 +91,21 @@ fn near(a: [f64; 2], b: [f64; 2]) -> bool { /// - `path` – the current train path pub fn add_segment(netelement_id: &str, network: &RailwayNetwork, path: &TrainPath) -> TrainPath { // If the path is empty, just add the segment as the sole element. + // There is no adjacent segment, so GNSS indices start at 0. if path.segments.is_empty() { let mut new_path = path.clone(); new_path .segments - .push(manual_segment(netelement_id.to_string())); + .push(manual_segment(netelement_id.to_string(), 0, 0)); return new_path; } + // GNSS indices inherited from the adjacent segment at each potential insertion point. + // • Prepend: inherit the current first segment's gnss_start_index (preserves ordering). + // • Append: inherit the current last segment's gnss_end_index (preserves ordering). + let first_gnss = path.segments[0].gnss_start_index; + let last_gnss = path.segments[path.segments.len() - 1].gnss_end_index; + // Look up the new segment's endpoints from the network geometry. let new_head = first_coord(network, netelement_id); let new_tail = last_coord(network, netelement_id); @@ -118,15 +130,18 @@ pub fn add_segment(netelement_id: &str, network: &RailwayNetwork, path: &TrainPa }; let mut new_path = path.clone(); - let seg = manual_segment(netelement_id.to_string()); match (can_prepend, can_append) { (true, false) => { - new_path.segments.insert(0, seg); + new_path + .segments + .insert(0, manual_segment(netelement_id.to_string(), first_gnss, first_gnss)); } (false, true) | (false, false) => { // Append (also the fallback / disconnected case) - new_path.segments.push(seg); + new_path + .segments + .push(manual_segment(netelement_id.to_string(), last_gnss, last_gnss)); } (true, true) => { // Ambiguous: segment connects to both ends. @@ -134,7 +149,9 @@ pub fn add_segment(netelement_id: &str, network: &RailwayNetwork, path: &TrainPa let new_mid = match (new_head, new_tail) { (Some(h), Some(t)) => [(h[0] + t[0]) / 2.0, (h[1] + t[1]) / 2.0], _ => { - new_path.segments.push(seg); + new_path + .segments + .push(manual_segment(netelement_id.to_string(), last_gnss, last_gnss)); return new_path; } }; @@ -142,9 +159,13 @@ pub fn add_segment(netelement_id: &str, network: &RailwayNetwork, path: &TrainPa let d_tail = path_tail_end.map_or(f64::MAX, |t| dist2(new_mid, t)); if d_head <= d_tail { - new_path.segments.insert(0, seg); + new_path + .segments + .insert(0, manual_segment(netelement_id.to_string(), first_gnss, first_gnss)); } else { - new_path.segments.push(seg); + new_path + .segments + .push(manual_segment(netelement_id.to_string(), last_gnss, last_gnss)); } } } diff --git a/tp-webapp/src/lib.rs b/tp-webapp/src/lib.rs index fd435cd..1d599fd 100644 --- a/tp-webapp/src/lib.rs +++ b/tp-webapp/src/lib.rs @@ -82,7 +82,7 @@ pub fn run_webapp_standalone( let port_range = if port == 0 { DEFAULT_PORTS } else { - port..=port + 9 + port..=port.saturating_add(9) }; let (listener, bound_port) = bind_port(port_range)?; @@ -139,7 +139,7 @@ pub fn run_webapp_integrated( let port_range = if port == 0 { DEFAULT_PORTS } else { - port..=port + 9 + port..=port.saturating_add(9) }; let (listener, bound_port) = bind_port(port_range)?; diff --git a/tp-webapp/src/server/routes.rs b/tp-webapp/src/server/routes.rs index 8a53027..b109a80 100644 --- a/tp-webapp/src/server/routes.rs +++ b/tp-webapp/src/server/routes.rs @@ -212,7 +212,7 @@ pub async fn put_path( .segments .into_iter() .map(|seg| { - let origin = parse_origin(&seg.origin); + let origin = parse_origin(&seg.origin)?; let mut element = tp_lib_core::AssociatedNetElement::new( seg.netelement_id, seg.probability, @@ -243,10 +243,14 @@ pub async fn put_path( .into_response() } -fn parse_origin(s: &str) -> tp_lib_core::PathOrigin { +fn parse_origin(s: &str) -> Result { match s { - "manual" => tp_lib_core::PathOrigin::Manual, - _ => tp_lib_core::PathOrigin::Algorithm, + "manual" => Ok(tp_lib_core::PathOrigin::Manual), + "algorithm" => Ok(tp_lib_core::PathOrigin::Algorithm), + other => Err(format!( + "unknown origin '{}': expected 'algorithm' or 'manual'", + other + )), } } @@ -389,7 +393,7 @@ pub async fn post_abort(State(state): State) -> Response { } match state.confirm_tx.take() { - None => error_response(StatusCode::CONFLICT, "already confirmed"), + None => error_response(StatusCode::CONFLICT, "already handled"), Some(tx) => { let _ = tx.send(ConfirmResult::Aborted); (StatusCode::OK, Json(json!({"ok": true}))).into_response() diff --git a/tp-webapp/static/app.js b/tp-webapp/static/app.js index 33b9720..db1095b 100644 --- a/tp-webapp/static/app.js +++ b/tp-webapp/static/app.js @@ -159,10 +159,27 @@ function confidenceColor(conf) { return '#dc2626'; // red } +/** + * Escape a value for safe insertion into an HTML context. + * Converts `&`, `<`, `>`, `"`, and `'` to their HTML entity equivalents. + * Use this whenever embedding untrusted data (e.g. netelement IDs from a + * network file) into Leaflet tooltip HTML to prevent XSS injection. + * @param {*} str - Value to escape (coerced to string). + * @returns {string} + */ +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + function makeTooltip(props, seg) { const conf = props.confidence != null ? (props.confidence * 100).toFixed(1) + '%' : '—'; - const origin = props.origin ?? '—'; - return `${props.netelement_id}
Confidence: ${conf}
Origin: ${origin}`; + const origin = escapeHtml(props.origin ?? '—'); + return `${escapeHtml(props.netelement_id)}
Confidence: ${conf}
Origin: ${origin}`; } // --------------------------------------------------------------------------- @@ -221,7 +238,8 @@ function updateSidebar(pathData) { async function onSave() { const pathData = await apiFetch('/api/path'); if (pathData && pathData.segments.length === 0) { - if (!confirm('The path is empty. Save anyway?')) return; + setStatus('Cannot save: path is empty.'); + return; } const result = await apiPost('/api/save'); if (result && result.ok) { @@ -230,6 +248,11 @@ async function onSave() { } async function onConfirm() { + const pathData = await apiFetch('/api/path'); + if (pathData && pathData.segments.length === 0) { + setStatus('Cannot confirm: path is empty.'); + return; + } const result = await apiPost('/api/confirm'); if (result && result.ok) { setStatus('Path confirmed — you may close this window.'); diff --git a/tp-webapp/tests/unit/routes_test.rs b/tp-webapp/tests/unit/routes_test.rs index 42c5e84..5f30221 100644 --- a/tp-webapp/tests/unit/routes_test.rs +++ b/tp-webapp/tests/unit/routes_test.rs @@ -279,6 +279,34 @@ async fn test_put_path_422_on_probability_out_of_range() { assert_eq!(resp.status(), 422); } +#[tokio::test] +async fn test_put_path_422_on_unknown_origin() { + let (base, _h) = start_server(standalone_state()).await; + + let body = json!({ + "segments": [{ + "netelement_id": "NE001", + "probability": 0.9, + "start_intrinsic": 0.0, + "end_intrinsic": 1.0, + "gnss_start_index": 0, + "gnss_end_index": 0, + "origin": "manul" // typo: not a valid origin + }] + }); + + let resp = Client::new() + .put(format!("{base}/api/path")) + .json(&body) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 422); + let json: Value = resp.json().await.unwrap(); + assert_eq!(json["ok"], false); +} + // --------------------------------------------------------------------------- // T011 — POST /api/save // --------------------------------------------------------------------------- @@ -403,6 +431,27 @@ async fn test_post_abort_409_in_standalone_mode() { assert_eq!(resp.status(), 409); } +#[tokio::test] +async fn test_post_abort_409_already_handled_when_tx_consumed() { + // confirm_tx is None → session already handled (confirmed or aborted) + let state = WebAppState { + mode: AppMode::Integrated, + confirm_tx: None, + ..standalone_state() + }; + let (base, _h) = start_server(state).await; + + let resp = Client::new() + .post(format!("{base}/api/abort")) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 409); + let json: Value = resp.json().await.unwrap(); + assert_eq!(json["error"], "already handled"); +} + // --------------------------------------------------------------------------- // T030 — GET /api/gnss // ---------------------------------------------------------------------------