Skip to main content

acropolis/interfaces/
mod.rs

1use crate::config::{DataMode, InterfaceConfig};
2use crate::events::{Event, EventPayload};
3use std::fmt;
4
5pub const INTERFACE_BLOCKED_EVENT: &str = "interfaces.blocked";
6pub const INTERFACE_PLAN_EVENT: &str = "interfaces.plan";
7pub const INTERFACE_REQUEST_PLAN_EVENT: &str = "interfaces.request_plan";
8pub const INTERFACE_ROUTE_PLAN_EVENT: &str = "interfaces.route_plan";
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct InterfacePlan {
12    pub transactions_enabled: bool,
13    pub state_enabled: bool,
14    pub batches_enabled: bool,
15    pub archive_enabled: bool,
16    pub blocked_reason: Option<String>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct InterfaceRequest {
21    pub name: String,
22    pub requested: bool,
23    pub port: u16,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct InterfaceRequestPlan {
28    pub requests: Vec<InterfaceRequest>,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum InterfaceKind {
33    Transactions,
34    State,
35    Batches,
36    Archive,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct InterfaceRoute {
41    pub kind: InterfaceKind,
42    pub name: String,
43    pub path: String,
44    pub port: u16,
45    pub state: InterfaceState,
46    pub max_body_bytes: usize,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct InterfaceRoutePlan {
51    pub routes: Vec<InterfaceRoute>,
52}
53
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
55pub struct InterfaceRouteCounts {
56    pub closed: usize,
57    pub planned: usize,
58    pub open: usize,
59}
60
61impl InterfaceRouteCounts {
62    pub fn total(self) -> usize {
63        self.closed + self.planned + self.open
64    }
65}
66
67impl InterfaceRequestPlan {
68    pub fn requested_count(&self) -> usize {
69        self.requests
70            .iter()
71            .filter(|request| request.requested)
72            .count()
73    }
74
75    pub fn summary_line(&self) -> String {
76        let requested = self.requested_count();
77        format!(
78            "interface_requests total={} requested={} unrequested={}",
79            self.requests.len(),
80            requested,
81            self.requests.len().saturating_sub(requested)
82        )
83    }
84
85    pub fn to_event(&self) -> Event {
86        Event::new(
87            INTERFACE_REQUEST_PLAN_EVENT,
88            EventPayload::Text(self.summary_line()),
89        )
90    }
91
92    pub fn event_batch(&self) -> Vec<Event> {
93        vec![self.to_event()]
94    }
95}
96
97impl InterfacePlan {
98    pub fn from_config(interfaces: &InterfaceConfig, data_mode: DataMode) -> Self {
99        let any_public_interface = interfaces.transaction_port != 0
100            || interfaces.state_port != 0
101            || interfaces.batch_port != 0;
102        let blocked_reason = if any_public_interface && data_mode != DataMode::Full {
103            Some("public interfaces require ACROPOLIS_DATA_MODE=full".to_string())
104        } else {
105            None
106        };
107        Self {
108            transactions_enabled: interfaces.transaction_port != 0 && blocked_reason.is_none(),
109            state_enabled: interfaces.state_port != 0 && blocked_reason.is_none(),
110            batches_enabled: interfaces.batch_port != 0 && blocked_reason.is_none(),
111            archive_enabled: interfaces.archive_port != 0,
112            blocked_reason,
113        }
114    }
115
116    pub fn request_plan(config: &InterfaceConfig) -> InterfaceRequestPlan {
117        InterfaceRequestPlan {
118            requests: vec![
119                InterfaceRequest {
120                    name: "transactions".to_string(),
121                    requested: config.transaction_port != 0,
122                    port: config.transaction_port,
123                },
124                InterfaceRequest {
125                    name: "state".to_string(),
126                    requested: config.state_port != 0,
127                    port: config.state_port,
128                },
129                InterfaceRequest {
130                    name: "batches".to_string(),
131                    requested: config.batch_port != 0,
132                    port: config.batch_port,
133                },
134                InterfaceRequest {
135                    name: "archive".to_string(),
136                    requested: config.archive_port != 0,
137                    port: config.archive_port,
138                },
139            ],
140        }
141    }
142
143    pub fn route_plan(config: &InterfaceConfig, data_mode: DataMode) -> InterfaceRoutePlan {
144        let plan = Self::from_config(config, data_mode);
145        InterfaceRoutePlan {
146            routes: vec![
147                InterfaceRoute::new(
148                    InterfaceKind::Transactions,
149                    "transactions",
150                    "/transactions",
151                    config.transaction_port,
152                    plan.transactions_enabled,
153                ),
154                InterfaceRoute::new(
155                    InterfaceKind::State,
156                    "state",
157                    "/state",
158                    config.state_port,
159                    plan.state_enabled,
160                ),
161                InterfaceRoute::new(
162                    InterfaceKind::Batches,
163                    "batches",
164                    "/batches",
165                    config.batch_port,
166                    plan.batches_enabled,
167                ),
168                InterfaceRoute::new(
169                    InterfaceKind::Archive,
170                    "archive",
171                    "/archive",
172                    config.archive_port,
173                    plan.archive_enabled,
174                ),
175            ],
176        }
177    }
178
179    pub fn events(&self) -> Vec<Event> {
180        let Some(reason) = &self.blocked_reason else {
181            return Vec::new();
182        };
183        vec![Event::new(
184            INTERFACE_BLOCKED_EVENT,
185            EventPayload::Text(format!(
186                "transactions={} state={} batches={} archive={} reason={reason}",
187                self.transactions_enabled,
188                self.state_enabled,
189                self.batches_enabled,
190                self.archive_enabled
191            )),
192        )]
193    }
194
195    pub fn summary_line(&self) -> String {
196        format!(
197            "interface_plan transactions={} state={} batches={} archive={} blocked={}",
198            self.transactions_enabled,
199            self.state_enabled,
200            self.batches_enabled,
201            self.archive_enabled,
202            self.blocked_reason.is_some()
203        )
204    }
205
206    pub fn to_event(&self) -> Event {
207        Event::new(
208            INTERFACE_PLAN_EVENT,
209            EventPayload::Text(self.summary_line()),
210        )
211    }
212
213    pub fn event_batch(&self) -> Vec<Event> {
214        let mut events = vec![self.to_event()];
215        events.extend(self.events());
216        events
217    }
218}
219
220impl InterfaceRoute {
221    pub fn new(
222        kind: InterfaceKind,
223        name: impl Into<String>,
224        path: impl Into<String>,
225        port: u16,
226        enabled: bool,
227    ) -> Self {
228        Self {
229            kind,
230            name: name.into(),
231            path: path.into(),
232            port,
233            state: if enabled {
234                InterfaceState::Planned
235            } else {
236                InterfaceState::Closed
237            },
238            max_body_bytes: match kind {
239                InterfaceKind::Transactions => 16 * 1024,
240                InterfaceKind::State => 1024 * 1024,
241                InterfaceKind::Batches => 64 * 1024,
242                InterfaceKind::Archive => 4 * 1024 * 1024,
243            },
244        }
245    }
246}
247
248impl InterfaceRoutePlan {
249    pub fn summary_line(&self) -> String {
250        let counts = self.state_counts();
251        format!(
252            "interface_routes total={} closed={} planned={} open={}",
253            counts.total(),
254            counts.closed,
255            counts.planned,
256            counts.open
257        )
258    }
259
260    pub fn to_event(&self) -> Event {
261        Event::new(
262            INTERFACE_ROUTE_PLAN_EVENT,
263            EventPayload::Text(self.summary_line()),
264        )
265    }
266
267    pub fn event_batch(&self) -> Vec<Event> {
268        vec![self.to_event()]
269    }
270
271    pub fn planned(&self) -> Vec<&InterfaceRoute> {
272        self.routes
273            .iter()
274            .filter(|route| route.state == InterfaceState::Planned)
275            .collect()
276    }
277
278    pub fn state_counts(&self) -> InterfaceRouteCounts {
279        let mut counts = InterfaceRouteCounts::default();
280        for route in &self.routes {
281            match route.state {
282                InterfaceState::Closed => counts.closed += 1,
283                InterfaceState::Planned => counts.planned += 1,
284                InterfaceState::Open => counts.open += 1,
285            }
286        }
287        counts
288    }
289
290    pub fn handle_local(
291        &self,
292        kind: InterfaceKind,
293        body: &[u8],
294    ) -> Result<InterfaceResponse, InterfaceError> {
295        let route = self
296            .routes
297            .iter()
298            .find(|route| route.kind == kind)
299            .ok_or(InterfaceError::UnknownRoute(kind))?;
300        route.handle_local(body)
301    }
302}
303
304impl InterfaceRoute {
305    pub fn handle_local(&self, body: &[u8]) -> Result<InterfaceResponse, InterfaceError> {
306        if self.state == InterfaceState::Closed {
307            return Err(InterfaceError::RouteClosed(self.name.clone()));
308        }
309        if body.len() > self.max_body_bytes {
310            return Err(InterfaceError::BodyTooLarge {
311                name: self.name.clone(),
312                max: self.max_body_bytes,
313                actual: body.len(),
314            });
315        }
316        if matches!(
317            self.kind,
318            InterfaceKind::Transactions | InterfaceKind::Batches
319        ) && body.is_empty()
320        {
321            return Err(InterfaceError::EmptyBody(self.name.clone()));
322        }
323        let status_code = match self.kind {
324            InterfaceKind::Transactions | InterfaceKind::Batches => 202,
325            InterfaceKind::State | InterfaceKind::Archive => 200,
326        };
327        Ok(InterfaceResponse {
328            kind: self.kind,
329            status_code,
330            body: format!(
331                "{} handled locally at {} bytes={}",
332                self.name,
333                self.path,
334                body.len()
335            ),
336        })
337    }
338}
339
340#[derive(Debug, Clone, PartialEq, Eq)]
341pub struct InterfaceResponse {
342    pub kind: InterfaceKind,
343    pub status_code: u16,
344    pub body: String,
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum InterfaceState {
349    Closed,
350    Planned,
351    Open,
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
355pub struct InterfaceLatch {
356    pub name: String,
357    pub state: InterfaceState,
358}
359
360impl InterfaceLatch {
361    pub fn new(name: impl Into<String>) -> Self {
362        Self {
363            name: name.into(),
364            state: InterfaceState::Closed,
365        }
366    }
367
368    pub fn plan(&mut self) {
369        if self.state == InterfaceState::Closed {
370            self.state = InterfaceState::Planned;
371        }
372    }
373
374    pub fn open(&mut self, allow_paths: bool) -> Result<(), InterfaceError> {
375        if !allow_paths {
376            return Err(InterfaceError::SafetyClosed(self.name.clone()));
377        }
378        self.state = InterfaceState::Open;
379        Ok(())
380    }
381
382    pub fn close(&mut self) {
383        self.state = InterfaceState::Closed;
384    }
385}
386
387#[derive(Debug, Clone, PartialEq, Eq)]
388pub enum InterfaceError {
389    SafetyClosed(String),
390    RouteClosed(String),
391    UnknownRoute(InterfaceKind),
392    EmptyBody(String),
393    BodyTooLarge {
394        name: String,
395        max: usize,
396        actual: usize,
397    },
398}
399
400impl fmt::Display for InterfaceError {
401    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
402        match self {
403            Self::SafetyClosed(name) => write!(f, "interface {name} blocked by safety config"),
404            Self::RouteClosed(name) => write!(f, "interface route {name} is closed"),
405            Self::UnknownRoute(kind) => write!(f, "unknown interface route {kind:?}"),
406            Self::EmptyBody(name) => write!(f, "interface route {name} requires a body"),
407            Self::BodyTooLarge { name, max, actual } => write!(
408                f,
409                "interface route {name} body too large: max={max} actual={actual}"
410            ),
411        }
412    }
413}
414
415impl std::error::Error for InterfaceError {}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn interface_latch_stays_closed_without_safety_permission() {
423        let mut latch = InterfaceLatch::new("transactions");
424        latch.plan();
425        assert_eq!(latch.state, InterfaceState::Planned);
426        assert_eq!(
427            latch.open(false),
428            Err(InterfaceError::SafetyClosed("transactions".to_string()))
429        );
430        assert_eq!(latch.state, InterfaceState::Planned);
431        latch.close();
432        assert_eq!(latch.state, InterfaceState::Closed);
433    }
434
435    #[test]
436    fn request_plan_lists_closed_interface_requests() {
437        let config = InterfaceConfig {
438            transaction_port: 10,
439            state_port: 0,
440            batch_port: 0,
441            archive_port: 20,
442            archive_base_url: None,
443        };
444        let plan = InterfacePlan::request_plan(&config);
445        assert_eq!(plan.requested_count(), 2);
446        assert_eq!(plan.event_batch().len(), 1);
447        assert_eq!(
448            plan.event_batch()[0].name.as_str(),
449            INTERFACE_REQUEST_PLAN_EVENT
450        );
451        assert_eq!(
452            plan.summary_line(),
453            "interface_requests total=4 requested=2 unrequested=2"
454        );
455    }
456
457    #[test]
458    fn route_plan_keeps_public_interfaces_closed_without_full_data() {
459        let config = InterfaceConfig {
460            transaction_port: 10,
461            state_port: 11,
462            batch_port: 12,
463            archive_port: 13,
464            archive_base_url: None,
465        };
466        let plan = InterfacePlan::route_plan(&config, DataMode::Core);
467        assert_eq!(plan.planned().len(), 1);
468        assert_eq!(plan.planned()[0].kind, InterfaceKind::Archive);
469        assert_eq!(plan.routes[0].state, InterfaceState::Closed);
470        assert_eq!(
471            plan.state_counts(),
472            InterfaceRouteCounts {
473                closed: 3,
474                planned: 1,
475                open: 0,
476            }
477        );
478        assert_eq!(plan.state_counts().total(), 4);
479        assert_eq!(plan.event_batch().len(), 1);
480        assert_eq!(
481            plan.event_batch()[0].name.as_str(),
482            INTERFACE_ROUTE_PLAN_EVENT
483        );
484        assert_eq!(
485            plan.summary_line(),
486            "interface_routes total=4 closed=3 planned=1 open=0"
487        );
488    }
489
490    #[test]
491    fn interface_plan_emits_blocked_event_without_opening_paths() {
492        let config = InterfaceConfig {
493            transaction_port: 10,
494            state_port: 11,
495            batch_port: 12,
496            archive_port: 13,
497            archive_base_url: None,
498        };
499        let plan = InterfacePlan::from_config(&config, DataMode::Core);
500        let events = plan.events();
501
502        assert_eq!(events.len(), 1);
503        assert_eq!(events[0].name.as_str(), INTERFACE_BLOCKED_EVENT);
504        assert_eq!(
505            events[0].payload,
506            EventPayload::Text(
507                "transactions=false state=false batches=false archive=true reason=public interfaces require ACROPOLIS_DATA_MODE=full"
508                    .to_string()
509            )
510        );
511        let event_batch = plan.event_batch();
512        assert_eq!(event_batch.len(), 2);
513        assert_eq!(event_batch[0].name.as_str(), INTERFACE_PLAN_EVENT);
514        assert_eq!(
515            plan.summary_line(),
516            "interface_plan transactions=false state=false batches=false archive=true blocked=true"
517        );
518    }
519
520    #[test]
521    fn local_interface_handler_accepts_planned_transaction_without_opening_paths() {
522        let config = InterfaceConfig {
523            transaction_port: 10,
524            state_port: 11,
525            batch_port: 0,
526            archive_port: 0,
527            archive_base_url: None,
528        };
529        let plan = InterfacePlan::route_plan(&config, DataMode::Full);
530
531        let response = plan
532            .handle_local(InterfaceKind::Transactions, b"transaction-bytes")
533            .unwrap();
534        assert_eq!(response.kind, InterfaceKind::Transactions);
535        assert_eq!(response.status_code, 202);
536        assert!(response.body.contains("handled locally"));
537    }
538
539    #[test]
540    fn local_interface_handler_rejects_closed_route() {
541        let config = InterfaceConfig {
542            transaction_port: 10,
543            state_port: 0,
544            batch_port: 0,
545            archive_port: 0,
546            archive_base_url: None,
547        };
548        let plan = InterfacePlan::route_plan(&config, DataMode::Core);
549
550        assert_eq!(
551            plan.handle_local(InterfaceKind::Transactions, b"transaction-bytes"),
552            Err(InterfaceError::RouteClosed("transactions".to_string()))
553        );
554    }
555
556    #[test]
557    fn local_interface_handler_rejects_oversized_body() {
558        let route =
559            InterfaceRoute::new(InterfaceKind::Transactions, "transactions", "/tx", 10, true);
560        let body = vec![0; route.max_body_bytes + 1];
561
562        assert_eq!(
563            route.handle_local(&body),
564            Err(InterfaceError::BodyTooLarge {
565                name: "transactions".to_string(),
566                max: 16 * 1024,
567                actual: 16 * 1024 + 1,
568            })
569        );
570    }
571}