fix: complete profile source tree review fixes
This commit is contained in:
@@ -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(®istry, sources)?;
|
||||
let archive = build_profile_archive_for_selector(®istry, 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(®istry, 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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user