1use std::collections::BTreeMap;
8use std::fs::{self, OpenOptions};
9use std::io::Write;
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12
13use serde_json::{json, Map, Value};
14use sha2::{Digest, Sha256};
15
16use crate::openapi::{OperationTable, TableSource};
17
18pub const UNREACHABLE_STDERR: &str = r#"Error: Could not reach server at http://127.0.0.1:1
21 at cli.server-connection.resolve (/$bunfs/root/chunk-sjncn5eg.js:2:1149)
22 at cli.server-connection.resolve (definition) (/$bunfs/root/chunk-bsm4erxq.js:2:402)
23 at cli.api (/$bunfs/root/chunk-7wjtqsrz.js:7:21275)
24 at cli.api (definition) (/$bunfs/root/chunk-sjncn5eg.js:2:1109) {
25 [cause]: ClientError: Transport: Unable to connect. Is the computer able to access the url?
26 at <anonymous> (/$bunfs/root/chunk-71dqgg9j.js:2:1017)
27 at async <anonymous> (/$bunfs/root/chunk-71dqgg9j.js:2:1265)
28 at processTicksAndRejections (native:7:39) {
29 [cause]: TypeError: Unable to connect. Is the computer able to access the url?
30 at async <anonymous> (/$bunfs/root/chunk-71dqgg9j.js:2:981)
31 at async <anonymous> (/$bunfs/root/chunk-71dqgg9j.js:2:1265)
32 }
33}
34"#;
35
36const DEFAULT_URL: &str = "http://127.0.0.1:49374";
37const PIPE_CAP: usize = 262_144;
38const DEFAULT_BIG: usize = 300_000;
39
40pub fn percent_encode(s: &str) -> String {
42 let mut out = String::with_capacity(s.len());
43 for b in s.bytes() {
44 if b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-') {
45 out.push(b as char);
46 } else {
47 out.push_str(&format!("%{b:02X}"));
48 }
49 }
50 out
51}
52
53pub fn fixture_key(op: &str, params: &[(String, String)]) -> String {
56 if params.is_empty() {
57 return op.to_string();
58 }
59 let mut sorted: Vec<&(String, String)> = params.iter().collect();
60 sorted.sort_by(|a, b| a.0.cmp(&b.0));
61 let pairs: Vec<String> = sorted
62 .iter()
63 .map(|(k, v)| format!("{k}={}", percent_encode(v)))
64 .collect();
65 format!("{op}__{}", pairs.join("&"))
66}
67
68pub fn select_fixture(
72 fixtures: &Path,
73 op: &str,
74 params: &[(String, String)],
75 n: u32,
76) -> Option<PathBuf> {
77 let dir = fixtures.join("mock");
78 let numbered = |stem: &str| {
79 (1..=n.max(1))
80 .rev()
81 .map(|m| dir.join(format!("{stem}__{m}.json")))
82 .find(|p| p.is_file())
83 };
84 let plain = |stem: &str| Some(dir.join(format!("{stem}.json"))).filter(|p| p.is_file());
85 let with_params = fixture_key(op, params);
86 if with_params != op {
87 if let Some(p) = numbered(&with_params).or_else(|| plain(&with_params)) {
88 return Some(p);
89 }
90 }
91 numbered(op).or_else(|| plain(op))
92}
93
94pub fn parse_mode(spec: &str) -> Vec<(String, String)> {
96 spec.split(',')
97 .filter_map(|part| {
98 let (k, m) = part.split_once(':')?;
99 (!k.is_empty() && !m.is_empty()).then(|| (k.trim().to_string(), m.trim().to_string()))
100 })
101 .collect()
102}
103
104#[cfg(unix)]
106pub fn is_stdout_pipe() -> bool {
107 use std::os::fd::AsFd;
108 use std::os::unix::fs::FileTypeExt;
109 std::io::stdout()
110 .as_fd()
111 .try_clone_to_owned()
112 .ok()
113 .and_then(|fd| fs::File::from(fd).metadata().ok())
114 .is_some_and(|m| m.file_type().is_fifo())
115}
116
117#[cfg(windows)]
118pub fn is_stdout_pipe() -> bool {
119 use std::os::windows::io::AsRawHandle;
120 use windows::Win32::Foundation::HANDLE;
121 use windows::Win32::Storage::FileSystem::{GetFileType, FILE_TYPE_PIPE};
122 let handle = HANDLE(std::io::stdout().as_raw_handle());
123 unsafe { GetFileType(handle) == FILE_TYPE_PIPE }
125}
126
127#[cfg(not(any(unix, windows)))]
128pub fn is_stdout_pipe() -> bool {
129 false
130}
131
132pub fn env_presence_line(argv: &[String], env: &[(String, String)], stdout_kind: &str) -> String {
135 let mut seen: BTreeMap<String, Value> = BTreeMap::new();
136 for fixed in ["OPENCODE_PASSWORD", "OPENCODE_SERVER_PASSWORD"] {
137 seen.insert(
138 fixed.to_string(),
139 json!({"present": false, "len": 0, "sha8": null}),
140 );
141 }
142 for (k, v) in env {
143 if k == "OPENCODE_PASSWORD"
144 || k == "OPENCODE_SERVER_PASSWORD"
145 || k.starts_with("OPENCODE_CTL_")
146 {
147 let digest = Sha256::digest(v.as_bytes());
148 let sha8: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect();
149 seen.insert(
150 k.clone(),
151 json!({"present": true, "len": v.len(), "sha8": sha8}),
152 );
153 }
154 }
155 let env_obj: Map<String, Value> = seen.into_iter().collect();
156 json!({"argv": argv, "env": env_obj, "stdout_kind": stdout_kind}).to_string()
157}
158
159pub fn validate_body(table: &OperationTable, op: &str, body: &Value) -> Option<String> {
161 let operation = table.get(op)?;
162 operation
163 .body_required
164 .iter()
165 .find(|prop| body.get(prop.as_str()).is_none())
166 .cloned()
167}
168
169struct Reply {
170 exit: i32,
171 stdout: String,
172 stderr: String,
173}
174
175impl Reply {
176 fn new(exit: i32, stdout: &str, stderr: &str) -> Self {
177 Self {
178 exit,
179 stdout: stdout.to_string(),
180 stderr: stderr.to_string(),
181 }
182 }
183
184 fn http(status_line: &str, tag: &str, message: &str) -> Self {
185 Self::new(1, &tagged(tag, message), status_line)
186 }
187}
188
189fn tagged(tag: &str, message: &str) -> String {
190 json!({"_tag": tag, "message": message}).to_string()
191}
192
193fn unknown_error_stack(cause: &str) -> String {
194 format!(
195 "UnknownError\n at cli.api (/$bunfs/root/chunk-7wjtqsrz.js:7:21275)\n at cli.api (definition) (/$bunfs/root/chunk-sjncn5eg.js:2:1109) {{\n [cause]: {cause}\n at <anonymous> (/$bunfs/root/chunk-7wjtqsrz.js:7:21275)\n}}\n"
196 )
197}
198
199fn now_ms(env: &dyn Fn(&str) -> Option<String>) -> i64 {
200 env("MOCK_OPENCODE_NOW_MS")
201 .and_then(|v| v.trim().parse().ok())
202 .unwrap_or_else(|| {
203 std::time::SystemTime::now()
204 .duration_since(std::time::UNIX_EPOCH)
205 .map(|d| d.as_millis() as i64)
206 .unwrap_or(0)
207 })
208}
209
210fn expand_now(v: &mut Value, now: i64) {
212 match v {
213 Value::String(s) => {
214 if let Some(rest) = s.strip_prefix("{{now").and_then(|r| r.strip_suffix("}}")) {
215 let delta = match rest {
216 "" => Some(0),
217 r if r.starts_with('-') || r.starts_with('+') => r[1..]
218 .parse::<i64>()
219 .ok()
220 .map(|n| if r.starts_with('-') { -n } else { n }),
221 _ => None,
222 };
223 if let Some(d) = delta {
224 *v = json!(now + d);
225 }
226 }
227 }
228 Value::Array(a) => a.iter_mut().for_each(|x| expand_now(x, now)),
229 Value::Object(o) => o.values_mut().for_each(|x| expand_now(x, now)),
230 _ => {}
231 }
232}
233
234fn next_seq(state: &Path, key: &str) -> u32 {
236 let mut n = 1;
237 loop {
238 let marker = state.join(format!("{key}.{n}.seen"));
239 match OpenOptions::new()
240 .write(true)
241 .create_new(true)
242 .open(&marker)
243 {
244 Ok(_) => return n,
245 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => n += 1,
246 Err(_) => return n,
247 }
248 }
249}
250
251fn count_poll(state: &Path, key: &str) -> u64 {
253 let path = state.join(format!("{key}.polls"));
254 if let Ok(mut f) = OpenOptions::new().append(true).create(true).open(&path) {
255 let _ = f.write_all(b".");
256 }
257 fs::metadata(&path).map(|m| m.len()).unwrap_or(1)
258}
259
260fn reply_from_fixture(path: &Path, now: i64) -> Reply {
261 let text = fs::read_to_string(path).unwrap_or_default();
262 let Ok(mut value) = serde_json::from_str::<Value>(&text) else {
263 return Reply::new(0, &text, "");
265 };
266 expand_now(&mut value, now);
267 let stream = |v: Option<&Value>| match v {
268 None | Some(Value::Null) => String::new(),
269 Some(Value::String(s)) => s.clone(),
270 Some(other) => other.to_string(),
271 };
272 match value.get("exit").and_then(Value::as_i64) {
273 Some(exit) => Reply {
274 exit: exit as i32,
275 stdout: stream(value.get("stdout")),
276 stderr: stream(value.get("stderr")),
277 },
278 None => Reply::new(0, &value.to_string(), ""),
279 }
280}
281
282fn pending_row(kind: &str, params: &[(String, String)]) -> Value {
284 let session = params
285 .iter()
286 .find(|(k, _)| k == "sessionID")
287 .map_or("ses_aaaaaaaaaaaaaaaaaaaaaaaaaa", |(_, v)| v.as_str());
288 let row = if kind == "form" {
289 json!({"id": "frm_mock1", "sessionID": session, "title": "mock form",
290 "fields": [{"key": "answer", "type": "string"}]})
291 } else {
292 json!({"id": "per_mock1", "sessionID": session, "action": "edit",
293 "resources": ["/tmp/w/file.txt"]})
294 };
295 json!({"data": [row], "cursor": {"previous": null, "next": null}})
296}
297
298fn big_array(n: usize) -> Vec<u8> {
299 let n = n.max(3);
301 let pad = (n - 3) % 2;
302 let k = (n - 3 - pad) / 2;
303 let mut v = Vec::with_capacity(n);
304 v.push(b'[');
305 v.extend(std::iter::repeat_n(b' ', pad));
306 for _ in 0..k {
307 v.extend_from_slice(b"0,");
308 }
309 v.extend_from_slice(b"0]");
310 v
311}
312
313fn write_stdout(bytes: &[u8]) {
314 let mut out = std::io::stdout().lock();
315 let _ = out.write_all(bytes);
316 let _ = out.flush();
317}
318
319fn spawn_grandchild(state: &Path) {
320 #[cfg(unix)]
321 let mut cmd = {
322 let mut c = Command::new("sleep");
323 c.arg("300");
324 c
325 };
326 #[cfg(windows)]
327 let mut cmd = {
328 let mut c = Command::new("ping");
329 c.args(["-n", "300", "127.0.0.1"]);
330 c
331 };
332 #[cfg(not(any(unix, windows)))]
333 let mut cmd = Command::new("sleep");
334 cmd.stdin(Stdio::null())
335 .stdout(Stdio::null())
336 .stderr(Stdio::null());
337 if let Ok(child) = cmd.spawn() {
338 let _ = fs::write(state.join("grandchild.pid"), child.id().to_string());
339 }
340}
341
342struct Ctx<'a> {
343 fixtures: &'a Path,
344 state: &'a Path,
345 modes: Vec<(String, String)>,
346 now: i64,
347 after_polls: u64,
348 big_bytes: usize,
349}
350
351enum Outcome {
352 Reply(Reply),
353 Big,
354 Slow,
355}
356
357impl Ctx<'_> {
358 fn mode_of(&self, key: &str) -> Option<&str> {
359 self.modes
360 .iter()
361 .find(|(k, _)| k == key)
362 .map(|(_, m)| m.as_str())
363 }
364
365 fn fixture_reply(&self, key: &str, params: &[(String, String)], n: u32) -> Option<Reply> {
366 select_fixture(self.fixtures, key, params, n).map(|p| reply_from_fixture(&p, self.now))
367 }
368
369 fn mode_outcome(&self, key: &str, mode: &str, params: &[(String, String)]) -> Option<Outcome> {
371 let r = match mode {
372 "fail" => Reply::http(
373 "HTTP 500 Internal Server Error",
374 "UnknownError",
375 "mock fail",
376 ),
377 "unmapped" => Reply::new(1, "", "Error: something the mapping table does not know"),
378 "garbage" => Reply::new(0, "not json", ""),
379 "silent" => Reply::new(0, "", ""),
380 "stopped" => Reply::new(0, "stopped", ""),
381 "not-found" => Reply::http(
382 "HTTP 404 Not Found",
383 "SessionNotFoundError",
384 "session not found",
385 ),
386 "invalid-request" => {
387 Reply::http("HTTP 400 Bad Request", "InvalidRequestError", "bad request")
388 }
389 "409" => Reply::http("HTTP 409 Conflict", "ConflictError", "busy"),
390 "synthetic-conflict" => Reply::http(
391 "HTTP 409 Conflict",
392 "ConflictError",
393 "Synthetic input ID conflicts with an existing durable record: msg_x",
394 ),
395 "404-vanished" => Reply::http("HTTP 404 Not Found", "PermissionNotFoundError", "gone"),
396 "503" => Reply::http(
397 "HTTP 503 Service Unavailable",
398 "ServiceUnavailableError",
399 "unavailable",
400 ),
401 "unauthorized" => Reply::new(1, "", "Error: Server at http://x rejected the password"),
402 "unknown-op" => Reply::new(
403 1,
404 "",
405 &unknown_error_stack("Error: Operation not found: nope"),
406 ),
407 "unknown-error" => Reply::new(
408 1,
409 "",
410 &unknown_error_stack("Error: Missing path parameter: sessionID"),
411 ),
412 "unreachable" => Reply::new(1, "", UNREACHABLE_STDERR),
413 "slow" => return Some(Outcome::Slow),
414 "leave" => {
416 spawn_grandchild(self.state);
417 Reply::new(0, "{}", "")
418 }
419 "big" => return Some(Outcome::Big),
420 "permission" | "form" => {
421 if count_poll(self.state, key) > self.after_polls {
423 Reply::new(0, &pending_row(mode, params).to_string(), "")
424 } else {
425 return None;
426 }
427 }
428 other => Reply::new(2, "", &format!("mock-opencode: unknown mode {other}")),
429 };
430 Some(Outcome::Reply(r))
431 }
432
433 fn api(&self, op: &str, params: &[(String, String)], body: Option<&str>) -> Outcome {
434 let n = next_seq(self.state, op);
435 if let Some(mode) = self.mode_of(op) {
436 if let Some(o) = self.mode_outcome(op, mode, params) {
437 return o;
438 }
439 }
440 let get = |name: &str| params.iter().any(|(k, _)| k == name);
441 let bad =
442 |tag: &str, msg: &str| Outcome::Reply(Reply::new(1, &tagged(tag, msg), "HTTP 400"));
443 if op == "session.message.list" {
444 if params.iter().any(|(k, v)| k == "type" && v == "idle") {
445 return bad("InvalidRequestError", "invalid type");
446 }
447 if get("cursor") && get("order") {
448 return bad("InvalidCursorError", "order not allowed with cursor");
449 }
450 }
451 if let Some(body) = body {
452 let table = fs::read_to_string(self.fixtures.join("openapi.json"))
453 .ok()
454 .and_then(|t| serde_json::from_str::<Value>(&t).ok())
455 .and_then(|d| OperationTable::from_openapi(&d, TableSource::Live).ok());
456 match serde_json::from_str::<Value>(body) {
457 Err(_) => return bad("InvalidRequestError", "invalid json body"),
458 Ok(v) => {
459 if let Some(prop) = table.and_then(|t| validate_body(&t, op, &v)) {
460 return bad("InvalidRequestError", &format!("missing {prop}"));
461 }
462 }
463 }
464 }
465 match self.fixture_reply(op, params, n) {
466 Some(r) => Outcome::Reply(r),
467 None => Outcome::Reply(Reply::new(
468 1,
469 &json!({"_tag": "MockMissingFixture", "key": fixture_key(op, params)}).to_string(),
470 "",
471 )),
472 }
473 }
474
475 fn service(&self, verb: &str) -> Outcome {
476 let key = format!("service.{verb}");
477 let n = next_seq(self.state, &key);
478 if let Some(mode) = self.mode_of(&key) {
479 if mode != "stopped" || verb == "status" {
481 if let Some(o) = self.mode_outcome(&key, mode, &[]) {
482 return o;
483 }
484 }
485 }
486 if let Some(r) = self.fixture_reply(&key, &[], n) {
487 return Outcome::Reply(r);
488 }
489 let out = if verb == "status" { DEFAULT_URL } else { "" };
490 Outcome::Reply(Reply::new(0, out, ""))
491 }
492}
493
494fn finish(ctx: &Ctx, outcome: Outcome) -> i32 {
495 match outcome {
496 Outcome::Reply(r) => {
497 write_stdout(r.stdout.as_bytes());
498 let _ = std::io::stderr().write_all(r.stderr.as_bytes());
499 r.exit
500 }
501 Outcome::Big => {
502 let mut bytes = big_array(ctx.big_bytes);
503 if is_stdout_pipe() {
504 bytes.truncate(PIPE_CAP);
505 }
506 write_stdout(&bytes);
507 0
508 }
509 Outcome::Slow => {
510 spawn_grandchild(ctx.state);
511 loop {
512 std::thread::sleep(std::time::Duration::from_secs(3600));
513 }
514 }
515 }
516}
517
518fn parse_api(args: &[String]) -> (Vec<String>, Vec<(String, String)>, Option<String>) {
521 let (mut pos, mut params, mut body) = (Vec::new(), Vec::new(), None);
522 let mut it = args.iter();
523 while let Some(a) = it.next() {
524 match a.as_str() {
525 "--param" => {
526 if let Some((k, v)) = it.next().and_then(|kv| kv.split_once('=')) {
527 params.push((k.to_string(), v.to_string()));
528 }
529 }
530 "-d" | "--data" => body = it.next().cloned(),
531 "--server" => {
532 it.next();
533 }
534 _ => pos.push(a.clone()),
535 }
536 }
537 (pos, params, body)
538}
539
540fn append_log(state: &Path, line: &str) {
541 let mut text = line.to_string();
542 text.push('\n');
543 if let Ok(mut f) = OpenOptions::new()
544 .append(true)
545 .create(true)
546 .open(state.join("argv.jsonl"))
547 {
548 let _ = f.write_all(text.as_bytes());
550 }
551}
552
553pub fn main() {
554 let env = |k: &str| std::env::var(k).ok();
555 let (Some(state), Some(fixtures)) = (
556 env("MOCK_OPENCODE_STATE_DIR"),
557 env("MOCK_OPENCODE_FIXTURES"),
558 ) else {
559 eprintln!("mock-opencode: MOCK_OPENCODE_STATE_DIR and MOCK_OPENCODE_FIXTURES are required");
560 std::process::exit(2);
561 };
562 let (state, fixtures) = (PathBuf::from(state), PathBuf::from(fixtures));
563 let _ = fs::create_dir_all(&state);
564 let argv: Vec<String> = std::env::args().skip(1).collect();
565 let kind = if is_stdout_pipe() { "pipe" } else { "file" };
566 let vars: Vec<(String, String)> = std::env::vars().collect();
567 append_log(&state, &env_presence_line(&argv, &vars, kind));
568
569 let ctx = Ctx {
570 fixtures: &fixtures,
571 state: &state,
572 modes: env("MOCK_OPENCODE_MODE")
573 .map(|m| parse_mode(&m))
574 .unwrap_or_default(),
575 now: now_ms(&env),
576 after_polls: env("MOCK_OPENCODE_AFTER_POLLS")
577 .and_then(|v| v.trim().parse().ok())
578 .unwrap_or(0),
579 big_bytes: env("MOCK_OPENCODE_BIG_BYTES")
580 .and_then(|v| v.trim().parse().ok())
581 .unwrap_or(DEFAULT_BIG),
582 };
583 let outcome = match argv.first().map(String::as_str) {
584 Some("--version") => Outcome::Reply(Reply::new(0, "opencode v2.0.32\n", "")),
585 Some("service") => match argv.get(1).map(String::as_str) {
586 Some(v @ ("start" | "status" | "stop" | "restart" | "get" | "set" | "unset")) => {
587 ctx.service(v)
588 }
589 _ => Outcome::Reply(Reply::new(2, "", "mock-opencode: unknown service verb")),
590 },
591 Some("api") => {
592 let (pos, params, body) = parse_api(&argv[1..]);
593 match pos.as_slice() {
594 [m, path] if m.eq_ignore_ascii_case("GET") && path == "/openapi.json" => {
595 match fs::read_to_string(fixtures.join("openapi.json")) {
596 Ok(t) => Outcome::Reply(Reply::new(0, &t, "")),
597 Err(e) => Outcome::Reply(Reply::new(1, "", &format!("mock-opencode: {e}"))),
598 }
599 }
600 [op] => ctx.api(op, ¶ms, body.as_deref()),
601 _ => Outcome::Reply(Reply::new(
602 2,
603 "",
604 "Expected an operation name or an HTTP method and path",
605 )),
606 }
607 }
608 _ => Outcome::Reply(Reply::new(2, "", "mock-opencode: unsupported command")),
609 };
610 std::process::exit(finish(&ctx, outcome));
611}