Skip to main content

compose_lens/resolution/
environment.rs

1//! Caller-authorized service-environment and secret-value resolution.
2
3use 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
12/// A required `env_file` was not supplied by the caller-authorized provider.
13pub const ENVIRONMENT_FILE_UNAVAILABLE: DiagnosticCode = DiagnosticCode::new("compose.environment.file-unavailable");
14/// A supplied `env_file` contains an entry `ComposeLens` cannot interpret safely.
15pub const ENVIRONMENT_FILE_INVALID_ENTRY: DiagnosticCode =
16    DiagnosticCode::new("compose.environment.file-invalid-entry");
17/// The caller-authorized environment-file provider denied a request.
18pub const ENVIRONMENT_FILE_DENIED: DiagnosticCode = DiagnosticCode::new("compose.environment.file-denied");
19/// A selected secret source could not be resolved by the caller-authorized provider.
20pub const SECRET_VALUE_UNAVAILABLE: DiagnosticCode = DiagnosticCode::new("compose.secret.value-unavailable");
21/// A secret definition does not identify one resolvable source.
22pub const SECRET_SOURCE_UNRESOLVED: DiagnosticCode = DiagnosticCode::new("compose.secret.source-unresolved");
23/// The caller-authorized secret provider denied a request.
24pub const SECRET_VALUE_DENIED: DiagnosticCode = DiagnosticCode::new("compose.secret.value-denied");
25
26/// Parser mode selected by one effective Compose `env_file` declaration.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28#[non_exhaustive]
29pub enum EnvironmentFileMode {
30    /// Compose syntax including quote, escape, and interpolation handling.
31    Compose,
32    /// Compose `format: raw`; retain the right-hand side literally.
33    Raw,
34}
35
36/// One explicit request made to a caller-owned environment-file provider.
37#[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    /// Returns the authored path without opening or normalizing it.
61    #[must_use]
62    pub const fn path(&self) -> &'a str {
63        self.path
64    }
65
66    /// Reports whether Compose requires this file to exist.
67    #[must_use]
68    pub const fn required(&self) -> bool {
69        self.required
70    }
71
72    /// Returns the selected parser mode.
73    #[must_use]
74    pub const fn mode(&self) -> EnvironmentFileMode {
75        self.mode
76    }
77
78    /// Returns the declaration source span.
79    #[must_use]
80    pub const fn source(&self) -> SourceSpan {
81        self.source
82    }
83
84    /// Reports whether the path itself came from sensitive interpolation.
85    #[must_use]
86    pub const fn is_sensitive(&self) -> bool {
87        self.sensitive
88    }
89}
90
91/// UTF-8 environment-file content supplied through an explicit authorization boundary.
92#[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    /// Creates non-sensitive content.
110    #[must_use]
111    pub fn plain(text: impl Into<String>) -> Self {
112        Self {
113            text: text.into(),
114            sensitive: false,
115        }
116    }
117
118    /// Creates content whose derived values must remain redacted in debug output.
119    #[must_use]
120    pub fn sensitive(text: impl Into<String>) -> Self {
121        Self {
122            text: text.into(),
123            sensitive: true,
124        }
125    }
126
127    /// Returns content after the caller explicitly crosses the sensitivity boundary.
128    #[must_use]
129    pub fn expose(&self) -> &str {
130        &self.text
131    }
132
133    /// Reports whether derived values must be treated as sensitive.
134    #[must_use]
135    pub const fn is_sensitive(&self) -> bool {
136        self.sensitive
137    }
138}
139
140/// Bounded failure categories for a caller-owned environment-file provider.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142#[non_exhaustive]
143pub enum EnvironmentFileLoadError {
144    /// The caller did not authorize this request.
145    Denied,
146}
147
148/// Supplies environment-file bytes without granting `ComposeLens` ambient filesystem access.
149pub trait EnvironmentFileProvider {
150    /// Returns `None` when the selected path is unavailable.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`EnvironmentFileLoadError::Denied`] when the caller did not authorize the request.
155    fn load(
156        &self,
157        request: &EnvironmentFileRequest<'_>,
158    ) -> Result<Option<EnvironmentFileContent>, EnvironmentFileLoadError>;
159}
160
161/// One final effective service-environment value.
162#[derive(Debug, Clone, PartialEq, Eq)]
163#[non_exhaustive]
164pub enum ResolvedEnvironmentValue {
165    /// A concrete, possibly empty value.
166    Value(EnvironmentValue),
167    /// A key-only entry whose caller-authorized host lookup was unavailable.
168    Unset,
169}
170
171/// Where one final effective environment entry came from.
172#[derive(Debug, Clone, PartialEq, Eq)]
173#[non_exhaustive]
174pub enum ResolvedEnvironmentOrigin {
175    /// One caller-supplied environment file.
176    File {
177        /// Authored environment-file path.
178        path: String,
179        /// Path declaration source span.
180        source: SourceSpan,
181    },
182    /// The service's explicit `environment` collection.
183    Service {
184        /// Mapping, list key/value, or list key-only syntax.
185        syntax: EntrySyntax,
186        /// Effective entry source span.
187        source: SourceSpan,
188    },
189}
190
191/// One effective service-environment entry, sorted by name in the result.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct ResolvedEnvironmentEntry {
194    name: String,
195    value: ResolvedEnvironmentValue,
196    origin: ResolvedEnvironmentOrigin,
197}
198
199impl ResolvedEnvironmentEntry {
200    /// Returns the variable name.
201    #[must_use]
202    pub fn name(&self) -> &str {
203        &self.name
204    }
205
206    /// Returns concrete or explicitly unset state.
207    #[must_use]
208    pub const fn value(&self) -> &ResolvedEnvironmentValue {
209        &self.value
210    }
211
212    /// Returns the final value's source category and span.
213    #[must_use]
214    pub const fn origin(&self) -> &ResolvedEnvironmentOrigin {
215        &self.origin
216    }
217}
218
219/// Result of one explicit service-environment resolution.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct ServiceEnvironmentResolution {
222    entries: Vec<ResolvedEnvironmentEntry>,
223    diagnostics: Vec<Diagnostic>,
224}
225
226impl ServiceEnvironmentResolution {
227    /// Returns final entries in deterministic key order.
228    #[must_use]
229    pub fn entries(&self) -> &[ResolvedEnvironmentEntry] {
230        &self.entries
231    }
232
233    /// Returns file-loading, parsing, and interpolation diagnostics.
234    #[must_use]
235    pub fn diagnostics(&self) -> &[Diagnostic] {
236        &self.diagnostics
237    }
238
239    /// Reports whether resolution emitted no error diagnostics.
240    #[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/// Resolves one service's environment only through caller-owned providers.
250///
251/// Environment files are applied in declaration order, then service `environment` entries
252/// override them. The result is sorted by key. Key-only entries remain explicitly unset when
253/// the supplied environment provider has no value.
254#[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/// One top-level Compose secret's selected native source.
513#[derive(Debug, Clone, PartialEq, Eq)]
514#[non_exhaustive]
515pub enum SecretSource {
516    /// Caller-owned file path.
517    File(String),
518    /// Caller-owned host environment-variable name.
519    Environment(String),
520    /// Platform-managed external secret name.
521    External(String),
522    /// Opaque provider driver name.
523    Driver(String),
524}
525
526/// One explicit request to a caller-owned secret provider.
527#[derive(Debug, Clone, PartialEq, Eq)]
528pub struct SecretRequest {
529    name: String,
530    source: SecretSource,
531    source_span: SourceSpan,
532}
533
534impl SecretRequest {
535    /// Returns the Compose secret name.
536    #[must_use]
537    pub fn name(&self) -> &str {
538        &self.name
539    }
540
541    /// Returns the source `ComposeLens` selected without reading it.
542    #[must_use]
543    pub const fn source(&self) -> &SecretSource {
544        &self.source
545    }
546
547    /// Returns the source declaration span.
548    #[must_use]
549    pub const fn source_span(&self) -> SourceSpan {
550        self.source_span
551    }
552}
553
554/// Secret payload exposed only through an explicit accessor and always redacted in `Debug`.
555#[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    /// Wraps a caller-authorized secret payload.
566    #[must_use]
567    pub fn new(value: impl Into<String>) -> Self {
568        Self(value.into())
569    }
570
571    /// Returns the payload after the caller explicitly crosses the sensitivity boundary.
572    #[must_use]
573    pub fn expose(&self) -> &str {
574        &self.0
575    }
576}
577
578/// Bounded failure categories for a caller-owned secret provider.
579#[derive(Debug, Clone, Copy, PartialEq, Eq)]
580#[non_exhaustive]
581pub enum SecretResolveError {
582    /// The caller did not authorize this request.
583    Denied,
584}
585
586/// Resolves selected secret sources without granting `ComposeLens` ambient access.
587pub trait SecretProvider {
588    /// Returns `None` when the provider cannot resolve the selected source.
589    ///
590    /// # Errors
591    ///
592    /// Returns [`SecretResolveError::Denied`] when the caller did not authorize the request.
593    fn resolve(&self, request: &SecretRequest) -> Result<Option<SecretValue>, SecretResolveError>;
594}
595
596/// One caller-authorized resolved top-level secret.
597#[derive(Debug, Clone, PartialEq, Eq)]
598pub struct ResolvedSecret {
599    request: SecretRequest,
600    value: SecretValue,
601}
602
603impl ResolvedSecret {
604    /// Returns the source request retained as provenance.
605    #[must_use]
606    pub const fn request(&self) -> &SecretRequest {
607        &self.request
608    }
609
610    /// Returns the protected payload wrapper.
611    #[must_use]
612    pub const fn value(&self) -> &SecretValue {
613        &self.value
614    }
615}
616
617/// Result of one explicit top-level secret-resolution operation.
618#[derive(Debug, Clone, PartialEq, Eq)]
619pub struct SecretResolution {
620    secrets: Vec<ResolvedSecret>,
621    diagnostics: Vec<Diagnostic>,
622}
623
624impl SecretResolution {
625    /// Returns resolved secrets in deterministic Compose-name order.
626    #[must_use]
627    pub fn secrets(&self) -> &[ResolvedSecret] {
628        &self.secrets
629    }
630
631    /// Returns unavailable, ambiguous, or denied-source diagnostics.
632    #[must_use]
633    pub fn diagnostics(&self) -> &[Diagnostic] {
634        &self.diagnostics
635    }
636
637    /// Reports whether resolution emitted no error diagnostics.
638    #[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/// Resolves top-level secret definitions only through a caller-owned provider.
648#[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}