1use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
4use crate::interpolation::{EnvironmentProvider, EnvironmentValue, InterpolationInput, interpolate};
5use crate::merge::EntrySyntax;
6use crate::model::{BooleanValue, ComposeScalar, EnvironmentFileFormatKind, SecretDefinition};
7use crate::project::{ProjectEnvironmentFile, ProjectResource, ProjectService, ProjectValue, ProjectView};
8use crate::source::{SourceId, SourceSpan};
9use std::collections::BTreeMap;
10use std::fmt;
11
12pub const ENVIRONMENT_FILE_UNAVAILABLE: DiagnosticCode = DiagnosticCode::new("compose.environment.file-unavailable");
14pub const ENVIRONMENT_FILE_INVALID_ENTRY: DiagnosticCode =
16 DiagnosticCode::new("compose.environment.file-invalid-entry");
17pub const ENVIRONMENT_FILE_DENIED: DiagnosticCode = DiagnosticCode::new("compose.environment.file-denied");
19pub const SECRET_VALUE_UNAVAILABLE: DiagnosticCode = DiagnosticCode::new("compose.secret.value-unavailable");
21pub const SECRET_SOURCE_UNRESOLVED: DiagnosticCode = DiagnosticCode::new("compose.secret.source-unresolved");
23pub const SECRET_VALUE_DENIED: DiagnosticCode = DiagnosticCode::new("compose.secret.value-denied");
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28#[non_exhaustive]
29pub enum EnvironmentFileMode {
30 Compose,
32 Raw,
34}
35
36#[derive(Clone, Copy, PartialEq, Eq)]
38pub struct EnvironmentFileRequest<'a> {
39 path: &'a str,
40 required: bool,
41 mode: EnvironmentFileMode,
42 source: SourceSpan,
43 sensitive: bool,
44}
45
46impl fmt::Debug for EnvironmentFileRequest<'_> {
47 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48 formatter
49 .debug_struct("EnvironmentFileRequest")
50 .field("path", &if self.sensitive { "<redacted>" } else { self.path })
51 .field("required", &self.required)
52 .field("mode", &self.mode)
53 .field("source", &self.source)
54 .field("sensitive", &self.sensitive)
55 .finish()
56 }
57}
58
59impl<'a> EnvironmentFileRequest<'a> {
60 #[must_use]
62 pub const fn path(&self) -> &'a str {
63 self.path
64 }
65
66 #[must_use]
68 pub const fn required(&self) -> bool {
69 self.required
70 }
71
72 #[must_use]
74 pub const fn mode(&self) -> EnvironmentFileMode {
75 self.mode
76 }
77
78 #[must_use]
80 pub const fn source(&self) -> SourceSpan {
81 self.source
82 }
83
84 #[must_use]
86 pub const fn is_sensitive(&self) -> bool {
87 self.sensitive
88 }
89}
90
91#[derive(Clone, PartialEq, Eq)]
93pub struct EnvironmentFileContent {
94 text: String,
95 sensitive: bool,
96}
97
98impl fmt::Debug for EnvironmentFileContent {
99 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
100 formatter
101 .debug_struct("EnvironmentFileContent")
102 .field("text", &if self.sensitive { "<redacted>" } else { &self.text })
103 .field("sensitive", &self.sensitive)
104 .finish()
105 }
106}
107
108impl EnvironmentFileContent {
109 #[must_use]
111 pub fn plain(text: impl Into<String>) -> Self {
112 Self {
113 text: text.into(),
114 sensitive: false,
115 }
116 }
117
118 #[must_use]
120 pub fn sensitive(text: impl Into<String>) -> Self {
121 Self {
122 text: text.into(),
123 sensitive: true,
124 }
125 }
126
127 #[must_use]
129 pub fn expose(&self) -> &str {
130 &self.text
131 }
132
133 #[must_use]
135 pub const fn is_sensitive(&self) -> bool {
136 self.sensitive
137 }
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142#[non_exhaustive]
143pub enum EnvironmentFileLoadError {
144 Denied,
146}
147
148pub trait EnvironmentFileProvider {
150 fn load(
156 &self,
157 request: &EnvironmentFileRequest<'_>,
158 ) -> Result<Option<EnvironmentFileContent>, EnvironmentFileLoadError>;
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
163#[non_exhaustive]
164pub enum ResolvedEnvironmentValue {
165 Value(EnvironmentValue),
167 Unset,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
173#[non_exhaustive]
174pub enum ResolvedEnvironmentOrigin {
175 File {
177 path: String,
179 source: SourceSpan,
181 },
182 Service {
184 syntax: EntrySyntax,
186 source: SourceSpan,
188 },
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct ResolvedEnvironmentEntry {
194 name: String,
195 value: ResolvedEnvironmentValue,
196 origin: ResolvedEnvironmentOrigin,
197}
198
199impl ResolvedEnvironmentEntry {
200 #[must_use]
202 pub fn name(&self) -> &str {
203 &self.name
204 }
205
206 #[must_use]
208 pub const fn value(&self) -> &ResolvedEnvironmentValue {
209 &self.value
210 }
211
212 #[must_use]
214 pub const fn origin(&self) -> &ResolvedEnvironmentOrigin {
215 &self.origin
216 }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct ServiceEnvironmentResolution {
222 entries: Vec<ResolvedEnvironmentEntry>,
223 diagnostics: Vec<Diagnostic>,
224}
225
226impl ServiceEnvironmentResolution {
227 #[must_use]
229 pub fn entries(&self) -> &[ResolvedEnvironmentEntry] {
230 &self.entries
231 }
232
233 #[must_use]
235 pub fn diagnostics(&self) -> &[Diagnostic] {
236 &self.diagnostics
237 }
238
239 #[must_use]
241 pub fn is_valid(&self) -> bool {
242 !self
243 .diagnostics
244 .iter()
245 .any(|diagnostic| diagnostic.severity() == Severity::Error)
246 }
247}
248
249#[must_use]
255pub fn resolve_service_environment(
256 service: &ProjectService,
257 environment: &dyn EnvironmentProvider,
258 files: &dyn EnvironmentFileProvider,
259) -> ServiceEnvironmentResolution {
260 let mut entries = BTreeMap::new();
261 let mut diagnostics = Vec::new();
262
263 if let Some(environment_files) = service.environment_files() {
264 for file in environment_files.value() {
265 resolve_environment_file(file, environment, files, &mut entries, &mut diagnostics);
266 }
267 }
268
269 if let Some(service_environment) = service.environment() {
270 for entry in service_environment.value().entries() {
271 let source = entry
272 .value()
273 .effective_source()
274 .or_else(|| entry.name().effective_source())
275 .unwrap_or_else(empty_span);
276 let value = match entry.value().value() {
277 ComposeScalar::Null => environment
278 .get(entry.name().value())
279 .map_or(ResolvedEnvironmentValue::Unset, ResolvedEnvironmentValue::Value),
280 scalar => {
281 ResolvedEnvironmentValue::Value(scalar_environment_value(scalar, entry.value().is_sensitive()))
282 }
283 };
284 entries.insert(
285 entry.name().value().to_owned(),
286 ResolvedEnvironmentEntry {
287 name: entry.name().value().to_owned(),
288 value,
289 origin: ResolvedEnvironmentOrigin::Service {
290 syntax: entry.syntax(),
291 source,
292 },
293 },
294 );
295 }
296 }
297
298 ServiceEnvironmentResolution {
299 entries: entries.into_values().collect(),
300 diagnostics,
301 }
302}
303
304fn resolve_environment_file(
305 file: &ProjectValue<ProjectEnvironmentFile>,
306 environment: &dyn EnvironmentProvider,
307 files: &dyn EnvironmentFileProvider,
308 entries: &mut BTreeMap<String, ResolvedEnvironmentEntry>,
309 diagnostics: &mut Vec<Diagnostic>,
310) {
311 let (path, required, mode, source, sensitive) = match file.value() {
312 ProjectEnvironmentFile::Short(path) => (
313 path.as_str(),
314 true,
315 EnvironmentFileMode::Compose,
316 file.effective_source().unwrap_or_else(empty_span),
317 file.is_sensitive(),
318 ),
319 ProjectEnvironmentFile::Long(long) => {
320 let Some(path) = long.path() else {
321 return;
322 };
323 let required = match long.required().map(ProjectValue::value) {
324 Some(BooleanValue::Literal(value)) => *value,
325 _ => true,
326 };
327 let mode = match long.format().map(ProjectValue::value) {
328 Some(format) if format.kind() == EnvironmentFileFormatKind::Raw => EnvironmentFileMode::Raw,
329 _ => EnvironmentFileMode::Compose,
330 };
331 (
332 path.value().as_str(),
333 required,
334 mode,
335 path.effective_source().unwrap_or_else(empty_span),
336 path.is_sensitive(),
337 )
338 }
339 };
340 let request = EnvironmentFileRequest {
341 path,
342 required,
343 mode,
344 source,
345 sensitive,
346 };
347 let content = match files.load(&request) {
348 Ok(Some(content)) => content,
349 Ok(None) => {
350 if required {
351 diagnostics.push(diagnostic_at(
352 ENVIRONMENT_FILE_UNAVAILABLE,
353 Severity::Error,
354 "required environment file was not supplied by the caller-authorized provider",
355 source,
356 "required environment file declared here",
357 ));
358 }
359 return;
360 }
361 Err(EnvironmentFileLoadError::Denied) => {
362 diagnostics.push(diagnostic_at(
363 ENVIRONMENT_FILE_DENIED,
364 Severity::Error,
365 "caller-owned environment-file provider denied the request",
366 source,
367 "environment file requested here",
368 ));
369 return;
370 }
371 };
372 parse_environment_file(&request, &content, environment, entries, diagnostics);
373}
374
375fn parse_environment_file(
376 request: &EnvironmentFileRequest<'_>,
377 content: &EnvironmentFileContent,
378 environment: &dyn EnvironmentProvider,
379 entries: &mut BTreeMap<String, ResolvedEnvironmentEntry>,
380 diagnostics: &mut Vec<Diagnostic>,
381) {
382 for raw_line in content.expose().lines() {
383 let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
384 let trimmed = line.trim();
385 if trimmed.is_empty() || trimmed.starts_with('#') {
386 continue;
387 }
388 let (name, raw_value) = line
389 .split_once('=')
390 .map_or((trimmed, None), |(name, value)| (name.trim(), Some(value)));
391 if !valid_environment_name(name) {
392 diagnostics.push(diagnostic_at(
393 ENVIRONMENT_FILE_INVALID_ENTRY,
394 Severity::Error,
395 "environment file contains an invalid variable name",
396 request.source,
397 "environment file declared here",
398 ));
399 continue;
400 }
401 let value = match raw_value {
402 None => environment
403 .get(name)
404 .map_or(ResolvedEnvironmentValue::Unset, ResolvedEnvironmentValue::Value),
405 Some(raw_value) => {
406 let Ok(decoded) = decode_environment_file_value(raw_value, request.mode) else {
407 diagnostics.push(diagnostic_at(
408 ENVIRONMENT_FILE_INVALID_ENTRY,
409 Severity::Error,
410 "environment file contains an unterminated quoted value",
411 request.source,
412 "environment file declared here",
413 ));
414 continue;
415 };
416 let (value, interpolate_value) = decoded;
417 let value = if interpolate_value {
418 let input = if content.is_sensitive() {
419 InterpolationInput::new(&value, request.source).sensitive()
420 } else {
421 InterpolationInput::new(&value, request.source)
422 };
423 let interpolation = interpolate(input, environment);
424 diagnostics.extend(interpolation.diagnostics().iter().cloned());
425 if interpolation.is_sensitive() {
426 EnvironmentValue::sensitive(interpolation.resolved())
427 } else {
428 EnvironmentValue::plain(interpolation.resolved())
429 }
430 } else if content.is_sensitive() {
431 EnvironmentValue::sensitive(value)
432 } else {
433 EnvironmentValue::plain(value)
434 };
435 ResolvedEnvironmentValue::Value(value)
436 }
437 };
438 entries.insert(
439 name.to_owned(),
440 ResolvedEnvironmentEntry {
441 name: name.to_owned(),
442 value,
443 origin: ResolvedEnvironmentOrigin::File {
444 path: request.path.to_owned(),
445 source: request.source,
446 },
447 },
448 );
449 }
450}
451
452fn decode_environment_file_value(raw: &str, mode: EnvironmentFileMode) -> Result<(String, bool), ()> {
453 if mode == EnvironmentFileMode::Raw {
454 return Ok((raw.to_owned(), false));
455 }
456 let value = raw.trim();
457 if let Some(quoted) = value.strip_prefix('\'') {
458 let Some(quoted) = quoted.strip_suffix('\'') else {
459 return Err(());
460 };
461 return Ok((quoted.replace("\\'", "'"), false));
462 }
463 if let Some(quoted) = value.strip_prefix('"') {
464 let Some(quoted) = quoted.strip_suffix('"') else {
465 return Err(());
466 };
467 let mut decoded = String::with_capacity(quoted.len());
468 let mut characters = quoted.chars();
469 while let Some(character) = characters.next() {
470 if character != '\\' {
471 decoded.push(character);
472 continue;
473 }
474 match characters.next() {
475 Some('n') => decoded.push('\n'),
476 Some('r') => decoded.push('\r'),
477 Some('t') => decoded.push('\t'),
478 Some('\\') | None => decoded.push('\\'),
479 Some('"') => decoded.push('"'),
480 Some(other) => {
481 decoded.push('\\');
482 decoded.push(other);
483 }
484 }
485 }
486 return Ok((decoded, true));
487 }
488 let value = value.find(" #").map_or(value, |comment| value[..comment].trim_end());
489 Ok((value.to_owned(), true))
490}
491
492fn valid_environment_name(name: &str) -> bool {
493 !name.is_empty()
494 && !name
495 .chars()
496 .any(|character| character == '=' || character == '\0' || character.is_whitespace())
497}
498
499fn scalar_environment_value(value: &ComposeScalar, sensitive: bool) -> EnvironmentValue {
500 let value = match value {
501 ComposeScalar::Null => String::new(),
502 ComposeScalar::Boolean(value) => value.to_string(),
503 ComposeScalar::Number(value) | ComposeScalar::String(value) => value.clone(),
504 };
505 if sensitive {
506 EnvironmentValue::sensitive(value)
507 } else {
508 EnvironmentValue::plain(value)
509 }
510}
511
512#[derive(Debug, Clone, PartialEq, Eq)]
514#[non_exhaustive]
515pub enum SecretSource {
516 File(String),
518 Environment(String),
520 External(String),
522 Driver(String),
524}
525
526#[derive(Debug, Clone, PartialEq, Eq)]
528pub struct SecretRequest {
529 name: String,
530 source: SecretSource,
531 source_span: SourceSpan,
532}
533
534impl SecretRequest {
535 #[must_use]
537 pub fn name(&self) -> &str {
538 &self.name
539 }
540
541 #[must_use]
543 pub const fn source(&self) -> &SecretSource {
544 &self.source
545 }
546
547 #[must_use]
549 pub const fn source_span(&self) -> SourceSpan {
550 self.source_span
551 }
552}
553
554#[derive(Clone, PartialEq, Eq)]
556pub struct SecretValue(String);
557
558impl fmt::Debug for SecretValue {
559 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
560 formatter.write_str("SecretValue(<redacted>)")
561 }
562}
563
564impl SecretValue {
565 #[must_use]
567 pub fn new(value: impl Into<String>) -> Self {
568 Self(value.into())
569 }
570
571 #[must_use]
573 pub fn expose(&self) -> &str {
574 &self.0
575 }
576}
577
578#[derive(Debug, Clone, Copy, PartialEq, Eq)]
580#[non_exhaustive]
581pub enum SecretResolveError {
582 Denied,
584}
585
586pub trait SecretProvider {
588 fn resolve(&self, request: &SecretRequest) -> Result<Option<SecretValue>, SecretResolveError>;
594}
595
596#[derive(Debug, Clone, PartialEq, Eq)]
598pub struct ResolvedSecret {
599 request: SecretRequest,
600 value: SecretValue,
601}
602
603impl ResolvedSecret {
604 #[must_use]
606 pub const fn request(&self) -> &SecretRequest {
607 &self.request
608 }
609
610 #[must_use]
612 pub const fn value(&self) -> &SecretValue {
613 &self.value
614 }
615}
616
617#[derive(Debug, Clone, PartialEq, Eq)]
619pub struct SecretResolution {
620 secrets: Vec<ResolvedSecret>,
621 diagnostics: Vec<Diagnostic>,
622}
623
624impl SecretResolution {
625 #[must_use]
627 pub fn secrets(&self) -> &[ResolvedSecret] {
628 &self.secrets
629 }
630
631 #[must_use]
633 pub fn diagnostics(&self) -> &[Diagnostic] {
634 &self.diagnostics
635 }
636
637 #[must_use]
639 pub fn is_valid(&self) -> bool {
640 !self
641 .diagnostics
642 .iter()
643 .any(|diagnostic| diagnostic.severity() == Severity::Error)
644 }
645}
646
647#[must_use]
649pub fn resolve_project_secrets(project: &ProjectView, provider: &dyn SecretProvider) -> SecretResolution {
650 let mut resources: Vec<_> = project.secrets().iter().collect();
651 resources.sort_by(|left, right| left.name().value().cmp(right.name().value()));
652 let mut secrets = Vec::new();
653 let mut diagnostics = Vec::new();
654 for resource in resources {
655 let Some(request) = secret_request(resource) else {
656 let span = resource.definition().effective_source().unwrap_or_else(empty_span);
657 diagnostics.push(diagnostic_at(
658 SECRET_SOURCE_UNRESOLVED,
659 Severity::Error,
660 "secret definition must select exactly one caller-resolvable source",
661 span,
662 "secret source cannot be selected here",
663 ));
664 continue;
665 };
666 match provider.resolve(&request) {
667 Ok(Some(value)) => secrets.push(ResolvedSecret { request, value }),
668 Ok(None) => diagnostics.push(diagnostic_at(
669 SECRET_VALUE_UNAVAILABLE,
670 Severity::Error,
671 "secret payload was not supplied by the caller-authorized provider",
672 request.source_span,
673 "secret source declared here",
674 )),
675 Err(SecretResolveError::Denied) => diagnostics.push(diagnostic_at(
676 SECRET_VALUE_DENIED,
677 Severity::Error,
678 "caller-owned secret provider denied the request",
679 request.source_span,
680 "secret source requested here",
681 )),
682 }
683 }
684 SecretResolution { secrets, diagnostics }
685}
686
687fn secret_request(resource: &ProjectResource<SecretDefinition>) -> Option<SecretRequest> {
688 let definition = resource.definition().value();
689 let mut sources = Vec::new();
690 if let Some(file) = definition.file() {
691 sources.push((SecretSource::File(file.value().clone()), file.span()));
692 }
693 if let Some(environment) = definition.environment() {
694 sources.push((
695 SecretSource::Environment(environment.value().clone()),
696 environment.span(),
697 ));
698 }
699 if let Some(driver) = definition.driver() {
700 sources.push((SecretSource::Driver(driver.value().clone()), driver.span()));
701 }
702 if let Some(external) = definition
703 .external()
704 .filter(|external| external.is_explicitly_external())
705 {
706 let (name, span) = definition
707 .custom_name()
708 .map(|name| (name.value().clone(), name.span()))
709 .or_else(|| {
710 external
711 .name_mapping()
712 .and_then(|mapping| mapping.name().map(|name| (name.value().clone(), name.span())))
713 })
714 .unwrap_or_else(|| {
715 (
716 resource.name().value().to_owned(),
717 external.name_mapping().map_or_else(
718 || resource.definition().effective_source().unwrap_or_else(empty_span),
719 crate::model::ExternalNameMapping::span,
720 ),
721 )
722 });
723 sources.push((SecretSource::External(name), span));
724 }
725 if sources.len() != 1 {
726 return None;
727 }
728 let (source, source_span) = sources.pop()?;
729 Some(SecretRequest {
730 name: resource.name().value().to_owned(),
731 source,
732 source_span,
733 })
734}
735
736fn diagnostic_at(
737 code: DiagnosticCode,
738 severity: Severity,
739 message: &'static str,
740 source: SourceSpan,
741 label: &'static str,
742) -> Diagnostic {
743 Diagnostic::new(code, severity, message).with_label(DiagnosticLabel::primary(source, label))
744}
745
746fn empty_span() -> SourceSpan {
747 SourceSpan::from_valid_offsets(SourceId::new(0), 0, 0)
748}