Skip to main content

frame_cli/
coherence.rs

1//! `frame.toml` port-coherence rules.
2//!
3//! `frame check` runs these immediately after the config loads, so a partial
4//! hand-edit — the classic "I bumped `[frame].bind` but forgot
5//! `[bus.websocket].allowed_origins`" — fails loudly here rather than as an
6//! Origin-refused page in the browser, or as a mid-boot bind error. The rules
7//! are a pure function over the four addresses and the allow-list so they are
8//! provable in isolation and reusable wherever a loaded [`FrameConfig`] is in
9//! hand.
10//!
11//! There is coherence to check ONLY when the operator stated the topology
12//! verbatim ([`FrameConfig::ports_explicit`]): a portless config
13//! (2026-07-22 ruling) has its page port chosen by prefer-and-walk, its
14//! internal ports OS-assigned, and its `allowed_origins` DERIVED from the real
15//! page address at boot — so it is coherent by construction and there is
16//! nothing static to check. That path returns no violations, which is a clean
17//! pass.
18
19use std::net::SocketAddr;
20
21use frame_host::FrameConfig;
22
23/// Every port-coherence violation in `config`, each naming the exact
24/// frame.toml keys involved. An empty vector means the config is coherent.
25///
26/// Only a fully-stated topology ([`FrameConfig::ports_explicit`]) has
27/// coherence to check. A derived/portless config is coherent by construction
28/// (the host resolves and reconciles its ports at boot), so this returns an
29/// empty vector for it — a clean pass, never a false "origin missing"
30/// complaint about an OS-assigned `:0` address or a not-yet-resolved page
31/// port.
32#[must_use]
33pub fn coherence_violations(config: &FrameConfig) -> Vec<String> {
34    if !config.ports_explicit {
35        return Vec::new();
36    }
37    let (Some(bind), Some(websocket)) = (config.frame.bind, config.bus.websocket.as_ref()) else {
38        // `ports_explicit` already guarantees both are present; this is a
39        // defensive clean pass rather than a panic.
40        return Vec::new();
41    };
42    port_coherence_violations(
43        bind,
44        config.bus.listen_address,
45        config.bus.health_listen_address,
46        websocket.listen_address,
47        &websocket.allowed_origins,
48    )
49}
50
51/// The pure coherence rules over the four addresses and the allow-list, split
52/// out so they are provable without loading a whole `FrameConfig`.
53fn port_coherence_violations(
54    bind: SocketAddr,
55    bus: SocketAddr,
56    health: SocketAddr,
57    websocket: SocketAddr,
58    allowed_origins: &[String],
59) -> Vec<String> {
60    let mut violations = Vec::new();
61
62    let origin = format!("http://{bind}");
63    if !allowed_origins.iter().any(|allowed| allowed == &origin) {
64        violations.push(format!(
65            "[bus.websocket].allowed_origins {allowed_origins:?} does not contain the page origin \
66             \"{origin}\" formed from [frame].bind: the served page would be Origin-refused by the \
67             bus. If you change [frame].bind you must also add its origin to \
68             [bus.websocket].allowed_origins."
69        ));
70    }
71
72    let labelled = [
73        ("[frame].bind", bind.port()),
74        ("[bus].listen_address", bus.port()),
75        ("[bus].health_listen_address", health.port()),
76        ("[bus.websocket].listen_address", websocket.port()),
77    ];
78    for (index, (key_a, port_a)) in labelled.iter().enumerate() {
79        for (key_b, port_b) in &labelled[index + 1..] {
80            if port_a == port_b {
81                violations.push(format!(
82                    "{key_a} and {key_b} both use port {port_a}: the four frame.toml ports \
83                     ([frame].bind, [bus].listen_address, [bus].health_listen_address, \
84                     [bus.websocket].listen_address) must be pairwise distinct or the host cannot \
85                     bind them all."
86                ));
87            }
88        }
89    }
90
91    violations
92}
93
94#[cfg(test)]
95mod tests {
96    use super::port_coherence_violations;
97    use std::net::SocketAddr;
98
99    fn addr(port: u16) -> SocketAddr {
100        SocketAddr::from(([127, 0, 0, 1], port))
101    }
102
103    #[test]
104    fn a_coherent_default_family_has_no_violations() {
105        let violations = port_coherence_violations(
106            addr(4190),
107            addr(9460),
108            addr(9461),
109            addr(9463),
110            &["http://127.0.0.1:4190".to_owned()],
111        );
112        assert!(
113            violations.is_empty(),
114            "unexpected violations: {violations:?}"
115        );
116    }
117
118    #[test]
119    fn missing_origin_names_the_allowed_origins_key() {
120        // The page bind moved to 4191 but allowed_origins still names 4190 —
121        // the exact partial hand-edit that yields an Origin-refused page.
122        let violations = port_coherence_violations(
123            addr(4191),
124            addr(9460),
125            addr(9461),
126            addr(9463),
127            &["http://127.0.0.1:4190".to_owned()],
128        );
129        assert_eq!(
130            violations.len(),
131            1,
132            "one violation expected: {violations:?}"
133        );
134        let message = &violations[0];
135        assert!(message.contains("[bus.websocket].allowed_origins"));
136        assert!(message.contains("[frame].bind"));
137        assert!(message.contains("http://127.0.0.1:4191"));
138    }
139
140    #[test]
141    fn a_duplicated_port_names_both_keys() {
142        // Health collides with the bus TCP port.
143        let violations = port_coherence_violations(
144            addr(4190),
145            addr(9460),
146            addr(9460),
147            addr(9463),
148            &["http://127.0.0.1:4190".to_owned()],
149        );
150        assert_eq!(
151            violations.len(),
152            1,
153            "one violation expected: {violations:?}"
154        );
155        let message = &violations[0];
156        assert!(message.contains("[bus].listen_address"));
157        assert!(message.contains("[bus].health_listen_address"));
158        assert!(message.contains("9460"));
159    }
160
161    #[test]
162    fn both_kinds_of_violation_surface_together() {
163        // Moved bind (origin now stale) AND websocket sharing the bus port.
164        let violations = port_coherence_violations(
165            addr(4191),
166            addr(9460),
167            addr(9461),
168            addr(9460),
169            &["http://127.0.0.1:4190".to_owned()],
170        );
171        assert_eq!(
172            violations.len(),
173            2,
174            "origin + duplicate expected: {violations:?}"
175        );
176    }
177}