ocppsim 0.3.0

A terminal OCPP-J charge point simulator.
Documentation
use serde_json::Value;

use crate::sensitive::{REDACTED_VALUE, redact_text_secrets};

use super::{
  OcppFrame, build_call, build_call_error, build_call_result,
  build_call_result_error, json, normalize_identifier, parse_frame,
};

pub(in crate::simulator) fn sanitized_trace_frame_text(text: &str) -> String {
  parse_frame(text).map_or_else(
    |_| "OCPP frame omitted from trace.".to_string(),
    |frame| sanitized_trace_frame(&frame),
  )
}

pub(in crate::simulator) fn sanitized_trace_frame(frame: &OcppFrame) -> String {
  match frame {
    OcppFrame::Call {
      message_id,
      action,
      payload,
    } => build_call(message_id, action, &redact_call_payload(action, payload)),
    OcppFrame::CallResult {
      message_id,
      payload,
    } => build_call_result(message_id, &redact_sensitive_fields(payload)),
    OcppFrame::CallError {
      message_id,
      code,
      description,
      details,
    } => build_call_error(
      message_id,
      code,
      description,
      &redact_sensitive_fields(details),
    ),
    OcppFrame::CallResultError {
      message_id,
      code,
      description,
      details,
    } => build_call_result_error(
      message_id,
      code,
      description,
      &redact_sensitive_fields(details),
    ),
    OcppFrame::Send {
      message_id,
      action,
      payload,
    } => json!([6, message_id, action, redact_call_payload(action, payload)])
      .to_string(),
    OcppFrame::Unsupported {
      message_type,
      message_id,
    } => json!([message_type, message_id]).to_string(),
  }
}

pub(in crate::simulator) fn sanitized_trace_details(details: &Value) -> String {
  redact_sensitive_fields(details).to_string()
}

pub(in crate::simulator) fn sanitized_trace_payload(
  action: &str,
  payload: &Value,
) -> String {
  redact_call_payload(action, payload).to_string()
}

fn redact_call_payload(action: &str, payload: &Value) -> Value {
  let mut redacted = redact_sensitive_fields(payload);
  match action {
    "ChangeConfiguration" => redact_change_configuration(&mut redacted),
    "SetNetworkProfile" => redact_set_network_profile(&mut redacted),
    "SetVariables" => redact_set_variables(&mut redacted),
    _ => {}
  }
  redacted
}

fn redact_change_configuration(payload: &mut Value) {
  let Some(key) = payload.get("key").and_then(Value::as_str) else {
    return;
  };
  if is_secret_variable(key)
    && let Some(object) = payload.as_object_mut()
  {
    object.insert(
      "value".to_string(),
      Value::String(REDACTED_VALUE.to_string()),
    );
  }
}

fn redact_set_variables(payload: &mut Value) {
  let Some(entries) = payload
    .get_mut("setVariableData")
    .and_then(Value::as_array_mut)
  else {
    return;
  };
  for entry in entries {
    let variable_name = entry
      .get("variable")
      .and_then(Value::as_object)
      .and_then(|variable| variable.get("name"))
      .and_then(Value::as_str);
    if variable_name.is_some_and(is_secret_variable)
      && let Some(object) = entry.as_object_mut()
    {
      object.insert(
        "attributeValue".to_string(),
        Value::String(REDACTED_VALUE.to_string()),
      );
    }
  }
}

fn redact_set_network_profile(payload: &mut Value) {
  let Some(vpn) = payload
    .get_mut("connectionData")
    .and_then(Value::as_object_mut)
    .and_then(|connection_data| connection_data.get_mut("vpn"))
    .and_then(Value::as_object_mut)
  else {
    return;
  };
  if vpn.contains_key("key") {
    vpn.insert("key".to_string(), Value::String(REDACTED_VALUE.to_string()));
  }
}

fn redact_sensitive_fields(value: &Value) -> Value {
  match value {
    Value::Object(object) => Value::Object(
      object
        .iter()
        .map(|(key, value)| {
          let redacted_value =
            if is_secret_variable(key) || is_token_field(key, value) {
              Value::String(REDACTED_VALUE.to_string())
            } else {
              redact_sensitive_fields(value)
            };
          (key.clone(), redacted_value)
        })
        .collect(),
    ),
    Value::Array(items) => {
      Value::Array(items.iter().map(redact_sensitive_fields).collect())
    }
    Value::String(text) => Value::String(redact_text_secrets(text)),
    _ => value.clone(),
  }
}

fn is_secret_variable(value: &str) -> bool {
  matches!(
    normalize_identifier(value).as_str(),
    "apnpassword"
      | "authorizationkey"
      | "basicauthpassword"
      | "password"
      | "simpin"
  )
}

fn is_token_field(key: &str, value: &Value) -> bool {
  matches!(
    normalize_identifier(key).as_str(),
    "additionalidtoken" | "groupidtoken" | "idtag" | "idtoken" | "parentidtag"
  ) && value.is_string()
}