fix: pin evaluated Plugin config after Profile overrides

This commit is contained in:
2026-10-29 09:17:52 +09:00
parent 9b7a58d5c4
commit fc1115b167
2 changed files with 79 additions and 6 deletions
+16 -4
View File
@@ -406,7 +406,8 @@ fn project_profile_plugins(
for (key, value) in &selection.config { for (key, value) in &selection.config {
object.insert(key.clone(), value.clone()); object.insert(key.clone(), value.clone());
} }
validate_effective_config(&config, &resolved.artifacts[alias].config_schema.dcdl)?; let config =
evaluate_effective_config(&config, &resolved.artifacts[alias].config_schema.dcdl)?;
let mut grants = Vec::new(); let mut grants = Vec::new();
if selection.logging { if selection.logging {
grants.push(PluginGrant::Logging); grants.push(PluginGrant::Logging);
@@ -442,7 +443,7 @@ fn project_profile_plugins(
Ok(pins) Ok(pins)
} }
fn validate_effective_config(value: &serde_json::Value, schema: &str) -> Result<()> { fn evaluate_effective_config(value: &serde_json::Value, schema: &str) -> Result<serde_json::Value> {
use config_source::{ConfigContentType, ConfigEntry, VirtualPath}; use config_source::{ConfigContentType, ConfigEntry, VirtualPath};
if serde_json::to_vec(value).map_err(config_error)?.len() > 64 * 1024 { if serde_json::to_vec(value).map_err(config_error)?.len() > 64 * 1024 {
return Err(config_error("non-secret config exceeds 64 KiB")); return Err(config_error("non-secret config exceeds 64 KiB"));
@@ -466,8 +467,19 @@ fn validate_effective_config(value: &serde_json::Value, schema: &str) -> Result<
) )
.map_err(config_error)?]) .map_err(config_error)?])
.map_err(config_error)?; .map_err(config_error)?;
evaluate(&snapshot, &contract(bundle))?; // Static singleton fields can concretize omitted nested override values.
Ok(()) // Pin the complete projection, not just the candidate that passed validation.
let evaluated = evaluate(&snapshot, &contract(bundle))?;
let config = evaluated
.projections
.first()
.and_then(|projection| projection.data_json.get("config"))
.filter(|config| config.is_object())
.ok_or_else(|| config_error("effective Plugin config projection must be an object"))?;
if serde_json::to_vec(config).map_err(config_error)?.len() > 64 * 1024 {
return Err(config_error("non-secret config exceeds 64 KiB"));
}
Ok(config.clone())
} }
pub(crate) fn render_value(value: &serde_json::Value) -> Result<String> { pub(crate) fn render_value(value: &serde_json::Value) -> Result<String> {
@@ -15,6 +15,9 @@ struct Fixture {
} }
impl Fixture { impl Fixture {
async fn new() -> Self { async fn new() -> Self {
Self::with_schema("{ mode = String; }").await
}
async fn with_schema(schema: &str) -> Self {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let store = crate::SqliteWorkspaceStore::open_for_test(dir.path().join("db")).unwrap(); let store = crate::SqliteWorkspaceStore::open_for_test(dir.path().join("db")).unwrap();
for id in ["a", "b"] { for id in ["a", "b"] {
@@ -35,7 +38,7 @@ impl Fixture {
&dir.path().join("artifacts"), &dir.path().join("artifacts"),
"a", "a",
"owner", "owner",
&package("0.1.0"), &package_with_schema("0.1.0", schema),
) )
.unwrap(); .unwrap();
let installed = store let installed = store
@@ -135,6 +138,9 @@ fn bundle() -> WorkspaceConfigSchemaBundle {
.unwrap() .unwrap()
} }
fn package(version: &str) -> Vec<u8> { fn package(version: &str) -> Vec<u8> {
package_with_schema(version, "{ mode = String; }")
}
fn package_with_schema(version: &str, schema: &str) -> Vec<u8> {
use manifest::plugin::*; use manifest::plugin::*;
let d = tempdir().unwrap(); let d = tempdir().unwrap();
let source = let source =
@@ -142,7 +148,7 @@ fn package(version: &str) -> Vec<u8> {
.replace("version = \"0.1.0\"", &format!("version = \"{version}\"")) .replace("version = \"0.1.0\"", &format!("version = \"{version}\""))
.replace( .replace(
"config_schema = \"{}\"", "config_schema = \"{}\"",
"config_schema = \"{ mode = String; }\"", &format!("config_schema = {}", serde_json::to_string(schema).unwrap()),
); );
std::fs::write(d.path().join("plugin.toml"), source).unwrap(); std::fs::write(d.path().join("plugin.toml"), source).unwrap();
std::fs::write( std::fs::write(
@@ -521,3 +527,58 @@ async fn https_config_binding_is_validated_pinned_by_digest_and_never_copied_to_
receipt receipt
); );
} }
#[tokio::test]
async fn nested_profile_override_pins_evaluated_singletons_through_create_restart_and_replay() {
use crate::plugin_plan::{WorkerPluginExecutionPlan, WorkerPluginPlanInput};
let f = Fixture::with_schema("{ nested = { mode = String; count = 1; }; }").await;
let mut source = f.source();
source["plugins"]["enabled"]["echo"]["config"] = json!({"nested":{"mode":"base","count":1}});
source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["config"] =
json!({"nested":{"mode":"override"}});
let state = f.commit(source);
let plan = f.plan(&state);
let expected = json!({"nested":{"count":1,"mode":"override"}});
assert_eq!(plan.plugins()[0].projection().config, expected);
assert_eq!(
serde_json::to_value(&plan).unwrap()["input"]["plugins"][0]["config_digest"],
config_source::digest_bytes(&serde_json::to_vec(&expected).unwrap())
);
let receipt = f
.store
.plugin_plan_create("host", "singletons", &plan)
.unwrap();
assert_eq!(receipt.plan.plugins()[0].projection().config, expected);
let reopened = crate::SqliteWorkspaceStore::open_for_test(f.dir.path().join("db")).unwrap();
let stored = reopened.plugin_plan_load("a", &receipt.plan_id).unwrap();
assert_eq!(stored.plan, plan);
assert_eq!(
reopened
.plugin_plan_create("host", "singletons", &plan)
.unwrap(),
receipt
);
// Even a canonically rehashed candidate cannot substitute the raw override
// for the complete projection reconstructed at the public create fence.
let mut forged = serde_json::to_value(&plan).unwrap()["input"].clone();
forged["plugins"][0]["projection"]["config"] = json!({"nested":{"mode":"override"}});
let input: WorkerPluginPlanInput = serde_json::from_value(forged).unwrap();
let incomplete = WorkerPluginExecutionPlan::canonicalize(input).unwrap();
assert!(
reopened
.plugin_plan_create("host", "raw-override", &incomplete)
.is_err()
);
}
#[test]
fn effective_config_size_limit_applies_after_schema_concretization() {
let schema = format!("{{ value = {}; }}", json!("a".repeat(64 * 1024)));
let error = evaluate_effective_config(&json!({}), &schema).unwrap_err();
assert!(
error
.to_string()
.contains("non-secret config exceeds 64 KiB"),
"{error}"
);
}