Skip to main content

use_oci_config/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5use std::error::Error;
6
7use use_oci_annotation::Annotation;
8use use_oci_platform::{OciArchitecture, OciOs};
9
10/// Errors returned when config metadata is invalid.
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum ConfigError {
13    Empty,
14    InvalidEnv,
15    InvalidPort,
16}
17
18impl fmt::Display for ConfigError {
19    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::Empty => formatter.write_str("OCI config value cannot be empty"),
22            Self::InvalidEnv => formatter.write_str("invalid OCI config environment entry"),
23            Self::InvalidPort => formatter.write_str("invalid OCI exposed port"),
24        }
25    }
26}
27
28impl Error for ConfigError {}
29
30macro_rules! text_value {
31    ($name:ident) => {
32        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
33        pub struct $name(String);
34
35        impl $name {
36            /// Creates a non-empty config text value.
37            pub fn new(value: impl AsRef<str>) -> Result<Self, ConfigError> {
38                let trimmed = value.as_ref().trim();
39                if trimmed.is_empty() {
40                    return Err(ConfigError::Empty);
41                }
42                if trimmed.contains('\0') {
43                    return Err(ConfigError::InvalidEnv);
44                }
45                Ok(Self(trimmed.to_string()))
46            }
47
48            /// Returns the text value.
49            #[must_use]
50            pub fn as_str(&self) -> &str {
51                &self.0
52            }
53        }
54
55        impl AsRef<str> for $name {
56            fn as_ref(&self) -> &str {
57                self.as_str()
58            }
59        }
60
61        impl fmt::Display for $name {
62            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63                formatter.write_str(self.as_str())
64            }
65        }
66    };
67}
68
69text_value!(ConfigUser);
70text_value!(Entrypoint);
71text_value!(Command);
72text_value!(WorkingDir);
73text_value!(VolumePath);
74text_value!(StopSignal);
75
76/// An environment variable entry.
77#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
78pub struct EnvVar {
79    key: String,
80    value: String,
81}
82
83impl EnvVar {
84    /// Creates an environment variable entry.
85    pub fn new(key: impl AsRef<str>, value: impl AsRef<str>) -> Result<Self, ConfigError> {
86        let key = key.as_ref().trim();
87        if key.is_empty()
88            || key.contains('=')
89            || key
90                .bytes()
91                .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
92        {
93            return Err(ConfigError::InvalidEnv);
94        }
95        let value = value.as_ref();
96        if value.contains('\0') {
97            return Err(ConfigError::InvalidEnv);
98        }
99        Ok(Self {
100            key: key.to_string(),
101            value: value.to_string(),
102        })
103    }
104
105    /// Returns the key.
106    #[must_use]
107    pub fn key(&self) -> &str {
108        &self.key
109    }
110
111    /// Returns the value.
112    #[must_use]
113    pub fn value(&self) -> &str {
114        &self.value
115    }
116}
117
118impl fmt::Display for EnvVar {
119    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
120        write!(formatter, "{}={}", self.key, self.value)
121    }
122}
123
124/// Exposed port metadata.
125#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
126pub struct ExposedPort(String);
127
128impl ExposedPort {
129    /// Creates exposed port metadata such as `8080/tcp`.
130    pub fn new(value: impl AsRef<str>) -> Result<Self, ConfigError> {
131        let trimmed = value.as_ref().trim().to_ascii_lowercase();
132        let Some((number, protocol)) = trimmed.split_once('/') else {
133            return Err(ConfigError::InvalidPort);
134        };
135        if number.parse::<u16>().is_err() || !matches!(protocol, "tcp" | "udp" | "sctp") {
136            return Err(ConfigError::InvalidPort);
137        }
138        Ok(Self(trimmed))
139    }
140
141    /// Returns the port text.
142    #[must_use]
143    pub fn as_str(&self) -> &str {
144        &self.0
145    }
146}
147
148/// OCI image config primitives.
149#[derive(Clone, Debug, Eq, PartialEq)]
150pub struct OciImageConfig {
151    architecture: OciArchitecture,
152    os: OciOs,
153    user: Option<ConfigUser>,
154    env: Vec<EnvVar>,
155    entrypoint: Vec<Entrypoint>,
156    command: Vec<Command>,
157    working_dir: Option<WorkingDir>,
158    exposed_ports: Vec<ExposedPort>,
159    labels: Vec<Annotation>,
160    volumes: Vec<VolumePath>,
161    stop_signal: Option<StopSignal>,
162    annotations: Vec<Annotation>,
163}
164
165impl OciImageConfig {
166    /// Creates image config metadata.
167    #[must_use]
168    pub fn new(architecture: OciArchitecture, os: OciOs) -> Self {
169        Self {
170            architecture,
171            os,
172            user: None,
173            env: Vec::new(),
174            entrypoint: Vec::new(),
175            command: Vec::new(),
176            working_dir: None,
177            exposed_ports: Vec::new(),
178            labels: Vec::new(),
179            volumes: Vec::new(),
180            stop_signal: None,
181            annotations: Vec::new(),
182        }
183    }
184
185    /// Adds a user label.
186    #[must_use]
187    pub fn with_user(mut self, user: ConfigUser) -> Self {
188        self.user = Some(user);
189        self
190    }
191
192    /// Adds an environment variable.
193    #[must_use]
194    pub fn with_env(mut self, env: EnvVar) -> Self {
195        self.env.push(env);
196        self
197    }
198
199    /// Adds an entrypoint part.
200    #[must_use]
201    pub fn with_entrypoint(mut self, entrypoint: Entrypoint) -> Self {
202        self.entrypoint.push(entrypoint);
203        self
204    }
205
206    /// Adds a command part.
207    #[must_use]
208    pub fn with_command(mut self, command: Command) -> Self {
209        self.command.push(command);
210        self
211    }
212
213    /// Adds an exposed port.
214    #[must_use]
215    pub fn with_exposed_port(mut self, port: ExposedPort) -> Self {
216        self.exposed_ports.push(port);
217        self
218    }
219
220    /// Adds an annotation.
221    #[must_use]
222    pub fn with_annotation(mut self, annotation: Annotation) -> Self {
223        self.annotations.push(annotation);
224        self
225    }
226
227    /// Returns the architecture label.
228    #[must_use]
229    pub const fn architecture(&self) -> &OciArchitecture {
230        &self.architecture
231    }
232
233    /// Returns the OS label.
234    #[must_use]
235    pub const fn os(&self) -> &OciOs {
236        &self.os
237    }
238
239    /// Returns environment entries.
240    #[must_use]
241    pub fn env(&self) -> &[EnvVar] {
242        &self.env
243    }
244
245    /// Returns annotations.
246    #[must_use]
247    pub fn annotations(&self) -> &[Annotation] {
248        &self.annotations
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::{ConfigError, EnvVar, ExposedPort, OciImageConfig};
255    use use_oci_platform::{OciArchitecture, OciOs};
256
257    #[test]
258    fn models_image_config_metadata() -> Result<(), Box<dyn std::error::Error>> {
259        let config = OciImageConfig::new(OciArchitecture::Amd64, OciOs::Linux)
260            .with_env(EnvVar::new("RUST_LOG", "info")?)
261            .with_exposed_port(ExposedPort::new("8080/tcp")?);
262
263        assert_eq!(config.architecture(), &OciArchitecture::Amd64);
264        assert_eq!(config.env()[0].to_string(), "RUST_LOG=info");
265        assert_eq!(
266            EnvVar::new("BAD KEY", "value"),
267            Err(ConfigError::InvalidEnv)
268        );
269        assert_eq!(ExposedPort::new("tcp/8080"), Err(ConfigError::InvalidPort));
270        Ok(())
271    }
272}