compose-lens 0.3.2

Source-aware Rust library for Docker Compose parsing, processing, validation, editing, and rendering
Documentation
//! Caller-authorized service-environment and secret-resolution contracts.

use compose_lens::interpolation::{EnvironmentValue, InterpolationInput, MapEnvironment, interpolate};
use compose_lens::loader::{DocumentInput, DocumentOrigin, LoadedProject};
use compose_lens::merge::merge_project;
use compose_lens::project::{ProjectView, ProjectViewResult, build_project_view};
use compose_lens::resolution::{
    ENVIRONMENT_FILE_INVALID_ENTRY, ENVIRONMENT_FILE_UNAVAILABLE, EnvironmentFileContent, EnvironmentFileLoadError,
    EnvironmentFileProvider, EnvironmentFileRequest, ResolvedEnvironmentOrigin, ResolvedEnvironmentValue,
    SECRET_SOURCE_UNRESOLVED, SECRET_VALUE_DENIED, SecretProvider, SecretRequest, SecretResolveError, SecretSource,
    SecretValue, resolve_project_secrets, resolve_service_environment,
};
use compose_lens::source::SourceId;

struct SyntheticEnvironmentFiles;

impl EnvironmentFileProvider for SyntheticEnvironmentFiles {
    fn load(
        &self,
        request: &EnvironmentFileRequest<'_>,
    ) -> Result<Option<EnvironmentFileContent>, EnvironmentFileLoadError> {
        let content = match request.path() {
            "./base.env" => EnvironmentFileContent::sensitive(concat!(
                "Z_LAST=from-file\n",
                "A_DEFAULT=${MISSING:-fallback}\n",
                "B_ESCAPED=\"line\\n${SET}\"\n",
                "C_LITERAL='${SET}'\n",
                "EMPTY_FILE=\n",
                "UNSET_FILE\n",
            )),
            "./raw.env" => EnvironmentFileContent::plain("RAW=${SET} # literal\n"),
            _ => return Ok(None),
        };
        Ok(Some(content))
    }
}

struct SyntheticSecrets;

impl SecretProvider for SyntheticSecrets {
    fn resolve(&self, request: &SecretRequest) -> Result<Option<SecretValue>, SecretResolveError> {
        match request.source() {
            SecretSource::File(path) if path == "./secret.txt" => Ok(Some(SecretValue::new("synthetic-file-secret"))),
            SecretSource::Environment(name) if name == "SECRET_FROM_ENV" => {
                Ok(Some(SecretValue::new("synthetic-environment-secret")))
            }
            _ => Ok(None),
        }
    }
}

struct DeniedSecrets;

impl SecretProvider for DeniedSecrets {
    fn resolve(&self, _request: &SecretRequest) -> Result<Option<SecretValue>, SecretResolveError> {
        Err(SecretResolveError::Denied)
    }
}

struct ExternalSecrets;

impl SecretProvider for ExternalSecrets {
    fn resolve(&self, request: &SecretRequest) -> Result<Option<SecretValue>, SecretResolveError> {
        match request.source() {
            SecretSource::External(name)
                if matches!(name.as_str(), "deprecated-platform-name" | "preferred-platform-name") =>
            {
                Ok(Some(SecretValue::new("synthetic-external-secret")))
            }
            _ => Ok(None),
        }
    }
}

fn project(source: &str, source_id: u32) -> Result<ProjectViewResult, Box<dyn std::error::Error>> {
    let loaded = LoadedProject::load([DocumentInput::new(
        SourceId::new(source_id),
        DocumentOrigin::new("compose.yaml", "synthetic"),
        source,
    )])?;
    let merged = merge_project(&loaded, None);
    Ok(build_project_view(
        merged.project().ok_or("merged project expected")?,
        None,
    ))
}

