Skip to content

Commit ee9e99e

Browse files
committed
fix(hiroz): harden the .msg schema loader (cycle guard, package match, error logging)
Adversarial review of the loader surfaced three issues: - A self-referential or mutually recursive .msg (malformed) recursed until the stack overflowed, since the get_schema memo only hits after a type is fully registered. Track in-progress types and bail with a warning on a cycle. - find_msg_file's package-directory form matched on filename alone, so a request for pkg_a/msg/Status could resolve to an unrelated pkg_b/msg/Status.msg earlier on HIROZ_MSG_PATH. Try the prefix form (which includes the package) first, and only use the package-dir form when the entry's basename equals the package. - A found-but-unparseable .msg was swallowed via .ok()? and looked identical to 'not on disk'. Log parse/build failures so a broken file doesn't masquerade as not-found when the caller falls back to live discovery.
1 parent d95aedd commit ee9e99e

1 file changed

Lines changed: 54 additions & 10 deletions

File tree

crates/hiroz/src/dynamic/registry.rs

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -198,18 +198,55 @@ fn convert_base_type(
198198
/// `None` if the type cannot be found or parsed.
199199
#[cfg(feature = "dynamic-schema-loader")]
200200
pub fn load_schema(type_name: &str) -> Option<Arc<MessageSchema>> {
201+
let in_progress = std::cell::RefCell::new(std::collections::HashSet::new());
202+
load_schema_inner(type_name, &in_progress)
203+
}
204+
205+
/// Recursive worker for [`load_schema`]. `in_progress` tracks the types whose
206+
/// resolution is on the current stack so a self-referential or mutually
207+
/// recursive `.msg` (malformed — well-formed ROS messages form a DAG) bails
208+
/// with a warning instead of recursing until the stack overflows: a cycle would
209+
/// otherwise re-enter here for a type that isn't registered yet, so the
210+
/// `get_schema` memo never hits.
211+
#[cfg(feature = "dynamic-schema-loader")]
212+
fn load_schema_inner(
213+
type_name: &str,
214+
in_progress: &std::cell::RefCell<std::collections::HashSet<String>>,
215+
) -> Option<Arc<MessageSchema>> {
201216
if let Some(schema) = get_schema(type_name) {
202217
return Some(schema);
203218
}
204219
let (package, name) = split_msg_type(type_name)?;
220+
// File not on disk is a legitimate "try the next source" (live discovery),
221+
// so return None quietly. Errors *after* a file is found are logged below,
222+
// since a broken `.msg` masquerading as "not found" would be misleading.
205223
let path = find_msg_file(&package, &name)?;
206-
let parsed = hiroz_codegen::parser::msg::parse_msg_file(&path, &package).ok()?;
224+
if !in_progress.borrow_mut().insert(type_name.to_string()) {
225+
tracing::warn!("cyclic .msg definition for {type_name}; skipping schema load");
226+
return None;
227+
}
228+
let parsed = match hiroz_codegen::parser::msg::parse_msg_file(&path, &package) {
229+
Ok(parsed) => parsed,
230+
Err(e) => {
231+
tracing::warn!("failed to parse .msg file {}: {e}", path.display());
232+
in_progress.borrow_mut().remove(type_name);
233+
return None;
234+
}
235+
};
207236
// Resolve nested message-typed fields by loading them the same way; each
208237
// recursive load registers itself, so the outer conversion sees them.
209238
let resolver = |field_pkg: &str, field_type: &str| -> Option<Arc<MessageSchema>> {
210-
load_schema(&format!("{field_pkg}/msg/{field_type}"))
239+
load_schema_inner(&format!("{field_pkg}/msg/{field_type}"), in_progress)
240+
};
241+
let schema = match parsed_message_to_schema(&parsed, &resolver) {
242+
Ok(schema) => schema,
243+
Err(e) => {
244+
tracing::warn!("failed to build schema for {type_name}: {e}");
245+
in_progress.borrow_mut().remove(type_name);
246+
return None;
247+
}
211248
};
212-
let schema = parsed_message_to_schema(&parsed, &resolver).ok()?;
249+
in_progress.borrow_mut().remove(type_name);
213250
Some(register_schema(schema))
214251
}
215252

@@ -225,9 +262,12 @@ fn split_msg_type(type_name: &str) -> Option<(String, String)> {
225262
}
226263

227264
/// Find `<pkg>/msg/<Name>.msg` under `HIROZ_MSG_PATH`. Each colon-separated
228-
/// entry is tried both as the package directory itself
229-
/// (`<entry>/msg/<Name>.msg`) and as a prefix that contains packages
230-
/// (`<entry>/<pkg>/msg/<Name>.msg`, e.g. an ament `.../share`).
265+
/// entry is tried as a prefix that contains packages
266+
/// (`<entry>/<pkg>/msg/<Name>.msg`, e.g. an ament `.../share`), and — only when
267+
/// the entry's own basename equals `pkg` — as the package directory itself
268+
/// (`<entry>/msg/<Name>.msg`). The basename guard is what keeps a request for
269+
/// `pkg_a/msg/Status` from silently resolving to an unrelated
270+
/// `pkg_b/msg/Status.msg` that happens to appear earlier in the path.
231271
#[cfg(feature = "dynamic-schema-loader")]
232272
fn find_msg_file(package: &str, name: &str) -> Option<std::path::PathBuf> {
233273
let msg_path = std::env::var("HIROZ_MSG_PATH").ok()?;
@@ -238,14 +278,18 @@ fn find_msg_file(package: &str, name: &str) -> Option<std::path::PathBuf> {
238278
continue;
239279
}
240280
let base = std::path::Path::new(entry);
241-
let as_package = base.join("msg").join(&file);
242-
if as_package.is_file() {
243-
return Some(as_package);
244-
}
281+
// Prefix layout: `package` is part of the path, so it can't cross packages.
245282
let as_prefix = base.join(package).join("msg").join(&file);
246283
if as_prefix.is_file() {
247284
return Some(as_prefix);
248285
}
286+
// Package-directory layout: valid only if this entry IS the package's dir.
287+
if base.file_name().and_then(|n| n.to_str()) == Some(package) {
288+
let as_package = base.join("msg").join(&file);
289+
if as_package.is_file() {
290+
return Some(as_package);
291+
}
292+
}
249293
}
250294
None
251295
}

0 commit comments

Comments
 (0)