fix: retain structured Workdir refusal diagnostics across provider boundaries

This commit is contained in:
2026-10-09 22:23:22 +09:00
parent e8828cd1b5
commit f9d70e2ae2
28 changed files with 2053 additions and 198 deletions
@@ -0,0 +1,110 @@
#![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,
};
use workdir::{
StatRequest, Workdir, WorkdirDenialReason as Reason, WorkdirPath, WorkdirSession,
http::{
OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirTransportError,
WorkdirTransportErrorCode as Code,
},
};
fn reply(listener: &TcpListener, status: &str, body: &str) {
let (mut stream, _) = listener.accept().unwrap();
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",
r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"#,
);
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();
}
}