1use crate::{EnvironmentValue, ModelError, MountSource, ProtectedString, Service, Sourced};
3use std::{collections::BTreeSet, fmt};
4
5pub const DEFERRED_MAX_BYTES: usize = 1_048_576;
7pub const DEFERRED_MAX_SEGMENTS: usize = 65_536;
9
10#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
12pub struct ServiceEnvironmentName(String);
13impl fmt::Debug for ServiceEnvironmentName {
14 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
15 f.write_str("ServiceEnvironmentName([REDACTED])")
16 }
17}
18impl ServiceEnvironmentName {
19 pub fn new(value: impl Into<String>) -> Result<Self, ModelError> {
23 let value = value.into();
24 let mut bytes = value.bytes();
25 if !bytes.next().is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
26 || !bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
27 || value.len() > DEFERRED_MAX_BYTES
28 {
29 return Err(ModelError::InvalidDeferred("invalid service environment name"));
30 }
31 Ok(Self(value))
32 }
33 #[must_use]
35 pub fn as_str(&self) -> &str {
36 &self.0
37 }
38}
39
40#[derive(Clone, Debug, Eq, PartialEq)]
42pub enum DeferredSegment {
43 Literal(ProtectedString),
45 Reference(ServiceEnvironmentName),
47}
48
49#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct DeferredExpression(Vec<Sourced<DeferredSegment>>);
56impl DeferredExpression {
57 pub fn new(parts: Vec<Sourced<DeferredSegment>>) -> Result<Self, ModelError> {
61 let mut bytes = 0_usize;
62 let mut reference = false;
63 for part in &parts {
64 let value = match part.value() {
65 DeferredSegment::Literal(value) => value.expose(),
66 DeferredSegment::Reference(name) => {
67 reference = true;
68 name.as_str()
69 }
70 };
71 bytes = bytes.saturating_add(value.len());
72 if value.contains('\0') {
73 return Err(ModelError::InvalidDeferred("NUL in deferred expression"));
74 }
75 }
76 if !reference || parts.len() > DEFERRED_MAX_SEGMENTS || bytes > DEFERRED_MAX_BYTES {
77 return Err(ModelError::InvalidDeferred(
78 "deferred expression requires references within finite bounds",
79 ));
80 }
81 Ok(Self(parts))
82 }
83 #[must_use]
85 pub fn segments(&self) -> &[Sourced<DeferredSegment>] {
86 &self.0
87 }
88 #[must_use]
90 pub fn has_nonempty_literal(&self) -> bool {
91 self.0
92 .iter()
93 .any(|part| matches!(part.value(), DeferredSegment::Literal(value) if !value.expose().is_empty()))
94 }
95 pub fn validate_host_bind(&self) -> Result<(), ModelError> {
99 if !matches!(self.0.first().map(Sourced::value), Some(DeferredSegment::Reference(_))) {
100 return Err(ModelError::InvalidDeferred("host bind requires one leading reference"));
101 }
102 let mut suffix = String::new();
103 for part in self.0.iter().skip(1) {
104 match part.value() {
105 DeferredSegment::Literal(value) => suffix.push_str(value.expose()),
106 DeferredSegment::Reference(_) => {
107 return Err(ModelError::InvalidDeferred("host bind requires one reference"));
108 }
109 }
110 }
111 if (!suffix.is_empty() && !suffix.starts_with('/')) || suffix.chars().any(|ch| ch.is_control() || ch == ':') {
112 return Err(ModelError::InvalidDeferred(
113 "unsafe host-bind suffix; use conversion-time resolution",
114 ));
115 }
116 Ok(())
117 }
118}
119
120#[derive(Clone, Debug, Eq, PartialEq)]
122pub struct ServiceEnvironmentAssignment {
123 name: ServiceEnvironmentName,
124 value: ProtectedString,
125}
126impl ServiceEnvironmentAssignment {
127 pub fn new(name: ServiceEnvironmentName, value: impl Into<String>) -> Result<Self, ModelError> {
131 let value = value.into();
132 if value.contains('\0') || value.len() > DEFERRED_MAX_BYTES {
133 return Err(ModelError::InvalidDeferred(
134 "invalid inline service environment assignment",
135 ));
136 }
137 Ok(Self {
138 name,
139 value: ProtectedString::sensitive(value),
140 })
141 }
142 #[must_use]
144 pub const fn name(&self) -> &ServiceEnvironmentName {
145 &self.name
146 }
147 #[must_use]
149 pub const fn value(&self) -> &ProtectedString {
150 &self.value
151 }
152}
153
154#[derive(Clone, Eq, PartialEq)]
156pub struct ServiceEnvironmentFile(String);
157impl fmt::Debug for ServiceEnvironmentFile {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 f.write_str("ServiceEnvironmentFile([REDACTED])")
160 }
161}
162impl ServiceEnvironmentFile {
163 pub fn new(path: impl Into<String>) -> Result<Self, ModelError> {
167 let path = path.into();
168 if !path.starts_with('/')
169 || path.len() > DEFERRED_MAX_BYTES
170 || path
171 .chars()
172 .any(|ch| ch.is_control() || matches!(ch, '*' | '?' | '[' | ']'))
173 {
174 return Err(ModelError::InvalidDeferred(
175 "service environment file requires an absolute literal path",
176 ));
177 }
178 Ok(Self(path))
179 }
180 #[must_use]
182 pub fn path(&self) -> &str {
183 &self.0
184 }
185}
186
187#[derive(Clone, Debug, Eq, PartialEq)]
192pub struct ServiceExecutionEnvironment {
193 references: Vec<Sourced<ServiceEnvironmentName>>,
194 assignments: Vec<Sourced<ServiceEnvironmentAssignment>>,
195 files: Vec<Sourced<ServiceEnvironmentFile>>,
196}
197impl ServiceExecutionEnvironment {
198 pub fn new(
202 references: Vec<Sourced<ServiceEnvironmentName>>,
203 assignments: Vec<Sourced<ServiceEnvironmentAssignment>>,
204 files: Vec<Sourced<ServiceEnvironmentFile>>,
205 ) -> Result<Self, ModelError> {
206 let names: BTreeSet<_> = references.iter().map(Sourced::value).collect();
207 let file_paths: BTreeSet<_> = files.iter().map(|item| item.value().path()).collect();
208 let inline: BTreeSet<_> = assignments.iter().map(|item| item.value().name()).collect();
209 let bytes = references
210 .iter()
211 .map(|item| item.value().as_str().len())
212 .chain(assignments.iter().map(|item| {
213 item.value()
214 .name()
215 .as_str()
216 .len()
217 .saturating_add(item.value().value().expose().len())
218 }))
219 .chain(files.iter().map(|item| item.value().path().len()))
220 .fold(0_usize, usize::saturating_add);
221 if file_paths.len() != files.len()
222 || names.is_empty()
223 || names.len() != references.len()
224 || inline.len() != assignments.len()
225 || !inline.is_subset(&names)
226 || references
227 .len()
228 .saturating_add(assignments.len())
229 .saturating_add(files.len())
230 > DEFERRED_MAX_SEGMENTS
231 || bytes > DEFERRED_MAX_BYTES
232 {
233 return Err(ModelError::InvalidDeferred(
234 "invalid or duplicate service environment plan entries",
235 ));
236 }
237 Ok(Self {
238 references,
239 assignments,
240 files,
241 })
242 }
243 #[must_use]
245 pub fn references(&self) -> &[Sourced<ServiceEnvironmentName>] {
246 &self.references
247 }
248 #[must_use]
250 pub fn assignments(&self) -> &[Sourced<ServiceEnvironmentAssignment>] {
251 &self.assignments
252 }
253 #[must_use]
255 pub fn files(&self) -> &[Sourced<ServiceEnvironmentFile>] {
256 &self.files
257 }
258}
259
260pub(crate) fn validate_service(service: &Service) -> Result<(), ModelError> {
261 let mut used = BTreeSet::new();
262 for expression in service
263 .environment()
264 .iter()
265 .filter_map(|entry| match entry.value().value() {
266 EnvironmentValue::Deferred(value) => Some(value),
267 _ => None,
268 })
269 .chain(
270 service
271 .mounts()
272 .iter()
273 .filter_map(|entry| match entry.value().source() {
274 MountSource::DeferredHostPath(value) => Some(value),
275 _ => None,
276 }),
277 )
278 {
279 for segment in expression.segments() {
280 if let DeferredSegment::Reference(name) = segment.value() {
281 used.insert(name);
282 }
283 }
284 }
285 for mount in service.mounts() {
286 if let MountSource::DeferredHostPath(expression) = mount.value().source() {
287 expression.validate_host_bind()?;
288 }
289 }
290 match service.execution_environment() {
291 None if used.is_empty() => Ok(()),
292 Some(plan) => {
293 let plan = plan.value();
294 if plan.files().is_empty() {
295 for mount in service.mounts() {
296 if let MountSource::DeferredHostPath(expression) = mount.value().source()
297 && let Some(DeferredSegment::Reference(name)) =
298 expression.segments().first().map(Sourced::value)
299 && let Some(assignment) = plan.assignments().iter().find(|item| item.value().name() == name)
300 {
301 let value = assignment.value().value().expose();
302 if !value.starts_with('/') || value.chars().any(|ch| ch.is_control() || ch == ':') {
303 return Err(ModelError::InvalidDeferred(
304 "inline bind root must be nonempty absolute and free of colon or control characters",
305 ));
306 }
307 }
308 }
309 }
310 let declared: BTreeSet<_> = plan.references().iter().map(Sourced::value).collect();
311 let provided: BTreeSet<_> = plan.assignments().iter().map(|item| item.value().name()).collect();
312 if used != declared || used.is_empty() || (plan.files().is_empty() && !used.is_subset(&provided)) {
313 Err(ModelError::InvalidDeferred(
314 "every used reference must be declared and externally bound; unused plans are forbidden",
315 ))
316 } else {
317 Ok(())
318 }
319 }
320 None => Err(ModelError::InvalidDeferred(
321 "deferred references require an explicit execution environment",
322 )),
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use crate::{Application, EnvironmentVariable, Identifier};
330 fn name(value: &str) -> Result<ServiceEnvironmentName, ModelError> {
331 ServiceEnvironmentName::new(value)
332 }
333 fn reference(value: &str) -> Result<DeferredExpression, ModelError> {
334 DeferredExpression::new(vec![Sourced::generated(DeferredSegment::Reference(name(value)?))])
335 }
336 #[test]
337 fn invalid_names_and_paths_return_value_free_errors() -> Result<(), ModelError> {
338 for value in ["", "1BAD", "A-B", "PRIVATE=CANARY", "A\0B"] {
339 assert!(name(value).is_err());
340 }
341 for value in ["relative", "-/optional", "/path/*", "/path/\n"] {
342 assert!(ServiceEnvironmentFile::new(value).is_err());
343 }
344 let value = ServiceEnvironmentAssignment::new(name("VALUE")?, "PRIVATE_CANARY")?;
345 assert!(!format!("{value:?}").contains("PRIVATE_CANARY"));
346 Ok(())
347 }
348 #[test]
349 fn application_rejects_undeclared_unbound_and_unused_plans() -> Result<(), ModelError> {
350 for (declared, bound, used) in [("A", true, "B"), ("A", false, "A"), ("A", true, "")] {
351 let mut service = Service::new(Identifier::new("app")?);
352 if !used.is_empty() {
353 service.add_environment(Sourced::generated(EnvironmentVariable::new(
354 Identifier::new("CONTAINER")?,
355 EnvironmentValue::Deferred(reference(used)?),
356 )));
357 }
358 let inline = if bound {
359 vec![Sourced::generated(ServiceEnvironmentAssignment::new(
360 name(declared)?,
361 "",
362 )?)]
363 } else {
364 Vec::new()
365 };
366 service.set_execution_environment(Sourced::generated(ServiceExecutionEnvironment::new(
367 vec![Sourced::generated(name(declared)?)],
368 inline,
369 Vec::new(),
370 )?));
371 assert!(
372 Application::new(Identifier::new("test")?)
373 .add_service(Sourced::generated(service))
374 .is_err()
375 );
376 }
377 Ok(())
378 }
379 #[test]
380 fn bind_shape_and_known_inline_roots_are_checked() -> Result<(), ModelError> {
381 for suffix in [":bad", "relative", "/bad\n"] {
382 let value = DeferredExpression::new(vec![
383 Sourced::generated(DeferredSegment::Reference(name("ROOT")?)),
384 Sourced::generated(DeferredSegment::Literal(ProtectedString::plain(suffix))),
385 ])?;
386 assert!(value.validate_host_bind().is_err());
387 }
388 assert!(reference("ROOT")?.validate_host_bind().is_ok());
389 assert!(
390 DeferredExpression::new(vec![Sourced::generated(DeferredSegment::Literal(
391 ProtectedString::plain("literal")
392 ))])
393 .is_err()
394 );
395 assert!(
396 ServiceExecutionEnvironment::new(
397 vec![Sourced::generated(name("A")?), Sourced::generated(name("A")?)],
398 Vec::new(),
399 Vec::new()
400 )
401 .is_err()
402 );
403 Ok(())
404 }
405 #[test]
406 fn expression_and_provider_bounds_are_finite() -> Result<(), ModelError> {
407 let expression = vec![
408 Sourced::generated(DeferredSegment::Reference(name("A")?)),
409 Sourced::generated(DeferredSegment::Literal(ProtectedString::sensitive(
410 "x".repeat(DEFERRED_MAX_BYTES),
411 ))),
412 ];
413 assert!(DeferredExpression::new(expression).is_err());
414 assert!(
415 DeferredExpression::new(vec![
416 Sourced::generated(DeferredSegment::Reference(name("A")?));
417 DEFERRED_MAX_SEGMENTS + 1
418 ])
419 .is_err()
420 );
421 assert!(ServiceEnvironmentAssignment::new(name("A")?, "x".repeat(DEFERRED_MAX_BYTES + 1)).is_err());
422 assert!(!format!("{:?}", ServiceEnvironmentFile::new("/PRIVATE_PATH")?).contains("PRIVATE_PATH"));
423 assert!(!format!("{:?}", name("PRIVATE_NAME")?).contains("PRIVATE_NAME"));
424 Ok(())
425 }
426}