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
Port occurrence translation and contract authoring to Rust; publish verified R21 source
a06dfd7
26d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
//! Rust replacement for catalog authoring; no Python execution or CAD operations.
use serde::de::{self, MapAccess, SeqAccess, Visitor};
use serde::{Deserialize, Deserializer};
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use std::{
collections::BTreeSet,
fmt, fs,
io::{Read, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
pub mod literal;
pub type Result<T> = std::result::Result<T, String>;
pub const MAX_BYTES: usize = 4 * 1024 * 1024;
pub fn sha(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
struct Unique(Value);
impl<'de> Deserialize<'de> for Unique {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
struct V;
impl<'de> Visitor<'de> for V {
type Value = Unique;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("JSON without duplicate keys")
}
fn visit_bool<E: de::Error>(self, v: bool) -> std::result::Result<Unique, E> {
Ok(Unique(json!(v)))
}
fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Unique, E> {
Ok(Unique(json!(v)))
}
fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Unique, E> {
Ok(Unique(json!(v)))
}
fn visit_f64<E: de::Error>(self, v: f64) -> std::result::Result<Unique, E> {
serde_json::Number::from_f64(v)
.map(|n| Unique(Value::Number(n)))
.ok_or_else(|| E::custom("nonfinite"))
}
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Unique, E> {
Ok(Unique(json!(v)))
}
fn visit_string<E: de::Error>(self, v: String) -> std::result::Result<Unique, E> {
Ok(Unique(json!(v)))
}
fn visit_unit<E: de::Error>(self) -> std::result::Result<Unique, E> {
Ok(Unique(Value::Null))
}
fn visit_none<E: de::Error>(self) -> std::result::Result<Unique, E> {
Ok(Unique(Value::Null))
}
fn visit_seq<A: SeqAccess<'de>>(
self,
mut a: A,
) -> std::result::Result<Unique, A::Error> {
let mut v = vec![];
while let Some(Unique(x)) = a.next_element()? {
v.push(x)
}
Ok(Unique(Value::Array(v)))
}
fn visit_map<A: MapAccess<'de>>(
self,
mut a: A,
) -> std::result::Result<Unique, A::Error> {
let mut m = Map::new();
while let Some(k) = a.next_key::<String>()? {
if m.contains_key(&k) {
return Err(de::Error::custom(format!("duplicate key: {k}")));
}
let Unique(v) = a.next_value()?;
m.insert(k, v);
}
Ok(Unique(Value::Object(m)))
}
}
d.deserialize_any(V)
}
}
pub fn parse(bytes: &[u8]) -> Result<Value> {
if bytes.len() > MAX_BYTES {
return Err("input exceeds 4MiB".into());
}
let mut d = serde_json::Deserializer::from_slice(bytes);
let Unique(v) = Unique::deserialize(&mut d).map_err(|e| e.to_string())?;
d.end().map_err(|e| e.to_string())?;
Ok(v)
}
pub fn read(path: &Path) -> Result<Vec<u8>> {
let mut v = vec![];
fs::File::open(path)
.map_err(|e| e.to_string())?
.take(MAX_BYTES as u64 + 1)
.read_to_end(&mut v)
.map_err(|e| e.to_string())?;
if v.len() > MAX_BYTES {
return Err("input exceeds 4MiB".into());
}
Ok(v)
}
pub fn sorted(v: &Value) -> Value {
match v {
Value::Object(o) => {
let mut keys: Vec<_> = o.keys().collect();
keys.sort();
let mut out = Map::new();
for k in keys {
out.insert(k.clone(), sorted(&o[k]));
}
Value::Object(out)
}
Value::Array(a) => Value::Array(a.iter().map(sorted).collect()),
_ => v.clone(),
}
}
/// Python json.dumps(indent=2,ensure_ascii=True) encoding, including surrogate pairs.
pub fn encode(v: &Value, sort: bool) -> Result<Vec<u8>> {
let v = if sort { sorted(v) } else { v.clone() };
let text = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
let text = python_exponents(&text);
let mut out = String::new();
for c in text.chars() {
if (c as u32) >= 127 {
let mut b = [0; 2];
for u in c.encode_utf16(&mut b) {
out.push_str(&format!("\\u{u:04x}"));
}
} else {
out.push(c)
}
}
out.push('\n');
if out.len() > MAX_BYTES {
return Err("encoded output exceeds 4MiB".into());
}
Ok(out.into_bytes())
}
fn keys(v: &Value, wanted: &[&str]) -> Result<()> {
let o = v.as_object().ok_or("expected object")?;
if o.len() != wanted.len() || wanted.iter().any(|k| !o.contains_key(*k)) {
return Err("unexpected or missing object fields".into());
}
Ok(())
}
fn expand(v: &Value, schema: &Value, status: &str) -> Result<Value> {
if let Some(o) = v.as_object() {
if o.contains_key("$mechanicalSchema") {
keys(v, &["$mechanicalSchema"])?;
if v["$mechanicalSchema"] != true {
return Err("invalid schema marker".into());
}
return Ok(schema.clone());
}
if o.contains_key("$mechanicalOperationEnum") {
keys(v, &["$mechanicalOperationEnum"])?;
if v["$mechanicalOperationEnum"] != true {
return Err("invalid enum marker".into());
}
return Ok(json!(format!(
"required enum: {}",
schema
.as_object()
.unwrap()
.keys()
.cloned()
.collect::<Vec<_>>()
.join("|")
)));
}
if o.contains_key("$statusDefault") {
keys(v, &["$statusDefault"])?;
if v["$statusDefault"] != true {
return Err("invalid status marker".into());
}
return Ok(json!(status));
}
let mut out = Map::new();
for (k, x) in o {
out.insert(k.clone(), expand(x, schema, status)?);
}
return Ok(Value::Object(out));
}
if let Some(a) = v.as_array() {
return a
.iter()
.map(|x| expand(x, schema, status))
.collect::<Result<Vec<_>>>()
.map(Value::Array);
}
Ok(v.clone())
}
#[derive(Clone)]
pub struct Catalog {
rows: Vec<Value>,
metadata: Value,
canonical: Value,
}
impl Catalog {
pub fn load(source: &[u8], schema_bytes: &[u8]) -> Result<Self> {
let s = parse(source)?;
keys(
&s,
&[
"formatVersion",
"defaultStatus",
"mechanicalSchemaSha256",
"mechanicalOperationOrder",
"metadata",
"rows",
],
)?;
if s["formatVersion"] != 1 {
return Err("unsupported canonical format".into());
}
let schema = parse(schema_bytes)?;
let o = schema
.as_object()
.filter(|o| !o.is_empty())
.ok_or("schema must be nonempty object")?;
if s["mechanicalSchemaSha256"].as_str() != Some(&sha(schema_bytes)) {
return Err("mechanical schema digest mismatch".into());
}
if s["mechanicalOperationOrder"] != json!(o.keys().collect::<Vec<_>>()) {
return Err("mechanical schema order mismatch".into());
}
for (name, op) in o {
if name.is_empty() || !op.is_object() {
return Err("invalid operation schema".into());
}
}
let status = s["defaultStatus"]
.as_str()
.filter(|s| !s.is_empty())
.ok_or("default status missing")?;
keys(&s["metadata"], &["contract", "frozen", "note", "source"])?;
if s["metadata"]
.as_object()
.unwrap()
.values()
.any(|v| !v.is_string())
{
return Err("metadata must contain strings".into());
}
fn schemas(v: &Value) -> usize {
match v {
Value::Object(o) => {
if o.contains_key("$mechanicalSchema") {
1
} else {
o.values().map(schemas).sum()
}
}
Value::Array(a) => a.iter().map(schemas).sum(),
_ => 0,
}
}
let expanded_bound = schemas(&s["rows"])
.checked_mul(schema_bytes.len())
.and_then(|n| n.checked_add(source.len()))
.ok_or("expansion bound")?;
if expanded_bound > MAX_BYTES / 2 {
return Err("schema expansion exceeds bounded catalog budget".into());
}
let table = s["rows"]
.as_array()
.filter(|a| !a.is_empty() && a.len() <= 4096)
.ok_or("row count bound")?;
let mut seen = BTreeSet::new();
let mut rows = vec![];
for raw in table {
let expanded = expand(raw, &schema, status)?;
let r = expanded
.as_array()
.filter(|r| r.len() == 8)
.ok_or("canonical row must have exactly eight fields")?;
let name = r[0]
.as_str()
.filter(|s| !s.is_empty() && s.len() <= 256)
.ok_or("invalid verb name")?;
if !seen.insert(name.to_owned()) {
return Err(format!("duplicate verb: {name}"));
}
if !r[1].is_string()
|| ![2, 3, 7].iter().all(|&i| r[i].is_object())
|| r[4].as_u64().is_none_or(|n| n == 0 || n > 86400)
|| !(r[5].is_null() || r[5].is_string())
|| !r[6].is_boolean()
{
return Err(format!("invalid row types: {name}"));
}
rows.push(json!({"name":r[0],"summary":r[1],"input":r[2],"output":r[3],"timeoutSeconds":r[4],"statusVerb":r[5],"longRunning":r[6],"example":r[7]}));
}
Ok(Self {
rows,
metadata: s["metadata"].clone(),
canonical: s,
})
}
pub fn describe(&self) -> Value {
let rows: Vec<_> = self
.rows
.iter()
.filter(|v| !v["name"].as_str().unwrap().ends_with("_note"))
.cloned()
.collect();
json!({"success":true,"bridge":"fusion360","verbCount":rows.len(),"verbs":rows,"data":{"bridge":"fusion360","verbCount":rows.len(),"verbs":rows}})
}
pub fn frozen(&self) -> Value {
let mut rows = self.rows.clone();
rows.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
let mut out = self.metadata.clone();
out["verbs"] = json!(rows);
sorted(&out)
}
pub fn check(&self, baseline: &[u8]) -> Result<Value> {
let old = parse(baseline)?;
let generated = self.frozen();
if old != generated {
return Err(
"complete frozen contract differs from baseline; no output published".into(),
);
}
let bytes = encode(&generated, true)?;
if bytes != baseline {
return Err("baseline encoding/order differs; no silent normalization".into());
}
Ok(
json!({"success":true,"frozenVerbs":self.rows.len(),"runtimeVerbs":self.describe()["verbCount"],"completeContractByteEqual":true,"contractSha256":sha(&bytes)}),
)
}
pub fn manifest(&self, bytes: &[u8]) -> Result<Vec<u8>> {
let mut m = parse(bytes)?;
if !m.is_object() || !m["verbs"].is_array() {
return Err("bridge manifest verbs array missing".into());
}
let names = self.frozen()["verbs"]
.as_array()
.unwrap()
.iter()
.map(|v| v["name"].clone())
.collect::<Vec<_>>();
if m["verbs"] == json!(names) {
return Ok(bytes.to_vec());
}
m["verbs"] = json!(names);
encode(&m, false)
}
pub fn freeze(&self, baseline: &[u8], manifest: &[u8]) -> Result<Vec<(String, Vec<u8>)>> {
self.check(baseline)?;
Ok(vec![
("describe.json".into(), baseline.to_vec()),
("bridge.json".into(), self.manifest(manifest)?),
])
}
/// Explicit authoring mode. Like the legacy freezer, unchanged verbs preserve
/// the entire old contract; changed verbs refresh metadata and frozen date.
/// The caller supplies the local date instead of consulting an implicit clock.
pub fn refreeze(
&self,
prior: Option<&[u8]>,
manifest: &[u8],
date: &str,
) -> Result<Vec<(String, Vec<u8>)>> {
validate_date(date)?;
let mut next = self.frozen();
next["frozen"] = json!(date);
let (doc, bytes) = if let Some(bytes) = prior {
let old = parse(bytes)?;
if !old.is_object() {
return Err("prior contract must be an object".into());
}
if let Some(verbs) = old.get("verbs") {
if !verbs.is_array() {
return Err("prior contract verbs must be an array".into());
}
}
if old.get("verbs") == next.get("verbs") {
keys(&old, &["contract", "frozen", "note", "source", "verbs"])?;
if ["contract", "frozen", "note", "source"]
.iter()
.any(|key| !old[*key].is_string())
{
return Err("unchanged prior metadata must contain strings".into());
}
(old, bytes.to_vec())
} else {
let bytes = encode(&next, true)?;
(next, bytes)
}
} else {
let bytes = encode(&next, true)?;
(next, bytes)
};
let mut canonical = self.canonical.clone();
let mut metadata = doc;
metadata.as_object_mut().unwrap().remove("verbs");
canonical["metadata"] = metadata;
Ok(vec![
("describe.json".into(), bytes),
("bridge.json".into(), self.manifest(manifest)?),
("catalog.json".into(), encode(&canonical, false)?),
])
}
}
fn validate_date(date: &str) -> Result<()> {
let b = date.as_bytes();
if b.len() != 10
|| b[4] != b'-'
|| b[7] != b'-'
|| b.iter()
.enumerate()
.any(|(i, c)| i != 4 && i != 7 && !c.is_ascii_digit())
{
return Err("date must be explicit YYYY-MM-DD".into());
}
let y: u32 = date[..4].parse().unwrap();
let m: u32 = date[5..7].parse().unwrap();
let d: u32 = date[8..].parse().unwrap();
let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0);
let days = match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if leap {
29
} else {
28
}
}
_ => 0,
};
if y == 0 || d == 0 || d > days {
return Err("invalid Gregorian date".into());
}
Ok(())
}
static NEXT: AtomicU64 = AtomicU64::new(1);
struct Temp(PathBuf);
impl Drop for Temp {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
/// Publish a completed fresh directory without replacing any existing path.
pub fn publish(dest: &Path, files: &[(String, Vec<u8>)]) -> Result<()> {
if files.is_empty()
|| files.iter().any(|(n, b)| {
n.contains(['/', '\\']) || n == "." || n == ".." || n.is_empty() || b.len() > MAX_BYTES
})
{
return Err("invalid output bundle".into());
}
let mut names = BTreeSet::new();
if files.iter().any(|(n, _)| !names.insert(n)) {
return Err("duplicate output name".into());
}
let parent = dest
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let temp = parent.join(format!(
".contract-staging-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&temp).map_err(|e| e.to_string())?;
let guard = Temp(temp.clone());
for (n, b) in files {
let mut f = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(temp.join(n))
.map_err(|e| e.to_string())?;
f.write_all(b)
.and_then(|_| f.sync_all())
.map_err(|e| e.to_string())?;
}
rename_exclusive(&temp, dest)?;
drop(guard);
Ok(())
}
#[cfg(target_os = "linux")]
fn rename_exclusive(a: &Path, b: &Path) -> Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
unsafe extern "C" {
fn renameat2(
olddirfd: i32,
oldpath: *const std::ffi::c_char,
newdirfd: i32,
newpath: *const std::ffi::c_char,
flags: u32,
) -> i32;
}
let a = CString::new(a.as_os_str().as_bytes()).map_err(|_| "NUL path")?;
let b = CString::new(b.as_os_str().as_bytes()).map_err(|_| "NUL path")?;
if unsafe { renameat2(-100, a.as_ptr(), -100, b.as_ptr(), 1) } != 0 {
return Err(std::io::Error::last_os_error().to_string());
}
Ok(())
}
#[cfg(windows)]
fn rename_exclusive(a: &Path, b: &Path) -> Result<()> {
use std::os::windows::ffi::OsStrExt;
#[link(name = "kernel32")]
unsafe extern "system" {
fn MoveFileW(from: *const u16, to: *const u16) -> i32;
}
let convert = |p: &Path| -> Result<Vec<u16>> {
let mut x: Vec<u16> = p.as_os_str().encode_wide().collect();
if x.contains(&0) {
return Err("NUL path".into());
}
x.push(0);
Ok(x)
};
let a = convert(a)?;
let b = convert(b)?;
if unsafe { MoveFileW(a.as_ptr(), b.as_ptr()) } == 0 {
return Err(std::io::Error::last_os_error().to_string());
}
Ok(())
}
#[cfg(not(any(target_os = "linux", windows)))]
fn rename_exclusive(_: &Path, _: &Path) -> Result<()> {
Err("exclusive directory publication unsupported on this platform".into())
}
#[cfg(test)]
mod tests;
fn python_exponents(s: &str) -> String {
let mut out = String::new();
let mut p = 0;
let b = s.as_bytes();
let mut quoted = false;
let mut escaped = false;
while p < b.len() {
let c = s[p..].chars().next().unwrap();
if quoted {
out.push(c);
p += c.len_utf8();
if escaped {
escaped = false
} else if c == '\\' {
escaped = true
} else if c == '"' {
quoted = false
}
continue;
}
if c == '"' {
quoted = true;
out.push(c);
p += 1;
continue;
}
if c == '-' || c.is_ascii_digit() {
let start = p;
p += 1;
while p < b.len() && (b[p].is_ascii_digit() || b".eE+-".contains(&b[p])) {
p += 1
}
let token = &s[start..p];
if let Some(i) = token.find(['e', 'E']) {
let e: i32 = token[i + 1..].parse().expect("serializer exponent");
out.push_str(&token[..i]);
out.push('e');
out.push(if e < 0 { '-' } else { '+' });
out.push_str(&format!("{:02}", e.unsigned_abs()));
} else {
out.push_str(token)
}
continue;
}
out.push(c);
p += c.len_utf8();
}
out
}