fix: complete profile source tree review fixes

This commit is contained in:
2026-07-09 17:47:19 +09:00
parent 81abfa630a
commit 941b91261b
12 changed files with 629 additions and 15 deletions
+280 -9
View File
@@ -4,6 +4,7 @@ use std::path::{Component, Path, PathBuf};
use std::time::UNIX_EPOCH;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use worker_runtime::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
};
@@ -90,18 +91,30 @@ pub struct WorkspaceProfileSourceSummary {
pub profile_source_id: String,
pub display_path: String,
pub kind: String,
pub content_type: String,
pub content_digest: String,
pub provenance: WorkspaceProfileSourceProvenance,
pub editable: bool,
pub revision: String,
pub size_bytes: u64,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceProfileSourceProvenance {
ProjectProfileSourceTree,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceTreeSummary {
pub source_tree_id: String,
pub label: String,
pub root_path: String,
pub kind: String,
pub content_type: String,
pub content_digest: String,
pub provenance: WorkspaceProfileSourceProvenance,
pub editable: bool,
pub revision: String,
pub file_count: usize,
@@ -112,6 +125,9 @@ pub struct WorkspaceProfileSourceTreeSummary {
pub struct WorkspaceProfileSourceTreeFileSummary {
pub path: String,
pub kind: String,
pub content_type: String,
pub content_digest: String,
pub provenance: WorkspaceProfileSourceProvenance,
pub editable: bool,
pub revision: String,
pub size_bytes: u64,
@@ -701,11 +717,7 @@ pub fn write_profile_tree_file(
));
}
let relative_path = relative_source_path_for_virtual_path(&request.path)?;
let full = workspace_root.join(&relative_path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent)?;
}
let full = checked_source_path(workspace_root, &relative_path)?;
let full = prepare_source_path_for_write(workspace_root, &relative_path)?;
if let Some(expected) = request.revision.as_deref() {
ensure_revision(&full, expected, "profile_source_revision_conflict")?;
} else if full.exists() {
@@ -764,7 +776,7 @@ pub fn build_workspace_profile_archive(
}
let registry = read_registry(workspace_root).map_err(profile_registry_error)?;
let sources = read_profile_source_tree_contents(workspace_root)?;
let (archive, _) = build_profile_archive_from_tree_sources(&registry, sources)?;
let archive = build_profile_archive_for_selector(&registry, sources, selector)?;
archive
.verify()
.and_then(|verified| {
@@ -1001,6 +1013,10 @@ fn profile_source_tree_summary_with_count(
label: "Project profile sources".to_string(),
root_path: PROFILE_SOURCE_TREE_DISPLAY_ROOT.to_string(),
kind: "decodal_source_tree".to_string(),
content_type: "application/vnd.yoi.profile-source-tree+json".to_string(),
content_digest: profile_source_tree_digest(workspace_root)
.unwrap_or_else(|_| "sha256:unavailable".to_string()),
provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree,
editable: true,
revision: file_revision(&workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH)),
file_count,
@@ -1069,6 +1085,9 @@ fn summarize_tree_file(path: &Path, virtual_path: String) -> WorkspaceProfileSou
WorkspaceProfileSourceTreeFileSummary {
path: virtual_path,
kind: "decodal".to_string(),
content_type: "text/x-decodal".to_string(),
content_digest: file_content_digest(path),
provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree,
editable: true,
revision: file_revision(path),
size_bytes: source_metadata(path)
@@ -1150,7 +1169,7 @@ fn read_profile_source_tree_contents(workspace_root: &Path) -> Result<BTreeMap<S
fn build_profile_archive_from_tree_sources(
registry: &ProfileRegistryDocument,
mut sources: BTreeMap<String, String>,
sources: BTreeMap<String, String>,
) -> Result<(ProfileSourceArchive, BTreeMap<String, String>)> {
let mut entrypoints = BTreeMap::new();
for entry in project_entries(registry, &mut Vec::new()) {
@@ -1168,6 +1187,80 @@ fn build_profile_archive_from_tree_sources(
entrypoints.insert("default".to_string(), path);
}
}
build_profile_archive_from_source_set(entrypoints, sources)
}
fn build_profile_archive_for_selector(
registry: &ProfileRegistryDocument,
sources: BTreeMap<String, String>,
selector: &str,
) -> Result<ProfileSourceArchive> {
let Some(name) = selector.strip_prefix("project:") else {
return Err(profile_validation_error(
"profile_selector_invalid",
"Project profile archive selector must use project:*",
));
};
let entry = project_entries(registry, &mut Vec::new())
.into_iter()
.find(|entry| entry.name == name)
.ok_or_else(|| {
profile_validation_error(
"unknown_profile_selector",
"Selected project profile is not present in the workspace profile registry",
)
})?;
let root_path = display_source_path(&entry.relative_path);
if !sources.contains_key(&root_path) {
return Err(profile_validation_error(
"profile_source_missing",
"Selected project profile source is missing from the source tree",
));
}
let mut closure_sources = BTreeMap::new();
let mut imports = BTreeMap::new();
collect_profile_import_closure(&root_path, &sources, &mut closure_sources, &mut imports)?;
let mut entrypoints = BTreeMap::new();
entrypoints.insert(selector.to_string(), root_path);
build_profile_archive_from_source_set_with_imports(entrypoints, closure_sources, imports)
}
fn collect_profile_import_closure(
current_path: &str,
all_sources: &BTreeMap<String, String>,
closure_sources: &mut BTreeMap<String, String>,
imports: &mut BTreeMap<String, String>,
) -> Result<()> {
if closure_sources.contains_key(current_path) {
return Ok(());
}
let content = all_sources.get(current_path).ok_or_else(|| {
profile_validation_error(
"profile_source_import_missing",
&format!("Profile source import closure is missing {current_path}"),
)
})?;
closure_sources.insert(current_path.to_string(), content.clone());
for specifier in collect_decodal_import_specifiers(content) {
let target = resolve_profile_source_import(current_path, &specifier)?;
if !all_sources.contains_key(&target) {
return Err(profile_validation_error(
"profile_source_import_missing",
&format!(
"Profile source import {specifier:?} from {current_path} resolves to missing {target}"
),
));
}
imports.insert(format!("{current_path}\0{specifier}"), target.clone());
collect_profile_import_closure(&target, all_sources, closure_sources, imports)?;
}
Ok(())
}
fn build_profile_archive_from_source_set(
entrypoints: BTreeMap<String, String>,
sources: BTreeMap<String, String>,
) -> Result<(ProfileSourceArchive, BTreeMap<String, String>)> {
let mut imports = BTreeMap::new();
let source_snapshot = sources.clone();
for (current_path, content) in &source_snapshot {
@@ -1185,16 +1278,24 @@ fn build_profile_archive_from_tree_sources(
}
}
}
build_profile_archive_from_source_set_with_imports(entrypoints, sources, imports.clone())
.map(|archive| (archive, imports))
}
fn build_profile_archive_from_source_set_with_imports(
entrypoints: BTreeMap<String, String>,
mut sources: BTreeMap<String, String>,
imports: BTreeMap<String, String>,
) -> Result<ProfileSourceArchive> {
if sources.is_empty() {
sources.insert("profiles/.empty.dcdl".to_string(), "{}".to_string());
}
ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: "workspace-project-decodal-profiles-v1".to_string(),
entrypoints,
imports: imports.clone(),
imports,
sources,
})
.map(|archive| (archive, imports))
.map_err(|err| Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_archive_invalid".to_string(),
@@ -1369,6 +1470,11 @@ fn summarize_source(
profile_source_id: source_id.to_string(),
display_path,
kind: "decodal".to_string(),
content_type: "text/x-decodal".to_string(),
content_digest: checked_source_path(workspace_root, relative_path)
.map(|path| file_content_digest(&path))
.unwrap_or_else(|_| "sha256:unavailable".to_string()),
provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree,
editable: diagnostics
.iter()
.all(|d| d.severity != DiagnosticSeverity::Error),
@@ -1506,6 +1612,98 @@ fn validate_relative_source_path(path: &Path) -> std::result::Result<(), String>
Ok(())
}
fn profile_source_tree_digest(workspace_root: &Path) -> Result<String> {
let mut bytes = Vec::new();
for file in list_profile_tree_files(workspace_root)? {
bytes.extend_from_slice(file.path.as_bytes());
bytes.push(0);
bytes.extend_from_slice(file.content_digest.as_bytes());
bytes.push(0);
}
Ok(sha256_hex(&bytes))
}
fn file_content_digest(path: &Path) -> String {
fs::read(path)
.map(|bytes| sha256_hex(&bytes))
.unwrap_or_else(|_| "sha256:unavailable".to_string())
}
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::from("sha256:");
for byte in digest {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
out
}
fn prepare_source_path_for_write(workspace_root: &Path, relative_path: &Path) -> Result<PathBuf> {
validate_relative_source_path(relative_path).map_err(|message| {
Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_path_escape".to_string(),
message,
}
})?;
let source_root = workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH);
fs::create_dir_all(&source_root)?;
let canonical_root = fs::canonicalize(&source_root)?;
let full = workspace_root.join(relative_path);
let parent = full.parent().ok_or_else(|| {
profile_validation_error(
"profile_source_path_invalid",
"Profile source path has no parent directory",
)
})?;
let parent_relative = parent.strip_prefix(&source_root).map_err(|_| {
profile_validation_error(
"profile_source_path_escape",
"Profile source parent must remain inside the source tree",
)
})?;
let mut current = source_root.clone();
for component in parent_relative.components() {
let Component::Normal(name) = component else {
return Err(profile_validation_error(
"profile_source_path_escape",
"Profile source parent must be a normalized relative path",
));
};
let next = current.join(name);
match fs::symlink_metadata(&next) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err(Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_symlink_escape".to_string(),
message: "Profile source parent contains a symlink".to_string(),
});
}
if !metadata.is_dir() {
return Err(profile_validation_error(
"profile_source_path_invalid",
"Profile source parent component is not a directory",
));
}
let canonical_next = fs::canonicalize(&next)?;
if !canonical_next.starts_with(&canonical_root) {
return Err(Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_symlink_escape".to_string(),
message: "Profile source parent resolves outside the workspace profile source root".to_string(),
});
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => fs::create_dir(&next)?,
Err(err) => return Err(err.into()),
}
current = next;
}
checked_source_path(workspace_root, relative_path)
}
fn checked_source_path(workspace_root: &Path, relative_path: &Path) -> Result<PathBuf> {
validate_relative_source_path(relative_path).map_err(|message| {
Error::RuntimeOperationFailed {
@@ -1895,4 +2093,77 @@ mod tests {
assert!(rendered.contains("profile_source_symlink_escape"));
assert!(!rendered.contains(dir.path().to_string_lossy().as_ref()));
}
#[test]
fn selected_profile_archive_contains_only_import_closure() {
let mut registry = ProfileRegistryDocument::default();
registry.profile.insert(
"alpha".to_string(),
ProfileEntryFile::Table(ProfileEntryTable {
path: "profiles/alpha.dcdl".to_string(),
description: None,
}),
);
registry.profile.insert(
"beta".to_string(),
ProfileEntryFile::Table(ProfileEntryTable {
path: "profiles/beta.dcdl".to_string(),
description: None,
}),
);
let mut sources = BTreeMap::new();
sources.insert(
"profiles/alpha.dcdl".to_string(),
r#"{ extra = import "./shared.dcdl"; }"#.to_string(),
);
sources.insert("profiles/shared.dcdl".to_string(), "{}".to_string());
sources.insert("profiles/beta.dcdl".to_string(), "{}".to_string());
sources.insert("profiles/unregistered.dcdl".to_string(), "{}".to_string());
let archive =
build_profile_archive_for_selector(&registry, sources, "project:alpha").unwrap();
let verified = archive.verify().unwrap();
let manifest = verified.manifest();
let source_paths: Vec<_> = manifest
.sources
.iter()
.map(|source| source.path.as_str())
.collect();
assert_eq!(manifest.entrypoints.len(), 1);
assert_eq!(
manifest
.entrypoints
.get("project:alpha")
.map(String::as_str),
Some("profiles/alpha.dcdl")
);
assert!(source_paths.contains(&"profiles/alpha.dcdl"));
assert!(source_paths.contains(&"profiles/shared.dcdl"));
assert!(!source_paths.contains(&"profiles/beta.dcdl"));
assert!(!source_paths.contains(&"profiles/unregistered.dcdl"));
}
#[cfg(unix)]
#[test]
fn tree_write_rejects_symlink_parent_before_outside_side_effect() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap();
let outside = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(outside.path(), dir.path().join(".yoi/profiles/link")).unwrap();
let err = write_profile_tree_file(
"workspace-test",
dir.path(),
PROFILE_SOURCE_TREE_ID,
WriteWorkspaceProfileTreeFileRequest {
path: "profiles/link/nested/new.dcdl".to_string(),
content: valid_decodal("new"),
revision: None,
},
)
.unwrap_err();
assert!(err.to_string().contains("profile_source_symlink_escape"));
assert!(!outside.path().join("nested").exists());
}
}