#include "secsgem/config/validate.hpp" #include #include #include #include #include #include #include #include #include #include "secsgem/gem/control_job_state.hpp" #include "secsgem/gem/control_state.hpp" #include "secsgem/gem/process_job_state.hpp" namespace secsgem::config { namespace { namespace gem = secsgem::gem; // Map yaml-cpp's Mark to a 1-based line number when available. std::optional line_of(const YAML::Node& n) { const auto m = n.Mark(); if (m.line < 0) return std::nullopt; return m.line + 1; // yaml-cpp marks are 0-based } class Sink { public: Sink(std::vector& out, std::string file) : out_(out), file_(std::move(file)) {} void error(const std::string& field_path, const std::string& msg, const YAML::Node& at = {}) { out_.push_back({file_, line_of(at), field_path, msg, ConfigIssue::Severity::Error}); } void warn(const std::string& field_path, const std::string& msg, const YAML::Node& at = {}) { out_.push_back({file_, line_of(at), field_path, msg, ConfigIssue::Severity::Warning}); } private: std::vector& out_; std::string file_; }; bool valid_secs_type(const std::string& t) { static const std::set ok = { "ASCII", "BOOLEAN", "BINARY", "U1", "U2", "U4", "U8", "I1", "I2", "I4", "I8", "F4", "F8", }; return ok.count(t) > 0; } bool valid_hcack(const std::string& s) { static const std::set ok = { "Accept", "InvalidCommand", "CannotDoNow", "ParameterInvalid", "AcceptedWillFinishLater", "Rejected", "InvalidObject", }; return ok.count(s) > 0; } // Range-checked integer extraction. Returns nullopt and emits an // error if the node is missing, non-scalar, or out of range. template std::optional as_int_in_range(const YAML::Node& n, Sink& sink, const std::string& path, int64_t min, int64_t max) { if (!n) { sink.error(path, "missing required integer"); return std::nullopt; } if (!n.IsScalar()) { sink.error(path, "expected an integer", n); return std::nullopt; } int64_t v = 0; try { v = n.as(); } catch (const YAML::Exception&) { sink.error(path, "value `" + n.Scalar() + "` is not a valid integer", n); return std::nullopt; } if (v < min || v > max) { sink.error(path, "value " + std::to_string(v) + " out of range [" + std::to_string(min) + ", " + std::to_string(max) + "]", n); return std::nullopt; } return static_cast(v); } std::optional as_nonempty_string(const YAML::Node& n, Sink& sink, const std::string& path) { if (!n) { sink.error(path, "missing required string"); return std::nullopt; } if (!n.IsScalar()) { sink.error(path, "expected a string", n); return std::nullopt; } auto s = n.as(); if (s.empty()) { sink.error(path, "string must not be empty", n); return std::nullopt; } return s; } // API-facing names (variables, events, alarms, commands) should be valid // identifiers: language bindings expose them as kwargs/attributes // (eq.set(chamber_pressure=...)), where "Chamber Pressure" or "temp-2" can't // be written. Warning, not error โ€” the wire doesn't care, only bindings do. void warn_if_not_identifier(const std::optional& s, Sink& sink, const std::string& path, const YAML::Node& n) { if (!s) return; bool ok = !s->empty() && (std::isalpha(static_cast((*s)[0])) || (*s)[0] == '_'); for (std::size_t i = 1; ok && i < s->size(); ++i) { const auto c = static_cast((*s)[i]); ok = std::isalnum(c) || (*s)[i] == '_'; } if (!ok) sink.warn(path, "`" + *s + "` is not identifier-safe " + "([A-Za-z_][A-Za-z0-9_]*); language bindings expose " + "names as kwargs/attributes", n); } // Validate one entry that has the same (id, name, units, type, value) // shape as SVID/DVID/ECID. Returns the parsed id on success so the // caller can dedupe. std::optional validate_typed_entry( const YAML::Node& entry, Sink& sink, const std::string& path) { auto id = as_int_in_range(entry["id"], sink, path + ".id", 0, UINT32_MAX); warn_if_not_identifier(as_nonempty_string(entry["name"], sink, path + ".name"), sink, path + ".name", entry["name"]); if (auto tn = entry["type"]) { auto t = tn.as(); if (!valid_secs_type(t)) { sink.error(path + ".type", "unknown SECS-II type `" + t + "` (expected one of ASCII/BOOLEAN/BINARY/U1-U8/I1-I8/F4/F8)", tn); } } else { sink.error(path + ".type", "missing required field `type`"); } return id; } void validate_equipment_block(YAML::Node& root, Sink& sink) { std::set ceids; std::set alarm_ids; if (!root["device"]) { sink.error("device", "missing required `device` block"); } else { auto dev = root["device"]; as_int_in_range(dev["id"], sink, "device.id", 0, UINT16_MAX); as_nonempty_string(dev["model_name"], sink, "device.model_name"); as_nonempty_string(dev["software_rev"], sink, "device.software_rev"); } if (auto caps = root["capabilities"]; caps && caps.IsSequence()) { for (std::size_t i = 0; i < caps.size(); ++i) { const auto path = "capabilities[" + std::to_string(i) + "]"; const auto& c = caps[i]; as_int_in_range(c["code"], sink, path + ".code", 0, 255); as_nonempty_string(c["name"], sink, path + ".name"); } } auto validate_entry_list = [&](const YAML::Node& seq, const char* key, std::set* dedupe = nullptr) { if (!seq) return; if (!seq.IsSequence()) { sink.error(key, "expected a sequence", seq); return; } std::set local; for (std::size_t i = 0; i < seq.size(); ++i) { const auto path = std::string(key) + "[" + std::to_string(i) + "]"; auto id = validate_typed_entry(seq[i], sink, path); if (id) { if (!local.insert(*id).second) { sink.error(path + ".id", "duplicate id " + std::to_string(*id) + " in " + key, seq[i]); } if (dedupe) dedupe->insert(*id); } } }; validate_entry_list(root["svids"], "svids"); validate_entry_list(root["dvids"], "dvids"); validate_entry_list(root["ecids"], "ecids"); // CEIDs have a simpler shape (id + name). if (auto ces = root["ceids"]) { if (!ces.IsSequence()) { sink.error("ceids", "expected a sequence", ces); } else { for (std::size_t i = 0; i < ces.size(); ++i) { const auto path = "ceids[" + std::to_string(i) + "]"; auto id = as_int_in_range(ces[i]["id"], sink, path + ".id", 0, UINT32_MAX); warn_if_not_identifier( as_nonempty_string(ces[i]["name"], sink, path + ".name"), sink, path + ".name", ces[i]["name"]); if (id && !ceids.insert(*id).second) { sink.error(path + ".id", "duplicate CEID " + std::to_string(*id), ces[i]); } } } } if (auto als = root["alarms"]) { if (!als.IsSequence()) { sink.error("alarms", "expected a sequence", als); } else { for (std::size_t i = 0; i < als.size(); ++i) { const auto path = "alarms[" + std::to_string(i) + "]"; auto id = as_int_in_range(als[i]["id"], sink, path + ".id", 0, UINT32_MAX); as_nonempty_string(als[i]["text"], sink, path + ".text"); // ALCD lower-7 bits are the category bitmap (E5 ยง10.3). Bit 7 // is the set/clear flag, set at emit time โ€” must not be in YAML. as_int_in_range(als[i]["category"], sink, path + ".category", 0, 127); // Optional local key for name-based APIs (not on the wire). If // present it must be non-empty. if (als[i]["name"]) warn_if_not_identifier( as_nonempty_string(als[i]["name"], sink, path + ".name"), sink, path + ".name", als[i]["name"]); if (id && !alarm_ids.insert(*id).second) { sink.error(path + ".id", "duplicate ALID " + std::to_string(*id), als[i]); } } } } if (auto recs = root["recipes"]) { if (!recs.IsSequence()) { sink.error("recipes", "expected a sequence", recs); } else { std::set seen; for (std::size_t i = 0; i < recs.size(); ++i) { const auto path = "recipes[" + std::to_string(i) + "]"; auto id = as_nonempty_string(recs[i]["id"], sink, path + ".id"); if (!recs[i]["body"]) { sink.error(path + ".body", "missing required `body`"); } if (id && !seen.insert(*id).second) { sink.error(path + ".id", "duplicate PPID `" + *id + "`", recs[i]); } } } } if (auto cmds = root["host_commands"]) { if (!cmds.IsSequence()) { sink.error("host_commands", "expected a sequence", cmds); } else { std::set names; for (std::size_t i = 0; i < cmds.size(); ++i) { const auto path = "host_commands[" + std::to_string(i) + "]"; auto name = as_nonempty_string(cmds[i]["name"], sink, path + ".name"); warn_if_not_identifier(name, sink, path + ".name", cmds[i]["name"]); if (auto ack = cmds[i]["ack"]) { auto s = ack.as(); if (!valid_hcack(s)) sink.error(path + ".ack", "unknown HCACK `" + s + "`", ack); } else { sink.error(path + ".ack", "missing required `ack`"); } if (auto e = cmds[i]["emit_ceid"]) { auto v = as_int_in_range(e, sink, path + ".emit_ceid", 0, UINT32_MAX); if (v && !ceids.count(*v)) { sink.error(path + ".emit_ceid", "CEID " + std::to_string(*v) + " not declared in `ceids` section", e); } } if (auto a = cmds[i]["set_alarm"]) { auto v = as_int_in_range(a, sink, path + ".set_alarm", 0, UINT32_MAX); if (v && !alarm_ids.count(*v)) { sink.error(path + ".set_alarm", "ALID " + std::to_string(*v) + " not declared in `alarms` section", a); } } if (auto f = cmds[i]["force_spool"]) { if (!f.IsScalar()) sink.error(path + ".force_spool", "expected a boolean", f); } if (name && !names.insert(*name).second) { sink.error(path + ".name", "duplicate command name `" + *name + "`", cmds[i]); } } } } if (auto sp = root["spool"]) { if (auto s = sp["spoolable_streams"]) { if (!s.IsSequence()) { sink.error("spool.spoolable_streams", "expected a sequence", s); } else { for (std::size_t i = 0; i < s.size(); ++i) { // SECS-II streams are 1..127 (top bit is the W flag). as_int_in_range( s[i], sink, "spool.spoolable_streams[" + std::to_string(i) + "]", 1, 127); } } } if (auto m = sp["max_size"]) { as_int_in_range(m, sink, "spool.max_size", 1, INT32_MAX); } } if (auto e = root["emit_on_control_change"]) { if (!e.IsNull()) { auto v = as_int_in_range(e, sink, "emit_on_control_change", 0, UINT32_MAX); if (v && !ceids.count(*v)) { sink.error("emit_on_control_change", "CEID " + std::to_string(*v) + " not declared in `ceids` section", e); } } } } YAML::Node try_load(const std::string& path, Sink& sink) { try { return YAML::LoadFile(path); } catch (const YAML::Exception& e) { sink.error("", std::string("YAML parse error: ") + e.what()); return YAML::Node{}; } } } // namespace bool ConfigValidator::has_errors() const { return error_count() > 0; } std::size_t ConfigValidator::error_count() const { return static_cast(std::count_if( issues_.begin(), issues_.end(), [](const auto& i) { return i.severity == ConfigIssue::Severity::Error; })); } std::size_t ConfigValidator::warning_count() const { return issues_.size() - error_count(); } bool ConfigValidator::validate_equipment(const std::string& path) { const auto baseline = error_count(); Sink sink(issues_, path); YAML::Node root = try_load(path, sink); if (root) validate_equipment_block(root, sink); return error_count() == baseline; } bool ConfigValidator::validate_control_state(const std::string& path) { const auto baseline = error_count(); Sink sink(issues_, path); YAML::Node root = try_load(path, sink); if (!root) return false; if (auto in = root["initial"]) { if (!gem::parse_control_state(in.as())) { sink.error("initial", "unknown control state `" + in.as() + "`", in); } } const auto ts = root["transitions"]; if (!ts || !ts.IsSequence()) { sink.error("transitions", "missing or non-sequence"); return false; } for (std::size_t i = 0; i < ts.size(); ++i) { const auto path_i = "transitions[" + std::to_string(i) + "]"; const auto& row = ts[i]; auto from_n = row["from"]; auto on_n = row["on"]; if (!from_n) sink.error(path_i + ".from", "missing required `from`"); if (!on_n) sink.error(path_i + ".on", "missing required `on`"); if (from_n && !gem::parse_control_state(from_n.as())) { sink.error(path_i + ".from", "unknown control state `" + from_n.as() + "`", from_n); } if (on_n && !gem::parse_control_event(on_n.as())) { sink.error(path_i + ".on", "unknown control event `" + on_n.as() + "`", on_n); } if (auto to = row["to"]) { if (!gem::parse_control_state(to.as())) { sink.error(path_i + ".to", "unknown control state `" + to.as() + "`", to); } } if (auto th = row["then"]) { if (!gem::parse_control_state(th.as())) { sink.error(path_i + ".then", "unknown control state `" + th.as() + "`", th); } } } return error_count() == baseline; } bool ConfigValidator::validate_process_job_state(const std::string& path) { const auto baseline = error_count(); Sink sink(issues_, path); YAML::Node root = try_load(path, sink); if (!root) return false; if (auto in = root["initial"]) { auto parsed = gem::parse_process_job_state(in.as()); if (!parsed) { sink.error("initial", "unknown PJ state `" + in.as() + "`", in); } else if (*parsed == gem::ProcessJobState::NoState) { sink.error("initial", "`NoState` is a sentinel and cannot be the initial state", in); } } const auto ts = root["transitions"]; if (!ts || !ts.IsSequence()) { sink.error("transitions", "missing or non-sequence"); return false; } for (std::size_t i = 0; i < ts.size(); ++i) { const auto path_i = "transitions[" + std::to_string(i) + "]"; const auto& row = ts[i]; if (auto from = row["from"]) { auto parsed = gem::parse_process_job_state(from.as()); if (!parsed) sink.error(path_i + ".from", "unknown PJ state", from); else if (*parsed == gem::ProcessJobState::NoState) sink.error(path_i + ".from", "`NoState` is a sentinel and cannot appear as `from`", from); } else { sink.error(path_i + ".from", "missing required `from`"); } if (auto on = row["on"]) { if (!gem::parse_process_job_event(on.as())) sink.error(path_i + ".on", "unknown PJ event", on); } else { sink.error(path_i + ".on", "missing required `on`"); } if (auto to = row["to"]) { auto parsed = gem::parse_process_job_state(to.as()); if (!parsed) sink.error(path_i + ".to", "unknown PJ state", to); else if (*parsed == gem::ProcessJobState::NoState) sink.error(path_i + ".to", "`NoState` is a sentinel and cannot appear as `to`", to); } } return error_count() == baseline; } bool ConfigValidator::validate_control_job_state(const std::string& path) { const auto baseline = error_count(); Sink sink(issues_, path); YAML::Node root = try_load(path, sink); if (!root) return false; if (auto in = root["initial"]) { if (!gem::parse_control_job_state(in.as())) sink.error("initial", "unknown CJ state `" + in.as() + "`", in); } const auto ts = root["transitions"]; if (!ts || !ts.IsSequence()) { sink.error("transitions", "missing or non-sequence"); return false; } for (std::size_t i = 0; i < ts.size(); ++i) { const auto path_i = "transitions[" + std::to_string(i) + "]"; const auto& row = ts[i]; if (auto from = row["from"]) { if (!gem::parse_control_job_state(from.as())) sink.error(path_i + ".from", "unknown CJ state", from); } else { sink.error(path_i + ".from", "missing required `from`"); } if (auto on = row["on"]) { if (!gem::parse_control_job_event(on.as())) sink.error(path_i + ".on", "unknown CJ event", on); } else { sink.error(path_i + ".on", "missing required `on`"); } if (auto to = row["to"]) { if (!gem::parse_control_job_state(to.as())) sink.error(path_i + ".to", "unknown CJ state", to); } } return error_count() == baseline; } void format_issues_to(std::ostream& os, const std::vector& issues) { for (const auto& i : issues) { os << (i.severity == ConfigIssue::Severity::Error ? "[error] " : "[warn] ") << i.file; if (i.line) os << ":" << *i.line; if (!i.field_path.empty()) os << " " << i.field_path; os << " โ€” " << i.message << "\n"; } } } // namespace secsgem::config