Skip to main content

urma_node/
service.rs

1//! Small authenticated loopback control surface. The archive engine remains
2//! usable directly; HTTP is not a prerequisite for embedded applications.
3use crate::{
4    Error,
5    engine::Engine,
6    model::{CacheReceipt, ServiceStatus},
7    peer::SyncPeers,
8    peer_discovery::Discovery,
9    peer_sync::Scheduler,
10    recorded::{RecordedError, read},
11    retrieval::RecordSource,
12    source::{CommitSource, CommitSources},
13};
14
15mod overlay;
16mod worker;
17use rand::RngCore;
18use serde_json::json;
19use std::{
20    fs::File,
21    io::{Read, Write},
22    net::{Shutdown, SocketAddr, TcpListener, TcpStream},
23    path::{Path, PathBuf},
24    sync::{
25        Arc, Mutex,
26        atomic::{AtomicBool, AtomicUsize, Ordering},
27    },
28    time::{Duration, Instant},
29};
30use urma_io::Access;
31
32#[derive(Default, Clone)]
33struct CacheStatus {
34    receipt: Option<CacheReceipt>,
35    error: Option<RecordedError>,
36}
37
38type ErrorSlot = Arc<Mutex<Option<RecordedError>>>;
39
40/// Cooperative stop request shared with the owner of a running service.
41/// Requesting shutdown does not wait for completion or interrupt entered I/O
42/// or SQL. The owner must wait for `Service::run` to return before reopening
43/// its archive. Embedded services do not install process signal handlers.
44#[derive(Clone)]
45pub struct ShutdownHandle {
46    stop: Arc<AtomicBool>,
47}
48
49impl ShutdownHandle {
50    pub fn request(&self) {
51        self.stop.store(true, Ordering::Release);
52    }
53}
54
55pub struct Service {
56    listener: TcpListener,
57    token: String,
58    engine: Arc<Engine>,
59    stop: Arc<AtomicBool>,
60    sync_error: ErrorSlot,
61    names_error: ErrorSlot,
62    retention_error: ErrorSlot,
63    cache_status: Arc<Mutex<CacheStatus>>,
64    cache_bytes: u64,
65    retention: Option<(PathBuf, Duration)>,
66    sources: Vec<Arc<dyn RecordSource + Send + Sync>>,
67    record_peers: Option<SyncPeers>,
68    commit_sources: Option<CommitSources>,
69    dependency_error: ErrorSlot,
70    peer_sync: Option<Scheduler>,
71    inventory_error: ErrorSlot,
72    discovery: Option<Discovery>,
73    discovery_error: ErrorSlot,
74}
75
76impl Service {
77    /// Control requests handled at once; more are refused with 503.
78    const CONTROL_REQUESTS: usize = 8;
79    const CAPABILITIES: [&str; 9] = [
80        "status",
81        "header-sync",
82        "full-block-scan",
83        "record-read",
84        "object-status",
85        "object-pin",
86        "cache-prune",
87        "wire-index",
88        "names-index",
89    ];
90
91    pub fn bind(address: SocketAddr, token: String, engine: Arc<Engine>) -> Result<Self, Error> {
92        if !address.ip().is_loopback()
93            || token.len() != 64
94            || !token.bytes().all(|c| c.is_ascii_hexdigit())
95        {
96            return Err(Error::Invalid(
97                "service requires loopback and a 32-byte hexadecimal bearer token".into(),
98            ));
99        }
100        let listener = TcpListener::bind(address)?;
101        listener.set_nonblocking(true)?;
102        Ok(Self {
103            listener,
104            token,
105            engine,
106            stop: Arc::new(AtomicBool::new(false)),
107            sync_error: Arc::new(Mutex::new(None)),
108            names_error: Arc::new(Mutex::new(None)),
109            retention_error: Arc::new(Mutex::new(None)),
110            cache_status: Arc::new(Mutex::new(CacheStatus::default())),
111            cache_bytes: crate::cache::DEFAULT_CACHE_BYTES,
112            retention: None,
113            sources: Vec::new(),
114            record_peers: None,
115            commit_sources: None,
116            dependency_error: Arc::new(Mutex::new(None)),
117            peer_sync: None,
118            inventory_error: Arc::new(Mutex::new(None)),
119            discovery: None,
120            discovery_error: Arc::new(Mutex::new(None)),
121        })
122    }
123
124    pub fn address(&self) -> Result<SocketAddr, Error> {
125        Ok(self.listener.local_addr()?)
126    }
127
128    pub fn shutdown_handle(&self) -> ShutdownHandle {
129        ShutdownHandle {
130            stop: Arc::clone(&self.stop),
131        }
132    }
133
134    /// Explicit optional candidates for background object recovery. Merely
135    /// configuring handles does not dial, fetch, change headers or scan coverage.
136    pub fn with_record_sources(
137        mut self,
138        sources: Vec<Arc<dyn RecordSource + Send + Sync>>,
139    ) -> Result<Self, Error> {
140        if sources.len() > 1024 {
141            return Err(Error::Invalid("too many configured record sources".into()));
142        }
143        self.sources = sources;
144        Ok(self)
145    }
146
147    pub fn with_commit_sources(mut self, sources: CommitSources) -> Result<Self, Error> {
148        if sources.is_empty() {
149            return Err(Error::Invalid("commit source set is empty".into()));
150        }
151        let expected = self.engine.status()?.chain_genesis;
152        let mut chains = sources.source_chains();
153        chains.push(sources.chain());
154        for chain in chains {
155            let genesis = chain
156                .genesis()
157                .map_err(|error| Error::Invalid(error.to_string()))?
158                .0;
159            if genesis != expected {
160                return Err(Error::Invalid(
161                    "commit source belongs to another chain".into(),
162                ));
163            }
164        }
165        self.commit_sources = Some(sources);
166        Ok(self)
167    }
168
169    /// Operational soft target, not persisted archive identity or a disk quota.
170    /// Only run(true) schedules enforcement; run(false) remains worker-free.
171    /// Zero requests eventual drainage of unprotected cache over bounded steps.
172    pub fn with_cache_limit(mut self, bytes: u64) -> Result<Self, Error> {
173        if bytes > i64::MAX as u64 {
174            return Err(Error::Invalid("cache limit exceeds storage range".into()));
175        }
176        self.cache_bytes = bytes;
177        Ok(self)
178    }
179
180    /// Configure background acquisition of every object, bounded only by the
181    /// protocol geometry. Each round spends at most `slice` on one object; an
182    /// unfinished object resumes from its stored records in a later round. The
183    /// caller supplies an owned scratch directory. Only run(true) schedules it;
184    /// pruned archives perform no automatic content acquisition.
185    pub fn with_retention(mut self, scratch: PathBuf, slice: Duration) -> Result<Self, Error> {
186        if slice.is_zero() {
187            return Err(Error::Invalid(
188                "object retention slice must be nonzero".into(),
189            ));
190        }
191        self.retention = Some((scratch, slice));
192        Ok(self)
193    }
194
195    /// Run foreground. Network acquisition is opt-in; status works offline.
196    /// run(true) adds the sync and maintenance threads (see `worker`); stop
197    /// joins both before this returns.
198    pub fn run(self, synchronize: bool) -> Result<(), Error> {
199        std::thread::scope(|scope| {
200            if synchronize {
201                scope.spawn(|| self.sync_loop());
202                scope.spawn(|| self.maintenance_loop());
203            }
204            let outcome = self.accept_loop(scope);
205            self.stop.store(true, Ordering::Release);
206            outcome
207        })
208    }
209
210    fn status(&self) -> Result<ServiceStatus, Error> {
211        let (last_sync_error, last_sync_error_at) =
212            RecordedError::split(read(&self.sync_error, "sync")?);
213        let (last_retention_error, last_retention_error_at) =
214            RecordedError::split(read(&self.retention_error, "retention")?);
215        let (last_names_error, last_names_error_at) =
216            RecordedError::split(read(&self.names_error, "Names")?);
217        let (last_dependency_error, last_dependency_error_at) =
218            RecordedError::split(read(&self.dependency_error, "dependency")?);
219        let (last_inventory_error, last_inventory_error_at) =
220            RecordedError::split(read(&self.inventory_error, "inventory")?);
221        let (last_discovery_error, last_discovery_error_at) =
222            RecordedError::split(read(&self.discovery_error, "discovery")?);
223        let cache = self
224            .cache_status
225            .lock()
226            .map_err(|_| Error::Invalid("cache status poisoned".into()))?
227            .clone();
228        let (last_cache_error, last_cache_error_at) = RecordedError::split(cache.error);
229        let overlay = self.overlay_status()?;
230        let mut status = ServiceStatus::default();
231        // Archive first: a source records a lookup before its parent is
232        // admitted, so a resolved parent's outcome is already in the table.
233        status.archive = self.engine.status()?;
234        let mut commit_sources = Vec::new();
235        for sources in self.commit_sources.iter() {
236            commit_sources.extend(sources.status()?);
237        }
238        status.last_sync_error = last_sync_error;
239        status.last_sync_error_at = last_sync_error_at;
240        status.last_retention_error = last_retention_error;
241        status.last_retention_error_at = last_retention_error_at;
242        status.last_names_error = last_names_error;
243        status.last_names_error_at = last_names_error_at;
244        status.last_dependency_error = last_dependency_error;
245        status.last_dependency_error_at = last_dependency_error_at;
246        status.last_inventory_error = last_inventory_error;
247        status.last_inventory_error_at = last_inventory_error_at;
248        status.last_discovery_error = last_discovery_error;
249        status.last_discovery_error_at = last_discovery_error_at;
250        status.commit_sources = commit_sources;
251        status.cache_limit_bytes = self.cache_bytes;
252        status.last_cache_error = last_cache_error;
253        status.last_cache_error_at = last_cache_error_at;
254        status.last_cache_maintenance = cache.receipt;
255        status.overlay = overlay;
256        status.chain_peers = self.engine.chain_peers()?;
257        status.chain_target = self.engine.chain_target();
258        status.retention_configured = self.retention.is_some();
259        status.record_sources_configured = u64::try_from(self.record_source_count())
260            .map_err(|error| Error::Invalid(format!("record source count: {error}")))?;
261        status.capabilities = Self::CAPABILITIES.map(String::from).to_vec();
262        Ok(status)
263    }
264
265    /// Each request runs on its own scoped thread, so a slow client or handler
266    /// never delays another; past `CONTROL_REQUESTS` in flight, new ones get
267    /// 503 at once. Stop joins the in-flight requests with the scope.
268    fn accept_loop<'scope, 'env>(
269        &'env self,
270        scope: &'scope std::thread::Scope<'scope, 'env>,
271    ) -> Result<(), Error> {
272        let in_flight = Arc::new(AtomicUsize::new(0));
273        while !self.stop.load(Ordering::Acquire) {
274            match self.listener.accept() {
275                Ok((mut stream, peer)) => {
276                    if !peer.ip().is_loopback() {
277                        continue;
278                    }
279                    stream.set_read_timeout(Some(Duration::from_secs(2)))?;
280                    stream.set_write_timeout(Some(Duration::from_secs(2)))?;
281                    if in_flight.fetch_add(1, Ordering::AcqRel) >= Self::CONTROL_REQUESTS {
282                        in_flight.fetch_sub(1, Ordering::AcqRel);
283                        // Best effort: a busy reply that cannot be written is moot.
284                        let _ = respond(&mut stream, 503, json!({"error":"control service busy"}));
285                        close_gracefully(&mut stream);
286                        continue;
287                    }
288                    let in_flight = Arc::clone(&in_flight);
289                    scope.spawn(move || {
290                        // A malformed or disconnected client never stops the service.
291                        let _ = self.request(&mut stream);
292                        close_gracefully(&mut stream);
293                        in_flight.fetch_sub(1, Ordering::AcqRel);
294                    });
295                }
296                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
297                    std::thread::sleep(Duration::from_millis(50))
298                }
299                Err(e) => return Err(e.into()),
300            }
301        }
302        Ok(())
303    }
304
305    fn request(&self, stream: &mut TcpStream) -> Result<(), Error> {
306        let mut raw = Vec::new();
307        let began = std::time::Instant::now();
308        loop {
309            if raw.len() == 8192 || began.elapsed() > Duration::from_secs(3) {
310                return respond(stream, 400, json!({"error":"request header limit"}));
311            }
312            let mut byte = [0];
313            if stream.read(&mut byte)? == 0 {
314                return Ok(());
315            }
316            raw.push(byte[0]);
317            if began.elapsed() > Duration::from_secs(3) {
318                return respond(stream, 400, json!({"error":"request header deadline"}));
319            }
320            if raw.ends_with(b"\r\n\r\n") {
321                break;
322            }
323        }
324        let Ok(request) = std::str::from_utf8(&raw) else {
325            return respond(stream, 400, json!({"error":"invalid header encoding"}));
326        };
327        let mut lines = request.split("\r\n");
328        let first = lines.next().unwrap_or("");
329        let mut authorized = false;
330        let mut authorization_seen = false;
331        for line in lines.filter(|line| !line.is_empty()) {
332            let Some((name, value)) = line.split_once(':') else {
333                return respond(stream, 400, json!({"error":"malformed header"}));
334            };
335            if name.eq_ignore_ascii_case("authorization") {
336                if authorization_seen {
337                    return respond(stream, 400, json!({"error":"duplicate authorization"}));
338                }
339                authorization_seen = true;
340                authorized = value
341                    .trim()
342                    .strip_prefix("Bearer ")
343                    .is_some_and(|value| equal_token(value, &self.token));
344            }
345            if name.eq_ignore_ascii_case("transfer-encoding")
346                || (name.eq_ignore_ascii_case("content-length") && value.trim() != "0")
347            {
348                return respond(
349                    stream,
350                    400,
351                    json!({"error":"this control endpoint accepts no request body"}),
352                );
353            }
354        }
355        if !authorized {
356            return respond(stream, 401, json!({"error":"authentication required"}));
357        }
358        match first {
359            line if (line.starts_with("GET /v1/names/") || line.starts_with("POST /v1/names/"))
360                && line.ends_with(" HTTP/1.1") =>
361            {
362                let (method, path) = line[..line.len() - " HTTP/1.1".len()]
363                    .split_once(' ')
364                    .expect("matched HTTP method");
365                let request = match names_request(method, path) {
366                    Ok(Some(request)) => request,
367                    Ok(None) => {
368                        return respond(stream, 404, json!({"error":"unknown endpoint"}));
369                    }
370                    Err(_) => {
371                        return respond(stream, 400, json!({"error":"invalid Names request"}));
372                    }
373                };
374                // Reads never register or advance a registry. Mutations are
375                // explicit one-shot commands under the Engine archive mutex.
376                let result = match request {
377                    NamesRequest::Status(genesis) => match self.engine.names_status(genesis) {
378                        Ok(Some(status)) => serde_json::to_value(status).map_err(Error::from),
379                        Ok(None) => {
380                            return respond(
381                                stream,
382                                404,
383                                json!({"error":"Names registry is not registered locally"}),
384                            );
385                        }
386                        Err(error) => Err(error),
387                    },
388                    NamesRequest::Resolve(genesis, name) => self
389                        .engine
390                        .names_resolve(genesis, &name)
391                        .and_then(|value| serde_json::to_value(value).map_err(Error::from)),
392                    NamesRequest::Register(genesis, point) => self
393                        .engine
394                        .register_names(genesis, point)
395                        .and_then(|value| serde_json::to_value(value).map_err(Error::from)),
396                    NamesRequest::Step(genesis) => self
397                        .engine
398                        .names_step(genesis)
399                        .and_then(|value| serde_json::to_value(value).map_err(Error::from)),
400                    NamesRequest::View(genesis, limit, cursor) => self
401                        .engine
402                        .names_view(genesis, cursor.as_ref(), limit)
403                        .and_then(|value| serde_json::to_value(value).map_err(Error::from)),
404                };
405                match result {
406                    Ok(value) => respond(stream, 200, value),
407                    Err(error) => control_error(stream, error),
408                }
409            }
410            line if line.starts_with("GET /v1/wire?") && line.ends_with(" HTTP/1.1") => {
411                let query = &line["GET /v1/wire?".len()..line.len() - " HTTP/1.1".len()];
412                let (limit, cursor) = match wire_query(query) {
413                    Ok(value) => value,
414                    Err(_) => {
415                        return respond(stream, 400, json!({"error":"invalid Wire page query"}));
416                    }
417                };
418                match self.engine.wire_page(cursor.as_ref(), limit) {
419                    Ok(page) => respond(stream, 200, serde_json::to_value(page)?),
420                    Err(error) => control_error(stream, error),
421                }
422            }
423            "POST /v1/cache/prune HTTP/1.1" => match self.engine.prune_cache() {
424                Ok(records_removed) => respond(
425                    stream,
426                    200,
427                    serde_json::to_value(crate::model::PruneReceipt { records_removed })?,
428                ),
429                Err(error) => control_error(stream, error),
430            },
431            line if line.starts_with("POST /v1/objects/") && line.ends_with(" HTTP/1.1") => {
432                let path = &line["POST /v1/objects/".len()..line.len() - " HTTP/1.1".len()];
433                let (id, pin) = match path.rsplit_once('/') {
434                    Some((id, "pin")) => (id, true),
435                    Some((id, "unpin")) => (id, false),
436                    _ => return respond(stream, 404, json!({"error":"unknown endpoint"})),
437                };
438                if id.len() != 64 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
439                    return respond(stream, 400, json!({"error":"invalid root transaction ID"}));
440                }
441                let root = id
442                    .parse()
443                    .map_err(|e| Error::Invalid(format!("TXID: {e}")))?;
444                // Engine serializes validation and mutation with pruning/reorg.
445                // The acknowledgement describes this operation's commit point;
446                // a lost response must not be treated as proof of no mutation.
447                match self.engine.set_pin(root, pin) {
448                    Ok(()) => respond(
449                        stream,
450                        200,
451                        serde_json::to_value(crate::model::PinReceipt { root, pinned: pin })?,
452                    ),
453                    Err(error) => control_error(stream, error),
454                }
455            }
456            line if line.starts_with("GET /v1/objects/") && line.ends_with(" HTTP/1.1") => {
457                let id = &line["GET /v1/objects/".len()..line.len() - " HTTP/1.1".len()];
458                if id.len() != 64 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
459                    return respond(stream, 400, json!({"error":"invalid root transaction ID"}));
460                }
461                let root = id
462                    .parse()
463                    .map_err(|e| Error::Invalid(format!("TXID: {e}")))?;
464                match self.engine.object_status(root)? {
465                    Some(status) => respond(stream, 200, serde_json::to_value(status)?),
466                    None => respond(
467                        stream,
468                        404,
469                        json!({"error":"no completed object recorded locally"}),
470                    ),
471                }
472            }
473            line if line.starts_with("GET /v1/records/") && line.ends_with(" HTTP/1.1") => {
474                let id = &line["GET /v1/records/".len()..line.len() - " HTTP/1.1".len()];
475                if id.len() != 64 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
476                    return respond(stream, 400, json!({"error":"invalid transaction ID"}));
477                }
478                let txid = id
479                    .parse()
480                    .map_err(|e| Error::Invalid(format!("TXID: {e}")))?;
481                match self.engine.record(txid)? {
482                    Some(record) => respond(stream, 200, serde_json::to_value(record)?),
483                    None => respond(
484                        stream,
485                        404,
486                        json!({"error":"record is not locally available in the selected view"}),
487                    ),
488                }
489            }
490            "GET /v1/status HTTP/1.1" => {
491                respond(stream, 200, serde_json::to_value(self.status()?)?)
492            }
493            // Ascending by height; only the newest (highest) 1024 rows are returned.
494            "GET /v1/overlay/omissions HTTP/1.1" => match self.engine.peer_omissions_latest(1024) {
495                Ok(omissions) => respond(stream, 200, serde_json::to_value(omissions)?),
496                Err(error) => control_error(stream, error),
497            },
498            "GET /v1/overlay/book HTTP/1.1" => match self.overlay_book() {
499                Ok(Some(book)) => respond(stream, 200, serde_json::to_value(book)?),
500                Ok(None) => respond(stream, 404, json!({"error":"overlay disabled"})),
501                Err(error) => control_error(stream, error),
502            },
503            "POST /v1/shutdown HTTP/1.1" => {
504                respond(stream, 200, json!({"stopping":true}))?;
505                self.stop.store(true, Ordering::Release);
506                Ok(())
507            }
508            _ => respond(stream, 404, json!({"error":"unknown endpoint"})),
509        }
510    }
511}
512
513enum NamesRequest {
514    Status(bitcoin::Txid),
515    Resolve(bitcoin::Txid, urma_names::name::Name),
516    Register(bitcoin::Txid, crate::model::ChainPoint),
517    Step(bitcoin::Txid),
518    View(bitcoin::Txid, u32, Option<crate::names::NamesCursor>),
519}
520
521fn names_request(method: &str, path: &str) -> Result<Option<NamesRequest>, Error> {
522    let invalid = || Error::Invalid("invalid Names request".into());
523    let path = path.strip_prefix("/v1/names/").ok_or_else(invalid)?;
524    let (id, operation) = path.split_once('/').unwrap_or((path, ""));
525    if id.len() != 64 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
526        return Err(invalid());
527    }
528    let genesis = id.parse().map_err(|_| invalid())?;
529    let request = match (method, operation) {
530        ("GET", "") => NamesRequest::Status(genesis),
531        ("GET", operation) if operation.starts_with("view?") => {
532            let (limit, cursor) = names_view_query(&operation["view?".len()..])?;
533            if cursor.as_ref().is_some_and(|c| c.genesis != genesis) {
534                return Err(invalid());
535            }
536            NamesRequest::View(genesis, limit, cursor)
537        }
538        ("GET", operation) if operation.starts_with("resolve/") => {
539            // Canonical Names V0 uses only ASCII letters, digits and hyphens;
540            // do not normalize, percent-decode or accept alternate spellings.
541            let name = urma_names::name::Name::parse(&operation["resolve/".len()..])
542                .map_err(|_| invalid())?;
543            NamesRequest::Resolve(genesis, name)
544        }
545        ("POST", "step") => NamesRequest::Step(genesis),
546        ("POST", operation) if operation.starts_with("register?") => {
547            let mut height = None;
548            let mut hash = None;
549            for field in operation["register?".len()..].split('&') {
550                match field.split_once('=') {
551                    Some(("height", value)) if height.is_none() => {
552                        if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
553                            return Err(invalid());
554                        }
555                        let value: u64 = value.parse().map_err(|_| invalid())?;
556                        if value > i64::MAX as u64 {
557                            return Err(invalid());
558                        }
559                        height = Some(value);
560                    }
561                    Some(("hash", value)) if hash.is_none() => {
562                        if value.len() != 64 || !value.bytes().all(|b| b.is_ascii_hexdigit()) {
563                            return Err(invalid());
564                        }
565                        hash = Some(value.parse().map_err(|_| invalid())?);
566                    }
567                    _ => return Err(invalid()),
568                }
569            }
570            NamesRequest::Register(
571                genesis,
572                crate::model::ChainPoint {
573                    height: height.ok_or_else(invalid)?,
574                    hash: hash.ok_or_else(invalid)?,
575                },
576            )
577        }
578        _ => return Ok(None),
579    };
580    Ok(Some(request))
581}
582
583fn names_view_query(query: &str) -> Result<(u32, Option<crate::names::NamesCursor>), Error> {
584    let invalid = || Error::Invalid("invalid Names view query".into());
585    let mut limit = None;
586    let mut cursor = None;
587    for field in query.split('&') {
588        match field.split_once('=') {
589            Some(("limit", value)) if limit.is_none() => {
590                if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
591                    return Err(invalid());
592                }
593                let value: u32 = value.parse().map_err(|_| invalid())?;
594                if !(1..=crate::names::MAX_NAMES_PAGE).contains(&value) {
595                    return Err(invalid());
596                }
597                limit = Some(value);
598            }
599            Some(("cursor", value)) if cursor.is_none() => {
600                if value.len() > 1024 {
601                    return Err(invalid());
602                }
603                let bytes = hex::decode(value).map_err(|_| invalid())?;
604                let decoded: crate::names::NamesCursor = serde_json::from_slice(&bytes)?;
605                if decoded.snapshot.instance.len() != 32
606                    || !decoded
607                        .snapshot
608                        .instance
609                        .bytes()
610                        .all(|b| b.is_ascii_hexdigit())
611                {
612                    return Err(invalid());
613                }
614                cursor = Some(decoded);
615            }
616            _ => return Err(invalid()),
617        }
618    }
619    Ok((limit.ok_or_else(invalid)?, cursor))
620}
621
622fn wire_query(query: &str) -> Result<(u32, Option<crate::projection::WireCursor>), Error> {
623    let invalid = || Error::Invalid("invalid Wire page query".into());
624    let mut limit = None;
625    let mut cursor = None;
626    for field in query.split('&') {
627        match field.split_once('=') {
628            Some(("limit", value)) if limit.is_none() => {
629                if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
630                    return Err(invalid());
631                }
632                let value: u32 = value.parse().map_err(|_| invalid())?;
633                if !(1..=crate::projection::MAX_WIRE_PAGE).contains(&value) {
634                    return Err(invalid());
635                }
636                limit = Some(value);
637            }
638            Some(("cursor", value)) if cursor.is_none() => {
639                if value.len() > 1024 {
640                    return Err(invalid());
641                }
642                let bytes = hex::decode(value).map_err(|_| invalid())?;
643                let decoded: crate::projection::WireCursor = serde_json::from_slice(&bytes)?;
644                if decoded.snapshot.instance.len() != 32
645                    || !decoded
646                        .snapshot
647                        .instance
648                        .bytes()
649                        .all(|b| b.is_ascii_hexdigit())
650                    || decoded.height > i64::MAX as u64
651                {
652                    return Err(invalid());
653                }
654                cursor = Some(decoded);
655            }
656            _ => return Err(invalid()),
657        }
658    }
659    Ok((limit.ok_or_else(invalid)?, cursor))
660}
661
662fn control_error(stream: &mut TcpStream, error: Error) -> Result<(), Error> {
663    match error {
664        Error::Invalid(message) => respond(stream, 422, json!({"error":message})),
665        error @ (Error::Busy(_)
666        | Error::PeerBusy
667        | Error::Transport(_)
668        | Error::DialDenied(_)
669        | Error::Stale) => respond(stream, 409, json!({"error":error.to_string()})),
670        // Do not expose database internals or local filesystem paths over HTTP.
671        _ => respond(
672            stream,
673            500,
674            json!({"error":"local archive operation failed"}),
675        ),
676    }
677}
678
679fn equal_token(left: &str, right: &str) -> bool {
680    if left.len() != right.len() {
681        return false;
682    }
683    left.bytes()
684        .zip(right.bytes())
685        .fold(0u8, |different, (a, b)| different | (a ^ b))
686        == 0
687}
688
689/// Closing a socket with unread request bytes makes the kernel send RST and
690/// drop any reply still queued, so a client that sent more than we read (an
691/// oversized header) could lose its 400. Half-close first so the reply ends in
692/// FIN, then drain what the client still sends, bounded in bytes and time.
693fn close_gracefully(stream: &mut TcpStream) {
694    const DRAIN_BYTES: usize = 64 * 1024;
695    const DRAIN_TIME: Duration = Duration::from_millis(200);
696    if stream.shutdown(Shutdown::Write).is_err() {
697        return;
698    }
699    let deadline = Instant::now() + DRAIN_TIME;
700    let mut drained = 0;
701    let mut chunk = [0; 4096];
702    while drained < DRAIN_BYTES {
703        let left = deadline.saturating_duration_since(Instant::now());
704        if left.is_zero() || stream.set_read_timeout(Some(left)).is_err() {
705            return;
706        }
707        match stream.read(&mut chunk) {
708            Ok(0) => return,
709            Ok(read) => drained += read,
710            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
711            Err(_closed_or_timed_out) => return,
712        }
713    }
714}
715
716fn respond(stream: &mut TcpStream, code: u16, body: serde_json::Value) -> Result<(), Error> {
717    let body = serde_json::to_vec(&body)?;
718    if body.len() > 32 * 1024 * 1024 {
719        return respond(
720            stream,
721            413,
722            json!({"error":"record exceeds local API response limit"}),
723        );
724    }
725    let reason = match code {
726        200 => "OK",
727        400 => "Bad Request",
728        401 => "Unauthorized",
729        409 => "Conflict",
730        413 => "Content Too Large",
731        422 => "Unprocessable Content",
732        500 => "Internal Server Error",
733        503 => "Service Unavailable",
734        _ => "Not Found",
735    };
736    write!(
737        stream,
738        "HTTP/1.1 {code} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
739        body.len()
740    )?;
741    stream.write_all(&body)?;
742    Ok(())
743}
744
745/// New private service token, never a wallet/signing/decryption key. Existing
746/// files are not overwritten.
747pub fn create_token(path: &Path) -> Result<(), Error> {
748    let mut file = urma_io::create_file(path, Access::Private)?;
749    write_token(&mut file)?;
750    file.sync_all()?;
751    Ok(())
752}
753
754/// 32 random bytes, hex encoded, into a file whose access the caller chose.
755pub fn write_token(file: &mut File) -> Result<(), Error> {
756    let mut bytes = [0u8; 32];
757    rand::rngs::OsRng.fill_bytes(&mut bytes);
758    file.write_all(hex::encode(bytes).as_bytes())?;
759    Ok(())
760}