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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769
//! Bounded authenticated local HTTP. Bodies are accepted only for the native
//! mechanical route after transport authentication; no connection reuse/scripts.
use std::{
collections::BTreeMap,
io::{Read, Write},
net::TcpStream,
sync::atomic::{AtomicBool, Ordering},
time::{Duration, Instant},
};
pub const MAX_HEADER_BYTES: usize = 8192;
pub const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
const IO_TIMEOUT: Duration = Duration::from_millis(100);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(1);
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Route {
Health,
Documents,
Capabilities,
Mechanical,
OperationStatus { session: String, operation: String },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Request {
pub route: Route,
pub budget: Duration,
pub content_length: usize,
pub body: Vec<u8>,
pub mechanical_contract: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Error {
pub status: u16,
pub code: &'static str,
}
fn error(status: u16, code: &'static str) -> Error {
Error { status, code }
}
fn constant_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |diff, (a, b)| diff | (a ^ b)) == 0
}
pub fn parse(bytes: &[u8], host: &str, token: &str, epoch: &str) -> Result<Request, Error> {
if bytes.len() > MAX_HEADER_BYTES {
return Err(error(431, "headers_too_large"));
}
let text = std::str::from_utf8(bytes).map_err(|_| error(400, "invalid_headers"))?;
if !text.is_ascii() || !text.ends_with("\r\n\r\n") {
return Err(error(400, "invalid_headers"));
}
let mut lines = text[..text.len() - 4].split("\r\n");
let first = lines.next().ok_or(error(400, "invalid_request"))?;
let parts: Vec<_> = first.split(' ').collect();
if parts.len() != 3 || parts[2] != "HTTP/1.1" {
return Err(error(400, "invalid_request"));
}
let mechanical = parts[0] == "POST" && parts[1] == "/v1/mechanical";
if parts[0] != "GET" && !mechanical {
return Err(error(405, "read_only_method"));
}
let mut headers = BTreeMap::new();
for line in lines {
let (key, value) = line.split_once(':').ok_or(error(400, "invalid_headers"))?;
if key.is_empty()
|| !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
|| value.bytes().any(|b| b < 32 || b == 127)
{
return Err(error(400, "invalid_headers"));
}
if headers
.insert(key.to_ascii_lowercase(), value.trim())
.is_some()
{
return Err(error(400, "duplicate_header"));
}
}
if headers.get("host") != Some(&host) {
return Err(error(403, "host_refused"));
}
if headers.contains_key("origin") {
return Err(error(403, "browser_origin_refused"));
}
for forbidden in ["transfer-encoding", "upgrade", "expect"] {
if headers.contains_key(forbidden) {
return Err(error(400, "unsupported_http_framing"));
}
}
if !mechanical && headers.get("content-length").is_some_and(|v| *v != "0") {
return Err(error(400, "body_refused"));
}
let expected_auth = format!("Bearer {token}");
if !headers
.get("authorization")
.is_some_and(|value| constant_eq(value.as_bytes(), expected_auth.as_bytes()))
{
return Err(error(401, "authentication_required"));
}
if headers.get("x-adom-fusion-epoch") != Some(&epoch) {
return Err(error(409, "stale_runtime"));
}
let content_length = if mechanical {
if !matches!(
headers.get("x-adom-mechanical-contract"),
Some(
&"native-parameters-v1"
| &"native-lifecycle-v1"
| &"native-bom-v1"
| &"native-sketch-v1"
| &"native-sketch-geometry-v1"
| &"native-extrude-v1"
| &"native-sketch-entities-v1"
| &"native-material-catalog-v1"
| &"native-purchased-discovery-v1"
| &"native-root-physical-v1"
| &"native-sketch-line-state-v1"
| &"native-sketch-relations-v1"
| &"native-sketch-dimensions-v1"
| &"native-root-assembly-v1"
| &"native-root-occurrence-translation-v1"
)
) {
return Err(error(409, "native_contract_required"));
}
if headers.get("content-type") != Some(&"application/json") {
return Err(error(415, "json_content_type_required"));
}
let raw = headers
.get("content-length")
.ok_or(error(411, "content_length_required"))?;
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
return Err(error(400, "invalid_content_length"));
}
let length = raw
.parse::<usize>()
.map_err(|_| error(413, "body_too_large"))?;
if length == 0 || length > fusion_mechanical_core::MAX_WIRE_BYTES {
return Err(error(413, "body_too_large"));
}
length
} else {
0
};
let budget = match headers.get("x-adom-timeout-ms") {
None => Duration::from_millis(1500),
Some(value) => {
if !value.bytes().all(|b| b.is_ascii_digit()) {
return Err(error(400, "invalid_timeout"));
}
let ms = value
.parse::<u64>()
.map_err(|_| error(400, "invalid_timeout"))?;
if !(25..=2000).contains(&ms) {
return Err(error(400, "invalid_timeout"));
}
Duration::from_millis(ms)
}
};
let route = match parts[1] {
"/v1/health" => Route::Health,
"/v1/documents" => Route::Documents,
"/v1/capabilities" if parts[0] == "GET" => Route::Capabilities,
"/v1/mechanical" if mechanical => Route::Mechanical,
path if path.starts_with("/v1/operations/") => {
let selectors: Vec<_> = path["/v1/operations/".len()..].split('/').collect();
if selectors.len() != 2 || !selectors.iter().all(|id| status_selector(id)) {
return Err(error(400, "invalid_operation_selector"));
}
Route::OperationStatus {
session: selectors[0].into(),
operation: selectors[1].into(),
}
}
_ => return Err(error(404, "unsupported_route")),
};
Ok(Request {
route,
budget,
content_length,
body: Vec::new(),
mechanical_contract: headers
.get("x-adom-mechanical-contract")
.map(|v| (*v).to_owned()),
})
}
// Deliberately narrower than the journal's Unicode identifiers. This protocol
// performs no percent decoding, query parsing, filesystem lookup or fallback.
pub(crate) fn status_selector(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
&& id != "."
&& id != ".."
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_.:-".contains(&b))
}
pub fn read(
stream: &mut TcpStream,
stop: &AtomicBool,
host: &str,
token: &str,
epoch: &str,
) -> Result<Request, Error> {
read_tracking(stream, stop, host, token, epoch, &mut 0)
}
/// Preserve the wire response while recording evidence that stop interrupted
/// a header already partly received. This is observation, not a parser hold.
pub(crate) fn read_tracking(
stream: &mut TcpStream,
stop: &AtomicBool,
host: &str,
token: &str,
epoch: &str,
stopped_header_bytes: &mut usize,
) -> Result<Request, Error> {
*stopped_header_bytes = 0;
stream
.set_read_timeout(Some(IO_TIMEOUT))
.map_err(|_| error(500, "socket_setup_failed"))?;
receive_header(stream, stop, host, token, epoch, stopped_header_bytes)
}
fn receive_header(
stream: &mut impl Read,
stop: &AtomicBool,
host: &str,
token: &str,
epoch: &str,
stopped_header_bytes: &mut usize,
) -> Result<Request, Error> {
let deadline = Instant::now() + REQUEST_TIMEOUT;
let mut bytes = Vec::with_capacity(1024);
loop {
if stop.load(Ordering::Acquire) {
*stopped_header_bytes = bytes.len();
return Err(error(503, "runtime_stopping"));
}
if Instant::now() >= deadline {
return Err(error(408, "header_timeout"));
}
if bytes.len() >= MAX_HEADER_BYTES {
return Err(error(431, "headers_too_large"));
}
let mut chunk = [0u8; 1024];
let room = chunk.len().min(MAX_HEADER_BYTES - bytes.len());
match stream.read(&mut chunk[..room]) {
Ok(0) => return Err(error(400, "incomplete_request")),
Ok(count) => bytes.extend_from_slice(&chunk[..count]),
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::Interrupted
) =>
{
continue
}
Err(_) => return Err(error(400, "read_failed")),
}
if let Some(end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
let mut request = parse(&bytes[..end + 4], host, token, epoch)?;
let tail = &bytes[end + 4..];
if tail.len() > request.content_length {
return Err(error(400, "body_or_pipeline_refused"));
}
request.body = Vec::with_capacity(request.content_length);
request.body.extend_from_slice(tail);
while request.body.len() < request.content_length {
if stop.load(Ordering::Acquire) {
return Err(error(503, "runtime_stopping"));
}
if Instant::now() >= deadline {
return Err(error(408, "body_timeout"));
}
let mut chunk = [0u8; 4096];
let room = chunk.len().min(request.content_length - request.body.len());
match stream.read(&mut chunk[..room]) {
Ok(0) => return Err(error(400, "incomplete_body")),
Ok(n) => request.body.extend_from_slice(&chunk[..n]),
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::Interrupted
) =>
{
continue
}
Err(_) => return Err(error(400, "read_failed")),
}
}
return Ok(request);
}
}
}
pub fn respond(
stream: &mut TcpStream,
status: u16,
value: &serde_json::Value,
) -> std::io::Result<()> {
let body = serde_json::to_vec(value)?;
if body.len() > MAX_RESPONSE_BYTES {
return Err(std::io::Error::other("response limit exceeded"));
}
stream.set_write_timeout(Some(IO_TIMEOUT))?;
let reason = match status {
200 => "OK",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
408 => "Request Timeout",
409 => "Conflict",
411 => "Length Required",
413 => "Payload Too Large",
415 => "Unsupported Media Type",
431 => "Request Header Fields Too Large",
503 => "Service Unavailable",
507 => "Insufficient Storage",
_ => "Internal Server Error",
};
let header=format!("HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n",body.len());
let deadline = Instant::now() + REQUEST_TIMEOUT;
for buffer in [header.as_bytes(), body.as_slice()] {
let mut remaining = buffer;
while !remaining.is_empty() {
if Instant::now() >= deadline {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"response deadline",
));
}
match stream.write(remaining) {
Ok(0) => {
return Err(std::io::Error::new(
std::io::ErrorKind::WriteZero,
"closed response stream",
))
}
Ok(count) => remaining = &remaining[count..],
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::Interrupted
) =>
{
continue
}
Err(e) => return Err(e),
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn request(extra: &str) -> String {
format!("GET /v1/documents HTTP/1.1\r\nHost: 127.0.0.1:54321\r\nAuthorization: Bearer secret\r\nX-Adom-Fusion-Epoch: epoch\r\n{extra}\r\n")
}
fn parse_test(s: &str) -> Result<Request, Error> {
parse(s.as_bytes(), "127.0.0.1:54321", "secret", "epoch")
}
fn post_header(length: &str) -> String {
request(&format!("X-Adom-Mechanical-Contract: native-parameters-v1\r\nContent-Type: application/json\r\nContent-Length: {length}\r\n"))
.replace("GET /v1/documents", "POST /v1/mechanical")
}
#[test]
fn mechanical_post_requires_authenticated_explicit_contract_and_bounded_body() {
let valid = post_header("2");
let parsed = parse_test(&valid).unwrap();
assert_eq!(parsed.route, Route::Mechanical);
assert_eq!(parsed.content_length, 2);
assert_eq!(
parsed.mechanical_contract.as_deref(),
Some("native-parameters-v1")
);
let lifecycle =
parse_test(&valid.replace("native-parameters-v1", "native-lifecycle-v1")).unwrap();
assert_eq!(
lifecycle.mechanical_contract.as_deref(),
Some("native-lifecycle-v1")
);
assert_eq!(lifecycle.route, Route::Mechanical);
let bom = parse_test(&valid.replace("native-parameters-v1", "native-bom-v1")).unwrap();
assert_eq!(bom.mechanical_contract.as_deref(), Some("native-bom-v1"));
let sketch =
parse_test(&valid.replace("native-parameters-v1", "native-sketch-v1")).unwrap();
assert_eq!(
sketch.mechanical_contract.as_deref(),
Some("native-sketch-v1")
);
let geometry =
parse_test(&valid.replace("native-parameters-v1", "native-sketch-geometry-v1"))
.unwrap();
assert_eq!(
geometry.mechanical_contract.as_deref(),
Some("native-sketch-geometry-v1")
);
let entities =
parse_test(&valid.replace("native-parameters-v1", "native-sketch-entities-v1"))
.unwrap();
assert_eq!(
entities.mechanical_contract.as_deref(),
Some("native-sketch-entities-v1")
);
let extrude =
parse_test(&valid.replace("native-parameters-v1", "native-extrude-v1")).unwrap();
assert_eq!(
extrude.mechanical_contract.as_deref(),
Some("native-extrude-v1")
);
for (from, to, expected) in [
("Bearer secret", "Bearer wrong", "authentication_required"),
("Epoch: epoch", "Epoch: prior", "stale_runtime"),
("native-parameters-v1", "legacy", "native_contract_required"),
(
"application/json",
"text/plain",
"json_content_type_required",
),
("Content-Length: 2\r\n", "", "content_length_required"),
(
"Content-Length: 2",
"Content-Length: -2",
"invalid_content_length",
),
("Content-Length: 2", "Content-Length: 0", "body_too_large"),
] {
assert_eq!(
parse_test(&valid.replace(from, to)).unwrap_err().code,
expected
);
}
for length in [
(fusion_mechanical_core::MAX_WIRE_BYTES + 1).to_string(),
"9".repeat(100),
] {
let oversized = post_header(&length);
assert_eq!(parse_test(&oversized).unwrap_err().code, "body_too_large");
assert_eq!(
parse_test(&oversized.replace("Bearer secret", "Bearer wrong"))
.unwrap_err()
.code,
"authentication_required"
);
}
}
#[test]
fn fragmented_mechanical_body_is_exact_and_truncation_is_refused() {
struct Chunks(std::collections::VecDeque<Vec<u8>>);
impl Read for Chunks {
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
let Some(front) = self.0.front_mut() else {
return Ok(0);
};
let n = front.len().min(out.len());
out[..n].copy_from_slice(&front[..n]);
front.drain(..n);
if front.is_empty() {
self.0.pop_front();
}
Ok(n)
}
}
for (tail, expected) in [
(vec![b"{".to_vec(), b"}".to_vec()], Ok(())),
(vec![b"{".to_vec()], Err("incomplete_body")),
(vec![b"{}EXTRA".to_vec()], Err("body_or_pipeline_refused")),
] {
let mut chunks = if expected == Err("body_or_pipeline_refused") {
Chunks([format!("{}{}", post_header("2"), "{}EXTRA").into_bytes()].into())
} else {
Chunks(
std::iter::once(post_header("2").into_bytes())
.chain(tail)
.collect(),
)
};
let result = receive_header(
&mut chunks,
&AtomicBool::new(false),
"127.0.0.1:54321",
"secret",
"epoch",
&mut 0,
);
match expected {
Ok(()) => assert_eq!(result.unwrap().body, b"{}"),
Err(code) => assert_eq!(result.unwrap_err().code, code),
}
}
}
#[test]
fn stop_during_body_prevents_completed_mechanical_request() {
struct StopAfterHeader<'a> {
bytes: &'a [u8],
stop: &'a AtomicBool,
}
impl Read for StopAfterHeader<'_> {
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
let n = self.bytes.read(out)?;
self.stop.store(true, Ordering::Release);
Ok(n)
}
}
let stop = AtomicBool::new(false);
let header = post_header("2");
let mut reader = StopAfterHeader {
bytes: header.as_bytes(),
stop: &stop,
};
assert_eq!(
receive_header(
&mut reader,
&stop,
"127.0.0.1:54321",
"secret",
"epoch",
&mut 0
)
.unwrap_err()
.code,
"runtime_stopping"
);
}
#[test]
fn historical_status_selectors_are_explicit_and_authenticated() {
let valid = request("").replace("/v1/documents", "/v1/operations/old-session/op:1");
assert_eq!(
parse_test(&valid).unwrap().route,
Route::OperationStatus {
session: "old-session".into(),
operation: "op:1".into()
}
);
for path in [
"/v1/operations/a",
"/v1/operations/a/",
"/v1/operations//b",
"/v1/operations/a/b/c",
"/v1/operations/a/..",
"/v1/operations/./b",
"/v1/operations/a/%62",
"/v1/operations/a/b?session=c",
"/v1/operations/a/b#c",
] {
let bytes = request("").replace("/v1/documents", path);
assert_eq!(
parse_test(&bytes).unwrap_err().code,
"invalid_operation_selector",
"{path}"
);
assert_eq!(
parse_test(&bytes.replace("Bearer secret", "Bearer wrong"))
.unwrap_err()
.code,
"authentication_required",
"authentication must precede selector handling"
);
}
let too_long = request("").replace(
"/v1/documents",
&format!("/v1/operations/a/{}", "b".repeat(129)),
);
assert_eq!(
parse_test(&too_long).unwrap_err().code,
"invalid_operation_selector"
);
assert_eq!(
parse_test(&valid.replace("Epoch: epoch", "Epoch: old-session"))
.unwrap_err()
.code,
"stale_runtime"
);
}
#[test]
fn stop_evidence_requires_an_incomplete_header_already_received() {
// Force stop at the input boundary, without wall-clock sleeps or a
// diagnostic hold in the production parser.
struct StopAfterRead<'a> {
input: &'a [u8],
stop: &'a AtomicBool,
}
impl Read for StopAfterRead<'_> {
fn read(&mut self, output: &mut [u8]) -> std::io::Result<usize> {
let n = self.input.read(output)?;
self.stop.store(true, Ordering::Release);
Ok(n)
}
}
let full = request("");
for (input, stopped_before_read, expected_bytes, completes) in [
(
b"GET /v1/documents HTTP/1.1\r\n".as_slice(),
false,
28,
false,
),
(full.as_bytes(), false, 0, true),
(full.as_bytes(), true, 0, false),
] {
let stop = AtomicBool::new(stopped_before_read);
let mut reader = StopAfterRead { input, stop: &stop };
let mut observed = 0;
let result = receive_header(
&mut reader,
&stop,
"127.0.0.1:54321",
"secret",
"epoch",
&mut observed,
);
assert_eq!(observed, expected_bytes);
if completes {
assert!(result.is_ok());
} else {
assert_eq!(result.unwrap_err().code, "runtime_stopping");
}
}
}
#[test]
fn authenticates_and_bounds_read_only_routes() {
assert_eq!(
parse_test(&request("")).unwrap(),
Request {
route: Route::Documents,
budget: Duration::from_millis(1500),
content_length: 0,
body: Vec::new(),
mechanical_contract: None,
}
);
assert_eq!(
parse_test(&request("X-Adom-Timeout-Ms: 25\r\n"))
.unwrap()
.budget,
Duration::from_millis(25)
);
for timeout in ["0", "24", "2001", "-1", "1.5", "9999999999999999999999999"] {
assert_eq!(
parse_test(&request(&format!("X-Adom-Timeout-Ms: {timeout}\r\n")))
.unwrap_err()
.code,
"invalid_timeout"
);
}
}
#[test]
fn refuses_browser_cross_origin_and_wrong_endpoint_authority() {
assert_eq!(
parse_test(&request("Origin: null\r\n")).unwrap_err().code,
"browser_origin_refused"
);
assert_eq!(
parse_test(&request("").replace("127.0.0.1:54321", "attacker.invalid"))
.unwrap_err()
.code,
"host_refused"
);
assert_eq!(
parse_test(&request("").replace("Bearer secret", "Bearer other!"))
.unwrap_err()
.code,
"authentication_required"
);
assert_eq!(
parse_test(&request("").replace("Epoch: epoch", "Epoch: old"))
.unwrap_err()
.code,
"stale_runtime"
);
}
#[test]
fn ambiguous_http_framing_and_duplicates_fail_closed() {
for extra in [
"Transfer-Encoding: chunked\r\n",
"Upgrade: websocket\r\n",
"Expect: 100-continue\r\n",
] {
assert_eq!(
parse_test(&request(extra)).unwrap_err().code,
"unsupported_http_framing"
);
}
for extra in [
"authorization: Bearer other\r\n",
"HOST: 127.0.0.1:54321\r\n",
] {
assert_eq!(
parse_test(&request(extra)).unwrap_err().code,
"duplicate_header"
);
}
assert_eq!(
parse_test(&request("Content-Length: 1\r\n"))
.unwrap_err()
.code,
"body_refused"
);
}
#[test]
fn no_mutations_scripts_queries_or_absolute_urls() {
assert_eq!(
parse_test(&request("").replace("GET ", "POST "))
.unwrap_err()
.status,
405
);
for path in [
"/v1/mechanical",
"/v1/script",
"/v1/documents?x=1",
"http://127.0.0.1:54321/v1/documents",
] {
assert_eq!(
parse_test(&request("").replace("/v1/documents", path))
.unwrap_err()
.status,
404
);
}
}
#[test]
fn rejects_oversized_and_control_character_headers() {
assert_eq!(
parse_test(&request(&format!("X-Large: {}\r\n", "a".repeat(8192))))
.unwrap_err()
.status,
431
);
for bad in ["X-A: a\0b\r\n", " X-A: a\r\n", "X-A: \té\r\n"] {
assert_eq!(parse_test(&request(bad)).unwrap_err().status, 400);
}
}
}
#[cfg(test)]
mod material_contract_test {
use super::*;
#[test]
fn parses_material_catalog_contract_without_weakening_unknown_header_refusal() {
let bytes=b"POST /v1/mechanical HTTP/1.1\r\nHost: 127.0.0.1:54321\r\nAuthorization: Bearer secret\r\nX-Adom-Fusion-Epoch: epoch\r\nContent-Type: application/json\r\nContent-Length: 2\r\nX-Adom-Mechanical-Contract: native-material-catalog-v1\r\n\r\n";
let v = parse(bytes, "127.0.0.1:54321", "secret", "epoch");
assert_eq!(
v.unwrap().mechanical_contract.as_deref(),
Some("native-material-catalog-v1")
);
let unknown = String::from_utf8(bytes.to_vec()).unwrap().replace(
"native-material-catalog-v1",
"native-material-catalog-future",
);
assert!(parse(unknown.as_bytes(), "127.0.0.1:54321", "secret", "epoch").is_err());
}
}