feat: add profile source tree editor

This commit is contained in:
2026-07-09 17:22:57 +09:00
parent 7da14f1a8d
commit 81abfa630a
11 changed files with 1106 additions and 121 deletions
+239 -17
View File
@@ -2,7 +2,7 @@ use decodal::{Engine, LoadedSource, SourceLoader};
use manifest::{ProfileSource, WorkerManifest, resolve_profile_artifact_value};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Cursor, Read, Write};
use std::path::{Component, Path, PathBuf};
use tar::{Archive, Builder, Header};
@@ -27,6 +27,10 @@ pub enum ProfileArchiveError {
MissingManifest,
#[error("profile source archive missing source {0}")]
MissingSource(String),
#[error("profile source archive contains duplicate source {0}")]
DuplicateSource(String),
#[error("profile source archive contains source not declared in manifest {0}")]
UndeclaredSource(String),
#[error(
"profile source archive source digest mismatch for {path}: expected {expected}, got {actual}"
)]
@@ -47,6 +51,14 @@ pub enum ProfileArchiveError {
MissingEntrypoint(String),
#[error("profile source archive import is not declared in manifest import map: {0}")]
ImportNotDeclared(String),
#[error(
"profile source archive import map mismatch for {specifier}: expected {expected}, got {actual}"
)]
ImportMapMismatch {
specifier: String,
expected: String,
actual: String,
},
#[error("failed to read profile source archive: {0}")]
Io(String),
#[error("failed to parse profile source archive manifest: {0}")]
@@ -94,7 +106,11 @@ pub struct ProfileSourceArchiveManifest {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProfileSourceArchiveSource {
pub path: String,
#[serde(default)]
pub source_key: String,
pub kind: String,
#[serde(default = "default_decodal_content_type")]
pub content_type: String,
pub digest: String,
pub size_bytes: u64,
}
@@ -128,7 +144,9 @@ impl ProfileSourceArchive {
}
source_meta.push(ProfileSourceArchiveSource {
path: path.clone(),
source_key: path.clone(),
kind: "decodal".to_string(),
content_type: default_decodal_content_type(),
digest: sha256_hex(source.as_bytes()),
size_bytes: size,
});
@@ -238,7 +256,9 @@ impl VerifiedProfileSourceArchive {
} else {
let source = String::from_utf8(data)
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?;
entries.insert(path, source);
if entries.insert(path.clone(), source).is_some() {
return Err(ProfileArchiveError::DuplicateSource(path));
}
}
if entries.len() > MAX_SOURCES {
return Err(ProfileArchiveError::LimitExceeded("source count"));
@@ -258,8 +278,25 @@ impl VerifiedProfileSourceArchive {
});
}
let mut total = 0u64;
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 {
return Err(ProfileArchiveError::ImportMapMismatch {
specifier: source.path.clone(),
expected: source.path.clone(),
actual: source.source_key.clone(),
});
}
if source.content_type != default_decodal_content_type() {
return Err(ProfileArchiveError::UnsupportedSource {
path: source.path.clone(),
kind: source.content_type.clone(),
});
}
if !manifest_paths.insert(source.path.clone()) {
return Err(ProfileArchiveError::DuplicateSource(source.path.clone()));
}
if source.kind != "decodal" {
return Err(ProfileArchiveError::UnsupportedSource {
path: source.path.clone(),
@@ -285,6 +322,11 @@ impl VerifiedProfileSourceArchive {
}
total += size;
}
for entry_path in entries.keys() {
if !manifest_paths.contains(entry_path) {
return Err(ProfileArchiveError::UndeclaredSource(entry_path.clone()));
}
}
if total != reference.source_graph.total_source_bytes
|| manifest.sources.len() != reference.source_graph.source_count
{
@@ -298,10 +340,13 @@ impl VerifiedProfileSourceArchive {
.chain(manifest.imports.values())
{
validate_archive_path(path)?;
if !entries.contains_key(path) {
if !manifest_paths.contains(path) || !entries.contains_key(path) {
return Err(ProfileArchiveError::MissingSource(path.clone()));
}
}
for (key, path) in &manifest.imports {
validate_archive_import_map_entry(key, path, &manifest_paths)?;
}
Ok(Self {
reference,
manifest,
@@ -313,6 +358,10 @@ impl VerifiedProfileSourceArchive {
&self.reference
}
pub fn manifest(&self) -> &ProfileSourceArchiveManifest {
&self.manifest
}
pub fn resolve_profile(
&self,
selector: &str,
@@ -378,22 +427,26 @@ impl<'a> ArchiveSourceLoader<'a> {
impl SourceLoader for ArchiveSourceLoader<'_> {
fn load(
&mut self,
_current_key: Option<&str>,
current_key: Option<&str>,
specifier: &str,
) -> decodal::Result<LoadedSource> {
let path = self
.archive
.manifest
.imports
.get(specifier)
.ok_or_else(|| {
decodal::Diagnostic::new(
decodal::DiagnosticKind::Import,
decodal::Span::default(),
format!("archive import not declared: {specifier}"),
)
})?;
let source = self.archive.sources.get(path).ok_or_else(|| {
let path =
archive_import_map_lookup(&self.archive.manifest.imports, current_key, specifier)
.map_err(import_diagnostic)?;
if let Some(current_key) = current_key {
match resolve_archive_import_path(current_key, specifier) {
Ok(expected) if expected == path => {}
Ok(expected) => {
return Err(import_diagnostic(ProfileArchiveError::ImportMapMismatch {
specifier: format!("{current_key}\0{specifier}"),
expected,
actual: path.clone(),
}));
}
Err(err) => return Err(import_diagnostic(err)),
}
}
let source = self.archive.sources.get(&path).ok_or_else(|| {
decodal::Diagnostic::new(
decodal::DiagnosticKind::Import,
decodal::Span::default(),
@@ -408,6 +461,123 @@ impl SourceLoader for ArchiveSourceLoader<'_> {
}
}
fn default_decodal_content_type() -> String {
"text/x-decodal".to_string()
}
fn import_diagnostic(err: ProfileArchiveError) -> decodal::Diagnostic {
decodal::Diagnostic::new(
decodal::DiagnosticKind::Import,
decodal::Span::default(),
err.to_string(),
)
}
fn archive_import_map_lookup(
imports: &BTreeMap<String, String>,
current_key: Option<&str>,
specifier: &str,
) -> Result<String, ProfileArchiveError> {
if let Some(current_key) = current_key {
let scoped = format!("{current_key}\0{specifier}");
if let Some(path) = imports.get(&scoped) {
return Ok(path.clone());
}
}
imports
.get(specifier)
.cloned()
.ok_or_else(|| ProfileArchiveError::ImportNotDeclared(specifier.to_string()))
}
fn validate_archive_import_map_entry(
key: &str,
path: &str,
manifest_paths: &BTreeSet<String>,
) -> Result<(), ProfileArchiveError> {
if !manifest_paths.contains(path) {
return Err(ProfileArchiveError::MissingSource(path.to_string()));
}
if let Some((current_key, specifier)) = key.split_once('\0') {
validate_archive_path(current_key)?;
let expected = resolve_archive_import_path(current_key, specifier)?;
if expected != path {
return Err(ProfileArchiveError::ImportMapMismatch {
specifier: key.to_string(),
expected,
actual: path.to_string(),
});
}
} else {
validate_archive_import_specifier(key)?;
}
Ok(())
}
fn resolve_archive_import_path(
current_key: &str,
specifier: &str,
) -> Result<String, ProfileArchiveError> {
validate_archive_path(current_key)?;
validate_archive_import_specifier(specifier)?;
let raw = if let Some(rest) = specifier.strip_prefix("project:") {
rest
} else if let Some(rest) = specifier.strip_prefix("workspace:") {
rest
} else {
specifier
};
let base = if raw.starts_with("./") || raw.starts_with("../") {
Path::new(current_key)
.parent()
.map(Path::to_path_buf)
.unwrap_or_default()
.join(raw)
} else {
PathBuf::from(raw)
};
normalize_archive_virtual_path(&base.to_string_lossy())
}
fn validate_archive_import_specifier(specifier: &str) -> Result<(), ProfileArchiveError> {
if specifier.is_empty()
|| specifier.contains("://")
|| specifier.starts_with('/')
|| Path::new(specifier).is_absolute()
{
return Err(ProfileArchiveError::UnsafePath(specifier.to_string()));
}
if let Some((namespace, rest)) = specifier.split_once(':') {
if namespace != "project" && namespace != "workspace" {
return Err(ProfileArchiveError::UnsafePath(specifier.to_string()));
}
if rest.is_empty() || rest.starts_with('/') || rest.contains("://") {
return Err(ProfileArchiveError::UnsafePath(specifier.to_string()));
}
}
Ok(())
}
fn normalize_archive_virtual_path(path: &str) -> Result<String, ProfileArchiveError> {
let path_ref = Path::new(path);
if path_ref.is_absolute() {
return Err(ProfileArchiveError::UnsafePath(path.to_string()));
}
let mut out = PathBuf::new();
for component in path_ref.components() {
match component {
Component::CurDir => {}
Component::Normal(value) => out.push(value),
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(ProfileArchiveError::UnsafePath(path.to_string()));
}
}
}
let normalized = out.to_string_lossy().replace('\\', "/");
validate_archive_path(&normalized)?;
Ok(normalized)
}
fn decodal_data_to_json(data: &decodal::Data) -> serde_json::Value {
match data {
decodal::Data::Bool(value) => serde_json::Value::Bool(*value),
@@ -551,4 +721,56 @@ mod tests {
let mut loader = ArchiveSourceLoader::new(&verified);
assert!(loader.load(None, "missing").is_err());
}
#[test]
fn archive_loader_resolves_scoped_virtual_imports() {
let mut entrypoints = BTreeMap::new();
entrypoints.insert("default".to_string(), "profiles/main.dcdl".to_string());
let mut imports = BTreeMap::new();
imports.insert(
"profiles/main.dcdl\0./shared.dcdl".to_string(),
"profiles/shared.dcdl".to_string(),
);
let mut sources = BTreeMap::new();
sources.insert("profiles/main.dcdl".to_string(), "{}".to_string());
sources.insert("profiles/shared.dcdl".to_string(), "{}".to_string());
let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: "project".to_string(),
entrypoints,
imports,
sources,
})
.unwrap();
let verified = archive.verify().unwrap();
let mut loader = ArchiveSourceLoader::new(&verified);
let loaded = loader
.load(Some("profiles/main.dcdl"), "./shared.dcdl")
.unwrap();
assert_eq!(loaded.key, "profiles/shared.dcdl");
}
#[test]
fn archive_verify_rejects_import_map_that_bypasses_virtual_resolver() {
let mut entrypoints = BTreeMap::new();
entrypoints.insert("default".to_string(), "profiles/main.dcdl".to_string());
let mut imports = BTreeMap::new();
imports.insert(
"profiles/main.dcdl\0./shared.dcdl".to_string(),
"profiles/other.dcdl".to_string(),
);
let mut sources = BTreeMap::new();
sources.insert("profiles/main.dcdl".to_string(), "{}".to_string());
sources.insert("profiles/other.dcdl".to_string(), "{}".to_string());
let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: "project".to_string(),
entrypoints,
imports,
sources,
})
.unwrap();
assert!(matches!(
archive.verify(),
Err(ProfileArchiveError::ImportMapMismatch { .. })
));
}
}
+487 -67
View File
@@ -14,6 +14,8 @@ use crate::{Error, Result};
const PROFILE_REGISTRY_RELATIVE_PATH: &str = ".yoi/profiles.toml";
const PROFILE_SOURCE_ROOT_RELATIVE_PATH: &str = ".yoi/profiles";
const PROFILE_SOURCE_TREE_ID: &str = "project";
const PROFILE_SOURCE_TREE_DISPLAY_ROOT: &str = "profiles";
const MAX_PROFILE_SOURCE_BYTES: u64 = 256 * 1024;
const BUILTIN_PROFILE_IDS: &[&str] = &[
"builtin:default",
@@ -63,6 +65,8 @@ pub struct ProfileSettingsResponse {
pub default_profile: Option<String>,
pub profiles: Vec<WorkspaceProfileSummary>,
pub sources: Vec<WorkspaceProfileSourceSummary>,
#[serde(default)]
pub source_trees: Vec<WorkspaceProfileSourceTreeSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
@@ -92,6 +96,45 @@ pub struct WorkspaceProfileSourceSummary {
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceTreeSummary {
pub source_tree_id: String,
pub label: String,
pub root_path: String,
pub kind: String,
pub editable: bool,
pub revision: String,
pub file_count: usize,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceTreeFileSummary {
pub path: String,
pub kind: String,
pub editable: bool,
pub revision: String,
pub size_bytes: u64,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceTreeResponse {
pub workspace_id: String,
pub tree: WorkspaceProfileSourceTreeSummary,
pub files: Vec<WorkspaceProfileSourceTreeFileSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceTreeFileResponse {
pub workspace_id: String,
pub source_tree_id: String,
pub file: WorkspaceProfileSourceTreeFileSummary,
pub content: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceDetailResponse {
pub workspace_id: String,
@@ -137,6 +180,28 @@ pub struct UpdateWorkspaceProfileSourceRequest {
pub revision: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WriteWorkspaceProfileTreeFileRequest {
pub path: String,
pub content: String,
#[serde(default)]
pub revision: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeleteWorkspaceProfileTreeFileRequest {
pub path: String,
pub revision: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReadWorkspaceProfileTreeFileQuery {
pub path: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeleteWorkspaceProfileSourceRequest {
@@ -355,6 +420,7 @@ pub fn load_profile_settings(workspace_id: &str, workspace_root: &Path) -> Profi
default_profile: registry.default,
profiles,
sources,
source_trees: vec![profile_source_tree_summary(workspace_root)],
diagnostics,
}
}
@@ -580,6 +646,115 @@ pub fn delete_profile_source(
})
}
pub fn read_profile_source_tree(
workspace_id: &str,
workspace_root: &Path,
source_tree_id: &str,
) -> Result<WorkspaceProfileSourceTreeResponse> {
ensure_profile_source_tree_id(source_tree_id)?;
let files = list_profile_tree_files(workspace_root)?;
Ok(WorkspaceProfileSourceTreeResponse {
workspace_id: workspace_id.to_string(),
tree: profile_source_tree_summary_with_count(workspace_root, files.len()),
files,
diagnostics: Vec::new(),
})
}
pub fn read_profile_tree_file(
workspace_id: &str,
workspace_root: &Path,
source_tree_id: &str,
query: ReadWorkspaceProfileTreeFileQuery,
) -> Result<WorkspaceProfileSourceTreeFileResponse> {
ensure_profile_source_tree_id(source_tree_id)?;
let relative_path = relative_source_path_for_virtual_path(&query.path)?;
let full = checked_source_path(workspace_root, &relative_path)?;
let metadata = source_metadata(&full)?;
if metadata.len() > MAX_PROFILE_SOURCE_BYTES {
return Err(profile_validation_error(
"profile_source_too_large",
"Profile source is too large for browser editing",
));
}
let content = fs::read_to_string(&full)?;
Ok(WorkspaceProfileSourceTreeFileResponse {
workspace_id: workspace_id.to_string(),
source_tree_id: source_tree_id.to_string(),
file: summarize_tree_file(&full, display_source_path(&relative_path)),
content,
diagnostics: Vec::new(),
})
}
pub fn write_profile_tree_file(
workspace_id: &str,
workspace_root: &Path,
source_tree_id: &str,
request: WriteWorkspaceProfileTreeFileRequest,
) -> Result<WorkspaceProfileSourceTreeFileResponse> {
ensure_profile_source_tree_id(source_tree_id)?;
if request.content.as_bytes().len() as u64 > MAX_PROFILE_SOURCE_BYTES {
return Err(profile_validation_error(
"profile_source_too_large",
"Profile source exceeds the browser editing size limit",
));
}
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)?;
if let Some(expected) = request.revision.as_deref() {
ensure_revision(&full, expected, "profile_source_revision_conflict")?;
} else if full.exists() {
return Err(profile_validation_error(
"profile_source_revision_required",
"Existing profile source edits require a revision token",
));
}
validate_tree_content(workspace_root, &relative_path, &request.content)?;
fs::write(&full, &request.content)?;
Ok(WorkspaceProfileSourceTreeFileResponse {
workspace_id: workspace_id.to_string(),
source_tree_id: source_tree_id.to_string(),
file: summarize_tree_file(&full, display_source_path(&relative_path)),
content: request.content,
diagnostics: vec![diagnostic(
"profile_source_file_written",
DiagnosticSeverity::Info,
"Profile source file was written through the source tree API.",
)],
})
}
pub fn delete_profile_tree_file(
workspace_id: &str,
workspace_root: &Path,
source_tree_id: &str,
request: DeleteWorkspaceProfileTreeFileRequest,
) -> Result<WorkspaceProfileSourceTreeResponse> {
ensure_profile_source_tree_id(source_tree_id)?;
let relative_path = relative_source_path_for_virtual_path(&request.path)?;
let full = checked_source_path(workspace_root, &relative_path)?;
ensure_revision(&full, &request.revision, "profile_source_revision_conflict")?;
let registry = read_registry(workspace_root).map_err(profile_registry_error)?;
if project_entries(&registry, &mut Vec::new())
.iter()
.any(|entry| entry.relative_path == relative_path)
{
return Err(profile_validation_error(
"profile_source_registered",
"Registered profile entry sources must be deleted through the profile registry API",
));
}
if full.exists() {
fs::remove_file(full)?;
}
read_profile_source_tree(workspace_id, workspace_root, source_tree_id)
}
pub fn build_workspace_profile_archive(
workspace_root: &Path,
selector: &str,
@@ -588,43 +763,8 @@ pub fn build_workspace_profile_archive(
return Ok(None);
}
let registry = read_registry(workspace_root).map_err(profile_registry_error)?;
let mut entrypoints = BTreeMap::new();
let mut sources = BTreeMap::new();
for entry in project_entries(&registry, &mut Vec::new()) {
let path = archive_path_for_entry(&entry.name);
let full = checked_source_path(workspace_root, &entry.relative_path)?;
let content = fs::read_to_string(&full)?;
sources.insert(path.clone(), content);
entrypoints.insert(project_selector(&entry.name), path);
}
if !entrypoints.contains_key(selector) {
return Err(Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "unknown_profile_selector".to_string(),
message: "Selected project profile is not present in the workspace profile registry"
.to_string(),
});
}
if let Some(default) = registry
.default
.as_deref()
.filter(|value| value.starts_with("project:"))
{
if let Some(path) = entrypoints.get(default).cloned() {
entrypoints.insert("default".to_string(), path);
}
}
let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: "workspace-project-decodal-profiles-v1".to_string(),
entrypoints,
imports: BTreeMap::new(),
sources,
})
.map_err(|err| Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_archive_invalid".to_string(),
message: err.to_string(),
})?;
let sources = read_profile_source_tree_contents(workspace_root)?;
let (archive, _) = build_profile_archive_from_tree_sources(&registry, sources)?;
archive
.verify()
.and_then(|verified| {
@@ -845,6 +985,305 @@ fn validate_registry_default(registry: &ProfileRegistryDocument) -> Result<()> {
))
}
fn profile_source_tree_summary(workspace_root: &Path) -> WorkspaceProfileSourceTreeSummary {
let file_count = list_profile_tree_files(workspace_root)
.map(|files| files.len())
.unwrap_or(0);
profile_source_tree_summary_with_count(workspace_root, file_count)
}
fn profile_source_tree_summary_with_count(
workspace_root: &Path,
file_count: usize,
) -> WorkspaceProfileSourceTreeSummary {
WorkspaceProfileSourceTreeSummary {
source_tree_id: PROFILE_SOURCE_TREE_ID.to_string(),
label: "Project profile sources".to_string(),
root_path: PROFILE_SOURCE_TREE_DISPLAY_ROOT.to_string(),
kind: "decodal_source_tree".to_string(),
editable: true,
revision: file_revision(&workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH)),
file_count,
diagnostics: Vec::new(),
}
}
fn ensure_profile_source_tree_id(source_tree_id: &str) -> Result<()> {
if source_tree_id == PROFILE_SOURCE_TREE_ID {
Ok(())
} else {
Err(Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "unknown_profile_source_tree".to_string(),
message: "Unknown profile source tree".to_string(),
})
}
}
fn list_profile_tree_files(
workspace_root: &Path,
) -> Result<Vec<WorkspaceProfileSourceTreeFileSummary>> {
let mut files = Vec::new();
collect_profile_tree_files(
workspace_root,
&workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH),
&mut files,
)?;
files.sort_by(|a, b| a.path.cmp(&b.path));
Ok(files)
}
fn collect_profile_tree_files(
workspace_root: &Path,
dir: &Path,
files: &mut Vec<WorkspaceProfileSourceTreeFileSummary>,
) -> Result<()> {
if !dir.exists() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
collect_profile_tree_files(workspace_root, &path, files)?;
} else if file_type.is_file()
&& path.extension().and_then(|value| value.to_str()) == Some("dcdl")
{
let relative = path
.strip_prefix(workspace_root)
.map_err(|_| {
profile_validation_error(
"profile_source_path_invalid",
"Profile source is outside workspace",
)
})?
.to_path_buf();
files.push(summarize_tree_file(&path, display_source_path(&relative)));
}
}
Ok(())
}
fn summarize_tree_file(path: &Path, virtual_path: String) -> WorkspaceProfileSourceTreeFileSummary {
WorkspaceProfileSourceTreeFileSummary {
path: virtual_path,
kind: "decodal".to_string(),
editable: true,
revision: file_revision(path),
size_bytes: source_metadata(path)
.map(|metadata| metadata.len())
.unwrap_or(0),
diagnostics: Vec::new(),
}
}
fn relative_source_path_for_virtual_path(virtual_path: &str) -> Result<PathBuf> {
let normalized = normalize_virtual_profile_source_path(virtual_path)?;
let Some(rest) = normalized.strip_prefix("profiles/") else {
return Err(profile_validation_error(
"profile_source_path_invalid",
"Profile source paths must be under profiles/",
));
};
if rest.is_empty() || !rest.ends_with(".dcdl") {
return Err(profile_validation_error(
"profile_source_path_invalid",
"Profile source files must use the .dcdl extension",
));
}
Ok(Path::new(PROFILE_SOURCE_ROOT_RELATIVE_PATH).join(rest))
}
fn display_source_path(relative_path: &Path) -> String {
let profile_root = Path::new(PROFILE_SOURCE_ROOT_RELATIVE_PATH);
let relative = relative_path
.strip_prefix(profile_root)
.unwrap_or(relative_path);
let rest = relative.to_string_lossy().replace('\\', "/");
format!("{PROFILE_SOURCE_TREE_DISPLAY_ROOT}/{rest}")
}
fn normalize_virtual_profile_source_path(path: &str) -> Result<String> {
let path = path
.strip_prefix("project:")
.or_else(|| path.strip_prefix("workspace:"))
.unwrap_or(path);
if path.is_empty() || path.contains("://") || Path::new(path).is_absolute() {
return Err(profile_validation_error(
"profile_source_path_invalid",
"Profile source path must be a virtual relative path",
));
}
let mut normalized = PathBuf::new();
for component in Path::new(path).components() {
match component {
Component::CurDir => {}
Component::Normal(value) => normalized.push(value),
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(profile_validation_error(
"profile_source_path_invalid",
"Profile source path must not escape the virtual source tree",
));
}
}
}
let normalized = normalized.to_string_lossy().replace('\\', "/");
if normalized.is_empty() {
return Err(profile_validation_error(
"profile_source_path_invalid",
"Profile source path must not be empty",
));
}
Ok(normalized)
}
fn read_profile_source_tree_contents(workspace_root: &Path) -> Result<BTreeMap<String, String>> {
let mut sources = BTreeMap::new();
for file in list_profile_tree_files(workspace_root)? {
let relative = relative_source_path_for_virtual_path(&file.path)?;
let full = checked_source_path(workspace_root, &relative)?;
sources.insert(file.path, fs::read_to_string(full)?);
}
Ok(sources)
}
fn build_profile_archive_from_tree_sources(
registry: &ProfileRegistryDocument,
mut sources: BTreeMap<String, String>,
) -> Result<(ProfileSourceArchive, BTreeMap<String, String>)> {
let mut entrypoints = BTreeMap::new();
for entry in project_entries(registry, &mut Vec::new()) {
let path = display_source_path(&entry.relative_path);
if sources.contains_key(&path) {
entrypoints.insert(project_selector(&entry.name), path);
}
}
if let Some(default) = registry
.default
.as_deref()
.filter(|value| value.starts_with("project:"))
{
if let Some(path) = entrypoints.get(default).cloned() {
entrypoints.insert("default".to_string(), path);
}
}
let mut imports = BTreeMap::new();
let source_snapshot = sources.clone();
for (current_path, content) in &source_snapshot {
for specifier in collect_decodal_import_specifiers(content) {
let target = resolve_profile_source_import(current_path, &specifier)?;
if source_snapshot.contains_key(&target) {
imports.insert(format!("{current_path}\0{specifier}"), target);
} else {
return Err(profile_validation_error(
"profile_source_import_missing",
&format!(
"Profile source import {specifier:?} from {current_path} resolves to missing {target}"
),
));
}
}
}
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(),
sources,
})
.map(|archive| (archive, imports))
.map_err(|err| Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_archive_invalid".to_string(),
message: err.to_string(),
})
}
fn resolve_profile_source_import(current_path: &str, specifier: &str) -> Result<String> {
if specifier.is_empty() || specifier.contains("://") || Path::new(specifier).is_absolute() {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Profile source import must be a virtual relative path",
));
}
let raw = specifier
.strip_prefix("project:")
.or_else(|| specifier.strip_prefix("workspace:"))
.unwrap_or(specifier);
if specifier.contains(':') && raw == specifier {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Unsupported profile source import namespace",
));
}
let base = if raw.starts_with("profiles/") || raw.starts_with("./profiles/") {
PathBuf::from(raw)
} else {
Path::new(current_path)
.parent()
.map(Path::to_path_buf)
.unwrap_or_default()
.join(raw)
};
let normalized = normalize_virtual_profile_source_path(&base.to_string_lossy())?;
if !normalized.starts_with("profiles/") {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Profile source import must resolve inside the profiles/ tree",
));
}
Ok(normalized)
}
fn collect_decodal_import_specifiers(content: &str) -> Vec<String> {
let mut specifiers = Vec::new();
for line in content.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with('#') {
continue;
}
let Some(index) = trimmed.find("import") else {
continue;
};
let after = trimmed[index + "import".len()..].trim_start();
let Some(first) = after.chars().next() else {
continue;
};
if first == '"' || first == '\'' {
if let Some(end) = after[1..].find(first) {
specifiers.push(after[1..1 + end].to_string());
}
} else {
let ident: String = after
.chars()
.take_while(|ch| !ch.is_whitespace() && *ch != ';' && *ch != ',' && *ch != '{')
.collect();
if !ident.is_empty() {
specifiers.push(ident);
}
}
}
specifiers
}
fn validate_tree_content(workspace_root: &Path, relative_path: &Path, content: &str) -> Result<()> {
let mut sources = read_profile_source_tree_contents(workspace_root)?;
sources.insert(display_source_path(relative_path), content.to_string());
let registry = read_registry(workspace_root).map_err(profile_registry_error)?;
let (archive, _) = build_profile_archive_from_tree_sources(&registry, sources)?;
archive
.verify()
.map_err(|err| Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_invalid".to_string(),
message: err.to_string(),
})?;
Ok(())
}
fn validate_source_content(
workspace_root: &Path,
name: &str,
@@ -858,24 +1297,18 @@ fn validate_source_content(
message: "Profile source exceeds the browser editing size limit".to_string(),
});
}
let archive_path = archive_path_for_entry(name);
checked_source_path(workspace_root, relative_path)?;
let mut sources = read_profile_source_tree_contents(workspace_root)?;
sources.insert(display_source_path(relative_path), content.to_string());
let mut registry = read_registry(workspace_root).map_err(profile_registry_error)?;
registry.profile.entry(name.to_string()).or_insert_with(|| {
ProfileEntryFile::Table(ProfileEntryTable {
path: display_source_path(relative_path),
description: None,
})
});
let selector = project_selector(name);
let mut entrypoints = BTreeMap::new();
entrypoints.insert(selector.clone(), archive_path.clone());
entrypoints.insert("default".to_string(), archive_path.clone());
let mut sources = BTreeMap::new();
sources.insert(archive_path, content.to_string());
let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: format!("workspace-profile-validation-{name}"),
entrypoints,
imports: BTreeMap::new(),
sources,
})
.map_err(|err| Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_archive_invalid".to_string(),
message: err.to_string(),
})?;
let (archive, _) = build_profile_archive_from_tree_sources(&registry, sources)?;
let verified = archive
.verify()
.map_err(|err| Error::RuntimeOperationFailed {
@@ -883,7 +1316,6 @@ fn validate_source_content(
code: "profile_source_archive_invalid".to_string(),
message: err.to_string(),
})?;
checked_source_path(workspace_root, relative_path)?;
verified
.resolve_profile(&selector, workspace_root, "workspace-settings-validation")
.map_err(|err| Error::RuntimeOperationFailed {
@@ -1179,18 +1611,6 @@ pub fn selector_for_builtin_candidate(
}
}
fn archive_path_for_entry(name: &str) -> String {
format!("profiles/{name}.dcdl")
}
fn display_source_path(relative_path: &Path) -> String {
relative_path
.strip_prefix(".yoi")
.unwrap_or(relative_path)
.to_string_lossy()
.replace('\\', "/")
}
fn registry_path(workspace_root: &Path) -> PathBuf {
workspace_root.join(PROFILE_REGISTRY_RELATIVE_PATH)
}
+66 -1
View File
@@ -38,8 +38,9 @@ use crate::observation::{
};
use crate::profile_settings::{
CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest,
DeleteWorkspaceProfileTreeFileRequest, ReadWorkspaceProfileTreeFileQuery,
UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest,
UpdateWorkspaceProfileSourceRequest,
UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest,
};
use crate::records::{
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
@@ -281,6 +282,16 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/settings/profiles/registry",
put(scoped_update_profile_registry),
)
.route(
"/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}",
get(scoped_get_profile_source_tree),
)
.route(
"/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}/file",
get(scoped_get_profile_tree_file)
.put(scoped_write_profile_tree_file)
.delete(scoped_delete_profile_tree_file),
)
.route(
"/api/w/{workspace_id}/settings/profiles/{profile_source_id}",
get(scoped_get_profile_source)
@@ -907,6 +918,60 @@ async fn scoped_update_profile_registry(
)?))
}
async fn scoped_get_profile_source_tree(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeResponse>> {
validate_workspace_scope(&api, &workspace_id)?;
Ok(Json(crate::profile_settings::read_profile_source_tree(
&workspace_id,
&api.config.workspace_root,
&source_tree_id,
)?))
}
async fn scoped_get_profile_tree_file(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
Query(query): Query<ReadWorkspaceProfileTreeFileQuery>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeFileResponse>> {
validate_workspace_scope(&api, &workspace_id)?;
Ok(Json(crate::profile_settings::read_profile_tree_file(
&workspace_id,
&api.config.workspace_root,
&source_tree_id,
query,
)?))
}
async fn scoped_write_profile_tree_file(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
Json(request): Json<WriteWorkspaceProfileTreeFileRequest>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeFileResponse>> {
validate_workspace_scope(&api, &workspace_id)?;
Ok(Json(crate::profile_settings::write_profile_tree_file(
&workspace_id,
&api.config.workspace_root,
&source_tree_id,
request,
)?))
}
async fn scoped_delete_profile_tree_file(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
Json(request): Json<DeleteWorkspaceProfileTreeFileRequest>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeResponse>> {
validate_workspace_scope(&api, &workspace_id)?;
Ok(Json(crate::profile_settings::delete_profile_tree_file(
&workspace_id,
&api.config.workspace_root,
&source_tree_id,
request,
)?))
}
async fn scoped_get_profile_source(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,