#[test]
fn resolves_environment_precedence_states_escaping_and_key_order_without_ambient_access()
-> Result<(), Box<dyn std::error::Error>> {
    let result = project(
        concat!(
            "---\n",
            "services:\n",
            "  app:\n",
            "    env_file:\n",
            "      - ./base.env\n",
            "      - path: ./raw.env\n",
            "        required: false\n",
            "        format: raw\n",
            "    environment:\n",
            "      Z_LAST: from-service\n",
            "      EMPTY: \"\"\n",
            "      FROM_HOST:\n",
            "      NUMBER: 7\n",
            "secrets:\n",
            "  from_file:\n",
            "    file: ./secret.txt\n",
            "  from_environment:\n",
            "    environment: SECRET_FROM_ENV\n",
        ),
        81_001,
    )?;
    let view = result.view().ok_or("project view expected")?;
    let service = view.service("app").ok_or("service expected")?;
    let mut environment = MapEnvironment::new();
    environment.insert_sensitive("SET", "protected");
    environment.insert("FROM_HOST", "host-value");

    let resolution = resolve_service_environment(service, &environment, &SyntheticEnvironmentFiles);
    assert!(resolution.is_valid(), "{:?}", resolution.diagnostics());
    let names: Vec<_> = resolution
        .entries()
        .iter()
        .map(compose_lens::resolution::ResolvedEnvironmentEntry::name)
        .collect();
    assert_eq!(
        names,
        [
            "A_DEFAULT",
            "B_ESCAPED",
            "C_LITERAL",
            "EMPTY",
            "EMPTY_FILE",
            "FROM_HOST",
            "NUMBER",
            "RAW",
            "UNSET_FILE",
            "Z_LAST",
        ]
    );

    let value = |name: &str| {
        resolution
            .entries()
            .iter()
            .find(|entry| entry.name() == name)
            .map(compose_lens::resolution::ResolvedEnvironmentEntry::value)
    };
    assert!(matches!(
        value("A_DEFAULT"),
        Some(ResolvedEnvironmentValue::Value(value)) if value.value() == "fallback"
    ));
    assert!(matches!(
        value("B_ESCAPED"),
        Some(ResolvedEnvironmentValue::Value(value))
            if value.value() == "line\nprotected" && value.is_sensitive()
    ));
    assert!(matches!(
        value("C_LITERAL"),
        Some(ResolvedEnvironmentValue::Value(value)) if value.value() == "${SET}"
    ));
    assert!(matches!(
        value("EMPTY"),
        Some(ResolvedEnvironmentValue::Value(value)) if value.value().is_empty()
    ));
    assert!(matches!(
        value("FROM_HOST"),
        Some(ResolvedEnvironmentValue::Value(value)) if value.value() == "host-value"
    ));
    assert!(matches!(
        value("RAW"),
        Some(ResolvedEnvironmentValue::Value(value)) if value.value() == "${SET} # literal"
    ));
    assert!(matches!(value("UNSET_FILE"), Some(ResolvedEnvironmentValue::Unset)));
    let overridden = resolution
        .entries()
        .iter()
        .find(|entry| entry.name() == "Z_LAST")
        .ok_or("overridden value expected")?;
    assert!(matches!(
        overridden.value(),
        ResolvedEnvironmentValue::Value(value) if value.value() == "from-service"
    ));
    assert!(matches!(overridden.origin(), ResolvedEnvironmentOrigin::Service { .. }));

    let debug = format!("{resolution:?}");
    assert!(!debug.contains("protected"));

    assert_secret_resolution(view);
    Ok(())
}

fn assert_secret_resolution(view: &ProjectView) {
    let secrets = resolve_project_secrets(view, &SyntheticSecrets);
    assert!(secrets.is_valid(), "{:?}", secrets.diagnostics());
    assert_eq!(
        secrets
            .secrets()
            .iter()
            .map(|secret| secret.request().name())
            .collect::<Vec<_>>(),
        ["from_environment", "from_file"]
    );
    assert_eq!(secrets.secrets()[0].value().expose(), "synthetic-environment-secret");
    let debug = format!("{secrets:?}");
    assert!(debug.contains("<redacted>"));
    assert!(!debug.contains("synthetic-environment-secret"));
    assert!(!debug.contains("synthetic-file-secret"));
}

