Skip to main content

boxferry_model/
deferred.rs

1//! Explicit execution environment requirements without native variable syntax.
2use crate::{EnvironmentValue, ModelError, MountSource, ProtectedString, Service, Sourced};
3use std::{collections::BTreeSet, fmt};
4
5/// Maximum decoded bytes in an expression or execution provider plan.
6pub const DEFERRED_MAX_BYTES: usize = 1_048_576;
7/// Maximum flat segments or provider entries.
8pub const DEFERRED_MAX_SEGMENTS: usize = 65_536;
9
10/// Portable environment reference name.
11#[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    /// Validates the portable ASCII identifier grammar.
20    /// # Errors
21    /// Returns a value-free error for invalid names.
22    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    /// Returns the validated name.
34    #[must_use]
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40/// A flat expression segment, retaining literal protection independently of references.
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub enum DeferredSegment {
43    /// Literal text, never parsed as variable syntax.
44    Literal(ProtectedString),
45    /// Service execution environment lookup.
46    Reference(ServiceEnvironmentName),
47}
48
49/// Bounded flat concatenation with per-segment provenance.
50///
51/// Unset or empty references contribute an empty string to container environment assignments.
52/// Host-bind expressions additionally require a nonempty absolute effective source with no colon
53/// or control characters. These are external prerequisites, not runtime verification.
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct DeferredExpression(Vec<Sourced<DeferredSegment>>);
56impl DeferredExpression {
57    /// Creates an expression containing at least one explicit reference.
58    /// # Errors
59    /// Returns a value-free error for NUL, size limits, or missing references.
60    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    /// Returns segments in concatenation order.
84    #[must_use]
85    pub fn segments(&self) -> &[Sourced<DeferredSegment>] {
86        &self.0
87    }
88    /// Returns whether output needs explicit environment-value disclosure authorization.
89    #[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    /// Validates one leading direct reference and a safe optional slash suffix.
96    /// # Errors
97    /// Returns a value-free error for unsupported host-bind shapes or unsafe delimiters.
98    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/// Explicit protected assignment to the service execution environment.
121#[derive(Clone, Debug, Eq, PartialEq)]
122pub struct ServiceEnvironmentAssignment {
123    name: ServiceEnvironmentName,
124    value: ProtectedString,
125}
126impl ServiceEnvironmentAssignment {
127    /// Admits the value as protected data.
128    /// # Errors
129    /// Returns a value-free error for NUL or oversized values.
130    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    /// Returns the provider name.
143    #[must_use]
144    pub const fn name(&self) -> &ServiceEnvironmentName {
145        &self.name
146    }
147    /// Returns the protected value.
148    #[must_use]
149    pub const fn value(&self) -> &ProtectedString {
150        &self.value
151    }
152}
153
154/// Mandatory absolute literal external file; construction never reads the file.
155#[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    /// Validates an absolute path without control characters or glob activation; dollars and percent signs remain literal.
164    /// # Errors
165    /// Returns a value-free error for invalid paths.
166    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    /// Returns the literal path without filesystem access.
181    #[must_use]
182    pub fn path(&self) -> &str {
183        &self.0
184    }
185}
186
187/// Explicit per-service allowlist and external execution providers.
188///
189/// Files apply after inline assignments, in declaration order; later files override earlier values,
190/// including empty overrides. Provider variables are never automatically forwarded to containers.
191#[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    /// Creates a bounded plan with unique names and inline assignments covered by the allowlist.
199    /// # Errors
200    /// Returns a value-free error for duplicates, undeclared names, or size limits.
201    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    /// Returns declared reference names.
244    #[must_use]
245    pub fn references(&self) -> &[Sourced<ServiceEnvironmentName>] {
246        &self.references
247    }
248    /// Returns inline assignments in declaration order.
249    #[must_use]
250    pub fn assignments(&self) -> &[Sourced<ServiceEnvironmentAssignment>] {
251        &self.assignments
252    }
253    /// Returns files in precedence order.
254    #[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}