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#[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 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 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
78pub struct EnvVar {
79 key: String,
80 value: String,
81}
82
83impl EnvVar {
84 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 #[must_use]
107 pub fn key(&self) -> &str {
108 &self.key
109 }
110
111 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
126pub struct ExposedPort(String);
127
128impl ExposedPort {
129 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 #[must_use]
143 pub fn as_str(&self) -> &str {
144 &self.0
145 }
146}
147
148#[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 #[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 #[must_use]
187 pub fn with_user(mut self, user: ConfigUser) -> Self {
188 self.user = Some(user);
189 self
190 }
191
192 #[must_use]
194 pub fn with_env(mut self, env: EnvVar) -> Self {
195 self.env.push(env);
196 self
197 }
198
199 #[must_use]
201 pub fn with_entrypoint(mut self, entrypoint: Entrypoint) -> Self {
202 self.entrypoint.push(entrypoint);
203 self
204 }
205
206 #[must_use]
208 pub fn with_command(mut self, command: Command) -> Self {
209 self.command.push(command);
210 self
211 }
212
213 #[must_use]
215 pub fn with_exposed_port(mut self, port: ExposedPort) -> Self {
216 self.exposed_ports.push(port);
217 self
218 }
219
220 #[must_use]
222 pub fn with_annotation(mut self, annotation: Annotation) -> Self {
223 self.annotations.push(annotation);
224 self
225 }
226
227 #[must_use]
229 pub const fn architecture(&self) -> &OciArchitecture {
230 &self.architecture
231 }
232
233 #[must_use]
235 pub const fn os(&self) -> &OciOs {
236 &self.os
237 }
238
239 #[must_use]
241 pub fn env(&self) -> &[EnvVar] {
242 &self.env
243 }
244
245 #[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}