#[test]
fn external_false_stays_project_owned_and_external_names_follow_compose_precedence()
-> Result<(), Box<dyn std::error::Error>> {
    let result = project(
        concat!(
            "---\n",
            "services:\n",
            "  app:\n",
            "    image: example.invalid/app:1\n",
            "secrets:\n",
            "  project_owned:\n",
            "    external: false\n",
            "  deprecated_external:\n",
            "    external:\n",
            "      name: deprecated-platform-name\n",
            "  named_external:\n",
            "    name: preferred-platform-name\n",
            "    external:\n",
            "      name: ignored-deprecated-name\n",
        ),
        81_004,
    )?;
    let view = result.view().ok_or("project view expected")?;
    let resolution = resolve_project_secrets(view, &ExternalSecrets);

    assert_eq!(
        resolution
            .secrets()
            .iter()
            .map(|secret| { (secret.request().name().to_owned(), secret.request().source().clone(),) })
            .collect::<Vec<_>>(),
        vec![
            (
                "deprecated_external".to_owned(),
                SecretSource::External("deprecated-platform-name".to_owned()),
            ),
            (
                "named_external".to_owned(),
                SecretSource::External("preferred-platform-name".to_owned()),
            ),
        ]
    );
    assert_eq!(resolution.diagnostics().len(), 1);
    assert_eq!(resolution.diagnostics()[0].code(), SECRET_SOURCE_UNRESOLVED);
    assert!(!format!("{resolution:?}").contains("synthetic-external-secret"));
    Ok(())
}

#[test]
fn reports_missing_invalid_and_denied_inputs_without_echoing_payloads() -> Result<(), Box<dyn std::error::Error>> {
    struct InvalidFiles;
    impl EnvironmentFileProvider for InvalidFiles {
        fn load(
            &self,
            request: &EnvironmentFileRequest<'_>,
        ) -> Result<Option<EnvironmentFileContent>, EnvironmentFileLoadError> {
            match request.path() {
                "./invalid.env" => Ok(Some(EnvironmentFileContent::sensitive(
                    "INVALID NAME=must-not-appear\n",
                ))),
                _ => Ok(None),
            }
        }
    }

    let result = project(
        concat!(
            "---\n",
            "services:\n",
            "  app:\n",
            "    env_file:\n",
            "      - ./required.env\n",
            "      - ./invalid.env\n",
            "      - path: ./optional.env\n",
            "        required: false\n",
            "secrets:\n",
            "  unresolved: {}\n",
            "  denied:\n",
            "    environment: DENIED_SECRET\n",
        ),
        81_002,
    )?;
    let view = result.view().ok_or("project view expected")?;
    let service = view.service("app").ok_or("service expected")?;
    let resolution = resolve_service_environment(service, &MapEnvironment::new(), &InvalidFiles);
    assert!(!resolution.is_valid());
    assert!(
        resolution
            .diagnostics()
            .iter()
            .any(|diagnostic| diagnostic.code() == ENVIRONMENT_FILE_UNAVAILABLE)
    );
    assert!(
        resolution
            .diagnostics()
            .iter()
            .any(|diagnostic| diagnostic.code() == ENVIRONMENT_FILE_INVALID_ENTRY)
    );
    assert!(!format!("{:?}", resolution.diagnostics()).contains("must-not-appear"));

    let secrets = resolve_project_secrets(view, &DeniedSecrets);
    assert!(!secrets.is_valid());
    assert!(
        secrets
            .diagnostics()
            .iter()
            .any(|diagnostic| diagnostic.code() == SECRET_SOURCE_UNRESOLVED)
    );
    assert!(
        secrets
            .diagnostics()
            .iter()
            .any(|diagnostic| diagnostic.code() == SECRET_VALUE_DENIED)
    );
    Ok(())
}

#[test]
fn environment_value_debug_redacts_only_values_marked_sensitive() {
    let plain = EnvironmentValue::plain("public");
    let sensitive = EnvironmentValue::sensitive("private");
    assert!(format!("{plain:?}").contains("public"));
    assert!(format!("{sensitive:?}").contains("<redacted>"));
    assert!(!format!("{sensitive:?}").contains("private"));
}

#[test]
fn interpolation_debug_redacts_sensitive_inputs_and_results() -> Result<(), Box<dyn std::error::Error>> {
    let mut environment = MapEnvironment::new();
    environment.insert_sensitive("TOKEN", "synthetic-private-token");
    let span = compose_lens::source::SourceSpan::new(SourceId::new(81_003), 0, 8).ok_or("synthetic span expected")?;
    let input = InterpolationInput::new("${TOKEN}", span).sensitive();
    let input_debug = format!("{input:?}");
    assert!(input_debug.contains("<redacted>"));
    assert!(!input_debug.contains("${TOKEN}"));

    let result = interpolate(input, &environment);
    let result_debug = format!("{result:?}");
    assert!(result_debug.contains("<redacted>"));
    assert!(!result_debug.contains("synthetic-private-token"));
    assert!(!format!("{environment:?}").contains("synthetic-private-token"));
    Ok(())
}