use std::io::Read;
use std::path::PathBuf;
use greentic_deploy_spec::{Environment, Revision};
use greentic_deployer::environment::{HealthCheckId, HealthGateFailure};
const MAX_SIDECAR_BYTES: u64 = 1 << 20;
pub trait RevisionHealthGate: Send + Sync {
fn check(&self, env: &Environment, revision: &Revision) -> Result<(), HealthGateFailure>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopRevisionHealthGate;
impl RevisionHealthGate for NoopRevisionHealthGate {
fn check(&self, _env: &Environment, _revision: &Revision) -> Result<(), HealthGateFailure> {
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct StartRevisionHealthGate {
env_root: PathBuf,
}
impl StartRevisionHealthGate {
pub fn new(env_root: PathBuf) -> Self {
Self { env_root }
}
pub fn default_root() -> anyhow::Result<Self> {
let env_root =
greentic_deployer::environment::LocalFsStore::default_root().ok_or_else(|| {
anyhow::anyhow!(
"cannot determine the default environment store root (no home directory)"
)
})?;
Ok(Self { env_root })
}
fn verify_signature_status(&self, env_id: &str, revision: &Revision) -> Result<(), String> {
let digest_hex = revision
.bundle_digest
.strip_prefix("sha256:")
.or_else(|| revision.bundle_digest.strip_prefix("SHA256:"))
.unwrap_or(revision.bundle_digest.as_str());
if digest_hex.is_empty() {
return Err(format!(
"revision bundle_digest `{}` carries no digest to verify the signature against",
revision.bundle_digest
));
}
let env_dir = crate::runtime_config::env_dir_in(&self.env_root, env_id).map_err(|e| {
format!("environment id `{env_id}` is not a safe directory segment: {e:#}")
})?;
let trust_root = greentic_deployer::environment::load_trust_root(&env_dir)
.map_err(|e| format!("trust root for env `{env_id}` could not be loaded: {e}"))?;
let sig_path = greentic_deployer::path_safety::normalize_under_root(
&env_dir,
&revision.signature_sidecar_ref,
)
.map_err(|e| {
format!(
"signature_sidecar_ref `{}` is not a contained, existing sidecar: {e:#}",
revision.signature_sidecar_ref.display()
)
})?;
let meta = std::fs::metadata(&sig_path).map_err(|e| {
format!(
"signature sidecar `{}` could not be stat'd: {e}",
sig_path.display()
)
})?;
if !meta.is_file() {
return Err(format!(
"signature_sidecar_ref resolves to `{}`, which is not a regular file",
sig_path.display()
));
}
if meta.len() > MAX_SIDECAR_BYTES {
return Err(format!(
"signature sidecar `{}` is {} bytes, which exceeds the {MAX_SIDECAR_BYTES}-byte limit",
sig_path.display(),
meta.len()
));
}
let mut envelope_bytes = Vec::with_capacity(meta.len() as usize);
std::fs::File::open(&sig_path)
.and_then(|f| {
f.take(MAX_SIDECAR_BYTES + 1)
.read_to_end(&mut envelope_bytes)
.map(|_| ())
})
.map_err(|e| {
format!(
"signature sidecar `{}` could not be read: {e}",
sig_path.display()
)
})?;
if envelope_bytes.len() as u64 > MAX_SIDECAR_BYTES {
return Err(format!(
"signature sidecar `{}` exceeds the {MAX_SIDECAR_BYTES}-byte limit",
sig_path.display()
));
}
greentic_distributor_client::signing::verify_artifact_dsse(
&envelope_bytes,
&revision.bundle_digest,
&trust_root,
)
.map_err(|e| format!("DSSE signature verification failed: {e}"))?;
Ok(())
}
}
impl RevisionHealthGate for StartRevisionHealthGate {
fn check(&self, env: &Environment, revision: &Revision) -> Result<(), HealthGateFailure> {
let mut failed_checks: Vec<HealthCheckId> = Vec::new();
let mut messages: Vec<String> = Vec::new();
let env_id = env.environment_id.as_str();
match crate::runtime_config::load_in(&self.env_root, env_id) {
Ok(_) => {}
Err(e) => {
failed_checks.push(HealthCheckId::RuntimeConfig);
messages.push(format!("runtime-config load failed: {e:#}"));
}
}
if let Err(msg) = self.verify_signature_status(env_id, revision) {
failed_checks.push(HealthCheckId::SignatureStatus);
messages.push(msg);
}
if failed_checks.is_empty() {
crate::rollout_telemetry::emit_health_gate_transition(
greentic_telemetry::RolloutEvent::HealthGatePassed,
env,
revision,
);
Ok(())
} else {
crate::rollout_telemetry::emit_health_gate_transition(
greentic_telemetry::RolloutEvent::HealthGateFailed,
env,
revision,
);
Err(HealthGateFailure {
failed_checks,
message: messages.join("; "),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use greentic_deploy_spec::{
BundleId, DeploymentId, EnvId, EnvironmentHostConfig, PackId, PackListEntry, Revision,
RevisionId, RevisionLifecycle, SchemaVersion, SemVer,
};
use greentic_distributor_client::signing::{
InTotoStatement, SlsaProvenance, TrustedKey, key_id_for_public_key_pem, sign_statement,
};
use std::path::Path;
use tempfile::TempDir;
const ENV_ID: &str = "local";
const BUNDLE_DIGEST_HEX: &str =
"1111111111111111111111111111111111111111111111111111111111111111";
fn keypair(seed: u8) -> (String, String, String) {
use ed25519_dalek::SigningKey;
use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
use ed25519_dalek::pkcs8::{EncodePrivateKey, EncodePublicKey};
let sk = SigningKey::from_bytes(&[seed; 32]);
let vk = sk.verifying_key();
let priv_pem = sk.to_pkcs8_pem(LineEnding::LF).unwrap().to_string();
let pub_pem = vk.to_public_key_pem(LineEnding::LF).unwrap();
let key_id = key_id_for_public_key_pem(&pub_pem).unwrap();
(priv_pem, pub_pem, key_id)
}
fn envelope_bytes(digest_hex: &str, priv_pem: &str, key_id: &str) -> Vec<u8> {
let stmt = InTotoStatement::provenance(
"revision.bundle",
digest_hex,
SlsaProvenance {
builder_id: "greentic-start/test".into(),
build_type: "gtbundle".into(),
built_at: None,
tlog_entry_id: None,
},
);
let env = sign_statement(&stmt, priv_pem, key_id).unwrap();
serde_json::to_vec(&env).unwrap()
}
fn write_signed_sidecar_and_trust(env_dir: &Path, sig_name: &str) {
let (priv_pem, pub_pem, key_id) = keypair(1);
let bytes = envelope_bytes(BUNDLE_DIGEST_HEX, &priv_pem, &key_id);
std::fs::write(env_dir.join(sig_name), &bytes).unwrap();
greentic_deployer::environment::add_trusted_key(
env_dir,
TrustedKey {
key_id,
public_key_pem: pub_pem,
},
)
.unwrap();
}
fn seed_signed_env() -> (TempDir, PathBuf, Revision) {
let tmp = tempfile::tempdir().unwrap();
let env_dir = tmp.path().join(ENV_ID);
std::fs::create_dir_all(&env_dir).unwrap();
write_signed_sidecar_and_trust(&env_dir, "rev.sig");
let revision = make_revision(PathBuf::from("rev.sig"));
let env_root = tmp.path().to_path_buf();
(tmp, env_root, revision)
}
fn fixed_now() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 5, 22, 12, 0, 0).unwrap()
}
fn env_id() -> EnvId {
EnvId::try_from(ENV_ID).unwrap()
}
fn make_env() -> Environment {
Environment {
schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
environment_id: env_id(),
name: ENV_ID.to_string(),
host_config: EnvironmentHostConfig {
env_id: env_id(),
region: None,
tenant_org_id: None,
listen_addr: None,
},
packs: Vec::new(),
credentials_ref: None,
bundles: Vec::new(),
revisions: Vec::new(),
traffic_splits: Vec::new(),
revocation: Default::default(),
retention: Default::default(),
health: Default::default(),
}
}
fn make_revision(sig_ref: PathBuf) -> Revision {
Revision {
schema: SchemaVersion::new(SchemaVersion::REVISION_V1),
revision_id: RevisionId::new(),
env_id: env_id(),
bundle_id: BundleId::new("fast2flow"),
deployment_id: DeploymentId::new(),
sequence: 1,
created_at: fixed_now(),
bundle_digest: format!("sha256:{BUNDLE_DIGEST_HEX}"),
pack_list: vec![PackListEntry {
pack_id: PackId::new("greentic.test.pack"),
version: SemVer::new(1, 0, 0),
digest: "sha256:00".to_string(),
source_uri: None,
}],
pack_list_lock_ref: PathBuf::from("pack-list.lock"),
config_digest: "sha256:00".to_string(),
signature_sidecar_ref: sig_ref,
lifecycle: RevisionLifecycle::Warming,
staged_at: Some(fixed_now()),
warmed_at: None,
drain_seconds: 30,
abort_metrics: Vec::new(),
}
}
fn seed_env(sig_present: bool) -> (TempDir, PathBuf, Revision) {
let tmp = tempfile::tempdir().unwrap();
let env_dir = tmp.path().join(ENV_ID);
std::fs::create_dir_all(&env_dir).unwrap();
let sig_ref = PathBuf::from("rev.sig");
if sig_present {
std::fs::write(env_dir.join(&sig_ref), b"placeholder").unwrap();
}
let revision = make_revision(sig_ref);
let env_root = tmp.path().to_path_buf();
(tmp, env_root, revision)
}
#[test]
fn noop_always_passes() {
let (_tmp, _env_root, revision) = seed_env(false);
let env = make_env();
let gate = NoopRevisionHealthGate;
assert!(gate.check(&env, &revision).is_ok());
}
#[test]
fn start_passes_when_signature_verifies_and_no_runtime_config() {
let (_tmp, env_root, revision) = seed_signed_env();
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
let result = gate.check(&env, &revision);
assert!(result.is_ok(), "expected pass, got `{:?}`", result.err());
}
#[test]
fn start_fails_signature_status_when_sidecar_missing() {
let (_tmp, env_root, revision) = seed_env(false);
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
let err = gate.check(&env, &revision).unwrap_err();
assert_eq!(err.failed_checks, vec![HealthCheckId::SignatureStatus]);
assert!(
err.message.contains("signature_sidecar_ref"),
"msg: {}",
err.message
);
}
#[test]
fn start_fails_runtime_config_when_file_malformed() {
let (_tmp, env_root, revision) = seed_signed_env();
let env_dir = env_root.join(ENV_ID);
std::fs::write(
env_dir.join("runtime-config.json"),
b"{not even close to json",
)
.unwrap();
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
let err = gate.check(&env, &revision).unwrap_err();
assert_eq!(err.failed_checks, vec![HealthCheckId::RuntimeConfig]);
assert!(
err.message.contains("runtime-config load failed"),
"msg: {}",
err.message
);
}
#[test]
fn start_aggregates_multiple_failures() {
let (_tmp, env_root, revision) = seed_env(false);
let env_dir = env_root.join(ENV_ID);
std::fs::write(env_dir.join("runtime-config.json"), b"{bad").unwrap();
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
let err = gate.check(&env, &revision).unwrap_err();
assert_eq!(
err.failed_checks,
vec![HealthCheckId::RuntimeConfig, HealthCheckId::SignatureStatus]
);
assert!(err.message.contains("runtime-config load failed"));
assert!(err.message.contains("signature_sidecar_ref"));
assert!(err.message.contains("; "));
}
#[test]
fn start_fails_signature_status_on_absolute_sidecar_ref() {
let (_tmp, env_root, mut revision) = seed_env(true);
let outside = tempfile::NamedTempFile::new().unwrap();
assert!(outside.path().is_absolute());
revision.signature_sidecar_ref = outside.path().to_path_buf();
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
let err = gate.check(&env, &revision).unwrap_err();
assert!(
err.failed_checks.contains(&HealthCheckId::SignatureStatus),
"expected SignatureStatus failure, got {:?}",
err.failed_checks
);
}
#[test]
fn start_fails_signature_status_on_parent_traversal_sidecar_ref() {
let (_tmp, env_root, mut revision) = seed_env(true);
std::fs::write(env_root.join("outside.sig"), b"escaped").unwrap();
revision.signature_sidecar_ref = PathBuf::from("../outside.sig");
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
let err = gate.check(&env, &revision).unwrap_err();
assert!(
err.failed_checks.contains(&HealthCheckId::SignatureStatus),
"expected SignatureStatus failure, got {:?}",
err.failed_checks
);
}
#[cfg(unix)]
#[test]
fn start_fails_signature_status_on_symlink_escape() {
let (_tmp, env_root, mut revision) = seed_env(false);
let outside_dir = tempfile::tempdir().unwrap();
let outside_file = outside_dir.path().join("real.sig");
std::fs::write(&outside_file, b"escaped").unwrap();
let env_dir = env_root.join(ENV_ID);
std::os::unix::fs::symlink(&outside_file, env_dir.join("rev.sig")).unwrap();
revision.signature_sidecar_ref = PathBuf::from("rev.sig");
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
let err = gate.check(&env, &revision).unwrap_err();
assert!(
err.failed_checks.contains(&HealthCheckId::SignatureStatus),
"expected SignatureStatus failure, got {:?}",
err.failed_checks
);
}
#[test]
fn start_rejects_dotdot_env_id_on_both_dependent_checks() {
let (_tmp, env_root, revision) = seed_env(true);
let mut env = make_env();
env.environment_id = EnvId::try_from("..").unwrap();
let gate = StartRevisionHealthGate::new(env_root);
let err = gate.check(&env, &revision).unwrap_err();
assert!(
err.failed_checks.contains(&HealthCheckId::SignatureStatus),
"signature check must reject `..` env_id, got {:?}",
err.failed_checks
);
assert!(
err.failed_checks.contains(&HealthCheckId::RuntimeConfig),
"runtime-config check must reject `..` env_id, got {:?}",
err.failed_checks
);
assert!(
err.message.contains("not a safe directory segment"),
"msg: {}",
err.message
);
}
#[cfg(unix)]
#[test]
fn start_passes_on_contained_symlink_sidecar() {
let tmp = tempfile::tempdir().unwrap();
let env_dir = tmp.path().join(ENV_ID);
std::fs::create_dir_all(&env_dir).unwrap();
write_signed_sidecar_and_trust(&env_dir, "actual.sig");
std::os::unix::fs::symlink(env_dir.join("actual.sig"), env_dir.join("rev.sig")).unwrap();
let revision = make_revision(PathBuf::from("rev.sig"));
let env = make_env();
let gate = StartRevisionHealthGate::new(tmp.path().to_path_buf());
assert!(
gate.check(&env, &revision).is_ok(),
"a contained symlink to a valid envelope should pass"
);
}
#[test]
fn start_passes_with_absent_runtime_config_and_verifying_signature() {
let (_tmp, env_root, revision) = seed_signed_env();
assert!(!env_root.join(ENV_ID).join("runtime-config.json").exists());
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
assert!(gate.check(&env, &revision).is_ok());
}
#[test]
fn start_fails_signature_status_when_trust_root_missing() {
let tmp = tempfile::tempdir().unwrap();
let env_dir = tmp.path().join(ENV_ID);
std::fs::create_dir_all(&env_dir).unwrap();
let (priv_pem, _pub_pem, key_id) = keypair(1);
let bytes = envelope_bytes(BUNDLE_DIGEST_HEX, &priv_pem, &key_id);
std::fs::write(env_dir.join("rev.sig"), &bytes).unwrap();
let revision = make_revision(PathBuf::from("rev.sig"));
let env = make_env();
let gate = StartRevisionHealthGate::new(tmp.path().to_path_buf());
let err = gate.check(&env, &revision).unwrap_err();
assert_eq!(err.failed_checks, vec![HealthCheckId::SignatureStatus]);
assert!(
err.message.contains("DSSE signature verification failed"),
"msg: {}",
err.message
);
}
#[test]
fn start_fails_signature_status_when_signed_by_untrusted_key() {
let tmp = tempfile::tempdir().unwrap();
let env_dir = tmp.path().join(ENV_ID);
std::fs::create_dir_all(&env_dir).unwrap();
let (signer_priv, _signer_pub, signer_id) = keypair(2);
let (_trusted_priv, trusted_pub, trusted_id) = keypair(1);
let bytes = envelope_bytes(BUNDLE_DIGEST_HEX, &signer_priv, &signer_id);
std::fs::write(env_dir.join("rev.sig"), &bytes).unwrap();
greentic_deployer::environment::add_trusted_key(
&env_dir,
TrustedKey {
key_id: trusted_id,
public_key_pem: trusted_pub,
},
)
.unwrap();
let revision = make_revision(PathBuf::from("rev.sig"));
let env = make_env();
let gate = StartRevisionHealthGate::new(tmp.path().to_path_buf());
let err = gate.check(&env, &revision).unwrap_err();
assert!(
err.failed_checks.contains(&HealthCheckId::SignatureStatus),
"expected SignatureStatus failure, got {:?}",
err.failed_checks
);
}
#[test]
fn start_fails_signature_status_on_digest_mismatch() {
let tmp = tempfile::tempdir().unwrap();
let env_dir = tmp.path().join(ENV_ID);
std::fs::create_dir_all(&env_dir).unwrap();
let (priv_pem, pub_pem, key_id) = keypair(1);
let other_digest = "2222222222222222222222222222222222222222222222222222222222222222";
let bytes = envelope_bytes(other_digest, &priv_pem, &key_id);
std::fs::write(env_dir.join("rev.sig"), &bytes).unwrap();
greentic_deployer::environment::add_trusted_key(
&env_dir,
TrustedKey {
key_id,
public_key_pem: pub_pem,
},
)
.unwrap();
let revision = make_revision(PathBuf::from("rev.sig"));
let env = make_env();
let gate = StartRevisionHealthGate::new(tmp.path().to_path_buf());
let err = gate.check(&env, &revision).unwrap_err();
assert!(
err.failed_checks.contains(&HealthCheckId::SignatureStatus),
"expected SignatureStatus failure, got {:?}",
err.failed_checks
);
}
#[test]
fn start_fails_signature_status_when_sidecar_not_an_envelope() {
let tmp = tempfile::tempdir().unwrap();
let env_dir = tmp.path().join(ENV_ID);
std::fs::create_dir_all(&env_dir).unwrap();
std::fs::write(env_dir.join("rev.sig"), b"placeholder").unwrap();
let (_priv_pem, pub_pem, key_id) = keypair(1);
greentic_deployer::environment::add_trusted_key(
&env_dir,
TrustedKey {
key_id,
public_key_pem: pub_pem,
},
)
.unwrap();
let revision = make_revision(PathBuf::from("rev.sig"));
let env = make_env();
let gate = StartRevisionHealthGate::new(tmp.path().to_path_buf());
let err = gate.check(&env, &revision).unwrap_err();
assert_eq!(err.failed_checks, vec![HealthCheckId::SignatureStatus]);
assert!(
err.message.contains("DSSE signature verification failed"),
"msg: {}",
err.message
);
}
#[test]
fn start_fails_signature_status_on_empty_bundle_digest() {
let (_tmp, env_root, mut revision) = seed_signed_env();
revision.bundle_digest = "sha256:".to_string();
let env = make_env();
let gate = StartRevisionHealthGate::new(env_root);
let err = gate.check(&env, &revision).unwrap_err();
assert_eq!(err.failed_checks, vec![HealthCheckId::SignatureStatus]);
assert!(
err.message.contains("carries no digest"),
"msg: {}",
err.message
);
}
#[test]
fn start_fails_signature_status_on_contained_directory() {
let tmp = tempfile::tempdir().unwrap();
let env_dir = tmp.path().join(ENV_ID);
std::fs::create_dir_all(env_dir.join("rev.sig")).unwrap();
let revision = make_revision(PathBuf::from("rev.sig"));
let env = make_env();
let gate = StartRevisionHealthGate::new(tmp.path().to_path_buf());
let err = gate.check(&env, &revision).unwrap_err();
assert_eq!(err.failed_checks, vec![HealthCheckId::SignatureStatus]);
assert!(
err.message.contains("not a regular file"),
"msg: {}",
err.message
);
}
#[test]
fn start_fails_signature_status_on_oversized_sidecar() {
let tmp = tempfile::tempdir().unwrap();
let env_dir = tmp.path().join(ENV_ID);
std::fs::create_dir_all(&env_dir).unwrap();
let (_priv_pem, pub_pem, key_id) = keypair(1);
std::fs::write(
env_dir.join("rev.sig"),
vec![b'x'; (MAX_SIDECAR_BYTES + 1) as usize],
)
.unwrap();
greentic_deployer::environment::add_trusted_key(
&env_dir,
TrustedKey {
key_id,
public_key_pem: pub_pem,
},
)
.unwrap();
let revision = make_revision(PathBuf::from("rev.sig"));
let env = make_env();
let gate = StartRevisionHealthGate::new(tmp.path().to_path_buf());
let err = gate.check(&env, &revision).unwrap_err();
assert_eq!(err.failed_checks, vec![HealthCheckId::SignatureStatus]);
assert!(
err.message.contains("exceeds the") && err.message.contains("limit"),
"msg: {}",
err.message
);
}
#[test]
fn dyn_trait_object_dispatch_works_for_both_impls() {
let (_tmp, env_root, revision) = seed_signed_env();
let env = make_env();
let gates: Vec<std::sync::Arc<dyn RevisionHealthGate>> = vec![
std::sync::Arc::new(NoopRevisionHealthGate),
std::sync::Arc::new(StartRevisionHealthGate::new(env_root)),
];
for g in &gates {
assert!(g.check(&env, &revision).is_ok());
}
}
#[test]
fn adapts_to_warm_with_health_gate_closure_shape() {
let (_tmp, env_root, revision) = seed_signed_env();
let env = make_env();
let gate: std::sync::Arc<dyn RevisionHealthGate> =
std::sync::Arc::new(StartRevisionHealthGate::new(env_root));
let closure = |e: &Environment, r: &Revision| gate.check(e, r);
assert!(closure(&env, &revision).is_ok());
}
}