feat(profiles): seal builtin import provenance and expose read-only editor imports
Retain the evaluated JSON launch entrypoint, seal observed builtin source bytes and digests as verified provenance, and fail on missing/corrupt dependencies without Runtime builtin reevaluation. Cover real HTTP commits and launch resolution, including worker mode and Workspace Config feature composition. Expose read-only builtin import completions/diagnostics in the editor and rebuild wasm32 release assets with wasm-bindgen 0.2.117. Document public paths, ordinary composition, limits, resave semantics, and the lazy-cycle incident. Validation: worker-runtime 344 library + 34 integration tests; workspace-server 609 library + 11 binary tests plus final builtin HTTP/launch tests (2); archive tests (16); native WASM tests (2); real-WASM/web tests (37), editor component tests (10); root cargo check, cargo fmt --all -- --check and git diff --check HEAD pass.
This commit is contained in:
@@ -98,31 +98,47 @@ pub fn complete_current(
|
||||
let snapshot = session
|
||||
.as_ref()
|
||||
.ok_or_else(|| JsValue::from_str("config source snapshot is not initialized"))?;
|
||||
let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?;
|
||||
let result = session_environment(snapshot.clone())
|
||||
.complete_config(&entrypoint, &source, utf8_byte_offset, explicit)
|
||||
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?;
|
||||
let result = result
|
||||
.map(|result| {
|
||||
Ok::<WasmCompletionResult, JsValue>(WasmCompletionResult {
|
||||
from: utf8_to_utf16_offset(&source, result.from)?,
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| WasmCompletionItem {
|
||||
label: item.label,
|
||||
kind: format!("{:?}", item.kind).to_lowercase(),
|
||||
detail: item.detail,
|
||||
priority: item.priority,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
encode(result)
|
||||
encode(complete_snapshot(
|
||||
snapshot,
|
||||
&entrypoint,
|
||||
&source,
|
||||
utf16_offset,
|
||||
explicit,
|
||||
)?)
|
||||
})
|
||||
}
|
||||
|
||||
fn complete_snapshot(
|
||||
snapshot: &ConfigTreeSnapshot,
|
||||
entrypoint: &VirtualPath,
|
||||
source: &str,
|
||||
utf16_offset: usize,
|
||||
explicit: bool,
|
||||
) -> Result<Option<WasmCompletionResult>, JsValue> {
|
||||
let utf8_byte_offset = utf16_to_utf8_offset(source, utf16_offset)?;
|
||||
let result = session_environment(snapshot.clone())
|
||||
.complete_config(entrypoint, source, utf8_byte_offset, explicit)
|
||||
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?;
|
||||
let result = result
|
||||
.map(|result| {
|
||||
Ok::<WasmCompletionResult, JsValue>(WasmCompletionResult {
|
||||
from: utf8_to_utf16_offset(source, result.from)?,
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| WasmCompletionItem {
|
||||
label: item.label,
|
||||
kind: format!("{:?}", item.kind).to_lowercase(),
|
||||
detail: item.detail,
|
||||
priority: item.priority,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn evaluate_snapshot(snapshot: JsValue, contract: JsValue) -> Result<JsValue, JsValue> {
|
||||
let snapshot: ConfigTreeSnapshot = decode(snapshot)?;
|
||||
@@ -207,3 +223,98 @@ fn js_error(error: impl std::fmt::Display) -> JsValue {
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _assert_serializable(_: EvaluationResult) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use config_source::{ConfigContentType, ConfigEntry};
|
||||
|
||||
fn snapshot() -> ConfigTreeSnapshot {
|
||||
ConfigTreeSnapshot::from_entries(
|
||||
7,
|
||||
[
|
||||
ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
"{}",
|
||||
)
|
||||
.unwrap(),
|
||||
// A same-suffix workspace file must not be an unknown builtin fallback.
|
||||
ConfigEntry::new(
|
||||
VirtualPath::parse("profiles/missing.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
"{}",
|
||||
)
|
||||
.unwrap(),
|
||||
],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wasm_completion_boundary_preserves_read_only_builtin_detail() {
|
||||
let snapshot = snapshot();
|
||||
let source = "let 名 = 1; import \"$builtin/profiles/comp\"";
|
||||
let cursor = source.encode_utf16().count() - 1;
|
||||
let result = complete_snapshot(
|
||||
&snapshot,
|
||||
&VirtualPath::parse("main.dcdl").unwrap(),
|
||||
source,
|
||||
cursor,
|
||||
true,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(result.from, "let 名 = 1; import \"".encode_utf16().count());
|
||||
let item = result
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.label == "$builtin/profiles/companion.dcdl")
|
||||
.unwrap();
|
||||
assert_eq!(item.kind, "file");
|
||||
assert_eq!(
|
||||
item.detail.as_deref(),
|
||||
Some("read-only builtin Decodal source")
|
||||
);
|
||||
assert_eq!(snapshot.entries.len(), 2);
|
||||
assert!(
|
||||
snapshot
|
||||
.entries
|
||||
.keys()
|
||||
.all(|path| !path.as_str().starts_with("$builtin/"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wasm_analysis_boundary_reports_unknown_builtin_without_workspace_fallback() {
|
||||
let snapshot = snapshot();
|
||||
let path = VirtualPath::parse("main.dcdl").unwrap();
|
||||
let diagnostics = session_environment(snapshot.clone())
|
||||
.analyze(&path, Some("import \"$builtin/profiles/missing.dcdl\""));
|
||||
assert!(!diagnostics.is_empty());
|
||||
let diagnostic = diagnostics
|
||||
.iter()
|
||||
.find(|diagnostic| {
|
||||
diagnostic
|
||||
.message
|
||||
.contains("unknown or non-public read-only builtin source")
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(diagnostic.path, path);
|
||||
assert_eq!(diagnostic.revision, 7);
|
||||
assert_eq!(diagnostic.tree_digest, snapshot.digest);
|
||||
assert!(
|
||||
diagnostic
|
||||
.message
|
||||
.contains("$builtin/profiles/missing.dcdl")
|
||||
);
|
||||
assert!(
|
||||
session_environment(snapshot)
|
||||
.analyze(
|
||||
&VirtualPath::parse("main.dcdl").unwrap(),
|
||||
Some("import \"./profiles/missing.dcdl\"")
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,15 +134,31 @@ impl ProfileSourceArchive {
|
||||
selector: String,
|
||||
value: serde_json::Value,
|
||||
) -> Result<Self, ProfileArchiveError> {
|
||||
Self::build_evaluated_profile_with_builtin_sources(id, selector, value, BTreeMap::new())
|
||||
}
|
||||
|
||||
/// Seal the observed builtin import closure as provenance alongside the evaluated JSON.
|
||||
/// Builtin sources are verified, but never evaluated to resolve this entrypoint.
|
||||
pub fn build_evaluated_profile_with_builtin_sources(
|
||||
id: String,
|
||||
selector: String,
|
||||
value: serde_json::Value,
|
||||
sources: BTreeMap<String, String>,
|
||||
) -> Result<Self, ProfileArchiveError> {
|
||||
for key in sources.keys() {
|
||||
validate_builtin_source_key(key)?;
|
||||
}
|
||||
let path = "profiles/evaluated.json".to_string();
|
||||
let source = serde_json::to_string(&value)
|
||||
.map_err(|err| ProfileArchiveError::Json(err.to_string()))?;
|
||||
let mut sources = sources;
|
||||
sources.insert(path.clone(), source);
|
||||
Self::build_with_source_format(
|
||||
ProfileSourceArchiveInput {
|
||||
id,
|
||||
entrypoints: BTreeMap::from([(selector, path.clone())]),
|
||||
entrypoints: BTreeMap::from([(selector, path)]),
|
||||
imports: BTreeMap::new(),
|
||||
sources: BTreeMap::from([(path, source)]),
|
||||
sources,
|
||||
},
|
||||
"json",
|
||||
"application/json",
|
||||
@@ -171,6 +187,12 @@ impl ProfileSourceArchive {
|
||||
if total_source_bytes > MAX_TOTAL_BYTES {
|
||||
return Err(ProfileArchiveError::LimitExceeded("total source bytes"));
|
||||
}
|
||||
let (kind, content_type) = if path.starts_with("$builtin/") {
|
||||
validate_builtin_source_key(path)?;
|
||||
("decodal", "text/x-decodal")
|
||||
} else {
|
||||
(kind, content_type)
|
||||
};
|
||||
source_meta.push(ProfileSourceArchiveSource {
|
||||
path: path.clone(),
|
||||
source_key: path.clone(),
|
||||
@@ -310,7 +332,18 @@ impl VerifiedProfileSourceArchive {
|
||||
let mut manifest_paths = BTreeSet::new();
|
||||
for source in &manifest.sources {
|
||||
validate_archive_path(&source.path)?;
|
||||
if !source.source_key.is_empty() && source.source_key != source.path {
|
||||
if source.path.starts_with("$builtin/") {
|
||||
validate_builtin_source_key(&source.path)?;
|
||||
if source.kind != "decodal" {
|
||||
return Err(ProfileArchiveError::UnsupportedSource {
|
||||
path: source.path.clone(),
|
||||
kind: source.kind.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (source.path.starts_with("$builtin/") || !source.source_key.is_empty())
|
||||
&& source.source_key != source.path
|
||||
{
|
||||
return Err(ProfileArchiveError::ImportMapMismatch {
|
||||
specifier: source.path.clone(),
|
||||
expected: source.path.clone(),
|
||||
@@ -662,6 +695,26 @@ fn append_bytes<W: Write>(
|
||||
.map_err(|err| ProfileArchiveError::Io(err.to_string()))
|
||||
}
|
||||
|
||||
fn validate_builtin_source_key(key: &str) -> Result<(), ProfileArchiveError> {
|
||||
validate_archive_path(key)?;
|
||||
let Some(relative) = key.strip_prefix("$builtin/profiles/") else {
|
||||
return Err(ProfileArchiveError::UnsafePath(key.to_string()));
|
||||
};
|
||||
if !relative.ends_with(".dcdl")
|
||||
|| relative.split('/').any(|part| {
|
||||
part.is_empty()
|
||||
|| part == "."
|
||||
|| part == ".."
|
||||
|| !part
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(&byte))
|
||||
})
|
||||
{
|
||||
return Err(ProfileArchiveError::UnsafePath(key.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_archive_path(path: &str) -> Result<(), ProfileArchiveError> {
|
||||
if path.is_empty() || path == MANIFEST_PATH {
|
||||
return if path == MANIFEST_PATH {
|
||||
@@ -799,6 +852,155 @@ mod tests {
|
||||
archive.content = content;
|
||||
}
|
||||
|
||||
fn builtin_provenance_archive() -> ProfileSourceArchive {
|
||||
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
|
||||
"builtin-provenance".into(),
|
||||
"project:evaluated".into(),
|
||||
evaluated_profile(),
|
||||
BTreeMap::from([
|
||||
(
|
||||
"$builtin/profiles/companion.dcdl".into(),
|
||||
"import \"./base.dcdl\"".into(),
|
||||
),
|
||||
// Intentionally invalid Decodal: provenance must not be reevaluated.
|
||||
(
|
||||
"$builtin/profiles/base.dcdl".into(),
|
||||
"not valid Decodal !".into(),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_seals_builtin_closure_without_runtime_evaluation() {
|
||||
let archive = builtin_provenance_archive();
|
||||
let verified = archive.verify().unwrap();
|
||||
assert_eq!(
|
||||
archive,
|
||||
builtin_provenance_archive(),
|
||||
"archive must be deterministic"
|
||||
);
|
||||
assert_eq!(archive.reference.source_graph.source_count, 3);
|
||||
assert!(verified.manifest.imports.is_empty());
|
||||
assert_eq!(
|
||||
verified.manifest.entrypoints["project:evaluated"],
|
||||
"profiles/evaluated.json"
|
||||
);
|
||||
for source in &verified.manifest.sources {
|
||||
assert_eq!(source.source_key, source.path);
|
||||
assert_eq!(
|
||||
source.digest,
|
||||
sha256_hex(verified.sources[&source.path].as_bytes())
|
||||
);
|
||||
if source.path.starts_with("$builtin/") {
|
||||
assert_eq!(source.kind, "decodal");
|
||||
assert_eq!(source.content_type, "text/x-decodal");
|
||||
} else {
|
||||
assert_eq!(source.kind, "json");
|
||||
}
|
||||
}
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let resolved = verified
|
||||
.resolve_profile("project:evaluated", root.path(), "sealed-worker")
|
||||
.unwrap();
|
||||
assert_eq!(resolved.worker.mode, manifest::WorkerMode::Wip);
|
||||
assert!(resolved.feature.task.enabled);
|
||||
assert!(matches!(
|
||||
verified.resolve_profile("builtin:companion", root.path(), "sealed-worker"),
|
||||
Err(ProfileArchiveError::MissingEntrypoint(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_rejects_missing_or_corrupt_builtin_dependency() {
|
||||
for missing in [true, false] {
|
||||
let mut archive = builtin_provenance_archive();
|
||||
let mut verified = archive.verify().unwrap();
|
||||
let path = "$builtin/profiles/base.dcdl";
|
||||
if missing {
|
||||
verified.sources.remove(path);
|
||||
} else {
|
||||
verified
|
||||
.sources
|
||||
.insert(path.into(), "corrupt dependency".into());
|
||||
}
|
||||
repack_archive(&mut archive, &verified.manifest, &verified.sources);
|
||||
match archive.verify().unwrap_err() {
|
||||
ProfileArchiveError::MissingSource(actual) if missing => assert_eq!(actual, path),
|
||||
ProfileArchiveError::SourceDigestMismatch { path: actual, .. } if !missing => {
|
||||
assert_eq!(actual, path)
|
||||
}
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_builtin_provenance_rejects_unsafe_keys_and_reuses_limits() {
|
||||
for key in [
|
||||
"profiles/companion.dcdl",
|
||||
"builtin:profiles/companion.dcdl",
|
||||
"$builtin/prompts/companion.dcdl",
|
||||
"$builtin/profiles/../escape.dcdl",
|
||||
"$builtin/profiles/./base.dcdl",
|
||||
"$builtin/profiles//base.dcdl",
|
||||
"$builtin/profiles/base\\other.dcdl",
|
||||
"$builtin/profiles/base\0.dcdl",
|
||||
"/$builtin/profiles/base.dcdl",
|
||||
"$builtin/profiles/base.json",
|
||||
"profiles/evaluated.json",
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
|
||||
"unsafe".into(),
|
||||
"project:evaluated".into(),
|
||||
evaluated_profile(),
|
||||
BTreeMap::from([(key.into(), "{}".into())]),
|
||||
),
|
||||
Err(ProfileArchiveError::UnsafePath(_))
|
||||
),
|
||||
"{key:?}"
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
|
||||
"oversized".into(),
|
||||
"project:evaluated".into(),
|
||||
evaluated_profile(),
|
||||
BTreeMap::from([(
|
||||
"$builtin/profiles/base.dcdl".into(),
|
||||
"x".repeat(MAX_SOURCE_BYTES as usize + 1)
|
||||
)]),
|
||||
),
|
||||
Err(ProfileArchiveError::LimitExceeded("source bytes"))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_builtin_provenance_requires_canonical_key_and_source_format() {
|
||||
for key in ["", "profiles/base.dcdl", "$builtin/profiles/other.dcdl"] {
|
||||
let mut archive = builtin_provenance_archive();
|
||||
let mut verified = archive.verify().unwrap();
|
||||
verified.manifest.sources[0].source_key = key.into();
|
||||
repack_archive(&mut archive, &verified.manifest, &verified.sources);
|
||||
assert!(matches!(
|
||||
archive.verify(),
|
||||
Err(ProfileArchiveError::ImportMapMismatch { .. })
|
||||
));
|
||||
}
|
||||
let mut archive = builtin_provenance_archive();
|
||||
let mut verified = archive.verify().unwrap();
|
||||
verified.manifest.sources[0].kind = "json".into();
|
||||
verified.manifest.sources[0].content_type = "application/json".into();
|
||||
repack_archive(&mut archive, &verified.manifest, &verified.sources);
|
||||
assert!(matches!(
|
||||
archive.verify(),
|
||||
Err(ProfileArchiveError::UnsupportedSource { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_reuses_source_size_limit() {
|
||||
assert!(matches!(
|
||||
|
||||
@@ -464,10 +464,20 @@ fn build_virtual_profile_archive(
|
||||
entry: &VirtualProfileEntry,
|
||||
state: &WorkspaceConfigState,
|
||||
) -> Result<ProfileSourceArchive> {
|
||||
ProfileSourceArchive::build_evaluated_profile(
|
||||
let builtin_sources = config_source::SnapshotEnvironment::new(state.snapshot.clone())
|
||||
.builtin_import_sources(&state.contract)
|
||||
.map_err(|diagnostics| {
|
||||
profile_validation_error(
|
||||
"profile_builtin_snapshot_invalid",
|
||||
&serde_json::to_string(&diagnostics)
|
||||
.unwrap_or_else(|_| "builtin import snapshot failed".to_string()),
|
||||
)
|
||||
})?;
|
||||
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
|
||||
format!("workspace-config-profile-r{}", state.snapshot.revision),
|
||||
selector.to_string(),
|
||||
entry.profile.clone(),
|
||||
builtin_sources,
|
||||
)
|
||||
.map_err(|error| profile_validation_error("profile_value_archive_invalid", &error.to_string()))
|
||||
}
|
||||
|
||||
@@ -44,6 +44,287 @@ fn source(value: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_back() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
|
||||
let app = build_router(api.clone());
|
||||
let tree = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree");
|
||||
let commit = format!("{tree}/commit");
|
||||
let initial = get_json_authenticated(app.clone(), &tree, &token).await;
|
||||
let saved = request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&commit,
|
||||
Some(
|
||||
serde_json::to_value(request(
|
||||
&api,
|
||||
&source(r#"import "$builtin/profiles/companion.dcdl""#),
|
||||
vec![config_source::ConfigTreeChange::Create {
|
||||
// An ordinary Workspace file at the matching suffix must not rescue
|
||||
// an unknown builtin import.
|
||||
path: config_source::VirtualPath::parse("profiles/private.dcdl").unwrap(),
|
||||
content_type: config_source::ConfigContentType::Decodal,
|
||||
content: PROFILE.into(),
|
||||
}],
|
||||
))
|
||||
.unwrap(),
|
||||
),
|
||||
&token,
|
||||
StatusCode::CREATED,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
saved["snapshot"]["revision"].as_u64().unwrap(),
|
||||
initial["snapshot"]["revision"].as_u64().unwrap() + 1
|
||||
);
|
||||
let listed = get_json_authenticated(app.clone(), &tree, &token).await;
|
||||
assert_eq!(listed, saved);
|
||||
let entries = listed["snapshot"]["entries"].as_object().unwrap();
|
||||
assert!(entries.contains_key("profiles/private.dcdl"));
|
||||
assert!(
|
||||
entries
|
||||
.keys()
|
||||
.all(|key| key != "$builtin" && !key.starts_with("$builtin/"))
|
||||
);
|
||||
let shadow = get_json_authenticated(
|
||||
app.clone(),
|
||||
&format!("{tree}/entries/profiles%2Fprivate.dcdl"),
|
||||
&token,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(shadow, entries["profiles/private.dcdl"]);
|
||||
assert!(
|
||||
shadow["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("slug = \"alpha\"")
|
||||
);
|
||||
let active = api
|
||||
.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let main_digest = entries["main.dcdl"]["content_digest"].as_str().unwrap();
|
||||
let base = json!({
|
||||
"base_revision": listed["snapshot"]["revision"],
|
||||
"base_digest": listed["snapshot"]["digest"],
|
||||
"entrypoints": listed["contract"]["entrypoints"],
|
||||
});
|
||||
let reserved = "$builtin/profiles/companion.dcdl";
|
||||
for change in [
|
||||
json!({ "kind": "create", "path": reserved, "content_type": "decodal", "content": PROFILE }),
|
||||
json!({ "kind": "create", "path": "$builtin", "content_type": "text", "content": "shadow" }),
|
||||
json!({ "kind": "update", "path": reserved, "expected_digest": "not-a-workspace-entry", "content": PROFILE }),
|
||||
json!({ "kind": "delete", "path": reserved, "expected_digest": "not-a-workspace-entry" }),
|
||||
json!({ "kind": "rename", "from": "profiles/private.dcdl", "to": reserved, "expected_digest": shadow["content_digest"] }),
|
||||
json!({ "kind": "rename", "from": reserved, "to": "profiles/copy.dcdl", "expected_digest": "not-a-workspace-entry" }),
|
||||
] {
|
||||
let mut body = base.clone();
|
||||
body["changes"] = json!([change]);
|
||||
let error = request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&commit,
|
||||
Some(body),
|
||||
&token,
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
error["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("builtin namespace is read-only"),
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(
|
||||
get_json_authenticated(app.clone(), &tree, &token).await,
|
||||
listed
|
||||
);
|
||||
}
|
||||
let mut body = base;
|
||||
body["changes"] = json!([{
|
||||
"kind": "update", "path": "main.dcdl", "expected_digest": main_digest,
|
||||
"content": source(r#"import "$builtin/profiles/private.dcdl""#),
|
||||
}]);
|
||||
let error = request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&commit,
|
||||
Some(body),
|
||||
&token,
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.await;
|
||||
let message = error["message"].as_str().unwrap();
|
||||
assert!(
|
||||
message.contains(
|
||||
"unknown or non-public read-only builtin source: $builtin/profiles/private.dcdl"
|
||||
),
|
||||
"{error}"
|
||||
);
|
||||
assert!(message.contains("\"kind\":\"import\""), "{error}");
|
||||
assert_eq!(get_json_authenticated(app, &tree, &token).await, listed);
|
||||
assert_eq!(
|
||||
api.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
active
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_profiles_builtin_companion_composition_seals_observed_sources() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let recipe = r#"(import "$builtin/profiles/companion.dcdl") // {
|
||||
worker = { mode = "wip"; };
|
||||
feature = {
|
||||
task = { enabled = true; };
|
||||
ticket = { enabled = false; };
|
||||
workspace_config = { enabled = true; };
|
||||
};
|
||||
}"#;
|
||||
let state = commit_workspace_config_tree(
|
||||
&api,
|
||||
TEST_WORKSPACE_ID,
|
||||
&request(
|
||||
&api,
|
||||
&source(r#"import "./recipes/custom.dcdl""#),
|
||||
vec![
|
||||
config_source::ConfigTreeChange::Create {
|
||||
path: config_source::VirtualPath::parse("recipes/custom.dcdl").unwrap(),
|
||||
content_type: config_source::ConfigContentType::Decodal,
|
||||
content: recipe.into(),
|
||||
},
|
||||
// Builtin relative imports must not be shadowed by Workspace files.
|
||||
config_source::ConfigTreeChange::Create {
|
||||
path: config_source::VirtualPath::parse("profiles/base.dcdl").unwrap(),
|
||||
content_type: config_source::ConfigContentType::Decodal,
|
||||
content: "{ slug = \"workspace-shadow\"; }".into(),
|
||||
},
|
||||
],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap();
|
||||
let bundle = build_virtual_profile_config_bundle(
|
||||
&projection,
|
||||
&state,
|
||||
TEST_WORKSPACE_ID,
|
||||
"created",
|
||||
"project:alpha",
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(bundle.metadata.digest, bundle.computed_digest());
|
||||
let archive = bundle.profile_source_archive.as_ref().unwrap();
|
||||
let verified = archive.verify().unwrap();
|
||||
let environment = config_source::SnapshotEnvironment::new(state.snapshot.clone());
|
||||
let builtin_sources = environment.builtin_import_sources(&state.contract).unwrap();
|
||||
assert_eq!(
|
||||
builtin_sources
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"$builtin/profiles/base.dcdl",
|
||||
"$builtin/profiles/companion.dcdl",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
archive.reference.source_graph.source_count,
|
||||
builtin_sources.len() + 1
|
||||
);
|
||||
assert_eq!(archive.reference.source_graph.import_count, 0);
|
||||
assert_eq!(
|
||||
verified.manifest().entrypoints["project:alpha"],
|
||||
"profiles/evaluated.json"
|
||||
);
|
||||
for (key, bytes) in &builtin_sources {
|
||||
let source = verified
|
||||
.manifest()
|
||||
.sources
|
||||
.iter()
|
||||
.find(|source| &source.source_key == key)
|
||||
.unwrap();
|
||||
assert_eq!(source.path, *key);
|
||||
assert_eq!(source.kind, "decodal");
|
||||
assert_eq!(
|
||||
source.digest,
|
||||
worker_runtime::profile_archive::sha256_hex(bytes.as_bytes())
|
||||
);
|
||||
assert_eq!(source.size_bytes, bytes.len() as u64);
|
||||
}
|
||||
assert!(
|
||||
verified
|
||||
.manifest()
|
||||
.sources
|
||||
.iter()
|
||||
.any(|source| source.path == "profiles/evaluated.json" && source.kind == "json")
|
||||
);
|
||||
// Resolve using the Runtime archive consumer, without access to the config tree.
|
||||
let resolved = verified
|
||||
.resolve_profile(
|
||||
"project:alpha",
|
||||
Path::new("/runtime-worker"),
|
||||
"builtin-value-worker",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resolved.worker.mode, manifest::WorkerMode::Wip);
|
||||
assert!(resolved.feature.task.enabled);
|
||||
assert!(!resolved.feature.ticket.enabled);
|
||||
assert!(resolved.feature.workspace_config.enabled);
|
||||
let evaluation = environment.evaluate_contract(&state.contract).unwrap();
|
||||
assert_eq!(
|
||||
resolved.model.ref_.as_deref(),
|
||||
evaluation.projections[0]
|
||||
.data_json
|
||||
.pointer("/profile/entries/0/profile/model/ref")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
);
|
||||
let persisted = manifest::write_persisted_worker_manifest_snapshot(&resolved).unwrap();
|
||||
let old_bundle_digest = bundle.metadata.digest.clone();
|
||||
|
||||
let updated = commit_workspace_config_tree(
|
||||
&api,
|
||||
TEST_WORKSPACE_ID,
|
||||
&request(&api, &source(PROFILE), vec![]),
|
||||
)
|
||||
.unwrap();
|
||||
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &updated).unwrap();
|
||||
let updated_bundle = build_virtual_profile_config_bundle(
|
||||
&projection,
|
||||
&updated,
|
||||
TEST_WORKSPACE_ID,
|
||||
"created",
|
||||
"project:alpha",
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(old_bundle_digest, updated_bundle.metadata.digest);
|
||||
assert_eq!(
|
||||
updated_bundle
|
||||
.profile_source_archive
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.reference
|
||||
.source_graph
|
||||
.source_count,
|
||||
1
|
||||
);
|
||||
// Editing config changes future snapshots, not a sealed bundle or persisted Worker.
|
||||
assert_eq!(bundle.metadata.digest, old_bundle_digest);
|
||||
assert_eq!(
|
||||
serde_json::to_value(manifest::read_persisted_worker_manifest_snapshot(persisted).unwrap())
|
||||
.unwrap(),
|
||||
serde_json::to_value(resolved).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_profiles_save_project_and_runtime_consume_the_same_revision() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user