use std::io::Write;
use serde_json::Value;
use crate::error::CoreError;
use crate::webdev as bundle;
pub const DEFAULT_PROJECT: &str = "ign-cli";
const SCRIPT_EXEC_ROUTE_ROOT: &str = "com.inductiveautomation.webdev/resources/cli/scriptExec";
const SCRIPT_EXEC_RESOURCE_JSON: &str = include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/scriptExec/resource.json"
);
const SCRIPT_EXEC_CONFIG_JSON: &str = include_str!(
"../../webdev/routes/com.inductiveautomation.webdev/resources/cli/scriptExec/config.json"
);
pub fn always_on_routes() -> Vec<String> {
let mut routes = Vec::new();
for (name, _) in bundle::ROUTE_FILES {
let Some(rest) = name.strip_prefix("com.inductiveautomation.webdev/resources/cli/") else {
continue;
};
if let Some((route, file)) = rest.rsplit_once('/')
&& file == "doPost.py"
&& !routes.iter().any(|known: &String| known == route)
{
routes.push(route.to_string());
}
}
routes
}
pub(crate) fn route_url(project: &str, route: &str) -> String {
format!("/system/webdev/{project}/cli/{route}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteProbe {
Present {
route_version: String,
},
Absent,
Unlicensed,
AuthGated,
Denied {
code: String,
message: String,
traceback: Option<String>,
},
}
#[derive(Debug)]
pub(crate) enum RouteBody {
Ok(Value),
Denied {
code: String,
message: String,
traceback: Option<String>,
},
}
pub(crate) fn parse_route_body(body: &str) -> Result<RouteBody, CoreError> {
let value: Value = serde_json::from_str(body).map_err(|err| {
CoreError::Internal(format!(
"webdev route answered a body that is not the {{ok, data|error}} envelope: {err}"
))
})?;
if value.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(RouteBody::Ok(
value.get("data").cloned().unwrap_or(Value::Null),
))
} else {
let code = value
.pointer("/error/code")
.and_then(Value::as_str)
.unwrap_or("route_error")
.to_string();
let message = value
.pointer("/error/message")
.and_then(Value::as_str)
.unwrap_or("(the route sent no message)")
.to_string();
let traceback = value
.pointer("/error/traceback")
.and_then(Value::as_str)
.map(str::to_string);
Ok(RouteBody::Denied {
code,
message,
traceback,
})
}
}
pub(crate) fn denial_to_error(
code: &str,
message: &str,
traceback: Option<&str>,
endpoint: String,
) -> CoreError {
match code {
"not_found" => CoreError::NotFound {
endpoint: Some(endpoint),
},
"no_alarm_journal" => CoreError::AlarmJournalMissing {
endpoint: Some(endpoint),
},
"provider_root_unsupported" => CoreError::ProviderRootUnsupported {
endpoint: Some(endpoint),
},
_ => {
let mut full = message.to_string();
if let Some(traceback) = traceback {
full.push_str("\nroute traceback: ");
full.push_str(traceback);
}
CoreError::WebdevRouteError {
code: code.to_string(),
message: full,
endpoint: Some(endpoint),
}
}
}
}
pub fn build_deploy_zip(
project_title: &str,
with_script_exec: bool,
secret: Option<&str>,
) -> Result<Vec<u8>, CoreError> {
let script_exec_py = match (with_script_exec, secret) {
(false, _) => None,
(true, Some(secret)) => {
Some(bundle::SCRIPT_EXEC_TEMPLATE.replace("__IGN_CLI_SECRET__", secret))
}
(true, None) => {
return Err(CoreError::Internal(
"scriptExec deploy requires a substituted secret — the deploy \
action generates the secret before packing (fail-closed guard)"
.into(),
));
}
};
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (name, contents) in bundle::ROUTE_FILES {
let body = if *name == "project.json" && project_title != DEFAULT_PROJECT {
retitle_project_json(contents, project_title)?
} else {
(*contents).to_string()
};
writer.start_file(*name, options).map_err(zip_write_err)?;
writer.write_all(body.as_bytes()).map_err(|err| {
CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
})?;
}
if let Some(script_exec_py) = script_exec_py {
for (name, body) in [
(
format!("{SCRIPT_EXEC_ROUTE_ROOT}/resource.json"),
SCRIPT_EXEC_RESOURCE_JSON.to_string(),
),
(
format!("{SCRIPT_EXEC_ROUTE_ROOT}/config.json"),
SCRIPT_EXEC_CONFIG_JSON.to_string(),
),
(
format!("{SCRIPT_EXEC_ROUTE_ROOT}/doPost.py"),
script_exec_py,
),
] {
writer
.start_file(name.as_str(), options)
.map_err(zip_write_err)?;
writer.write_all(body.as_bytes()).map_err(|err| {
CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
})?;
}
}
writer
.finish()
.map_err(zip_write_err)
.map(|cursor| cursor.into_inner())
}
fn zip_write_err(err: zip::result::ZipError) -> CoreError {
CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
}
fn retitle_project_json(project_json: &str, title: &str) -> Result<String, CoreError> {
let mut value: Value = serde_json::from_str(project_json).map_err(|err| {
CoreError::Internal(format!("embedded project.json does not parse: {err}"))
})?;
value["title"] = Value::String(title.to_string());
serde_json::to_string(&value)
.map_err(|err| CoreError::Internal(format!("cannot re-serialize project.json: {err}")))
}
#[cfg(test)]
mod tests {
use super::{
DEFAULT_PROJECT, RouteBody, always_on_routes, build_deploy_zip, denial_to_error,
parse_route_body,
};
use crate::error::CoreError;
#[test]
fn always_on_routes_derive_from_the_manifest() {
assert_eq!(
always_on_routes(),
vec![
"tags".to_string(),
"tagConfig".to_string(),
"alarms".to_string(),
"tagHistory".to_string(),
]
);
}
#[test]
fn parse_route_body_envelope_shapes() {
match parse_route_body(r#"{"ok":true,"data":{"routeVersion":"1.0.0"}}"#)
.expect("ok body parses")
{
RouteBody::Ok(data) => {
assert_eq!(data["routeVersion"], "1.0.0");
}
other => panic!("wrong verdict: {other:?}"),
}
match parse_route_body(
r#"{"ok":false,"error":{"code":"secret_mismatch","message":"nope"}}"#,
)
.expect("denial parses")
{
RouteBody::Denied {
code,
message,
traceback,
} => {
assert_eq!(code, "secret_mismatch");
assert_eq!(message, "nope");
assert!(traceback.is_none(), "no traceback on the wire");
}
other => panic!("wrong verdict: {other:?}"),
}
match parse_route_body(
r#"{"ok":false,"error":{"code":"route_error","message":"boom","traceback":"Traceback (most recent call last):\n ValueError: nope"}}"#,
)
.expect("denial with traceback parses")
{
RouteBody::Denied { code, traceback, .. } => {
assert_eq!(code, "route_error");
assert_eq!(
traceback.as_deref(),
Some("Traceback (most recent call last):\n ValueError: nope")
);
}
other => panic!("wrong verdict: {other:?}"),
}
match parse_route_body(r#"{"ok":true}"#).expect("bare ok parses") {
RouteBody::Ok(data) => assert!(data.is_null()),
other => panic!("wrong verdict: {other:?}"),
}
match parse_route_body(r#"{"ok":false}"#).expect("bare denial parses") {
RouteBody::Denied { code, .. } => assert_eq!(code, "route_error"),
other => panic!("wrong verdict: {other:?}"),
}
let err = parse_route_body("<html>jetty</html>").expect_err("non-envelope fails");
assert!(matches!(err, CoreError::Internal(_)), "{err}");
}
#[test]
fn denial_mapping_reuses_not_found_and_rides_the_rest() {
let not_found = denial_to_error("not_found", "no such path", None, "/x".into());
assert_eq!(not_found.code(), "not_found");
assert_eq!(not_found.exit_code(), 6);
let secret = denial_to_error("secret_required", "missing header", None, "/x".into());
assert_eq!(secret.code(), "webdev_route_error");
assert_eq!(secret.exit_code(), 6);
assert!(secret.to_string().contains("secret_required"));
assert!(
secret.to_string().contains("missing header"),
"no traceback → the message rides VERBATIM (no suffix)"
);
let blown = denial_to_error(
"route_error",
"error processing action",
Some("java.lang.IllegalArgumentException: Invalid UUID string: 3f2504e0"),
"/x".into(),
);
assert_eq!(blown.code(), "webdev_route_error");
let text = blown.to_string();
assert!(
text.contains("\nroute traceback: java.lang.IllegalArgumentException: Invalid UUID string: 3f2504e0"),
"the traceback rides the message: {text}"
);
let journal = denial_to_error(
"no_alarm_journal",
"No alarm journal profile specified",
None,
"/system/webdev/ign-cli/cli/alarms".into(),
);
assert_eq!(journal.code(), "alarm_journal_missing");
assert_eq!(journal.exit_code(), 6);
assert!(
journal.hint().unwrap().contains("journal profile"),
"hint names the chain: {journal}"
);
let root = denial_to_error(
"provider_root_unsupported",
"provider-root tag paths are not supported on WebDev threads (no RpcContext) -- use a subtree path like [provider]folder",
None,
"/system/webdev/ign-cli/cli/tagConfig".into(),
);
assert_eq!(root.code(), "provider_root_unsupported");
assert_eq!(root.exit_code(), 6);
assert!(
root.to_string().contains("subtree like [provider]folder"),
"the fixed Display names the subtree workaround: {root}"
);
}
#[test]
fn deploy_zip_fails_closed_without_a_script_exec_secret() {
let err = build_deploy_zip(DEFAULT_PROJECT, true, None).expect_err("must refuse");
assert!(matches!(err, CoreError::Internal(_)), "{err}");
assert_eq!(err.exit_code(), 1);
}
}