Project alias, cwd, scope and command ceilings from actual sessions. Bind initial attachment cwd once in the shared broker/router while keeping root-relative delegation and detach generations. Preserve caller coordinates across reads, searches, checked checkout and commands; keep provider authorization and symlink boundaries. Connect real SubWorkers, all filesystem Tools, scoped aliases, HTTP providers and limited Host grants in permanent regressions. Validated related normal suites (workdir http-client, tools, worker, worker-runtime, server-api typescript, fs-operation), workspace-server normal suite (1272 passed, 3 existing ignores), root check, product binary builds, rustdoc/doctests, generated contract checks, format and state-contract names. Test roots are checkout-local; Git parent discovery requires GIT_CEILING_DIRECTORIES at TMPDIR, confirmed against exact b860 baseline.
326 lines
12 KiB
Rust
326 lines
12 KiB
Rust
#![cfg(feature = "http-client")]
|
|
//! Loopback fake at the HTTP provider boundary; no product process or live service.
|
|
use std::{
|
|
io::{Read, Write},
|
|
net::{TcpListener, TcpStream},
|
|
thread,
|
|
time::{Duration, Instant},
|
|
};
|
|
use workdir::{
|
|
StatRequest, Workdir, WorkdirDenialReason as Reason, WorkdirPath, WorkdirSession,
|
|
http::{
|
|
OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirTransportError,
|
|
WorkdirTransportErrorCode as Code,
|
|
},
|
|
};
|
|
|
|
fn opened(workdir_id: &str) -> String {
|
|
serde_json::to_string(&workdir::http::OpenWorkdirSessionResponse {
|
|
session_id: workdir::http::WorkdirSessionId::new("s").unwrap(),
|
|
workdir_id: Workdir::new(workdir_id).id().clone(),
|
|
capabilities: workdir::WorkdirSessionCapabilities::ALL,
|
|
environment: workdir::WorkdirSessionEnvironment::root(
|
|
workdir::WorkdirSessionCapabilities::ALL,
|
|
),
|
|
})
|
|
.unwrap()
|
|
}
|
|
|
|
fn reply(listener: &TcpListener, status: &str, body: &str) {
|
|
listener.set_nonblocking(true).unwrap();
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
let (mut stream, _) = loop {
|
|
match listener.accept() {
|
|
Ok(connection) => break connection,
|
|
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
|
assert!(Instant::now() < deadline, "fake provider accept timed out");
|
|
thread::yield_now();
|
|
}
|
|
Err(error) => panic!("fake provider accept failed: {error}"),
|
|
}
|
|
};
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.unwrap();
|
|
stream
|
|
.set_write_timeout(Some(Duration::from_secs(5)))
|
|
.unwrap();
|
|
read_request(&mut stream);
|
|
write!(stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
|
|
}
|
|
fn read_request(stream: &mut TcpStream) {
|
|
let mut bytes = Vec::new();
|
|
let mut byte = [0];
|
|
while !bytes.ends_with(b"\r\n\r\n") {
|
|
stream.read_exact(&mut byte).unwrap();
|
|
bytes.push(byte[0]);
|
|
assert!(bytes.len() < 16384);
|
|
}
|
|
let headers = String::from_utf8(bytes).unwrap();
|
|
let length: usize = headers
|
|
.lines()
|
|
.find_map(|line| {
|
|
let (name, value) = line.split_once(':')?;
|
|
name.eq_ignore_ascii_case("content-length")
|
|
.then(|| value.trim().parse().unwrap())
|
|
})
|
|
.unwrap_or(0);
|
|
assert!(length < 16384);
|
|
stream.read_exact(&mut vec![0; length]).unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remote_http_denials_forward_only_typed_reasons_and_old_missing_reason() {
|
|
for (code, reason) in [
|
|
(Code::Denied, Some(Reason::ReadOnlySession)),
|
|
(Code::Denied, Some(Reason::OsPermissionDenied)),
|
|
(Code::OutOfScope, Some(Reason::OsPermissionDenied)),
|
|
(Code::Denied, None),
|
|
] {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let address = listener.local_addr().unwrap();
|
|
// Host paths and secrets in provider-authored text must not become diagnostics.
|
|
let mut error = serde_json::json!({"code":code, "message":"/secret/host/root bearer-secret command-secret"});
|
|
if let Some(reason) = reason {
|
|
error["denial_reason"] = serde_json::to_value(reason).unwrap();
|
|
}
|
|
let server = thread::spawn(move || {
|
|
reply(&listener, "200 OK", &opened("w"));
|
|
reply(&listener, "403 Forbidden", &error.to_string());
|
|
});
|
|
let remote = RemoteWorkdirSession::open(
|
|
reqwest::Client::builder()
|
|
.no_proxy()
|
|
.timeout(Duration::from_secs(5))
|
|
.build()
|
|
.unwrap(),
|
|
format!("http://{address}").parse().unwrap(),
|
|
"request-secret",
|
|
Workdir::new("w").id().clone(),
|
|
OpenWorkdirSessionRequest::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let denied = remote
|
|
.stat(StatRequest {
|
|
path: WorkdirPath::new("file").unwrap(),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(denied.denial_reason(), reason);
|
|
assert!(!denied.to_string().contains("secret"));
|
|
let forwarded = WorkdirTransportError::from_workdir_error(&denied);
|
|
assert_eq!(forwarded.denial_reason, reason);
|
|
assert_eq!(forwarded.code, code);
|
|
assert_eq!(forwarded.code.http_status(), 403);
|
|
assert_eq!(
|
|
forwarded.message,
|
|
if code == Code::Denied {
|
|
"Workdir operation was denied"
|
|
} else {
|
|
"Workdir path is out of scope"
|
|
}
|
|
);
|
|
let wire = serde_json::to_string(&forwarded).unwrap();
|
|
assert!(!wire.contains("secret"));
|
|
assert!(wire.len() < 256);
|
|
server.join().unwrap();
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remote_checkout_diagnostics_preserve_mutation_outcome_and_read_failures() {
|
|
use workdir::{CheckoutOperation as Op, CheckoutRequest, WorkdirSessionCapability};
|
|
for code in [
|
|
Code::Unavailable,
|
|
Code::Transport,
|
|
Code::Denied,
|
|
Code::Conflict,
|
|
Code::OutcomeUnknown,
|
|
] {
|
|
for reason in [None, Some(Reason::ReadOnlySession)] {
|
|
for operation in [
|
|
Op::Read {
|
|
offset: 0,
|
|
limit: 1,
|
|
max_bytes: 10,
|
|
},
|
|
Op::Create {
|
|
path: WorkdirPath::new("new.txt").unwrap(),
|
|
content: b"fixture".to_vec(),
|
|
},
|
|
Op::Write {
|
|
content: b"fixture".to_vec(),
|
|
expected_hash: [0; 32],
|
|
},
|
|
Op::Edit {
|
|
old_string: "old".into(),
|
|
new_string: "new".into(),
|
|
replace_all: false,
|
|
expected_hash: [0; 32],
|
|
},
|
|
] {
|
|
let mutation = operation.capability() != WorkdirSessionCapability::Read;
|
|
let expected_code =
|
|
if mutation && matches!(code, Code::Unavailable | Code::Transport) {
|
|
Code::OutcomeUnknown
|
|
} else {
|
|
code
|
|
};
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let address = listener.local_addr().unwrap();
|
|
let error = serde_json::json!({"code":code, "message":"/secret/host provider-secret", "denial_reason":reason});
|
|
let server = thread::spawn(move || {
|
|
reply(&listener, "200 OK", &opened("w"));
|
|
reply(
|
|
&listener,
|
|
&format!("{} Fixture failure", code.http_status()),
|
|
&error.to_string(),
|
|
);
|
|
});
|
|
let remote = RemoteWorkdirSession::open(
|
|
reqwest::Client::builder()
|
|
.no_proxy()
|
|
.timeout(Duration::from_secs(5))
|
|
.build()
|
|
.unwrap(),
|
|
format!("http://{address}").parse().unwrap(),
|
|
"request-secret",
|
|
Workdir::new("w").id().clone(),
|
|
OpenWorkdirSessionRequest::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let error = remote
|
|
.checkout_execute(CheckoutRequest {
|
|
target: WorkdirPath::root(),
|
|
validator: vec![1],
|
|
operation,
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
server.join().unwrap();
|
|
let forwarded = WorkdirTransportError::from_workdir_error(&error);
|
|
assert_eq!(
|
|
forwarded.code, expected_code,
|
|
"{code:?}, {reason:?}, mutation={mutation}: {error:?}"
|
|
);
|
|
assert_eq!(error.denial_reason(), reason);
|
|
assert_eq!(forwarded.denial_reason, reason);
|
|
assert_eq!(forwarded.code.http_status(), expected_code.http_status());
|
|
if expected_code == Code::OutcomeUnknown {
|
|
assert!(forwarded.message.contains("inspect before retrying"));
|
|
}
|
|
assert!(
|
|
!serde_json::to_string(&forwarded)
|
|
.unwrap()
|
|
.contains("secret")
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mismatched_http_results_keep_cause_and_mutation_uncertainty() {
|
|
use workdir::{
|
|
CheckoutOperation, CheckoutRequest, CommandHandle, CommandOutputRequest,
|
|
WorkdirFailureReason as Failure,
|
|
};
|
|
for case in ["variant", "bytes", "mutation", "identity"] {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let address = listener.local_addr().unwrap();
|
|
let server = thread::spawn(move || {
|
|
reply(
|
|
&listener,
|
|
"200 OK",
|
|
&opened(if case == "identity" {
|
|
"wrong-secret"
|
|
} else {
|
|
"w"
|
|
}),
|
|
);
|
|
if case != "identity" {
|
|
let result = if case == "bytes" {
|
|
serde_json::json!({"operation":"command_output","result":{
|
|
"chunks": [], "next_cursor": 123, "terminal": null
|
|
}})
|
|
} else {
|
|
serde_json::to_value(
|
|
workdir::http::WorkdirSessionOperationResult::CommandCancel,
|
|
)
|
|
.unwrap()
|
|
};
|
|
reply(&listener, "200 OK", &result.to_string());
|
|
}
|
|
});
|
|
let opened = RemoteWorkdirSession::open(
|
|
reqwest::Client::builder()
|
|
.no_proxy()
|
|
.timeout(Duration::from_secs(5))
|
|
.build()
|
|
.unwrap(),
|
|
format!("http://{address}").parse().unwrap(),
|
|
"request-secret",
|
|
Workdir::new("w").id().clone(),
|
|
OpenWorkdirSessionRequest::default(),
|
|
)
|
|
.await;
|
|
let error = if case == "identity" {
|
|
opened.unwrap_err()
|
|
} else {
|
|
let remote = opened.unwrap();
|
|
match case {
|
|
"variant" => remote
|
|
.stat(StatRequest {
|
|
path: WorkdirPath::new("file-secret").unwrap(),
|
|
})
|
|
.await
|
|
.unwrap_err(),
|
|
"bytes" => remote
|
|
.command_output(CommandOutputRequest {
|
|
handle: CommandHandle("command-secret".into()),
|
|
cursor: 0,
|
|
limit: 16,
|
|
wait: false,
|
|
})
|
|
.await
|
|
.unwrap_err(),
|
|
"mutation" => remote
|
|
.checkout_execute(CheckoutRequest {
|
|
target: WorkdirPath::new("file-secret").unwrap(),
|
|
validator: vec![1],
|
|
operation: CheckoutOperation::Write {
|
|
content: b"body-secret".to_vec(),
|
|
expected_hash: [0; 32],
|
|
},
|
|
})
|
|
.await
|
|
.unwrap_err(),
|
|
_ => unreachable!(),
|
|
}
|
|
};
|
|
server.join().unwrap();
|
|
let expected = if case == "identity" {
|
|
Failure::SessionIdentityMismatch
|
|
} else {
|
|
Failure::OperationResultMismatch
|
|
};
|
|
assert_eq!(error.failure_reason(), Some(expected), "{case}: {error:?}");
|
|
let transport = WorkdirTransportError::from_workdir_error(&error);
|
|
assert_eq!(
|
|
transport.code,
|
|
if case == "mutation" {
|
|
Code::OutcomeUnknown
|
|
} else {
|
|
Code::Unavailable
|
|
}
|
|
);
|
|
let wire = serde_json::to_string(&transport).unwrap();
|
|
assert!(!wire.contains("secret"), "{wire}");
|
|
assert!(!format!("{error:?}").contains("secret"));
|
|
let forwarded = transport.into_workdir_error();
|
|
assert_eq!(forwarded.failure_reason(), Some(expected));
|
|
}
|
|
}
|