Skip to main content

urma_node_client/
client.rs

1//! Optional, explicitly configured loopback client. No daemon is started and no
2//! DNS, proxy, environment configuration or chain source is consulted.
3//!
4//! Responses are DATA supplied by a locally trusted operator, not independently
5//! verified chain evidence. A receiver using a package from a peer must pass it
6//! through `Engine::admit_record`, with its independently selected chain view.
7
8use crate::{
9    Error,
10    model::{ArchivedRecord, ChainPoint, ObjectStatus, PinReceipt, PruneReceipt, ServiceStatus},
11    names::{
12        MAX_NAMES_PAGE, MAX_NAMES_PAGE_BYTES, MAX_NAMES_STATE_BYTES, NamesCursor, NamesEntry,
13        NamesPage, NamesResolution, NamesStatus, NamesStep,
14    },
15    projection::{MAX_WIRE_PAGE, WireCursor, WirePage},
16};
17use bitcoin::Txid;
18use std::{
19    io::{self, Read, Write},
20    net::{SocketAddr, TcpStream},
21    time::{Duration, Instant},
22};
23use urma_names::name::Name;
24mod overlay;
25
26const CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
27const OPERATION_TIMEOUT: Duration = Duration::from_secs(5);
28const MAX_HEADER: usize = 8192;
29const MAX_BODY: usize = 32 * 1024 * 1024;
30
31/// An optional local service connection, trusting the configured local operator.
32/// Construction performs no I/O. Each operation opens one bounded connection.
33pub struct Client {
34    address: SocketAddr,
35    token: String,
36}
37
38impl Client {
39    pub fn new(address: SocketAddr, token: String) -> Result<Self, Error> {
40        if !address.ip().is_loopback()
41            || token.len() != 64
42            || !token.bytes().all(|byte| byte.is_ascii_hexdigit())
43        {
44            return Err(invalid(
45                "client requires loopback and a 64-hex bearer token",
46            ));
47        }
48        Ok(Self { address, token })
49    }
50
51    /// Typed service status reported by the local operator, without verifying
52    /// its claims. Unknown fields from a newer operator are ignored.
53    pub fn status(&self) -> Result<ServiceStatus, Error> {
54        Ok(serde_json::from_value(self.status_json()?)?)
55    }
56
57    /// Raw status JSON reported by the local operator, without verifying its claims.
58    pub fn status_json(&self) -> Result<serde_json::Value, Error> {
59        let deadline = Instant::now() + OPERATION_TIMEOUT;
60        let (code, body) = self.request("GET", "/v1/status", deadline)?;
61        if code != 200 {
62            return Err(invalid(format!("local status HTTP {code}")));
63        }
64        let value = serde_json::from_slice(&body)?;
65        remaining(deadline)?;
66        Ok(value)
67    }
68
69    /// Read locally archived data. `None` means HTTP 404 (not locally available),
70    /// not proof that a record does not exist. Authentication and other failures
71    /// remain errors. Only the receipt's TXID is compared with the requested ID.
72    ///
73    /// The chain is unknown here: reveal decoding, TXID/witness recomputation,
74    /// inclusion and URMA verification belong to independent admission through
75    /// `Engine::admit_record`. Deserialization does not establish those claims.
76    pub fn record(&self, txid: Txid) -> Result<Option<ArchivedRecord>, Error> {
77        let deadline = Instant::now() + OPERATION_TIMEOUT;
78        let (code, body) = self.request("GET", &format!("/v1/records/{txid}"), deadline)?;
79        match code {
80            200 => {
81                let record: ArchivedRecord = serde_json::from_slice(&body)?;
82                if record.receipt.txid != txid {
83                    return Err(invalid("local record receipt TXID differs from request"));
84                }
85                remaining(deadline)?;
86                Ok(Some(record))
87            }
88            404 => {
89                // Even a missing result must be a complete framed JSON response.
90                let _: serde_json::Value = serde_json::from_slice(&body)?;
91                remaining(deadline)?;
92                Ok(None)
93            }
94            _ => Err(invalid(format!("local record HTTP {code}"))),
95        }
96    }
97
98    /// Read object availability reported by the configured local operator.
99    /// These are operator data, not independently verified chain evidence.
100    /// Selected member count is independent of local retention: either count
101    /// may exceed the other, but both must be within a nonzero member total.
102    /// `None` means only that no completed object is recorded locally (HTTP 404),
103    /// not global absence. Root/count sanity checks establish no crypto claims.
104    pub fn object_status(&self, root: Txid) -> Result<Option<ObjectStatus>, Error> {
105        let deadline = Instant::now() + OPERATION_TIMEOUT;
106        let (code, body) = self.request("GET", &format!("/v1/objects/{root}"), deadline)?;
107        match code {
108            200 => {
109                let status: ObjectStatus = serde_json::from_slice(&body)?;
110                remaining(deadline)?;
111                if status.root != root {
112                    return Err(invalid("local object status root differs from request"));
113                }
114                if status.members_total == 0
115                    || status.members_local > status.members_total
116                    || status.members_selected > status.members_total
117                {
118                    return Err(invalid("local object status has impossible member counts"));
119                }
120                Ok(Some(status))
121            }
122            404 => {
123                let _: serde_json::Value = serde_json::from_slice(&body)?;
124                remaining(deadline)?;
125                Ok(None)
126            }
127            _ => Err(invalid(format!("local object status HTTP {code}"))),
128        }
129    }
130
131    /// Request a local pin or unpin and read the operator's acknowledgement at
132    /// its commit point. This is not cryptographic evidence or a promise of
133    /// future availability, chain selection or pin state. Only HTTP 200 with
134    /// the requested root and pin value succeeds. No automatic retry occurs:
135    /// the operation may already have committed before a socket failure.
136    pub fn set_pin(&self, root: Txid, pin: bool) -> Result<PinReceipt, Error> {
137        let deadline = Instant::now() + OPERATION_TIMEOUT;
138        let action = if pin { "pin" } else { "unpin" };
139        let (code, body) =
140            self.request("POST", &format!("/v1/objects/{root}/{action}"), deadline)?;
141        if code != 200 {
142            return Err(invalid(format!("local {action} HTTP {code}")));
143        }
144        let receipt: PinReceipt = serde_json::from_slice(&body)?;
145        remaining(deadline)?;
146        if receipt.root != root || receipt.pinned != pin {
147            return Err(invalid("local pin receipt differs from request"));
148        }
149        Ok(receipt)
150    }
151
152    /// Request explicit local cache pruning and read the operator's removal
153    /// count. This acknowledgement is not cryptographic evidence or a promise
154    /// of future cache contents. Only HTTP 200 succeeds. No automatic retry
155    /// occurs, including when a socket failure leaves the commit outcome unknown.
156    pub fn prune_cache(&self) -> Result<PruneReceipt, Error> {
157        let deadline = Instant::now() + OPERATION_TIMEOUT;
158        let (code, body) = self.request("POST", "/v1/cache/prune", deadline)?;
159        if code != 200 {
160            return Err(invalid(format!("local cache prune HTTP {code}")));
161        }
162        let receipt = serde_json::from_slice(&body)?;
163        remaining(deadline)?;
164        Ok(receipt)
165    }
166
167    /// Read one bounded page of Wire records from the configured local operator.
168    /// Entry parsing checks data consistency, not authorship or chain inclusion.
169    /// Selected records and scanned coverage remain separate; `next == None`
170    /// ends only the local result set and does not establish chain completeness.
171    /// A snapshot is opaque. HTTP 409 returns `Error::Busy`: discard the partial
172    /// traversal and explicitly restart. No paging, retry or readback is automatic.
173    pub fn wire_page(&self, cursor: Option<&WireCursor>, limit: u32) -> Result<WirePage, Error> {
174        let deadline = Instant::now() + OPERATION_TIMEOUT;
175        if !(1..=MAX_WIRE_PAGE).contains(&limit) {
176            return Err(invalid("local Wire limit must be 1..128"));
177        }
178        let mut path = format!("/v1/wire?limit={limit}");
179        if let Some(cursor) = cursor {
180            validate_instance(&cursor.snapshot.instance)?;
181            if cursor.height > i64::MAX as u64 {
182                return Err(invalid("local Wire cursor height is out of range"));
183            }
184            let encoded = serde_json::to_vec(cursor)?;
185            if encoded.len() > 512 {
186                return Err(invalid("local Wire cursor exceeds 1024 hex characters"));
187            }
188            path.push_str("&cursor=");
189            path.push_str(&hex::encode(encoded));
190        }
191        let (code, body) = self.request("GET", &path, deadline)?;
192        if code == 409 {
193            return Err(Error::Busy(
194                "local Wire snapshot changed; discard traversal and restart".into(),
195            ));
196        }
197        if code != 200 {
198            return Err(invalid(format!("local Wire HTTP {code}")));
199        }
200        let page: WirePage = serde_json::from_slice(&body)?;
201        remaining(deadline)?;
202        validate_wire_page(&page, cursor, limit, deadline)?;
203        remaining(deadline)?;
204        Ok(page)
205    }
206
207    /// Register one explicitly located Names genesis with the local operator.
208    /// The acknowledgement describes a local index, not independently verified
209    /// chain evidence or permanent registration/state. HTTP 409 is `Error::Busy`.
210    /// Heights above `i64::MAX` are refused before any I/O.
211    /// No retry occurs: registration may commit before a response is lost.
212    pub fn register_names(&self, genesis: Txid, block: ChainPoint) -> Result<NamesStatus, Error> {
213        let deadline = Instant::now() + OPERATION_TIMEOUT;
214        if block.height > i64::MAX as u64 {
215            return Err(invalid("local Names registration height is out of range"));
216        }
217        let path = format!(
218            "/v1/names/{genesis}/register?height={}&hash={}",
219            block.height, block.hash
220        );
221        let (code, body) = self.names_request("POST", &path, deadline)?;
222        if code != 200 {
223            return Err(invalid(format!("local Names registration HTTP {code}")));
224        }
225        let status: NamesStatus = serde_json::from_slice(&body)?;
226        remaining(deadline)?;
227        validate_names_status(&status, genesis)?;
228        if status.genesis_block != block {
229            return Err(invalid(
230                "local Names genesis block differs from registration",
231            ));
232        }
233        remaining(deadline)?;
234        Ok(status)
235    }
236
237    /// Request exactly one local Names projection step. Returned outcomes are
238    /// operator data, not cryptographic proofs or a promise of future state.
239    /// HTTP 409 is `Error::Busy`; no automatic stepping, retry or readback occurs,
240    /// including when a lost response leaves the operation's commit unknown.
241    pub fn names_step(&self, genesis: Txid) -> Result<NamesStep, Error> {
242        let deadline = Instant::now() + OPERATION_TIMEOUT;
243        let (code, body) =
244            self.names_request("POST", &format!("/v1/names/{genesis}/step"), deadline)?;
245        if code != 200 {
246            return Err(invalid(format!("local Names step HTTP {code}")));
247        }
248        let step: NamesStep = serde_json::from_slice(&body)?;
249        remaining(deadline)?;
250        validate_names_status(&step.status, genesis)?;
251        if step
252            .applied
253            .is_some_and(|point| point != step.status.projected)
254        {
255            return Err(invalid("local Names applied block differs from projection"));
256        }
257        remaining(deadline)?;
258        Ok(step)
259    }
260
261    /// Read the configured operator's local Names progress. HTTP 404 means only
262    /// that this registry is not registered locally, not global absence. Typed
263    /// progress and completion are not independent chain evidence. HTTP 409 is
264    /// `Error::Busy`; no registration, stepping or retry is performed.
265    pub fn names_status(&self, genesis: Txid) -> Result<Option<NamesStatus>, Error> {
266        let deadline = Instant::now() + OPERATION_TIMEOUT;
267        let (code, body) = self.names_request("GET", &format!("/v1/names/{genesis}"), deadline)?;
268        match code {
269            200 => {
270                let status: NamesStatus = serde_json::from_slice(&body)?;
271                remaining(deadline)?;
272                validate_names_status(&status, genesis)?;
273                remaining(deadline)?;
274                Ok(Some(status))
275            }
276            404 => {
277                let _: serde_json::Value = serde_json::from_slice(&body)?;
278                remaining(deadline)?;
279                Ok(None)
280            }
281            _ => Err(invalid(format!("local Names status HTTP {code}"))),
282        }
283    }
284
285    /// Read a canonical name's resolution from the local operator. This is
286    /// index data, not independent proof of ownership, coverage or future state.
287    /// The DTO carries no returned name to compare with the request. HTTP 409 is
288    /// `Error::Busy`; resolution never automatically steps or retries.
289    pub fn names_resolve(&self, genesis: Txid, name: &Name) -> Result<NamesResolution, Error> {
290        let deadline = Instant::now() + OPERATION_TIMEOUT;
291        let path = format!("/v1/names/{genesis}/resolve/{}", name.as_str());
292        let (code, body) = self.names_request("GET", &path, deadline)?;
293        if code != 200 {
294            return Err(invalid(format!("local Names resolution HTTP {code}")));
295        }
296        let resolution: NamesResolution = serde_json::from_slice(&body)?;
297        remaining(deadline)?;
298        validate_names_status(&resolution.status, genesis)?;
299        remaining(deadline)?;
300        Ok(resolution)
301    }
302
303    /// Read one Names view page supplied by the configured local operator.
304    /// State decoding and page consistency checks are not independent chain or
305    /// registry evidence. The consumer must check its expected chain genesis and
306    /// identical metadata across pages. No paging, stepping or retry is automatic.
307    /// HTTP 409 is Busy: discard the partial traversal and explicitly restart.
308    /// `next == None` ends this local snapshot only, not a chain completeness claim.
309    pub fn names_view(
310        &self,
311        genesis: Txid,
312        cursor: Option<&NamesCursor>,
313        limit: u32,
314    ) -> Result<NamesPage, Error> {
315        let deadline = Instant::now() + OPERATION_TIMEOUT;
316        if !(1..=MAX_NAMES_PAGE).contains(&limit) {
317            return Err(invalid("local Names view limit must be 1..128"));
318        }
319        let mut path = format!("/v1/names/{genesis}/view?limit={limit}");
320        if let Some(cursor) = cursor {
321            if cursor.genesis != genesis {
322                return Err(invalid("local Names cursor genesis differs from request"));
323            }
324            validate_names_instance(&cursor.snapshot.instance)?;
325            let encoded = serde_json::to_vec(cursor)?;
326            if encoded.len() > 512 {
327                return Err(invalid("local Names cursor exceeds 1024 hex characters"));
328            }
329            path.push_str("&cursor=");
330            path.push_str(&hex::encode(encoded));
331        }
332        let (code, body) = self.names_request("GET", &path, deadline)?;
333        if code != 200 {
334            return Err(invalid(format!("local Names view HTTP {code}")));
335        }
336        if body.len() > MAX_NAMES_PAGE_BYTES {
337            return Err(invalid("local Names page exceeds 16 MiB"));
338        }
339        let page: NamesPage = serde_json::from_slice(&body)?;
340        remaining(deadline)?;
341        validate_names_page(&page, genesis, cursor, limit, deadline)?;
342        remaining(deadline)?;
343        Ok(page)
344    }
345
346    fn names_request(
347        &self,
348        method: &str,
349        path: &str,
350        deadline: Instant,
351    ) -> Result<(u16, Vec<u8>), Error> {
352        let response = self.request(method, path, deadline)?;
353        if response.0 == 409 {
354            return Err(Error::Busy(
355                "local Names operation busy or selected state changed".into(),
356            ));
357        }
358        Ok(response)
359    }
360
361    fn request(
362        &self,
363        method: &str,
364        path: &str,
365        deadline: Instant,
366    ) -> Result<(u16, Vec<u8>), Error> {
367        let mut stream =
368            TcpStream::connect_timeout(&self.address, CONNECT_TIMEOUT.min(remaining(deadline)?))?;
369        let body_length = if method == "POST" {
370            "Content-Length: 0\r\n"
371        } else {
372            ""
373        };
374        let request = format!(
375            "{method} {path} HTTP/1.1\r\nHost: {}\r\nAuthorization: Bearer {}\r\nAccept: application/json\r\nAccept-Encoding: identity\r\n{body_length}Connection: close\r\n\r\n",
376            self.address, self.token
377        );
378        let mut pending = request.as_bytes();
379        while !pending.is_empty() {
380            stream.set_write_timeout(Some(remaining(deadline)?))?;
381            let count = match stream.write(pending) {
382                Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
383                result => result?,
384            };
385            remaining(deadline)?;
386            if count == 0 {
387                return Err(io::Error::from(io::ErrorKind::WriteZero).into());
388            }
389            pending = &pending[count..];
390        }
391        let mut header = Vec::new();
392        while !header.ends_with(b"\r\n\r\n") {
393            if header.len() == MAX_HEADER {
394                return Err(invalid("local HTTP header exceeds 8192 bytes"));
395            }
396            let mut byte = [0];
397            read(&mut stream, &mut byte, deadline)?;
398            header.push(byte[0]);
399        }
400        let (code, length) = parse_header(&header)?;
401        let mut body = vec![0; length];
402        let mut received = 0;
403        while received < length {
404            received += read(&mut stream, &mut body[received..], deadline)?;
405        }
406        remaining(deadline)?;
407        Ok((code, body))
408    }
409}
410
411fn invalid(message: impl Into<String>) -> Error {
412    Error::Invalid(message.into())
413}
414
415fn validate_names_status(status: &NamesStatus, genesis: Txid) -> Result<(), Error> {
416    if status.genesis != genesis {
417        return Err(invalid("local Names genesis differs from request"));
418    }
419    let tip = status
420        .selected_tip
421        .ok_or_else(|| invalid("local Names projection has no selected tip"))?;
422    if status.projected.height < status.genesis_block.height || tip.height < status.projected.height
423    {
424        return Err(invalid("local Names projection heights are inconsistent"));
425    }
426    for (left, right) in [
427        (status.genesis_block, status.projected),
428        (status.projected, tip),
429        (status.genesis_block, tip),
430    ] {
431        if left.height == right.height && left.hash != right.hash {
432            return Err(invalid("local Names equal-height block hashes differ"));
433        }
434    }
435    if status.complete_to_tip != (status.projected == tip) {
436        return Err(invalid(
437            "local Names completion differs from projection and tip",
438        ));
439    }
440    Ok(())
441}
442
443fn validate_names_instance(instance: &str) -> Result<(), Error> {
444    if instance.len() != 32 || !instance.bytes().all(|byte| byte.is_ascii_hexdigit()) {
445        return Err(invalid(
446            "local Names snapshot instance must be 32 hex characters",
447        ));
448    }
449    Ok(())
450}
451
452// Count serialized bytes with a bounded writer, without creating another large
453// JSON buffer. An over-limit value fails as a whole and is never truncated.
454fn names_json_bound(value: &impl serde::Serialize, max: usize, message: &str) -> Result<(), Error> {
455    struct Counter {
456        count: usize,
457        max: usize,
458        exceeded: bool,
459    }
460    impl Write for Counter {
461        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
462            if bytes.len() > self.max - self.count {
463                self.exceeded = true;
464                return Err(io::Error::other("serialized Names byte bound"));
465            }
466            self.count += bytes.len();
467            Ok(bytes.len())
468        }
469        fn flush(&mut self) -> io::Result<()> {
470            Ok(())
471        }
472    }
473    let mut counter = Counter {
474        count: 0,
475        max,
476        exceeded: false,
477    };
478    serde_json::to_writer(&mut counter, value).map_err(|error| {
479        if counter.exceeded {
480            invalid(message)
481        } else {
482            Error::Json(error)
483        }
484    })
485}
486
487fn validate_names_page(
488    page: &NamesPage,
489    genesis: Txid,
490    cursor: Option<&NamesCursor>,
491    limit: u32,
492    deadline: Instant,
493) -> Result<(), Error> {
494    validate_names_instance(&page.snapshot.instance)?;
495    validate_names_status(&page.status, genesis)?;
496    if page.entries.len() > limit as usize {
497        return Err(invalid(
498            "local Names page entry count exceeds requested limit",
499        ));
500    }
501    if cursor.is_some_and(|cursor| cursor.snapshot != page.snapshot) {
502        return Err(invalid("local Names page snapshot differs from cursor"));
503    }
504    names_json_bound(
505        &page.rules,
506        MAX_NAMES_STATE_BYTES,
507        "local Names rules exceed 64 KiB",
508    )?;
509    remaining(deadline)?;
510    page.rules
511        .validate()
512        .map_err(|_| invalid("local Names rules are invalid"))?;
513    let mut previous = cursor.map(|cursor| cursor.after.order_key());
514    for entry in &page.entries {
515        remaining(deadline)?;
516        let key = entry.key().order_key();
517        if previous.as_ref().is_some_and(|previous| &key <= previous) {
518            return Err(invalid(
519                "local Names keys are not strictly after cursor/in order",
520            ));
521        }
522        match entry {
523            NamesEntry::Name { state, .. } => names_json_bound(
524                state,
525                MAX_NAMES_STATE_BYTES,
526                "local Names state exceeds 64 KiB",
527            )?,
528            NamesEntry::Pending { record, .. } => names_json_bound(
529                record,
530                MAX_NAMES_STATE_BYTES,
531                "local Names pending state exceeds 64 KiB",
532            )?,
533        }
534        previous = Some(key);
535    }
536    if let Some(next) = &page.next {
537        let last = page
538            .entries
539            .last()
540            .ok_or_else(|| invalid("empty local Names page has a continuation"))?;
541        if next.genesis != genesis || next.snapshot != page.snapshot || next.after != last.key() {
542            return Err(invalid(
543                "local Names continuation differs from last key/genesis/snapshot",
544            ));
545        }
546    }
547    remaining(deadline)?;
548    names_json_bound(
549        page,
550        MAX_NAMES_PAGE_BYTES,
551        "local Names page exceeds 16 MiB",
552    )?;
553    remaining(deadline)?;
554    Ok(())
555}
556
557fn validate_instance(instance: &str) -> Result<(), Error> {
558    if instance.len() != 32 || !instance.bytes().all(|byte| byte.is_ascii_hexdigit()) {
559        return Err(invalid(
560            "local Wire snapshot instance must be 32 hex characters",
561        ));
562    }
563    Ok(())
564}
565
566fn validate_wire_page(
567    page: &WirePage,
568    cursor: Option<&WireCursor>,
569    limit: u32,
570    deadline: Instant,
571) -> Result<(), Error> {
572    validate_instance(&page.snapshot.instance)?;
573    if page.archive.api_version != crate::model::API_VERSION || page.records.len() > limit as usize
574    {
575        return Err(invalid(
576            "local Wire page version or record count is invalid",
577        ));
578    }
579    if cursor.is_some_and(|cursor| cursor.snapshot != page.snapshot) {
580        return Err(invalid("local Wire page snapshot differs from cursor"));
581    }
582    let mut previous_coordinate = cursor.map(|cursor| (cursor.height, cursor.position));
583    let mut previous_block = None;
584    for record in &page.records {
585        remaining(deadline)?;
586        let entry = &record.entry;
587        let coordinate = (entry.height, entry.position);
588        if previous_coordinate.is_some_and(|previous| coordinate <= previous) {
589            return Err(invalid(
590                "local Wire entries are not strictly after the cursor/in order",
591            ));
592        }
593        let start = page.archive.application_start;
594        let tip = page
595            .archive
596            .selected_tip
597            .ok_or_else(|| invalid("local Wire records require a selected tip"))?;
598        if entry.height < start.height || entry.height > tip.height {
599            return Err(invalid(
600                "local Wire entry lies outside the application/selected range",
601            ));
602        }
603        if (entry.height == start.height && record.block_hash != start.hash)
604            || (entry.height == tip.height && record.block_hash != tip.hash)
605            || previous_block
606                .is_some_and(|(height, hash)| height == entry.height && hash != record.block_hash)
607        {
608            return Err(invalid(
609                "local Wire block hashes disagree at the same height",
610            ));
611        }
612        let txid = entry
613            .txid
614            .parse::<Txid>()
615            .map_err(|_| invalid("local Wire entry has an invalid TXID"))?;
616        let author = entry
617            .author
618            .parse::<bitcoin::XOnlyPublicKey>()
619            .map_err(|_| invalid("local Wire entry has an invalid author"))?;
620        if entry.record.len() > urma_core::format::Urma::MAX_PUBLIC_BYTES * 2 {
621            return Err(invalid("local Wire record exceeds the public byte bound"));
622        }
623        let raw = hex::decode(&entry.record)
624            .map_err(|_| invalid("local Wire entry has invalid record hex"))?;
625        let parsed = urma_wire::index::entry_from_record(
626            &raw,
627            txid,
628            author,
629            None,
630            entry.height,
631            entry.position,
632        )
633        .map_err(|error| invalid(format!("invalid local Wire record: {error}")))?;
634        if parsed.is_none() {
635            return Err(invalid("local Wire entry is not a Wire record kind"));
636        }
637        previous_coordinate = Some(coordinate);
638        previous_block = Some((entry.height, record.block_hash));
639    }
640    if let Some(next) = &page.next {
641        let last = page
642            .records
643            .last()
644            .ok_or_else(|| invalid("empty local Wire page has a continuation"))?;
645        if next.snapshot != page.snapshot
646            || (next.height, next.position) != (last.entry.height, last.entry.position)
647        {
648            return Err(invalid(
649                "local Wire continuation differs from the last entry/snapshot",
650            ));
651        }
652    }
653    Ok(())
654}
655
656fn remaining(deadline: Instant) -> Result<Duration, Error> {
657    deadline
658        .checked_duration_since(Instant::now())
659        .filter(|duration| !duration.is_zero())
660        .ok_or_else(|| {
661            io::Error::new(io::ErrorKind::TimedOut, "local HTTP operation deadline").into()
662        })
663}
664
665fn read(stream: &mut TcpStream, bytes: &mut [u8], deadline: Instant) -> Result<usize, Error> {
666    loop {
667        stream.set_read_timeout(Some(remaining(deadline)?))?;
668        let count = match stream.read(bytes) {
669            Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
670            result => result?,
671        };
672        remaining(deadline)?;
673        if count == 0 {
674            return Err(io::Error::new(
675                io::ErrorKind::UnexpectedEof,
676                "truncated local HTTP response",
677            )
678            .into());
679        }
680        return Ok(count);
681    }
682}
683
684fn is_token(byte: u8) -> bool {
685    byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte)
686}
687
688fn json_content_type(value: &str) -> bool {
689    let mut parts = value.split(';');
690    if !parts
691        .next()
692        .unwrap()
693        .trim()
694        .eq_ignore_ascii_case("application/json")
695    {
696        return false;
697    }
698    if let Some(parameter) = parts.next() {
699        let Some((name, charset)) = parameter.trim().split_once('=') else {
700            return false;
701        };
702        let charset = charset.trim();
703        let charset = charset
704            .strip_prefix('"')
705            .and_then(|s| s.strip_suffix('"'))
706            .unwrap_or(charset);
707        if !name.trim().eq_ignore_ascii_case("charset")
708            || charset.is_empty()
709            || !charset.bytes().all(is_token)
710        {
711            return false;
712        }
713    }
714    parts.next().is_none()
715}
716
717fn parse_header(bytes: &[u8]) -> Result<(u16, usize), Error> {
718    let text =
719        std::str::from_utf8(bytes).map_err(|_| invalid("invalid local HTTP header encoding"))?;
720    let mut lines = text[..text.len() - 4].split("\r\n");
721    let mut status = lines.next().unwrap().splitn(3, ' ');
722    if status.next() != Some("HTTP/1.1") {
723        return Err(invalid("local response requires HTTP/1.1"));
724    }
725    let code = status.next().unwrap_or("");
726    if code.len() != 3 || !code.bytes().all(|byte| byte.is_ascii_digit()) {
727        return Err(invalid("malformed local HTTP status"));
728    }
729    let reason = status
730        .next()
731        .ok_or_else(|| invalid("malformed local HTTP status"))?;
732    if !reason
733        .bytes()
734        .all(|byte| byte == b'\t' || (32..=126).contains(&byte))
735    {
736        return Err(invalid("malformed local HTTP status"));
737    }
738    let code: u16 = code
739        .parse()
740        .map_err(|_| invalid("malformed local HTTP status"))?;
741    if !(100..=599).contains(&code) {
742        return Err(invalid("malformed local HTTP status"));
743    }
744    let mut length = None;
745    let mut content_type = None;
746    for line in lines {
747        let (name, value) = line
748            .split_once(':')
749            .ok_or_else(|| invalid("malformed local HTTP header"))?;
750        if name.is_empty()
751            || !name.bytes().all(is_token)
752            || !value
753                .bytes()
754                .all(|byte| byte == b'\t' || (32..=126).contains(&byte))
755        {
756            return Err(invalid("malformed local HTTP header"));
757        }
758        let value = value.trim_matches([' ', '\t']);
759        if name.eq_ignore_ascii_case("transfer-encoding") {
760            return Err(invalid("local HTTP transfer encoding is unsupported"));
761        }
762        if name.eq_ignore_ascii_case("content-encoding") && !value.eq_ignore_ascii_case("identity")
763        {
764            return Err(invalid("local HTTP content encoding is unsupported"));
765        }
766        if name.eq_ignore_ascii_case("content-length") {
767            if length.is_some()
768                || value.is_empty()
769                || !value.bytes().all(|byte| byte.is_ascii_digit())
770            {
771                return Err(invalid(
772                    "local HTTP requires a single decimal Content-Length",
773                ));
774            }
775            let parsed: usize = value
776                .parse()
777                .map_err(|_| invalid("invalid local HTTP Content-Length"))?;
778            if parsed > MAX_BODY {
779                return Err(invalid("local HTTP body exceeds 32 MiB"));
780            }
781            length = Some(parsed);
782        }
783        if name.eq_ignore_ascii_case("content-type") {
784            if content_type.is_some() || !json_content_type(value) {
785                return Err(invalid(
786                    "local HTTP requires application/json with optional charset",
787                ));
788            }
789            content_type = Some(());
790        }
791    }
792    if content_type.is_none() {
793        return Err(invalid("local HTTP Content-Type is missing"));
794    }
795    Ok((
796        code,
797        length.ok_or_else(|| invalid("local HTTP Content-Length is missing"))?,
798    ))
799}