Skip to main content

liminal_cli/
config.rs

1//! The ticket's configuration.
2//!
3//! The ticket's configuration (SDK-017 R1), read from the TOML file named on
4//! its command line: the server's address, the channel, the Lys pass from a
5//! file of its own (never printed or logged), and the limits. A missing or
6//! zero field is refused by its name before any socket or stdin is touched.
7
8use std::fmt;
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12use serde::Deserialize;
13
14/// The Lys pass, read from its file. Its Debug prints its length only.
15pub struct Token(String);
16
17impl Token {
18    /// Borrows the pass only for the authenticated connection boundary.
19    #[must_use]
20    pub fn as_str(&self) -> &str {
21        &self.0
22    }
23}
24
25impl fmt::Debug for Token {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        write!(f, "Token({} bytes)", self.0.len())
28    }
29}
30
31/// The ticket's limits; every one is positive.
32#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
33#[serde(deny_unknown_fields)]
34pub struct Limits {
35    /// The longest JSON-RPC line read from stdin, in bytes.
36    pub frame_bytes: usize,
37    /// The largest topology record accepted from `console.topology`, in bytes.
38    pub record_bytes: usize,
39    /// How long one routed request may wait for its reply, in milliseconds.
40    pub reply_timeout_ms: u64,
41    /// The most steps one flow may carry.
42    pub flow_steps: usize,
43    /// The largest request body sent on a subject, in bytes.
44    pub request_bytes: usize,
45}
46
47impl Limits {
48    /// The reply timeout as a duration.
49    #[must_use]
50    pub const fn reply_timeout(&self) -> Duration {
51        Duration::from_millis(self.reply_timeout_ms)
52    }
53
54    const fn validate(self) -> Result<Self, &'static str> {
55        if self.frame_bytes == 0 {
56            return Err("frame_bytes");
57        }
58        if self.record_bytes == 0 {
59            return Err("record_bytes");
60        }
61        if self.reply_timeout_ms == 0 {
62            return Err("reply_timeout_ms");
63        }
64        if self.flow_steps == 0 {
65            return Err("flow_steps");
66        }
67        if self.request_bytes == 0 {
68            return Err("request_bytes");
69        }
70        Ok(self)
71    }
72}
73
74/// Validated inputs for the ticket.
75#[derive(Debug)]
76pub struct Config {
77    /// The server address every connection dials.
78    pub server: String,
79    /// The channel routed requests travel on.
80    pub channel: String,
81    /// Where the pass comes from: a fixed pass, or Lys asked with the agent's key.
82    pub credential: Credential,
83    /// The limits validated before anything is opened.
84    pub limits: Limits,
85}
86
87/// Where the ticket's pass comes from.
88#[derive(Debug)]
89pub enum Credential {
90    /// A pass read once from `token_file`, whose Debug reveals only its length.
91    Token(Token),
92    /// `[lys]`: the agent's own pass, asked of Lys with its key and renewed.
93    Lys(crate::lys::LysTable),
94}
95
96/// Why a configuration is refused.
97#[derive(Debug, thiserror::Error)]
98pub enum ConfigError {
99    /// Reading the named configuration failed.
100    #[error("the configuration {path} cannot be read: {message}")]
101    Unreadable {
102        /// The configuration that could not be read.
103        path: PathBuf,
104        /// The operating-system failure, without file contents.
105        message: String,
106    },
107    /// The named configuration did not satisfy its schema.
108    #[error("the configuration {path} is refused: {message}")]
109    Invalid {
110        /// The refused configuration.
111        path: PathBuf,
112        /// The schema failure, without file contents.
113        message: String,
114    },
115    /// A limit is zero.
116    #[error("the configuration {path} is refused: {field} must be positive")]
117    Zero {
118        /// The refused configuration.
119        path: PathBuf,
120        /// The field that is zero.
121        field: &'static str,
122    },
123    /// Reading or validating the pass failed, without revealing its bytes.
124    #[error("the token file {path} cannot be read: {message}")]
125    Token {
126        /// The refused token file.
127        path: PathBuf,
128        /// The failure, without token contents.
129        message: String,
130    },
131}
132
133/// The file as written; every field is required and an unknown one is refused.
134#[derive(Deserialize)]
135#[serde(deny_unknown_fields)]
136struct Written {
137    server: String,
138    channel: String,
139    token_file: Option<PathBuf>,
140    lys: Option<crate::lys::LysTable>,
141    limits: Limits,
142}
143
144impl Config {
145    /// Reads and checks the configuration at `path`. A missing field is named
146    /// in the error (serde's own words, "missing field `frame_bytes`"), a zero
147    /// limit by its name; the pass's bytes never appear in any error.
148    ///
149    /// # Errors
150    /// Returns a named read, schema, zero-limit or token refusal.
151    pub fn load(path: &Path) -> Result<Self, ConfigError> {
152        let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Unreadable {
153            path: path.to_path_buf(),
154            message: e.to_string(),
155        })?;
156        let file: Written = toml::from_str(&text).map_err(|e| ConfigError::Invalid {
157            path: path.to_path_buf(),
158            message: e.message().to_owned(),
159        })?;
160        let limits = file.limits.validate().map_err(|field| ConfigError::Zero {
161            path: path.to_path_buf(),
162            field,
163        })?;
164        if file.server.is_empty() {
165            return Err(ConfigError::Zero {
166                path: path.to_path_buf(),
167                field: "server",
168            });
169        }
170        if file.channel.is_empty() {
171            return Err(ConfigError::Zero {
172                path: path.to_path_buf(),
173                field: "channel",
174            });
175        }
176        let token_file = match (file.token_file, file.lys) {
177            (None, Some(lys)) => {
178                return Ok(Self {
179                    server: file.server,
180                    channel: file.channel,
181                    credential: Credential::Lys(lys),
182                    limits,
183                });
184            }
185            (Some(token_file), None) if token_file.is_absolute() => token_file,
186            (Some(token_file), None) => path
187                .parent()
188                .map_or_else(|| token_file.clone(), |base| base.join(&token_file)),
189            _ => {
190                return Err(ConfigError::Invalid {
191                    path: path.to_path_buf(),
192                    message: "give exactly one of token_file and [lys]".to_owned(),
193                });
194            }
195        };
196        let token = std::fs::read_to_string(&token_file).map_err(|e| ConfigError::Token {
197            path: token_file.clone(),
198            message: e.to_string(),
199        })?;
200        let token = token.trim_end_matches(['\r', '\n']);
201        if token.is_empty() {
202            return Err(ConfigError::Token {
203                path: token_file,
204                message: "token file is empty".to_owned(),
205            });
206        }
207        Ok(Self {
208            server: file.server,
209            channel: file.channel,
210            credential: Credential::Token(Token(token.to_owned())),
211            limits,
212        })
213    }
214}
215
216#[cfg(test)]
217#[path = "config_tests.rs"]
218mod tests;