Skip to main content

mock_upcloud/
http.rs

1//! **The socket: HTTP/1.1, hand-rolled, because one of the behaviours is not a
2//! status code.**
3//!
4//! Behaviour 2 is that `GET /1.3/price` fails at the **transport** level —
5//! connection reset, nothing written — and monetize's start-time credential
6//! probe reported "the credential in UPCLOUD_TOKEN could not be verified" for
7//! what was a dropped socket. A framework that hands back a `Response` cannot
8//! express "write nothing and close", and a mock that answered `503` there would
9//! never have provoked the misreport. So the server is a `TcpListener`, a request
10//! parser and a response writer: about two hundred lines, and every one of the
11//! twenty-two behaviours is reachable from it.
12//!
13//! Keep-alive is supported (the real plugin's `reqwest` client pools
14//! connections, and a mock that closed every socket would hide a pooling bug).
15//! Bodies are read by `Content-Length`; `Transfer-Encoding: chunked` requests
16//! are refused by name, because the real API does not send them and a silent
17//! mis-parse is worse than a refusal.
18//!
19//! # The surface
20//!
21//! | method | path | notes |
22//! |---|---|---|
23//! | GET | `/1.3/price` | behaviour 2 lives here |
24//! | GET | `/1.3/account` | what a credential probe reads |
25//! | GET | `/1.3/server` | `?label=k=v`, repeatable; **thin rows** (behaviour 4) |
26//! | GET | `/1.3/server/{uuid}` | the only place attachments and IPs exist; `404 SERVER_NOT_FOUND` for a uuid the LIST carries (36, by name) |
27//! | POST | `/1.3/server` | 98–105 s in `maintenance` (behaviour 6); `412 out_of_stock` in a sold-out zone (37) |
28//! | PUT | `/1.3/server/{uuid}` | plan · boot_order · labels · remote_access (behaviours 9, 14, 17) |
29//! | DELETE | `/1.3/server/{uuid}` | `?storages=1&backups=keep`; needs `stopped` (11); slow (7) |
30//! | POST | `/1.3/server/{uuid}/start` | `412 out_of_stock` (behaviour 3) |
31//! | POST | `/1.3/server/{uuid}/stop` | `soft`/`hard`; `timeout` is a STRING |
32//! | POST | `/1.3/server/{uuid}/restart` | |
33//! | POST | `/1.3/server/{uuid}/storage/attach` | |
34//! | POST | `/1.3/server/{uuid}/storage/detach` | refused on a started server (16); can answer `200` and leave the device attached (38, by name) |
35//! | POST | `/1.3/server/{uuid}/cdrom/eject` | works on a started server (16) |
36//! | POST | `/1.3/server/{uuid}/cdrom/load` | a medium into an EMPTY tray; `409 CDROM_DEVICE_IN_USE` otherwise (L110, documented, not measured) |
37//! | GET | `/1.3/server/{uuid}/firewall_rule` | **`403 ERROR_AUTHENTICATION_FAILED` for a deleted server** (1); the SAME 403 for a live one under a credential without the firewall permission (35, by name) |
38//! | GET | `/1.3/storage` | includes public templates |
39//! | GET | `/1.3/storage/private` | the account's own |
40//! | GET | `/1.3/storage/{uuid}` | |
41//! | POST | `/1.3/storage` | |
42//! | PUT | `/1.3/storage/{uuid}` | a shrink is refused by name (22) |
43//! | POST | `/1.3/storage/{uuid}/resize` | takes a `Resize Backup` FIRST and hands it back as `resize_backup` (34) |
44//! | DELETE | `/1.3/storage/{uuid}` | |
45//! | POST | `/1.3/storage/{uuid}/import` | direct upload |
46//!
47//! # The terraform door
48//!
49//! `private-holger-ops` reaches the same account through `UpCloudLtd/upcloud`
50//! 5.44 rather than through the plugin, and asks for four things the plugin
51//! never asks for. They are listed apart because they are INFERRED from the
52//! provider's own calls rather than measured against the account — see
53//! [`crate::tf`].
54//!
55//! | method | path | notes |
56//! |---|---|---|
57//! | GET | `/1.3/plan` | read before EVERY server create; an unknown plan is refused there |
58//! | GET | `/1.3/storage/public` · `/template` · `/favorite` | how an OS template is resolved BY TITLE |
59//! | PUT · POST | `/1.3/server/{uuid}/firewall_rule` | the rule SET, written whole (PUT) or appended to (POST) |
60//! | DELETE | `/1.3/server/{uuid}/firewall_rule/{position}` | one rule by position |
61//!
62//! `POST /1.3/server` takes the same door's richer body: the machine's network
63//! interfaces, its boot order, its firewall flag, its timezone and every device
64//! — the template to clone AND the volumes to attach.
65//!
66//! Everything else answers `404 MOCK_UPCLOUD_NOT_IMPLEMENTED` naming the path.
67//!
68//! # The mock's own door
69//!
70//! `/mock/…` is not an UpCloud path and cannot collide with one: `/mock/estate`
71//! (everything, for a test's assertions), `/mock/fault/{name}/arm|disarm`,
72//! `/mock/relay` (behaviour 12: reshuffle the addresses), `/mock/seed`.
73
74use crate::estate::{BootOrder, Estate, Label, Refusal, StorageKind};
75use crate::faults::{Fault, Faults};
76use crate::render;
77use serde_json::{json, Value};
78use std::sync::{Arc, Mutex};
79use tokio::io::{AsyncReadExt, AsyncWriteExt};
80use tokio::net::{TcpListener, TcpStream};
81
82/// The whole mock: one estate behind one lock. A real UpCloud account is one
83/// serialized thing too — two `POST /1.3/storage` calls do not interleave — so
84/// the lock is not a simplification, it is the provider's own concurrency.
85pub struct Mock {
86    pub estate: Mutex<Estate>,
87    /// **Behaviour 63: who the mock has heard from.** Per credential DIGEST
88    /// (sha256 of the bearer value, never the value): how many requests, how
89    /// many of them `GET /1.3/account`. The terraform provider reaches this
90    /// mock only through the undocumented `UPCLOUD_DEBUG_API_BASE_URL`; a
91    /// release that drops it would send a "mock" run to the account. A verb
92    /// that mints a per-run token and asks `/mock/heard` before `apply` or
93    /// `destroy` proves the provider spoke to THIS mock, or refuses.
94    pub heard: Mutex<std::collections::BTreeMap<String, Heard>>,
95    /// Every call, `(status, method, path, error_code)`, when built with
96    /// [`Mock::recording`]. The terraform contract test reads it; a storm never
97    /// turns it on.
98    pub calls: Option<Mutex<Vec<(u16, String, String, String)>>>,
99    /// ★ **Log every call, in order, on stderr.** Off by default and never on
100    /// in a storm: a hundred thousand purchases is ten million lines.
101    ///
102    /// It exists because the ORDER a client calls in is a fact about the client
103    /// that nothing else in this crate can show, and on 2026-09-21 that order
104    /// was the whole question — `UpCloudLtd/upcloud` 5.44.1 met
105    /// `SERVER_STATE_ILLEGAL` on a filesystem resize and there was no way to
106    /// tell whether it had resized before stopping, after starting, or whether
107    /// the mock had simply never finished the stop. A mock that reproduces
108    /// provider defects and cannot say what was called when is asking every
109    /// user to guess.
110    pub log: bool,
111    /// **Test-only injections** — see [`Injection`]. Compiled only with the
112    /// `test-inject` feature, so a mock built for anything else can never answer
113    /// with an invented error.
114    #[cfg(feature = "test-inject")]
115    pub injections: Mutex<Vec<Injection>>,
116}
117
118impl Mock {
119    fn build(estate: Estate, calls: Option<Mutex<Vec<(u16, String, String, String)>>>, log: bool) -> Arc<Mock> {
120        Arc::new(Mock {
121            estate: Mutex::new(estate),
122            heard: Mutex::default(),
123            calls,
124            log,
125            #[cfg(feature = "test-inject")]
126            injections: Mutex::default(),
127        })
128    }
129
130    pub fn new(estate: Estate) -> Arc<Mock> {
131        Mock::build(estate, None, false)
132    }
133
134    /// The same mock, keeping every call for a test to read ([`Mock::calls`]).
135    pub fn recording(estate: Estate) -> Arc<Mock> {
136        Mock::build(estate, Some(Mutex::default()), false)
137    }
138
139    /// The same mock, narrating. See [`Mock::log`].
140    pub fn logging(estate: Estate) -> Arc<Mock> {
141        Mock::build(estate, None, true)
142    }
143}
144
145/// One credential's traffic, as the mock heard it (behaviour 63).
146#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
147pub struct Heard {
148    pub requests: u64,
149    pub account_calls: u64,
150}
151
152impl Mock {
153    fn heard(&self, credential: &str, method: &str, path: &str) {
154        let key = crate::digest::sha256_hex(credential.as_bytes());
155        let mut h = self.heard.lock().unwrap();
156        let e = h.entry(key).or_default();
157        e.requests += 1;
158        if method == "GET" && path.trim_end_matches('/') == "/1.3/account" {
159            e.account_calls += 1;
160        }
161    }
162
163    /// What the mock heard from the credential whose sha256 is `digest`.
164    pub fn heard_from(&self, digest: &str) -> Heard {
165        self.heard.lock().unwrap().get(digest).copied().unwrap_or_default()
166    }
167}
168
169/// Bind and serve until the process ends. Returns the bound port, which is what
170/// a test needs when it asked for port 0.
171pub async fn serve(mock: Arc<Mock>, addr: &str) -> std::io::Result<(u16, tokio::task::JoinHandle<()>)> {
172    let listener = TcpListener::bind(addr).await?;
173    let port = listener.local_addr()?.port();
174    // The import reply hands back a `direct_upload_url`. Pointed at the mock's
175    // own socket, a caller that FOLLOWS that URL (rather than building one) is
176    // exercised end to end without being told where to go.
177    mock.estate.lock().unwrap().upload_base = format!("http://127.0.0.1:{port}");
178    let h = tokio::spawn(async move {
179        loop {
180            let Ok((sock, _)) = listener.accept().await else { continue };
181            // **TCP_NODELAY, and it is not a micro-optimisation.** The reply
182            // was written as a head and then a body, two segments, and the
183            // client's delayed ACK met Nagle's algorithm: MEASURED 143
184            // requests per second on LOOPBACK, about 7 ms each, where the work
185            // is microseconds. A hundred thousand purchases is a hundred
186            // requests each, so the stall alone was two days of the storm.
187            // The head and the body are now one write as well — that is the
188            // actual fix; this is the belt.
189            let _ = sock.set_nodelay(true);
190            let m = mock.clone();
191            tokio::spawn(async move {
192                let _ = connection(m, sock).await;
193            });
194        }
195    });
196    Ok((port, h))
197}
198
199// ── the parser ───────────────────────────────────────────────────────────────
200
201struct Req {
202    method: String,
203    path: String,
204    query: Vec<(String, String)>,
205    authorized: bool,
206    body: Value,
207    /// The body as it arrived. The upload session needs the BYTES, because the
208    /// digests it answers with are the real digests of them.
209    raw: Vec<u8>,
210}
211
212pub(crate) enum Outcome {
213    Reply { status: u16, body: Value },
214    /// **Behaviour 2.** Write nothing, close the socket. The client sees a
215    /// transport failure, not a status — which is the distinction that was
216    /// misreported as a bad credential.
217    Reset,
218}
219
220fn reply(status: u16, body: Value) -> Outcome {
221    Outcome::Reply { status, body }
222}
223
224fn refuse(r: Refusal) -> Outcome {
225    reply(r.status, render::error(r.code, &r.message))
226}
227
228async fn connection(mock: Arc<Mock>, mut sock: TcpStream) -> std::io::Result<()> {
229    let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024);
230    loop {
231        // Read until the headers are complete.
232        let head_end = loop {
233            if let Some(i) = find_crlfcrlf(&buf) {
234                break i;
235            }
236            let mut chunk = [0u8; 4096];
237            let n = sock.read(&mut chunk).await?;
238            if n == 0 {
239                return Ok(());
240            }
241            buf.extend_from_slice(&chunk[..n]);
242        };
243        let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
244        let mut lines = head.split("\r\n");
245        let Some(start) = lines.next() else { return Ok(()) };
246        let mut parts = start.split_whitespace();
247        let method = parts.next().unwrap_or("").to_string();
248        let target = parts.next().unwrap_or("/").to_string();
249
250        let mut content_length = 0usize;
251        let mut authorized = false;
252        let mut credential = String::new();
253        let mut chunked = false;
254        let mut keep_alive = true;
255        for l in lines {
256            let Some((k, v)) = l.split_once(':') else { continue };
257            let (k, v) = (k.trim().to_ascii_lowercase(), v.trim());
258            match k.as_str() {
259                "content-length" => content_length = v.parse().unwrap_or(0),
260                "authorization" => {
261                    authorized = !v.is_empty();
262                    credential = v.strip_prefix("Bearer ").unwrap_or(v).trim().to_string();
263                }
264                "transfer-encoding" => chunked = v.eq_ignore_ascii_case("chunked"),
265                "connection" => keep_alive = !v.eq_ignore_ascii_case("close"),
266                _ => {}
267            }
268        }
269
270        let body_start = head_end + 4;
271        while buf.len() < body_start + content_length {
272            let mut chunk = [0u8; 4096];
273            let n = sock.read(&mut chunk).await?;
274            if n == 0 {
275                return Ok(());
276            }
277            buf.extend_from_slice(&chunk[..n]);
278        }
279        let raw: Vec<u8> = buf[body_start..body_start + content_length].to_vec();
280        buf.drain(..body_start + content_length);
281
282        let outcome = if chunked && target.starts_with("/uploader/") {
283            // **Behaviour 42.** The direct-upload host wants a length: a chunked
284            // PUT answers 411 (gunnar `http.rs`: "a chunked PUT to the
285            // direct-upload URL answers 411, so Content-Length must be set").
286            reply(411, render::error("LENGTH_REQUIRED", "Content-Length is required"))
287        } else if chunked {
288            reply(
289                400,
290                render::error(
291                    "MOCK_UPCLOUD_CHUNKED_REQUEST",
292                    "the real API is never sent a chunked request by this estate, and the mock refuses to guess",
293                ),
294            )
295        } else {
296            answer(&mock, &method, &target, authorized.then_some(credential.as_str()), raw)
297        };
298
299        match outcome {
300            Outcome::Reset => return Ok(()),
301            Outcome::Reply { status, body } => {
302                let text = if body.is_null() { String::new() } else { body.to_string() };
303                let head = format!(
304                    "HTTP/1.1 {status} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: {}\r\n\r\n",
305                    reason(status),
306                    if status == 403 && text.contains("correlation_id") {
307                        // Behaviour 1 answers problem+json, as the real one does.
308                        "application/problem+json"
309                    } else {
310                        "application/json"
311                    },
312                    text.len(),
313                    if keep_alive { "keep-alive" } else { "close" }
314                );
315                // ONE write: a head segment followed by a body segment is what
316                // Nagle holds on to. See the note at `accept`.
317                let mut out = Vec::with_capacity(head.len() + text.len());
318                out.extend_from_slice(head.as_bytes());
319                out.extend_from_slice(text.as_bytes());
320                sock.write_all(&out).await?;
321                sock.flush().await?;
322                if !keep_alive {
323                    return Ok(());
324                }
325            }
326        }
327    }
328}
329
330fn reason(s: u16) -> &'static str {
331    match s {
332        200 => "OK",
333        201 => "Created",
334        202 => "Accepted",
335        204 => "No Content",
336        400 => "Bad Request",
337        401 => "Unauthorized",
338        402 => "Payment Required",
339        403 => "Forbidden",
340        404 => "Not Found",
341        409 => "Conflict",
342        411 => "Length Required",
343        412 => "Precondition Failed",
344        429 => "Too Many Requests",
345        502 => "Bad Gateway",
346        503 => "Service Unavailable",
347        511 => "Network Authentication Required",
348        _ => "Error",
349    }
350}
351
352fn find_crlfcrlf(b: &[u8]) -> Option<usize> {
353    b.windows(4).position(|w| w == b"\r\n\r\n")
354}
355
356fn split_target(t: &str) -> (String, Vec<(String, String)>) {
357    match t.split_once('?') {
358        None => (t.to_string(), vec![]),
359        Some((p, q)) => {
360            let params = q
361                .split('&')
362                .filter(|s| !s.is_empty())
363                .map(|kv| {
364                    let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
365                    (percent_decode(k), percent_decode(v))
366                })
367                .collect();
368            (p.to_string(), params)
369        }
370    }
371}
372
373/// `%3D` → `=`, `+` → space. The whole need: a label filter is
374/// `?label=monetize_ref%3Dabc`, and nothing else in this API is encoded.
375fn percent_decode(s: &str) -> String {
376    let b = s.as_bytes();
377    let mut out = Vec::with_capacity(b.len());
378    let mut i = 0;
379    while i < b.len() {
380        match b[i] {
381            b'%' if i + 2 < b.len() => {
382                let h = (hex(b[i + 1]), hex(b[i + 2]));
383                if let (Some(a), Some(c)) = h {
384                    out.push(a * 16 + c);
385                    i += 3;
386                    continue;
387                }
388                out.push(b[i]);
389                i += 1;
390            }
391            b'+' => {
392                out.push(b' ');
393                i += 1;
394            }
395            c => {
396                out.push(c);
397                i += 1;
398            }
399        }
400    }
401    String::from_utf8_lossy(&out).to_string()
402}
403
404fn hex(c: u8) -> Option<u8> {
405    match c {
406        b'0'..=b'9' => Some(c - b'0'),
407        b'a'..=b'f' => Some(c - b'a' + 10),
408        b'A'..=b'F' => Some(c - b'A' + 10),
409        _ => None,
410    }
411}
412
413// ── the router ───────────────────────────────────────────────────────────────
414
415fn route(mock: &Arc<Mock>, r: Req) -> Outcome {
416    if r.path.starts_with("/mock/") {
417        return mock_door(mock, &r);
418    }
419    // The upload session lives OUTSIDE /1.3, at the host the import reply named
420    // — `https://<zone>.img.upcloud.com/uploader/session/<uuid>` at the real
421    // provider, the mock's own socket here. It takes no Authorization header:
422    // the session id in the path IS the credential, which is worth reproducing
423    // because it means the URL is a bearer token in a log line.
424    if let Some(uuid) = r.path.strip_prefix("/uploader/session/") {
425        let uuid = uuid.to_string();
426        let mut e = mock.estate.lock().unwrap();
427        e.tick();
428        return match e.upload(&uuid, &r.raw) {
429            Err(x) => refuse(x),
430            // **The PUT answers the import object BARE — `{"written_bytes": …,
431            // "sha256sum": …}` — not wrapped in `storage_import`** (behaviour 67).
432            // MEASURED by the live re-image on 2026-09-21 08:42 (gunnar-upcloud
433            // reads `written_bytes` at the top level and its receipt says
434            // `done: true`); the wrapped shape here failed that same procedure
435            // with "the upload reply carries no `written_bytes`" (lane T14).
436            // UN-ENVELOPED: the uploader answers the import's fields at the
437            // top level — `{"written_bytes": …, "md5sum": …, "sha256sum": …}`
438            // (gunnar deploy/upcloud/src/upload.rs:5-8), which is what the
439            // live tool parsed on every real re-image. The import object's
440            // own `GET /storage/{uuid}/import` keeps its `storage_import`
441            // envelope; this reply never had one.
442            Ok(im) => reply(200, render::import(&im)),
443        };
444    }
445    if !r.authorized {
446        // No credential at all is the one case that IS a clean 401. A revoked
447        // one is not (behaviour 5) — it answers 0 rows and 403s, which is the
448        // ambiguity the whole estate has tripped over.
449        return reply(
450            401,
451            render::error("AUTHENTICATION_FAILED", "no Authorization header was sent"),
452        );
453    }
454    let Some(rest) = r.path.strip_prefix("/1.3") else {
455        return reply(404, render::not_implemented(&r.method, &r.path));
456    };
457    // **Behaviour 39.** An empty segment (`/1.3//storage`, a base URL with a
458    // trailing slash) is a 404 at UpCloud, with a message that names no path
459    // (gunnar `api.rs`, MEASURED). The mock used to collapse it and answer.
460    if rest.contains("//") {
461        return reply(404, render::error("NOT_FOUND", "Not found."));
462    }
463    {
464        let e = mock.estate.lock().unwrap();
465        // **Behaviour 43.** A dead token is a clean 401, on every call
466        // (MEASURED 2026-09-14, private-gunnar-ops ROTATION §2.3).
467        if e.faults.fires(Fault::DeadToken) {
468            return reply(401, render::error("AUTHENTICATION_FAILED", "Authentication failed using the given username and password."));
469        }
470        // **Behaviour 40.** A read answers 502 now and then (gunnar `wait.rs`).
471        if r.method == "GET" && e.faults.fires(Fault::ReadBadGateway) {
472            return reply(502, render::error("BAD_GATEWAY", "Bad Gateway"));
473        }
474    }
475    let seg: Vec<&str> = rest.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect();
476    let mut e = mock.estate.lock().unwrap();
477    e.tick();
478    let m = r.method.as_str();
479
480    match (m, seg.as_slice()) {
481        // ── price ───────────────────────────────────────────────────────────
482        ("GET", ["price"]) => {
483            if e.faults.fires(Fault::PriceTransportReset) {
484                return Outcome::Reset;
485            }
486            let zone = e.zone.clone();
487            reply(200, render::price(&zone))
488        }
489
490        // What a credential probe reads. A REVOKED credential answers 403 here
491        // while still answering 200-with-no-rows on the lists: the two together
492        // are behaviour 5, and neither on its own reproduces it.
493        ("GET", ["account"]) => {
494            if e.faults.fires(Fault::RevokedCredential) {
495                let cid = e.next_correlation_id();
496                return reply(403, render::auth_failed(&cid));
497            }
498            reply(200, json!({"account": {"username": "mock", "credits": 100_000.0}}))
499        }
500
501        ("GET", ["zone"]) => reply(
502            200,
503            json!({"zones": {"zone": [{"id": e.zone, "description": "Stockholm #1", "public": "yes"}]}}),
504        ),
505
506        // ── servers ─────────────────────────────────────────────────────────
507        ("GET", ["server"]) => {
508            let labels = label_filters(&r.query);
509            let created = !e.faults.fires(Fault::WithholdCreatedField);
510            let rows: Vec<Value> = e
511                .servers_matching(&labels)
512                .into_iter()
513                .map(|s| render::server(s, false, created))
514                .collect();
515            reply(200, json!({"servers": {"server": rows}}))
516        }
517
518        ("GET", ["server", uuid]) => {
519            if e.faults.fires(Fault::RevokedCredential) {
520                let cid = e.next_correlation_id();
521                return reply(403, render::auth_failed(&cid));
522            }
523            let created = !e.faults.fires(Fault::WithholdCreatedField);
524            match e.server(uuid) {
525                // **Behaviour 36.** The list carries this uuid and the detail
526                // says it does not exist. Same envelope as a genuine 404, on
527                // purpose: the caller cannot tell them apart HERE, only by
528                // holding the list beside it.
529                Some(_) if e.faults.fires(Fault::DetailNotFoundForListedServer) => {
530                    reply(404, render::error("SERVER_NOT_FOUND", &format!("server {uuid} not found")))
531                }
532                Some(s) => reply(200, json!({"server": render::server(s, true, created)})),
533                None => reply(404, render::error("SERVER_NOT_FOUND", &format!("server {uuid} not found"))),
534            }
535        }
536
537        // **Behaviour 1.** The firewall endpoint, for a server that is not
538        // there, answers 403 ERROR_AUTHENTICATION_FAILED with a correlation id.
539        // Whether it was deleted a minute ago or never existed, the answer is
540        // the same, and it is indistinguishable from a revoked token.
541        //
542        // **Behaviour 35** is the same 403, byte for byte, for a server that IS
543        // there and reads 200 on its own detail — a credential without the
544        // firewall permission. Nothing in this reply tells the two apart; the
545        // server's own detail does.
546        ("GET", ["server", _uuid, "firewall_rule"]) | ("GET", ["server", _uuid, "firewall_rule", _]) => {
547            let uuid = seg[1];
548            if e.server(uuid).is_some() && !e.faults.fires(Fault::FirewallForbidden) {
549                let rules = e.rules(uuid).unwrap_or(&[]).to_vec();
550                // One rule asked for by position, or the whole set.
551                if let Some(pos) = seg.get(3) {
552                    return match rules.iter().find(|r| r.position == *pos) {
553                        Some(r) => reply(200, json!({ "firewall_rule": crate::tf::render_rule(r) })),
554                        None => reply(
555                            404,
556                            render::error("FIREWALL_RULE_NOT_FOUND", &format!("no rule at position {pos}")),
557                        ),
558                    };
559                }
560                return reply(200, crate::tf::render_rules(&rules));
561            }
562            let cid = e.next_correlation_id();
563            reply(403, render::auth_failed(&cid))
564        }
565
566        // ── the firewall, WRITTEN ────────────────────────────────────────────
567        // The set is replaced whole. The provider writes it in one PUT and
568        // terraform's `upcloud_firewall_rules` is one resource per machine for
569        // the same reason: UpCloud's firewall is a SET, and a caller that
570        // thinks it is patching one rule is replacing all of them.
571        //
572        // The 403 of behaviours 1 and 35 is a READ answer and is not repeated
573        // here: nothing has measured what a write to a deleted server's
574        // firewall does, and inventing it would be the mock claiming a
575        // behaviour. A write to a uuid that is not there is the ordinary 404
576        // `set_rules` gives.
577        ("PUT", ["server", _uuid, "firewall_rule"]) | ("POST", ["server", _uuid, "firewall_rule"]) => {
578            let uuid = seg[1].to_string();
579            let mut rules = crate::tf::rules_from_body(&r.body);
580            // A single POST APPENDS to the set; a PUT replaces it. Both end in
581            // `set_rules`, which renumbers, so there is one writer.
582            if m == "POST" {
583                let mut existing = e.rules(&uuid).unwrap_or(&[]).to_vec();
584                existing.append(&mut rules);
585                rules = existing;
586            }
587            match e.set_rules(&uuid, rules) {
588                Err(x) => refuse(x),
589                Ok(()) => {
590                    let rules = e.rules(&uuid).unwrap_or(&[]).to_vec();
591                    reply(if m == "POST" { 201 } else { 200 }, crate::tf::render_rules(&rules))
592                }
593            }
594        }
595
596        ("DELETE", ["server", _uuid, "firewall_rule", _pos]) => {
597            let uuid = seg[1].to_string();
598            let pos = seg[3].to_string();
599            let kept: Vec<_> = e.rules(&uuid).unwrap_or(&[]).iter().filter(|r| r.position != pos).cloned().collect();
600            match e.set_rules(&uuid, kept) {
601                Err(x) => refuse(x),
602                Ok(()) => reply(204, Value::Null),
603            }
604        }
605
606        // ── the plan table ──────────────────────────────────────────────────
607        // The provider reads this before EVERY server create and refuses a plan
608        // that is not in it. See `tf::plans`.
609        ("GET", ["plan"]) => reply(200, crate::tf::plans()),
610
611        ("POST", ["server"]) => {
612            let b = &r.body["server"];
613            let plan = b["plan"].as_str().unwrap_or("1xCPU-1GB").to_string();
614            if !render::plan_known(&plan) {
615                return refuse(Refusal::new(400, "INVALID_PLAN", format!("no such plan: {plan}")));
616            }
617            let title = b["title"].as_str().unwrap_or("").to_string();
618            let hostname = b["hostname"].as_str().unwrap_or(&title).to_string();
619            let zone = b["zone"].as_str().unwrap_or(&e.zone).to_string();
620            let labels = read_server_labels(&b["labels"]);
621            let devs: Vec<Value> = b["storage_devices"]["storage_device"].as_array().cloned().unwrap_or_default();
622            let dev = devs.first().cloned().unwrap_or(Value::Null);
623            let disk_title = dev["title"].as_str().unwrap_or("boot").to_string();
624            let disk_gib = dev["size"].as_u64().unwrap_or(20);
625            // Further entries that name a `storage` put an EXISTING one on the
626            // new server (terraform sends `"action": "attach"`; the plugin may not) — an installer medium as `"type": "cdrom"` is
627            // the case this estate uses. Checked BEFORE anything is minted, so a
628            // refused create leaves no half a server behind.
629            let mut attach: Vec<(String, String, Option<String>)> = Vec::new();
630            for d in devs.iter().skip(1) {
631                let Some(st) = d["storage"].as_str().map(str::to_string) else { continue };
632                let kind = if d["type"].as_str() == Some("cdrom") { "cdrom" } else { "disk" }.to_string();
633                match e.storage(&st) {
634                    None => {
635                        return refuse(Refusal::new(404, "STORAGE_NOT_FOUND", format!("storage {st} not found")))
636                    }
637                    Some(x) if x.state != "online" => {
638                        return refuse(Refusal::new(
639                            409,
640                            "STORAGE_STATE_ILLEGAL",
641                            format!("storage {st} is {} — wait for online", x.state),
642                        ))
643                    }
644                    Some(_) => attach.push((st, kind, d["address"].as_str().map(str::to_string))),
645                }
646            }
647            match e.create_server(&title, &hostname, &plan, &zone, labels, &disk_title, disk_gib) {
648                Err(x) => refuse(x),
649                Ok(uuid) => {
650                    // ── the terraform half of the create body ───────────────
651                    // The plugin sends a title, a plan and one disk. terraform
652                    // sends the machine's interfaces, its boot order, its
653                    // firewall flag, its timezone and EVERY device — the
654                    // template to clone AND the data volume to attach. All of
655                    // it is said here, in the create, because that is where the
656                    // provider says it; a machine that had to be PUT afterwards
657                    // to get its second interface would go through
658                    // `modify_server`'s stop/start and model a provider nobody
659                    // has.
660                    let ifaces = crate::tf::interfaces_from_body(b);
661                    let boot = b["boot_order"].as_str().and_then(BootOrder::parse);
662                    let firewall_on = b["firewall"].as_str().unwrap_or("off") == "on";
663                    let metadata = b["metadata"].as_str().unwrap_or("yes") != "no";
664                    let tz = b["timezone"].as_str().unwrap_or("UTC").to_string();
665                    if let Err(x) = e.configure_server(&uuid, ifaces, boot, firewall_on, metadata, &tz) {
666                        return refuse(x);
667                    }
668                    // Device 0 is the boot disk `create_server` already minted
669                    // from the template. Every device AFTER it that names an
670                    // existing storage is an attach (validated above, before
671                    // anything was minted). A cdrom is legal HERE — the server
672                    // has never run — which `attach_at_create` knows and a
673                    // later `attach` does not.
674                    for (st, kind, want) in &attach {
675                        if let Err(x) = e.attach_at_create(&uuid, st, kind, want.as_deref()) {
676                            return refuse(x);
677                        }
678                    }
679                    // The object is committed. Whether the CALLER hears about it
680                    // is a separate question (behaviour: CommitThenDropReply) —
681                    // and a plugin that retries without a label search buys twice.
682                    if e.faults.fires(Fault::CommitThenDropReply) {
683                        return Outcome::Reset;
684                    }
685                    let created = !e.faults.fires(Fault::WithholdCreatedField);
686                    let s = e.server(&uuid).expect("just created");
687                    reply(201, json!({"server": render::server(s, true, created)}))
688                }
689            }
690        }
691
692        ("PUT", ["server", uuid]) => {
693            let b = &r.body["server"];
694            let plan = b["plan"].as_str().map(str::to_string);
695            let bo = b["boot_order"].as_str().and_then(parse_boot_order);
696            let labels = if b["labels"].is_null() { None } else { Some(read_server_labels(&b["labels"])) };
697            // `"yes"`/`"no"` — strings, like everything else boolean in this API.
698            let ra = b["remote_access_enabled"].as_str().map(|s| s == "yes");
699            let rap = b["remote_access_password"].as_str().map(str::to_string);
700            // hostname and title are changed in place (terraform plans them so,
701            // MEASURED 5.44.1). The mock dropped both, and the provider then
702            // refused its own apply: ".hostname: was gunnar-front2, but now
703            // gunnar-front" (lane T13, 2026-09-21).
704            let rename = (b["hostname"].as_str().map(str::to_string), b["title"].as_str().map(str::to_string));
705            if let Some(p) = &plan {
706                if !render::plan_known(p) {
707                    return refuse(Refusal::new(400, "INVALID_PLAN", format!("no such plan: {p}")));
708                }
709            }
710            match e.modify_server(uuid, plan.as_deref(), bo, labels, ra, rap.as_deref()) {
711                Err(x) => refuse(x),
712                Ok(()) => {
713                    e.rename_server(uuid, rename.0.as_deref(), rename.1.as_deref());
714                    let created = !e.faults.fires(Fault::WithholdCreatedField);
715                    let s = e.server(uuid).expect("modified");
716                    // **Behaviour 41.** 202, MEASURED (RESUME-2026-09-19: boot_order PUT → 202).
717                    reply(202, json!({"server": render::server(s, true, created)}))
718                }
719            }
720        }
721
722        ("DELETE", ["server", uuid]) => {
723            let with_storages = r
724                .query
725                .iter()
726                .any(|(k, v)| k == "storages" && (v == "1" || v == "true"));
727            match e.delete_server(uuid, with_storages) {
728                Err(x) => refuse(x),
729                // 204, and the server is still there, in `maintenance`, for the
730                // next five minutes (behaviour 7). A caller that reads 204 as
731                // "gone" is wrong and its next list proves it.
732                Ok(()) => reply(204, Value::Null),
733            }
734        }
735
736        ("POST", ["server", uuid, "start"]) => match e.start_server(uuid) {
737            Err(x) => refuse(x),
738            Ok(()) => {
739                let created = !e.faults.fires(Fault::WithholdCreatedField);
740                let s = e.server(uuid).expect("started");
741                reply(200, json!({"server": render::server(s, true, created)}))
742            }
743        },
744
745        ("POST", ["server", uuid, "stop"]) => {
746            // **Behaviour 47.** `timeout_action` belongs to RESTART; a stop that
747            // carries it is a 400 (MEASURED 2026-09-14, memory:
748            // upcloud-reimage-install-loop). The error code is not recorded.
749            if !r.body["stop_server"]["timeout_action"].is_null() {
750                return refuse(Refusal::new(
751                    400,
752                    "INVALID_STOP_SERVER",
753                    "stop_server has no attribute timeout_action (it belongs to restart_server)",
754                ));
755            }
756            let hard = r.body["stop_server"]["stop_type"].as_str() == Some("hard");
757            match e.stop_server(uuid, hard) {
758                Err(x) => refuse(x),
759                Ok(()) => {
760                    let created = !e.faults.fires(Fault::WithholdCreatedField);
761                    let s = e.server(uuid).expect("stopping");
762                    reply(200, json!({"server": render::server(s, true, created)}))
763                }
764            }
765        }
766
767        // A restart is a stop and a start, and it goes through BOTH, so an
768        // `out_of_stock` on the way back up (behaviour 3) leaves the box
769        // stopped — which is the shape of the outage that was measured, and
770        // not something a single `restart` state would ever show.
771        ("POST", ["server", uuid, "restart"]) => {
772            let uuid = uuid.to_string();
773            if let Err(x) = e.stop_server(&uuid, false) {
774                return refuse(x);
775            }
776            e.run_to_quiet();
777            match e.start_server(&uuid) {
778                Err(x) => refuse(x),
779                Ok(()) => reply(200, json!({"server": {"uuid": uuid}})),
780            }
781        }
782
783        ("POST", ["server", uuid, "storage", "attach"]) => {
784            let d = &r.body["storage_device"];
785            let storage = d["storage"].as_str().unwrap_or("").to_string();
786            let kind = d["type"].as_str().unwrap_or("disk").to_string();
787            let want = d["address"].as_str().map(str::to_string);
788            match e.attach_at(uuid, &storage, &kind, want.as_deref()) {
789                Err(x) => refuse(x),
790                Ok(_) => {
791                    let created = !e.faults.fires(Fault::WithholdCreatedField);
792                    let s = e.server(uuid).expect("attached");
793                    reply(200, json!({"server": render::server(s, true, created)}))
794                }
795            }
796        }
797
798        ("POST", ["server", uuid, "storage", "detach"]) => {
799            let address = r.body["storage_device"]["address"].as_str().unwrap_or("").to_string();
800            match e.detach(uuid, &address) {
801                Err(x) => refuse(x),
802                Ok(()) => {
803                    let created = !e.faults.fires(Fault::WithholdCreatedField);
804                    let s = e.server(uuid).expect("detached");
805                    reply(200, json!({"server": render::server(s, true, created)}))
806                }
807            }
808        }
809
810        ("POST", ["server", uuid, "cdrom", "eject"]) => match e.eject(uuid) {
811            Err(x) => refuse(x),
812            Ok(()) => {
813                let created = !e.faults.fires(Fault::WithholdCreatedField);
814                let s = e.server(uuid).expect("ejected");
815                reply(200, json!({"server": render::server(s, true, created)}))
816            }
817        },
818
819        ("POST", ["server", uuid, "cdrom", "load"]) => {
820            let storage = r.body["storage_device"]["storage"].as_str().unwrap_or("").to_string();
821            match e.load_cdrom(uuid, &storage) {
822                Err(x) => refuse(x),
823                Ok(()) => {
824                    let created = !e.faults.fires(Fault::WithholdCreatedField);
825                    let s = e.server(uuid).expect("loaded");
826                    reply(200, json!({"server": render::server(s, true, created)}))
827                }
828            }
829        }
830
831        // ── storages ────────────────────────────────────────────────────────
832        // A bare `GET /1.3/storage` lists the PUBLIC templates too — thousands
833        // of rows at the real provider, four here, and none of them the
834        // account's. `/storage/private` is the one a cleanup must use.
835        ("GET", ["storage"]) | ("GET", ["storage", "private"]) => {
836            let private_only = seg.len() == 2;
837            let labels = label_filters(&r.query);
838            let created = !e.faults.fires(Fault::WithholdCreatedField);
839            let rows: Vec<Value> = e
840                .storages_matching(&labels, private_only)
841                .into_iter()
842                .map(|s| render::storage(&e, s, false, created))
843                .collect();
844            reply(200, json!({"storages": {"storage": rows}}))
845        }
846
847        // **`public`, `template` and `favorite` are ACCESS FILTERS, not uuids.**
848        // The provider resolves an OS template by TITLE through one of these
849        // before it creates a server — holger's estate names
850        // `Ubuntu Server 24.04 LTS (Noble Numbat)` and never a uuid — and
851        // without them the router read `template` as a storage uuid and
852        // answered `STORAGE_NOT_FOUND`, which reads as a missing image rather
853        // than a missing endpoint.
854        ("GET", ["storage", filter @ ("public" | "template" | "favorite")]) => {
855            let labels = label_filters(&r.query);
856            let created = !e.faults.fires(Fault::WithholdCreatedField);
857            let rows: Vec<Value> = e
858                .storages_matching(&labels, false)
859                .into_iter()
860                .filter(|s| match *filter {
861                    // `favorite` is an account's own shortlist and this account
862                    // has none — an empty list, not an error.
863                    "favorite" => false,
864                    _ => s.kind == StorageKind::Template,
865                })
866                .map(|s| render::storage(&e, s, false, created))
867                .collect();
868            reply(200, json!({"storages": {"storage": rows}}))
869        }
870
871        ("GET", ["storage", uuid]) => {
872            if e.faults.fires(Fault::RevokedCredential) {
873                let cid = e.next_correlation_id();
874                return reply(403, render::auth_failed(&cid));
875            }
876            let created = !e.faults.fires(Fault::WithholdCreatedField);
877            match e.storage(uuid) {
878                Some(s) => reply(200, json!({"storage": render::storage(&e, s, true, created)})),
879                None => reply(404, render::error("STORAGE_NOT_FOUND", &format!("storage {uuid} not found"))),
880            }
881        }
882
883        ("POST", ["storage"]) => {
884            let b = &r.body["storage"];
885            let title = b["title"].as_str().unwrap_or("").to_string();
886            let size = b["size"].as_u64().or_else(|| b["size"].as_str().and_then(|s| s.parse().ok())).unwrap_or(0);
887            let tier = b["tier"].as_str().unwrap_or("maxiops").to_string();
888            let zone = b["zone"].as_str().unwrap_or(&e.zone).to_string();
889            let labels = read_flat_labels(&b["labels"]);
890            match e.create_storage(&title, size, &tier, &zone, labels) {
891                Err(x) => refuse(x),
892                Ok(uuid) => {
893                    if e.faults.fires(Fault::CommitThenDropReply) {
894                        return Outcome::Reset;
895                    }
896                    let created = !e.faults.fires(Fault::WithholdCreatedField);
897                    let s = e.storage(&uuid).expect("just created");
898                    reply(201, json!({"storage": render::storage(&e, s, true, created)}))
899                }
900            }
901        }
902
903        ("PUT", ["storage", uuid]) => {
904            let b = &r.body["storage"];
905            let size = b["size"].as_u64().or_else(|| b["size"].as_str().and_then(|s| s.parse().ok()));
906            let title = b["title"].as_str().map(str::to_string);
907            match e.modify_storage(uuid, size, title.as_deref()) {
908                Err(x) => refuse(x),
909                Ok(()) => {
910                    let created = !e.faults.fires(Fault::WithholdCreatedField);
911                    let s = e.storage(uuid).expect("modified");
912                    reply(200, json!({"storage": render::storage(&e, s, true, created)}))
913                }
914            }
915        }
916
917        ("DELETE", ["storage", uuid]) => match e.delete_storage(uuid) {
918            Err(x) => refuse(x),
919            Ok(()) => reply(204, Value::Null),
920        },
921
922        // **Behaviour 34.** The filesystem resize takes a backup FIRST and hands
923        // it back whole as `resize_backup` — and that object then sits on the
924        // account, provider-titled, until somebody deletes it. See
925        // `Estate::resize_filesystem`.
926        ("POST", ["storage", uuid, "resize"]) => match e.resize_filesystem(uuid) {
927            Err(x) => refuse(x),
928            Ok(backup) => {
929                let created = !e.faults.fires(Fault::WithholdCreatedField);
930                let b = e.storage(&backup).expect("just minted");
931                reply(200, json!({"resize_backup": render::storage(&e, b, true, created)}))
932            }
933        },
934
935        // ── the direct-upload import ────────────────────────────────────────
936        // Two clocks. This opens the session; the PUT fills it; and the STORAGE
937        // then sits in `syncing` for a hundred and something seconds AFTER the
938        // import object already says `completed`.
939        ("POST", ["storage", uuid, "import"]) => {
940            let source = r.body["storage_import"]["source"].as_str().unwrap_or("direct_upload").to_string();
941            match e.start_import(uuid, &source) {
942                Err(x) => refuse(x),
943                Ok(im) => reply(201, json!({"storage_import": render::import(&im)})),
944            }
945        }
946
947        ("GET", ["storage", uuid, "import"]) => match e.storage(uuid).and_then(|s| s.import.as_ref()) {
948            None => reply(404, render::error("STORAGE_IMPORT_NOT_FOUND", &format!("no import session on {uuid}"))),
949            Some(im) => reply(200, json!({"storage_import": render::import(im)})),
950        },
951
952        // **The candidate fix.** See `Estate::clone_storage`: by default a clone
953        // waits exactly as long as an import, because whether it skips the sync
954        // is NOT MEASURED and the mock will not invent a saving.
955        ("POST", ["storage", uuid, "clone"]) => {
956            let title = r.body["storage"]["title"].as_str().unwrap_or("clone").to_string();
957            match e.clone_storage(uuid, &title) {
958                Err(x) => refuse(x),
959                Ok(new) => {
960                    let created = !e.faults.fires(Fault::WithholdCreatedField);
961                    let s = e.storage(&new).expect("just cloned");
962                    reply(201, json!({"storage": render::storage(&e, s, true, created)}))
963                }
964            }
965        }
966
967        _ => reply(404, render::not_implemented(m, &r.path)),
968    }
969}
970
971/// **One request against a mock, IN PROCESS, with no socket at all.**
972///
973/// The router is the only place several behaviours live — the transport reset,
974/// the dropped reply, the firewall's 403 — and [`crate::self_check`] has to
975/// reach them without binding a port, spawning a runtime or shelling out to
976/// anything. So the request struct is built here and handed straight to
977/// [`route`].
978///
979/// **Status `0` means [`Outcome::Reset`]**: the socket was closed with nothing
980/// written, which is not a status and must never be confused with one. A caller
981/// that treats 0 as success is making the exact mistake behaviour 2 was written
982/// for.
983///
984/// `authorized` is always true: the unauthenticated 401 is a property of the
985/// header parser above [`route`], not of the estate, and a calibration that
986/// drove it would be measuring the parser.
987pub fn probe(mock: &Arc<Mock>, method: &str, target: &str, body: Value) -> (u16, Value) {
988    let (path, query) = split_target(target);
989    let raw = if body.is_null() { vec![] } else { body.to_string().into_bytes() };
990    let r = Req { method: method.to_string(), path, query, authorized: true, body, raw };
991    match route(mock, r) {
992        Outcome::Reset => (0, Value::Null),
993        Outcome::Reply { status, body } => (status, body),
994    }
995}
996
997/// `"cdrom"`, `"disk"`, or the provider's list spelling `"cdrom,disk"` /
998/// `"disk,cdrom"` — whichever comes FIRST is the order that matters.
999fn parse_boot_order(s: &str) -> Option<BootOrder> {
1000    // The exact spelling first, so `cdrom,disk` reads back as it was written
1001    // (a terraform permadiff otherwise); any other list by its first entry.
1002    if let Some(b) = BootOrder::parse(s) {
1003        return Some(b);
1004    }
1005    match s.split(',').next().map(str::trim) {
1006        Some("cdrom") => Some(BootOrder::Cdrom),
1007        Some("disk") => Some(BootOrder::Disk),
1008        _ => None,
1009    }
1010}
1011
1012/// `?label=key=value`, repeatable, all must match.
1013fn label_filters(q: &[(String, String)]) -> Vec<(String, String)> {
1014    q.iter()
1015        .filter(|(k, _)| k == "label")
1016        .filter_map(|(_, v)| v.split_once('=').map(|(a, b)| (a.to_string(), b.to_string())))
1017        .collect()
1018}
1019
1020fn read_flat_labels(v: &Value) -> Vec<Label> {
1021    v.as_array()
1022        .map(|a| {
1023            a.iter()
1024                .filter_map(|l| {
1025                    Some(Label {
1026                        key: l["key"].as_str()?.to_string(),
1027                        value: l["value"].as_str().unwrap_or("").to_string(),
1028                    })
1029                })
1030                .collect()
1031        })
1032        .unwrap_or_default()
1033}
1034
1035/// A server's labels ride in an envelope (`{"label": [...]}`) while a storage's
1036/// do not. Both shapes are accepted on the way in, because both have been sent.
1037fn read_server_labels(v: &Value) -> Vec<Label> {
1038    if v["label"].is_array() {
1039        read_flat_labels(&v["label"])
1040    } else {
1041        read_flat_labels(v)
1042    }
1043}
1044
1045// ── the mock's own door ──────────────────────────────────────────────────────
1046
1047fn mock_door(mock: &Arc<Mock>, r: &Req) -> Outcome {
1048    let seg: Vec<&str> = r.path.trim_matches('/').split('/').skip(1).collect();
1049    let mut e = mock.estate.lock().unwrap();
1050    match (r.method.as_str(), seg.as_slice()) {
1051        ("GET", ["seed"]) => reply(200, json!({"seed": e.seed(), "lays": e.lays(), "now_ms": e.clock.now_ms()})),
1052        // **Behaviour 63.** What the mock heard from ONE credential, named by
1053        // its sha256 (the mock keeps no credential). A verb asks this before
1054        // `apply`/`destroy`: zero account calls from this run's token means the
1055        // provider did not talk to this mock, and the run must stop.
1056        ("GET", ["heard"]) => {
1057            let d = q(&r.query, "token_sha256");
1058            let h = mock.heard_from(&d);
1059            reply(200, json!({"token_sha256": d, "requests": h.requests, "account_calls": h.account_calls}))
1060        }
1061        // **Behaviour 62.** Inbound through the provider's firewall.
1062        ("GET", ["inbound", uuid]) => {
1063            e.settle();
1064            let port: u16 = q(&r.query, "port").parse().unwrap_or(22);
1065            let proto = { let p = q(&r.query, "proto"); if p.is_empty() { "tcp".to_string() } else { p } };
1066            match e.inbound(&q(&r.query, "from"), uuid, &proto, port) {
1067                Err(x) => reply(x.status, json!({"error": x.message})),
1068                Ok(reach) => reply(200, json!({"ok": reach.is_ok(), "why": reach.why()})),
1069            }
1070        }
1071        ("GET", ["udp-reply", uuid]) => {
1072            let port: u16 = q(&r.query, "port").parse().unwrap_or(53);
1073            match e.udp_reply_arrives(uuid, &q(&r.query, "from"), port) {
1074                Err(x) => reply(x.status, json!({"error": x.message})),
1075                Ok(yes) => reply(200, json!({"arrives": yes})),
1076            }
1077        }
1078        // **Behaviour 58.** The names the guest gives the disks.
1079        ("GET", ["disks", uuid]) => reply(
1080            200,
1081            json!({"disks": e.guest_disk_names(uuid).into_iter().map(|(a, n)| json!({"address": a, "name": n})).collect::<Vec<_>>()}),
1082        ),
1083        ("GET", ["estate"]) => {
1084            // `?as_is`: the estate NOW, without first running it to quiet. A
1085            // rebooting medium left in the tray (behaviour 69) never goes
1086            // quiet — each pass schedules the next — so the quiet view would
1087            // fast-forward a thousand passes to answer one question.
1088            if !r.query.iter().any(|(k, _)| k == "as_is") {
1089                e.run_to_quiet();
1090            }
1091            let servers: Vec<Value> = e
1092                .all_servers()
1093                .map(|s| json!({"uuid": s.uuid, "title": s.title, "state": s.state, "plan": s.plan,
1094                                "guest": format!("{:?}", s.guest), "public_ip": s.public_ip,
1095                                "utility_ip": s.utility_ip, "vnc_port": s.vnc_port,
1096                                "reported_vnc_port": s.reported_vnc_port, "boot_order": s.boot_order.as_str()}))
1097                .collect();
1098            let storages: Vec<Value> = e
1099                .all_storages()
1100                .filter(|s| s.kind != StorageKind::Template)
1101                .map(|s| json!({"uuid": s.uuid, "title": s.title, "state": s.state, "size": s.size_gib,
1102                                "type": s.kind.as_str(), "origin": s.origin,
1103                                "labels": s.labels.iter().map(|l| format!("{}={}", l.key, l.value)).collect::<Vec<_>>()}))
1104                .collect();
1105            reply(200, json!({"servers": servers, "storages": storages}))
1106        }
1107        // Move the virtual clock by hand: a test that must wait out a measured
1108        // window (the 2 s console settle) says so instead of polling for it.
1109        ("POST", ["advance", ms]) => {
1110            let ms: u64 = ms.parse().unwrap_or(0);
1111            e.clock.advance_ms(ms);
1112            e.settle();
1113            reply(200, json!({"now_ms": e.clock.now_ms()}))
1114        }
1115        ("POST", ["fault", name, verb]) => match Fault::parse(name) {
1116            None => reply(404, json!({"error": format!("no such fault: {name}")})),
1117            Some(f) => {
1118                match *verb {
1119                    "arm" => e.faults.arm(f),
1120                    "disarm" => e.faults.disarm(f),
1121                    _ => return reply(400, json!({"error": "arm or disarm"})),
1122                }
1123                reply(200, json!({"fault": f.name(), "armed": e.faults.is_armed(f)}))
1124            }
1125        },
1126        // **The guest's clock, and everything that follows from it.** Not an
1127        // UpCloud path: the provider has no endpoint that would tell you your
1128        // guest's wall clock is wrong, which is a large part of why it took an
1129        // afternoon. The mock has one so a fix can be asserted.
1130        ("GET", ["clock", uuid]) => {
1131            e.settle();
1132            let Some(s) = e.server(uuid) else {
1133                return reply(404, json!({"error": format!("no such server: {uuid}")}));
1134            };
1135            // The day it was measured, so the offset is the measured one.
1136            let t = crate::guest_clock::days_from_civil(2026, 9, 20) * 86_400;
1137            let skew = s.clock_skew_ms(t);
1138            let reds = crate::guest_clock::cascade(skew, crate::guest_clock::SkewWindow::default());
1139            reply(
1140                200,
1141                json!({
1142                    "uuid": s.uuid,
1143                    "zone": s.zone,
1144                    "hypervisor_rtc": "UTC",
1145                    "guest_reads_rtc_as": format!("{:?}", s.rtc),
1146                    "guest_clock_skew_ms": skew,
1147                    "udp_reply_arrives": crate::guest_clock::udp_reply_arrives(&e.faults),
1148                    "red": reds.iter().map(|r| json!({"row": r.row, "why": r.why})).collect::<Vec<_>>(),
1149                }),
1150            )
1151        }
1152        // **Reachability always names the asker.** There is no "is that address
1153        // up" here, only "is it up from there", because both of the asymmetries
1154        // this models make the answer depend on who is asking.
1155        ("GET", ["reach", from]) => {
1156            e.settle();
1157            let dest = r.query.iter().find(|(k, _)| k == "dest").map(|(_, v)| v.clone()).unwrap_or_default();
1158            let port: u16 = r
1159                .query
1160                .iter()
1161                .find(|(k, _)| k == "port")
1162                .and_then(|(_, v)| v.parse().ok())
1163                .unwrap_or(22);
1164            match e.reach(from, &dest, port) {
1165                Err(x) => reply(x.status, json!({"error": x.message})),
1166                Ok(reach) => reply(
1167                    200,
1168                    json!({
1169                        "from": from, "dest": dest, "port": port,
1170                        "ok": reach.is_ok(),
1171                        "why": reach.why(),
1172                        "kind": match &reach {
1173                            crate::net::Reach::Ok => "ok",
1174                            crate::net::Reach::NoRouteOutbound { .. } => "no-route-outbound",
1175                            crate::net::Reach::NoHairpin { .. } => "no-hairpin",
1176                            crate::net::Reach::Refused { .. } => "refused",
1177                            crate::net::Reach::Dropped { .. } => "dropped",
1178                        },
1179                        // The other direction, always, side by side — because a
1180                        // caller that asked only one of these is the caller who
1181                        // spent an afternoon on a clock.
1182                        "inbound_ok": crate::net::inbound_reaches(true),
1183                    }),
1184                ),
1185            }
1186        }
1187
1188        // The offer the utility NIC received. Always complete; what varies is
1189        // whether the guest took it.
1190        ("GET", ["dhcp", uuid]) => match e.server(uuid) {
1191            None => reply(404, json!({"error": format!("no such server: {uuid}")})),
1192            Some(s) => {
1193                let o = s.dhcp_offer();
1194                reply(
1195                    200,
1196                    json!({
1197                        "address": o.address,
1198                        "prefix": o.prefix,
1199                        "router": o.router,
1200                        "option_121": o.classless_static_routes.iter().map(|x| x.to_string()).collect::<Vec<_>>(),
1201                        "guest_dhcp_client": format!("{:?}", s.dhcp_client),
1202                    }),
1203                )
1204            }
1205        },
1206
1207        ("POST", ["dnat", on_server, port, to_address]) => {
1208            let port: u16 = port.parse().unwrap_or(0);
1209            e.dnat.push(crate::net::Dnat {
1210                on_server: on_server.to_string(),
1211                port,
1212                to_address: to_address.to_string(),
1213                to_port: port,
1214            });
1215            reply(200, json!({"rules": e.dnat.len()}))
1216        }
1217
1218        // **Three paths, one key.** A re-image mints a new host key every time,
1219        // so a changed key is expected and cannot be alarming on its own; the
1220        // only thing that separates a new machine from a stolen name is these
1221        // three agreeing.
1222        ("GET", ["hostkey", uuid]) => {
1223            e.settle();
1224            let mut keys = serde_json::Map::new();
1225            for p in crate::estate::HostKeyPath::ALL {
1226                match e.host_key_via(uuid, p) {
1227                    Err(x) => return reply(x.status, json!({"error": x.message})),
1228                    Ok(k) => {
1229                        keys.insert(p.name().to_string(), json!(k));
1230                    }
1231                }
1232            }
1233            let distinct: std::collections::BTreeSet<&str> =
1234                keys.values().filter_map(|v| v.as_str()).collect();
1235            reply(
1236                200,
1237                json!({
1238                    "paths": keys,
1239                    "agree": distinct.len() == 1,
1240                    "verdict": if distinct.len() == 1 {
1241                        "one key on three paths: this is the machine that was re-imaged"
1242                    } else {
1243                        "the paths disagree: a name is answering for a machine that is not behind the DNAT"
1244                    },
1245                }),
1246            )
1247        }
1248
1249        // **The same re-image, as the two observers saw it.**
1250        ("GET", ["reimage", uuid]) => {
1251            let (uart, wall) = e.timings.reimage_observers(e.seed(), uuid);
1252            reply(
1253                200,
1254                json!({
1255                    "guest_uart_ms": uart,
1256                    "ladder_wall_ms": wall,
1257                    "ratio": wall / uart.max(1),
1258                    "note": "the installer is not slow; the provider is. create, media sync, firmware, boot order, DHCP.",
1259                    "observers": {
1260                        "guest_uart_ms": "the guest's own PID 1, from inside",
1261                        "ladder_wall_ms": "the ladder's `install-time`, wall, from outside"
1262                    }
1263                }),
1264            )
1265        }
1266
1267        // What the machine behind a server left: argv, frame hashes, stdout
1268        // bytes, disk sizes. 404 from an engine that runs no machines.
1269        ("GET", ["guest", uuid]) => {
1270            e.tick();
1271            match e.engine.evidence(uuid) {
1272                Some(v) => reply(200, json!({"engine": e.engine.name(), "guest": v})),
1273                None => reply(404, json!({"error": format!("engine {} has no machine for {uuid}", e.engine.name())})),
1274            }
1275        }
1276
1277        ("POST", ["relay"]) => {
1278            e.relay();
1279            reply(200, json!({"lays": e.lays()}))
1280        }
1281        ("POST", ["seed", s]) => {
1282            let seed: u64 = s.parse().unwrap_or(0);
1283            let speed = e.clock.speed_milli();
1284            // The engine outlives the estate it served, and every machine and
1285            // disk it holds goes now: nothing in the new estate can name them.
1286            let engine = e.engine.clone();
1287            engine.forget_all();
1288            *e = Estate::new(crate::Clock::new(speed), Faults::seeded(seed), seed).with_engine(engine);
1289            reply(200, json!({"seed": seed}))
1290        }
1291        _ => reply(404, json!({"error": format!("no such mock door: {}", r.path)})),
1292    }
1293}
1294
1295/// One query parameter, or empty.
1296fn q(query: &[(String, String)], key: &str) -> String {
1297    query.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()).unwrap_or_default()
1298}
1299
1300// ── the ONE door every face comes through ────────────────────────────────────
1301
1302/// **One request, answered exactly as the HTTP face answers it** — the
1303/// bookkeeping (behaviour 63's `heard`, [`Mock::calls`], [`Mock::log`]) and the
1304/// router, in one place. The socket parser above calls it; so does
1305/// [`crate::face::FakeUpCloud`], the in-process trait face. That is what makes
1306/// them one world with two faces rather than two worlds: a Rust caller and
1307/// terraform reach the same `route`, the same `Estate` methods, the same
1308/// guest engine, and are heard and recorded the same way.
1309///
1310/// `credential` is the Authorization header's value, `None` when none was sent
1311/// (the one case that is a clean 401).
1312pub(crate) fn answer(mock: &Arc<Mock>, method: &str, target: &str, credential: Option<&str>, raw: Vec<u8>) -> Outcome {
1313    let (path, query) = split_target(target);
1314    let body: Value = if raw.is_empty() { Value::Null } else { serde_json::from_slice(&raw).unwrap_or(Value::Null) };
1315    let said = format!("{method} {path}");
1316    let (m_rec, p_rec) = (method.to_string(), path.clone());
1317    let authorized = credential.is_some();
1318    if let Some(c) = credential {
1319        mock.heard(c, method, &path);
1320    }
1321    #[cfg(feature = "test-inject")]
1322    let injected = mock.injected(method, &path);
1323    #[cfg(not(feature = "test-inject"))]
1324    let injected: Option<Outcome> = None;
1325    let out = match injected {
1326        Some(o) => o,
1327        None => route(mock, Req { method: method.to_string(), path, query, authorized, body, raw }),
1328    };
1329    if let Some(calls) = &mock.calls {
1330        let (st, code) = match &out {
1331            Outcome::Reset => (0, String::new()),
1332            Outcome::Reply { status, body } => (*status, body["error"]["error_code"].as_str().unwrap_or("").to_string()),
1333        };
1334        calls.lock().unwrap().push((st, m_rec, p_rec, code));
1335    }
1336    if mock.log {
1337        // The server's own state is printed beside the call, because the
1338        // question a log is opened for is almost always "what state was it in
1339        // when that happened".
1340        let states = {
1341            let e = mock.estate.lock().unwrap();
1342            e.all_servers().map(|s| format!("{}={}", &s.uuid[..4], s.state)).collect::<Vec<_>>().join(" ")
1343        };
1344        let code = match &out {
1345            Outcome::Reset => 0,
1346            Outcome::Reply { status, .. } => *status,
1347        };
1348        eprintln!("  {code:>3}  {said:<52}  [{states}]");
1349    }
1350    out
1351}
1352
1353// ── test-only injections ─────────────────────────────────────────────────────
1354
1355/// **An INJECTION: "answer the next N matching requests with this error".**
1356///
1357/// Not a behaviour, and it claims NOTHING about UpCloud. It exists for a
1358/// client's test that must prove how it handles an answer the provider CAN
1359/// give (a `409` on an eject, a refused delete) at a moment no behaviour of
1360/// this mock produces it on demand. Every such test says what it injected.
1361///
1362/// Compiled only with the `test-inject` feature: the published mock, and
1363/// every storm or rehearsal built without that feature, cannot answer with an
1364/// invented error — the field and the check do not exist in it.
1365#[cfg(feature = "test-inject")]
1366pub struct Injection {
1367    /// `GET`, `POST`, `PUT`, `DELETE`.
1368    pub method: String,
1369    /// Does this request match? Handed the path (`/1.3/…`) and the estate, so
1370    /// a test can name "the storage titled X" without knowing its uuid.
1371    pub matches: Box<dyn Fn(&str, &Estate) -> bool + Send + Sync>,
1372    pub status: u16,
1373    pub code: String,
1374    pub message: String,
1375    /// How many matching requests are answered this way; then it is spent.
1376    pub times: u32,
1377}
1378
1379#[cfg(feature = "test-inject")]
1380impl Mock {
1381    /// Arm one [`Injection`].
1382    pub fn inject(&self, i: Injection) {
1383        self.injections.lock().unwrap().push(i);
1384    }
1385
1386    fn injected(&self, method: &str, path: &str) -> Option<Outcome> {
1387        let e = self.estate.lock().unwrap();
1388        let mut all = self.injections.lock().unwrap();
1389        let hit = all.iter_mut().find(|i| i.times > 0 && i.method == method && (i.matches)(path, &e))?;
1390        hit.times -= 1;
1391        Some(reply(hit.status, render::error(&hit.code, &hit.message)))
1392    }
1393}