app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
master
Drew Owens
Share frozen native Rust mechanical development checkpoint and qualification scope
b2d7cdb
26d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
//! Rust request preparation for the Fusion mechanical bridge.
//!
//! This crate implements contracts only. Successful validation does not imply
//! that a native CAD handler exists, that durable admission happened, or that a
//! request may execute. No Python interpreter, process, Fusion object, journal,
//! native callback, or file system operation is used here.
mod operations;
mod schema;
mod wire;
pub use operations::{operation_contract, Access, OperationContract, OPERATION_CONTRACTS};
pub use schema::Schema;
use serde_json::Value;
use std::{collections::BTreeMap, sync::OnceLock};
pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
/// Bound the entire transport body before parsing, allowing at most 64 KiB of
/// caller metadata beyond the maximum stripped operation request.
pub const MAX_WIRE_BYTES: usize = MAX_REQUEST_BYTES + 64 * 1024;
pub const MAX_REQUEST_DEPTH: usize = 128;
pub const SCHEMA_JSON: &str = include_str!("../schema.json");
/// New namespace: never use these bytes to reinterpret legacy Python digests.
pub const CANONICAL_FORMAT: &str = "adom-fusion-rust-json-v1";
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct Error {
pub code: &'static str,
pub message: String,
}
impl Error {
fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
fn input(message: impl Into<String>) -> Self {
Self::new("invalid_input", message)
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub struct ValidatedSchema {
operations: BTreeMap<String, Schema>,
raw: Value,
}
impl ValidatedSchema {
pub fn operation(&self, name: &str) -> Option<&Schema> {
self.operations.get(name)
}
/// Contract inventory only; callers must separately advertise implemented handlers.
pub fn contracts_json(&self) -> &Value {
&self.raw
}
}
pub fn validated_schema() -> Result<&'static ValidatedSchema> {
static SCHEMA: OnceLock<Result<ValidatedSchema>> = OnceLock::new();
SCHEMA
.get_or_init(|| {
let operations: BTreeMap<String, Schema> = serde_json::from_str(SCHEMA_JSON)
.map_err(|error| Error::new("schema_invalid", error.to_string()))?;
if operations.len() != OPERATION_CONTRACTS.len()
|| OPERATION_CONTRACTS
.iter()
.any(|contract| !operations.contains_key(contract.name))
{
return Err(Error::new(
"schema_invalid",
"Schema and operation classification differ",
));
}
let raw = serde_json::from_str(SCHEMA_JSON)
.map_err(|error| Error::new("schema_invalid", error.to_string()))?;
Ok(ValidatedSchema { operations, raw })
})
.as_ref()
.map_err(Clone::clone)
}
#[derive(Debug, Clone)]
pub struct PreparedRequest {
arguments: Value,
contract: &'static OperationContract,
write: bool,
canonical: Vec<u8>,
}
impl PreparedRequest {
pub fn operation(&self) -> &'static str {
self.contract.name
}
pub fn arguments(&self) -> &Value {
&self.arguments
}
pub fn is_write(&self) -> bool {
self.write
}
pub fn canonical_bytes(&self) -> &[u8] {
&self.canonical
}
pub fn canonical_format(&self) -> &'static str {
CANONICAL_FORMAT
}
}
/// Parse untrusted JSON before preparing a request. This additionally refuses
/// integer literals outside i64/u64, which serde_json could otherwise round to f64.
pub fn prepare_json(input: &[u8], current_session: &str) -> Result<PreparedRequest> {
if input.len() > MAX_WIRE_BYTES {
return Err(Error::new(
"operation_record_too_large",
"Wire request exceeds its byte limit.",
));
}
let value: wire::UniqueValue = serde_json::from_slice(input)
.map_err(|error| Error::input(format!("Arguments must be finite JSON: {error}")))?;
reject_oversized_integer_literals(input)?;
prepare_request(value.0, current_session)
}
/// Validate an already-allocated, trusted in-process value without admitting or
/// executing it. Untrusted transport bytes must enter through `prepare_json` so
/// the wire cap applies before allocation. Transport authorization must be
/// enforced before this function is called.
pub fn prepare_request(mut arguments: Value, current_session: &str) -> Result<PreparedRequest> {
let object = arguments
.as_object_mut()
.ok_or_else(|| Error::input("Arguments must be an object."))?;
for name in ["noAutoStart", "noAutoRepair"] {
if object.get(name).is_some_and(|value| !value.is_boolean()) {
return Err(Error::input(format!(
"{name} must be a Boolean transport option."
)));
}
}
for name in ["caller", "_callerBudgetMs", "noAutoStart", "noAutoRepair"] {
object.remove(name);
}
let contract = object
.get("op")
.and_then(Value::as_str)
.and_then(operation_contract)
.ok_or_else(|| {
Error::new(
"unsupported_operation",
"Discover supported operations with fusion_mech_capabilities.",
)
})?;
check_depth(&arguments, 0)?;
let schema = validated_schema()?
.operation(contract.name)
.ok_or_else(|| Error::new("schema_invalid", "Operation schema is missing"))?;
schema.validate(&arguments, "args")?;
let object = arguments.as_object().expect("validated request object");
let mut unknown: Vec<_> = object
.keys()
.filter(|key| {
!schema.has_property(key)
&& !["op", "sessionId", "operationId", "target"].contains(&key.as_str())
})
.map(String::as_str)
.collect();
unknown.sort_unstable();
if !unknown.is_empty() {
return Err(Error::input(format!(
"Unknown operation arguments: {}",
unknown.join(", ")
)));
}
let write = contract.access == Access::Write
|| (contract.access == Access::Health
&& object.get("recompute") == Some(&Value::Bool(true)));
if write {
if current_session.is_empty()
|| object.get("sessionId").and_then(Value::as_str) != Some(current_session)
{
return Err(Error::new("session_mismatch",
"Old-session history is readable, but native execution requires the current add-in session."));
}
if !object
.get("operationId")
.and_then(Value::as_str)
.is_some_and(|id| (1..=128).contains(&id.chars().count()))
{
return Err(Error::new(
"operation_id_required",
"Every write needs a unique operationId; retries must preserve the exact request.",
));
}
}
let canonical = canonical_bytes(&arguments)?;
Ok(PreparedRequest {
arguments,
contract,
write,
canonical,
})
}
fn check_depth(value: &Value, depth: usize) -> Result<()> {
if depth > MAX_REQUEST_DEPTH {
return Err(Error::new(
"operation_record_invalid",
"Operation record exceeds its nesting limit.",
));
}
match value {
Value::Array(array) => {
for value in array {
check_depth(value, depth + 1)?;
}
}
Value::Object(object) => {
for value in object.values() {
check_depth(value, depth + 1)?;
}
}
_ => (),
}
Ok(())
}
fn canonical_bytes(value: &Value) -> Result<Vec<u8>> {
// Check serialized size without building another allocation first. Sorting
// object keys cannot alter the serialized length of this JSON value.
struct SizeLimit(usize);
impl std::io::Write for SizeLimit {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
if bytes.len() > self.0 {
return Err(std::io::Error::other("Request byte limit exceeded"));
}
self.0 -= bytes.len();
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
serde_json::to_writer(SizeLimit(MAX_REQUEST_BYTES), value).map_err(|_| {
Error::new(
"operation_record_too_large",
"Operation record exceeds its durable byte limit.",
)
})?;
fn sorted(value: &Value) -> Value {
match value {
Value::Object(object) => {
let fields: BTreeMap<_, _> = object.iter().collect();
Value::Object(
fields
.into_iter()
.map(|(key, value)| (key.clone(), sorted(value)))
.collect(),
)
}
Value::Array(array) => Value::Array(array.iter().map(sorted).collect()),
value => value.clone(),
}
}
let bytes = serde_json::to_vec(&sorted(value))
.map_err(|error| Error::new("operation_record_invalid", error.to_string()))?;
if bytes.len() > MAX_REQUEST_BYTES {
return Err(Error::new(
"operation_record_too_large",
"Operation record exceeds its durable byte limit.",
));
}
Ok(bytes)
}
fn reject_oversized_integer_literals(input: &[u8]) -> Result<()> {
let mut index = 0;
while index < input.len() {
if input[index] == b'"' {
index += 1;
while index < input.len() {
if input[index] == b'\\' {
index += 2;
} else if input[index] == b'"' {
index += 1;
break;
} else {
index += 1;
}
}
} else if input[index] == b'-' || input[index].is_ascii_digit() {
let start = index;
while index < input.len()
&& (input[index].is_ascii_digit()
|| [b'-', b'+', b'.', b'e', b'E'].contains(&input[index]))
{
index += 1;
}
let literal = &input[start..index];
if !literal.iter().any(|byte| [b'.', b'e', b'E'].contains(byte)) {
// serde_json already validated UTF-8 and JSON syntax.
let text = std::str::from_utf8(literal).expect("valid JSON number");
if text.parse::<i64>().is_err() && text.parse::<u64>().is_err() {
return Err(Error::input(
"Integer literal is outside the exact supported i64/u64 range.",
));
}
}
} else {
index += 1;
}
}
Ok(())
}