diff --git a/.yoi/tickets/00001KX1JNJ2Y/item.md b/.yoi/tickets/00001KX1JNJ2Y/item.md index 37f47bf6..7aff46ff 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/item.md +++ b/.yoi/tickets/00001KX1JNJ2Y/item.md @@ -2,7 +2,7 @@ title: 'Add ProfileSourceTree virtual filesystem and Decodal profile editor' state: 'inprogress' created_at: '2026-07-08T19:18:25Z' -updated_at: '2026-07-09T08:22:18Z' +updated_at: '2026-07-09T08:46:58Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-09T07:56:07Z' diff --git a/.yoi/tickets/00001KX1JNJ2Y/thread.md b/.yoi/tickets/00001KX1JNJ2Y/thread.md index 2fffc4de..7c5b9542 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/thread.md +++ b/.yoi/tickets/00001KX1JNJ2Y/thread.md @@ -148,4 +148,30 @@ Validation: - `nix build .#yoi --no-link` +--- + + + +## Implementation report + +Follow-up fixes for external review blockers. + +Summary: +- Added required `decodal-codemirror@0.1.2` dependency and wired the `decodal()` CodeMirror 6 extension into the Decodal source editor. +- Added a profile source tree settings route plus create/save/delete editor operations using Backend virtual-path APIs. +- Changed project profile archive generation to build a selected-root/import-closure-only archive for the requested selector instead of snapshotting the whole tree. +- Added safe source/tree summary metadata for content type, content digest, and typed provenance without host paths. +- Hardened tree writes so symlinked intermediate directories fail before any outside filesystem mutation. +- Added focused Rust regression tests for closure-only archive content and symlink write rejection; added web route/path model smoke tests. + +Validation: +- `git diff --check` +- `cargo test -p yoi-workspace-server` +- `cargo test -p worker-runtime --features ws-server,fs-store` +- `cargo check -p yoi` +- `cd web/workspace && deno task check && deno task test` +- `yoi ticket doctor` +- `nix build .#yoi --no-link` + + --- diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index 2475e0b5..9b3292a1 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -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, } +#[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, + sources: BTreeMap, ) -> Result<(ProfileSourceArchive, BTreeMap)> { 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, + selector: &str, +) -> Result { + 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, + closure_sources: &mut BTreeMap, + imports: &mut BTreeMap, +) -> 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, + sources: BTreeMap, +) -> Result<(ProfileSourceArchive, BTreeMap)> { 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, + mut sources: BTreeMap, + imports: BTreeMap, +) -> Result { 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 { + 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 { + 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 { 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()); + } } diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 74c4df10..61fa710b 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -17,6 +17,7 @@ "@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1", "@codemirror/state": "npm:@codemirror/state@6.5.2", "@codemirror/view": "npm:@codemirror/view@6.38.8", + "decodal-codemirror": "npm:decodal-codemirror@0.1.2", "clsx": "npm:clsx@2.1.1", "cookie": "npm:cookie@0.6.0", "devalue": "npm:devalue@5.6.4", diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock index 8778ced0..c417999c 100644 --- a/web/workspace/deno.lock +++ b/web/workspace/deno.lock @@ -8,6 +8,7 @@ "npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7", "npm:clsx@2.1.1": "2.1.1", "npm:cookie@0.6.0": "0.6.0", + "npm:decodal-codemirror@0.1.2": "0.1.2", "npm:devalue@5.6.4": "5.6.4", "npm:set-cookie-parser@2.7.2": "2.7.2", "npm:svelte-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3", @@ -16,6 +17,17 @@ "npm:vite@7.2.7": "7.2.7" }, "npm": { + "@codemirror/language@6.12.4": { + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "dependencies": [ + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + "style-mod" + ] + }, "@codemirror/state@6.5.2": { "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "dependencies": [ @@ -188,6 +200,21 @@ "@jridgewell/sourcemap-codec" ] }, + "@lezer/common@1.5.2": { + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==" + }, + "@lezer/highlight@1.2.3": { + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "dependencies": [ + "@lezer/common" + ] + }, + "@lezer/lr@1.4.10": { + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "dependencies": [ + "@lezer/common" + ] + }, "@marijn/find-cluster-break@1.0.3": { "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==" }, @@ -421,6 +448,14 @@ "ms" ] }, + "decodal-codemirror@0.1.2": { + "integrity": "sha512-VR+cFsBLPb9kZU3gJhElZck+IUVhOC1HoWgA8bxP1kxwn+eCZaeBHErHESlRZbsWvN2J5T8VKcDCtxLDYe9QyA==", + "dependencies": [ + "@codemirror/language", + "@lezer/highlight", + "@lezer/lr" + ] + }, "deepmerge@4.3.1": { "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" }, @@ -680,6 +715,7 @@ "npm:@sveltejs/vite-plugin-svelte@6.2.1", "npm:clsx@2.1.1", "npm:cookie@0.6.0", + "npm:decodal-codemirror@0.1.2", "npm:devalue@5.6.4", "npm:set-cookie-parser@2.7.2", "npm:svelte-check@4.3.4", diff --git a/web/workspace/src/lib/workspace-settings/DecodalSourceEditor.svelte b/web/workspace/src/lib/workspace-settings/DecodalSourceEditor.svelte index bd300a60..90af4525 100644 --- a/web/workspace/src/lib/workspace-settings/DecodalSourceEditor.svelte +++ b/web/workspace/src/lib/workspace-settings/DecodalSourceEditor.svelte @@ -1,6 +1,7 @@ + + + Profile source tree · Yoi Workspace + + +
+
+
+

Profile source tree

+

{sourceTreeId}

+
+ Back to profiles +
+ + {#if loading} +

Loading source tree…

+ {:else if tree} +

+ {tree.tree.root_path} · {tree.tree.file_count} files · {tree.tree.content_type} · {tree.tree.content_digest} +

+
+ + +
+
+
+

Files

+
    + {#each tree.files as file (file.path)} +
  • + + {file.content_type} · {file.content_digest} + {file.size_bytes} bytes · rev {file.revision} + +
  • + {/each} +
+
+ {#if selectedFile} +
+
+
+

{selectedFile.file.content_type}

+

{selectedFile.file.path}

+
+
+ + +
+
+ (draftContent = value)} ariaLabel={`Decodal source ${selectedFile.file.path}`} /> +
+ {/if} +
+ {/if} + + {#if message}

{message}

{/if} + +
diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/profiles/trees/[sourceTreeId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/settings/profiles/trees/[sourceTreeId]/+page.ts new file mode 100644 index 00000000..c8d0ef85 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/settings/profiles/trees/[sourceTreeId]/+page.ts @@ -0,0 +1,5 @@ +import type { PageLoad } from './$types'; + +export const load: PageLoad = ({ params }) => ({ + sourceTreeId: params.sourceTreeId, +});