2328 lines
83 KiB
Rust
2328 lines
83 KiB
Rust
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::fs;
|
|
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,
|
|
};
|
|
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
|
|
|
|
use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic};
|
|
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",
|
|
"builtin:companion",
|
|
"builtin:intake",
|
|
"builtin:orchestrator",
|
|
"builtin:coder",
|
|
"builtin:reviewer",
|
|
];
|
|
const BUILTIN_PROFILE_SLUGS: &[&str] = &[
|
|
"default",
|
|
"companion",
|
|
"intake",
|
|
"orchestrator",
|
|
"coder",
|
|
"reviewer",
|
|
];
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct WorkspaceMetadataSettingsResponse {
|
|
pub workspace_id: String,
|
|
pub display_name: String,
|
|
pub created_at: String,
|
|
pub revision: String,
|
|
pub source: String,
|
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct UpdateWorkspaceMetadataRequest {
|
|
pub display_name: String,
|
|
pub revision: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct WorkspaceMetadataMutationResponse {
|
|
pub workspace: WorkspaceMetadataSettingsResponse,
|
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ProfileSettingsResponse {
|
|
pub workspace_id: String,
|
|
pub registry_revision: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub default_profile: Option<String>,
|
|
pub profiles: Vec<WorkspaceProfileSummary>,
|
|
pub sources: Vec<WorkspaceProfileSourceSummary>,
|
|
#[serde(default)]
|
|
pub source_trees: Vec<WorkspaceProfileSourceTreeSummary>,
|
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct WorkspaceProfileSummary {
|
|
pub profile_id: String,
|
|
pub selector: String,
|
|
pub label: String,
|
|
pub source_kind: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub profile_source_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
pub editable: bool,
|
|
pub is_default: bool,
|
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
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,
|
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
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,
|
|
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,
|
|
pub profile: WorkspaceProfileSummary,
|
|
pub source: WorkspaceProfileSourceSummary,
|
|
pub content: String,
|
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct CreateWorkspaceProfileSourceRequest {
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub description: Option<String>,
|
|
pub content: String,
|
|
pub registry_revision: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct UpdateWorkspaceProfileRegistryRequest {
|
|
pub registry_revision: String,
|
|
#[serde(default)]
|
|
pub default_profile: Option<String>,
|
|
pub profiles: Vec<WorkspaceProfileRegistryEntryUpdate>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct WorkspaceProfileRegistryEntryUpdate {
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub description: Option<String>,
|
|
#[serde(default)]
|
|
pub profile_source_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct UpdateWorkspaceProfileSourceRequest {
|
|
pub content: String,
|
|
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 {
|
|
pub registry_revision: String,
|
|
pub source_revision: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ProfileSettingsMutationResponse {
|
|
pub workspace_id: String,
|
|
pub settings: ProfileSettingsResponse,
|
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct WorkspaceIdentityFile {
|
|
workspace_id: String,
|
|
created_at: String,
|
|
display_name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct ProfileRegistryDocument {
|
|
#[serde(default)]
|
|
default: Option<String>,
|
|
#[serde(default)]
|
|
profile: BTreeMap<String, ProfileEntryFile>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(untagged)]
|
|
enum ProfileEntryFile {
|
|
Path(String),
|
|
Table(ProfileEntryTable),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct ProfileEntryTable {
|
|
path: String,
|
|
#[serde(default)]
|
|
description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ProjectProfileEntry {
|
|
name: String,
|
|
description: Option<String>,
|
|
relative_path: PathBuf,
|
|
}
|
|
|
|
pub fn workspace_metadata_settings(
|
|
workspace_root: &Path,
|
|
fallback_workspace_id: &str,
|
|
fallback_created_at: &str,
|
|
fallback_display_name: &str,
|
|
) -> WorkspaceMetadataSettingsResponse {
|
|
let path = workspace_root.join(crate::identity::WORKSPACE_IDENTITY_RELATIVE_PATH);
|
|
let mut diagnostics = Vec::new();
|
|
let (workspace_id, created_at, display_name) = match fs::read_to_string(&path) {
|
|
Ok(raw) => match toml::from_str::<WorkspaceIdentityFile>(&raw) {
|
|
Ok(file) => (file.workspace_id, file.created_at, file.display_name),
|
|
Err(err) => {
|
|
diagnostics.push(diagnostic(
|
|
"workspace_identity_parse_failed",
|
|
DiagnosticSeverity::Error,
|
|
format!("Workspace identity could not be parsed: {err}"),
|
|
));
|
|
(
|
|
fallback_workspace_id.to_string(),
|
|
fallback_created_at.to_string(),
|
|
fallback_display_name.to_string(),
|
|
)
|
|
}
|
|
},
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
|
diagnostics.push(diagnostic(
|
|
"workspace_identity_missing",
|
|
DiagnosticSeverity::Warning,
|
|
"Workspace identity record is missing; showing active backend metadata.",
|
|
));
|
|
(
|
|
fallback_workspace_id.to_string(),
|
|
fallback_created_at.to_string(),
|
|
fallback_display_name.to_string(),
|
|
)
|
|
}
|
|
Err(err) => {
|
|
diagnostics.push(diagnostic(
|
|
"workspace_identity_read_failed",
|
|
DiagnosticSeverity::Error,
|
|
format!(
|
|
"Workspace identity could not be read: {}",
|
|
sanitize_error(&err.to_string())
|
|
),
|
|
));
|
|
(
|
|
fallback_workspace_id.to_string(),
|
|
fallback_created_at.to_string(),
|
|
fallback_display_name.to_string(),
|
|
)
|
|
}
|
|
};
|
|
WorkspaceMetadataSettingsResponse {
|
|
workspace_id,
|
|
display_name,
|
|
created_at,
|
|
revision: file_revision(&path),
|
|
source: "workspace_identity".to_string(),
|
|
diagnostics,
|
|
}
|
|
}
|
|
|
|
pub fn update_workspace_metadata(
|
|
workspace_root: &Path,
|
|
request: UpdateWorkspaceMetadataRequest,
|
|
) -> Result<WorkspaceMetadataSettingsResponse> {
|
|
let path = workspace_root.join(crate::identity::WORKSPACE_IDENTITY_RELATIVE_PATH);
|
|
let current_revision = file_revision(&path);
|
|
if request.revision != current_revision {
|
|
return Err(Error::RuntimeOperationFailed {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: "workspace_metadata_revision_conflict".to_string(),
|
|
message: "Workspace metadata changed before this update was applied".to_string(),
|
|
});
|
|
}
|
|
let raw = fs::read_to_string(&path)?;
|
|
let mut file: WorkspaceIdentityFile = toml::from_str(&raw)
|
|
.map_err(|err| Error::Config(format!("failed to parse workspace identity: {err}")))?;
|
|
let display_name = sanitize_display_name(&request.display_name)?;
|
|
file.display_name = display_name;
|
|
let encoded = toml::to_string_pretty(&file)
|
|
.map_err(|err| Error::Config(format!("failed to serialize workspace identity: {err}")))?;
|
|
fs::write(&path, encoded)?;
|
|
Ok(workspace_metadata_settings(
|
|
workspace_root,
|
|
&file.workspace_id,
|
|
&file.created_at,
|
|
&file.display_name,
|
|
))
|
|
}
|
|
|
|
pub fn load_profile_settings(workspace_id: &str, workspace_root: &Path) -> ProfileSettingsResponse {
|
|
let mut diagnostics = Vec::new();
|
|
let registry = match read_registry(workspace_root) {
|
|
Ok(registry) => registry,
|
|
Err(err) => {
|
|
diagnostics.push(diagnostic(
|
|
"profile_registry_schema_invalid",
|
|
DiagnosticSeverity::Error,
|
|
err,
|
|
));
|
|
ProfileRegistryDocument::default()
|
|
}
|
|
};
|
|
let registry_revision = file_revision(®istry_path(workspace_root));
|
|
let mut profiles = builtin_profile_summaries(registry.default.as_deref());
|
|
let mut sources = Vec::new();
|
|
let mut seen_selectors = BTreeSet::new();
|
|
for profile in &profiles {
|
|
seen_selectors.insert(profile.selector.clone());
|
|
}
|
|
for entry in project_entries(®istry, &mut diagnostics) {
|
|
let selector = project_selector(&entry.name);
|
|
let source_id = project_source_id(&entry.name);
|
|
let mut entry_diagnostics = Vec::new();
|
|
if !seen_selectors.insert(selector.clone()) {
|
|
entry_diagnostics.push(diagnostic(
|
|
"profile_selector_duplicate",
|
|
DiagnosticSeverity::Error,
|
|
format!("Profile selector '{selector}' is duplicated."),
|
|
));
|
|
}
|
|
let source_summary = summarize_source(workspace_root, &source_id, &entry.relative_path);
|
|
entry_diagnostics.extend(source_summary.diagnostics.clone());
|
|
entry_diagnostics.extend(validate_project_profile_entry(workspace_root, &entry));
|
|
if BUILTIN_PROFILE_SLUGS.contains(&entry.name.as_str()) {
|
|
entry_diagnostics.push(diagnostic(
|
|
"profile_selector_duplicate",
|
|
DiagnosticSeverity::Error,
|
|
format!(
|
|
"Project profile '{}' conflicts with a builtin selector.",
|
|
entry.name
|
|
),
|
|
));
|
|
}
|
|
profiles.push(WorkspaceProfileSummary {
|
|
profile_id: selector.clone(),
|
|
selector,
|
|
label: entry.name.clone(),
|
|
source_kind: "project".to_string(),
|
|
profile_source_id: Some(source_id.clone()),
|
|
description: entry.description.clone(),
|
|
editable: true,
|
|
is_default: registry.default.as_deref() == Some(project_selector(&entry.name).as_str()),
|
|
diagnostics: entry_diagnostics,
|
|
});
|
|
sources.push(source_summary);
|
|
}
|
|
let mut default_diagnostics = validate_registry_default(®istry)
|
|
.err()
|
|
.map(|err| vec![diagnostic_from_error(&err)])
|
|
.unwrap_or_default();
|
|
diagnostics.extend(
|
|
profiles
|
|
.iter()
|
|
.filter(|profile| profile.source_kind == "project")
|
|
.flat_map(|profile| profile.diagnostics.clone()),
|
|
);
|
|
diagnostics.append(&mut default_diagnostics);
|
|
ProfileSettingsResponse {
|
|
workspace_id: workspace_id.to_string(),
|
|
registry_revision,
|
|
default_profile: registry.default,
|
|
profiles,
|
|
sources,
|
|
source_trees: vec![profile_source_tree_summary(workspace_root)],
|
|
diagnostics,
|
|
}
|
|
}
|
|
|
|
pub fn read_profile_source(
|
|
workspace_id: &str,
|
|
workspace_root: &Path,
|
|
source_id: &str,
|
|
) -> Result<WorkspaceProfileSourceDetailResponse> {
|
|
let registry = read_registry(workspace_root).map_err(profile_registry_error)?;
|
|
let (name, entry) = entry_for_source_id(®istry, source_id)?;
|
|
let full = checked_source_path(workspace_root, &entry.relative_path)?;
|
|
let metadata = source_metadata(&full)?;
|
|
if metadata.len() > MAX_PROFILE_SOURCE_BYTES {
|
|
return Err(Error::RuntimeOperationFailed {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: "profile_source_too_large".to_string(),
|
|
message: "Profile source is too large for browser editing".to_string(),
|
|
});
|
|
}
|
|
let content = fs::read_to_string(&full)?;
|
|
let source = summarize_source(workspace_root, source_id, &entry.relative_path);
|
|
let selector = project_selector(&name);
|
|
let profile = WorkspaceProfileSummary {
|
|
profile_id: selector.clone(),
|
|
selector: selector.clone(),
|
|
label: name,
|
|
source_kind: "project".to_string(),
|
|
profile_source_id: Some(source_id.to_string()),
|
|
description: entry.description,
|
|
editable: true,
|
|
is_default: registry.default.as_deref() == Some(selector.as_str()),
|
|
diagnostics: source.diagnostics.clone(),
|
|
};
|
|
Ok(WorkspaceProfileSourceDetailResponse {
|
|
workspace_id: workspace_id.to_string(),
|
|
profile,
|
|
source,
|
|
content,
|
|
diagnostics: Vec::new(),
|
|
})
|
|
}
|
|
|
|
pub fn create_profile_source(
|
|
workspace_id: &str,
|
|
workspace_root: &Path,
|
|
request: CreateWorkspaceProfileSourceRequest,
|
|
) -> Result<ProfileSettingsMutationResponse> {
|
|
let registry_path = registry_path(workspace_root);
|
|
ensure_revision(
|
|
®istry_path,
|
|
&request.registry_revision,
|
|
"profile_registry_revision_conflict",
|
|
)?;
|
|
let mut registry = read_registry(workspace_root).map_err(profile_registry_error)?;
|
|
let name = validate_profile_name(&request.name)?;
|
|
if registry.profile.contains_key(&name) || BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) {
|
|
return Err(Error::RuntimeOperationFailed {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: "profile_selector_duplicate".to_string(),
|
|
message: "Profile selector already exists".to_string(),
|
|
});
|
|
}
|
|
let relative_path = PathBuf::from(".yoi")
|
|
.join("profiles")
|
|
.join(format!("{name}.dcdl"));
|
|
validate_source_content(workspace_root, &name, &relative_path, &request.content)?;
|
|
let full = prepare_source_path_for_write(workspace_root, &relative_path)?;
|
|
fs::write(&full, request.content)?;
|
|
registry.profile.insert(
|
|
name.clone(),
|
|
ProfileEntryFile::Table(ProfileEntryTable {
|
|
path: format!("profiles/{name}.dcdl"),
|
|
description: request
|
|
.description
|
|
.and_then(|value| optional_trim(value.as_str())),
|
|
}),
|
|
);
|
|
validate_registry_default(®istry)?;
|
|
validate_all_project_profiles(workspace_root, ®istry)?;
|
|
write_registry(workspace_root, ®istry)?;
|
|
Ok(ProfileSettingsMutationResponse {
|
|
workspace_id: workspace_id.to_string(),
|
|
settings: load_profile_settings(workspace_id, workspace_root),
|
|
diagnostics: vec![diagnostic(
|
|
"profile_settings_updated",
|
|
DiagnosticSeverity::Info,
|
|
"Profile source was created and profile discovery was refreshed.",
|
|
)],
|
|
})
|
|
}
|
|
|
|
pub fn update_profile_registry(
|
|
workspace_id: &str,
|
|
workspace_root: &Path,
|
|
request: UpdateWorkspaceProfileRegistryRequest,
|
|
) -> Result<ProfileSettingsMutationResponse> {
|
|
let path = registry_path(workspace_root);
|
|
ensure_revision(
|
|
&path,
|
|
&request.registry_revision,
|
|
"profile_registry_revision_conflict",
|
|
)?;
|
|
let mut profile = BTreeMap::new();
|
|
let mut seen = BTreeSet::new();
|
|
for update in request.profiles {
|
|
let name = validate_profile_name(&update.name)?;
|
|
if !seen.insert(name.clone()) || BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) {
|
|
return Err(Error::RuntimeOperationFailed {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: "profile_selector_duplicate".to_string(),
|
|
message: "Profile selector duplicate in registry update".to_string(),
|
|
});
|
|
}
|
|
let source_id = update
|
|
.profile_source_id
|
|
.as_deref()
|
|
.unwrap_or(project_source_id(&name).as_str())
|
|
.to_string();
|
|
let (source_name, _) = parse_project_source_id(&source_id)?;
|
|
if source_name != name {
|
|
return Err(Error::RuntimeOperationFailed {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: "profile_source_id_mismatch".to_string(),
|
|
message: "Profile source id must match its registry selector".to_string(),
|
|
});
|
|
}
|
|
profile.insert(
|
|
name.clone(),
|
|
ProfileEntryFile::Table(ProfileEntryTable {
|
|
path: format!("profiles/{name}.dcdl"),
|
|
description: update
|
|
.description
|
|
.and_then(|value| optional_trim(value.as_str())),
|
|
}),
|
|
);
|
|
}
|
|
let registry = ProfileRegistryDocument {
|
|
default: request.default_profile,
|
|
profile,
|
|
};
|
|
validate_registry_default(®istry)?;
|
|
validate_all_project_profiles(workspace_root, ®istry)?;
|
|
write_registry(workspace_root, ®istry)?;
|
|
Ok(ProfileSettingsMutationResponse {
|
|
workspace_id: workspace_id.to_string(),
|
|
settings: load_profile_settings(workspace_id, workspace_root),
|
|
diagnostics: vec![diagnostic(
|
|
"profile_registry_updated",
|
|
DiagnosticSeverity::Info,
|
|
"Profile registry was updated and profile discovery was refreshed.",
|
|
)],
|
|
})
|
|
}
|
|
|
|
pub fn update_profile_source(
|
|
workspace_id: &str,
|
|
workspace_root: &Path,
|
|
source_id: &str,
|
|
request: UpdateWorkspaceProfileSourceRequest,
|
|
) -> Result<ProfileSettingsMutationResponse> {
|
|
let registry = read_registry(workspace_root).map_err(profile_registry_error)?;
|
|
let (name, entry) = entry_for_source_id(®istry, source_id)?;
|
|
let full = checked_source_path(workspace_root, &entry.relative_path)?;
|
|
ensure_revision(&full, &request.revision, "profile_source_revision_conflict")?;
|
|
validate_source_content(
|
|
workspace_root,
|
|
&name,
|
|
&entry.relative_path,
|
|
&request.content,
|
|
)?;
|
|
fs::write(&full, request.content)?;
|
|
Ok(ProfileSettingsMutationResponse {
|
|
workspace_id: workspace_id.to_string(),
|
|
settings: load_profile_settings(workspace_id, workspace_root),
|
|
diagnostics: vec![diagnostic(
|
|
"profile_source_updated",
|
|
DiagnosticSeverity::Info,
|
|
"Profile source was updated and profile discovery was refreshed.",
|
|
)],
|
|
})
|
|
}
|
|
|
|
pub fn delete_profile_source(
|
|
workspace_id: &str,
|
|
workspace_root: &Path,
|
|
source_id: &str,
|
|
request: DeleteWorkspaceProfileSourceRequest,
|
|
) -> Result<ProfileSettingsMutationResponse> {
|
|
let registry_path = registry_path(workspace_root);
|
|
ensure_revision(
|
|
®istry_path,
|
|
&request.registry_revision,
|
|
"profile_registry_revision_conflict",
|
|
)?;
|
|
let mut registry = read_registry(workspace_root).map_err(profile_registry_error)?;
|
|
let (name, entry) = entry_for_source_id(®istry, source_id)?;
|
|
let full = checked_source_path(workspace_root, &entry.relative_path)?;
|
|
ensure_revision(
|
|
&full,
|
|
&request.source_revision,
|
|
"profile_source_revision_conflict",
|
|
)?;
|
|
registry.profile.remove(&name);
|
|
if registry.default.as_deref() == Some(project_selector(&name).as_str()) {
|
|
registry.default = None;
|
|
}
|
|
if full.exists() {
|
|
fs::remove_file(&full)?;
|
|
}
|
|
write_registry(workspace_root, ®istry)?;
|
|
Ok(ProfileSettingsMutationResponse {
|
|
workspace_id: workspace_id.to_string(),
|
|
settings: load_profile_settings(workspace_id, workspace_root),
|
|
diagnostics: vec![diagnostic(
|
|
"profile_source_deleted",
|
|
DiagnosticSeverity::Info,
|
|
"Profile source and registry entry were deleted and profile discovery was refreshed.",
|
|
)],
|
|
})
|
|
}
|
|
|
|
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 = 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() {
|
|
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(®istry, &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,
|
|
) -> Result<Option<ProfileSourceArchive>> {
|
|
if !selector.starts_with("project:") {
|
|
return Ok(None);
|
|
}
|
|
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_for_selector(®istry, sources, selector)?;
|
|
archive
|
|
.verify()
|
|
.and_then(|verified| {
|
|
verified
|
|
.resolve_profile(selector, workspace_root, "workspace-settings-validation")
|
|
.map(|_| ())
|
|
})
|
|
.map_err(|err| Error::RuntimeOperationFailed {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: "profile_source_invalid".to_string(),
|
|
message: err.to_string(),
|
|
})?;
|
|
Ok(Some(archive))
|
|
}
|
|
|
|
pub fn build_workspace_profile_config_bundle(
|
|
workspace_root: &Path,
|
|
workspace_id: &str,
|
|
workspace_created_at: &str,
|
|
selector: &str,
|
|
) -> Result<Option<ConfigBundle>> {
|
|
let Some(archive) = build_workspace_profile_archive(workspace_root, selector)? else {
|
|
return Ok(None);
|
|
};
|
|
let bundle = ConfigBundle {
|
|
metadata: ConfigBundleMetadata {
|
|
id: "workspace-project-profile-settings-v1".to_string(),
|
|
digest: String::new(),
|
|
revision: file_revision(®istry_path(workspace_root)),
|
|
workspace_id: workspace_id.to_string(),
|
|
created_at: workspace_created_at.to_string(),
|
|
provenance: ConfigBundleProvenance {
|
|
source: "workspace_profile_settings".to_string(),
|
|
detail: Some("workspace Decodal profile registry".to_string()),
|
|
},
|
|
},
|
|
profiles: vec![ConfigProfileDescriptor {
|
|
selector: worker_runtime::catalog::ProfileSelector::Named(selector.to_string()),
|
|
label: Some(selector.to_string()),
|
|
}],
|
|
declarations: Vec::new(),
|
|
profile_source_archive: Some(archive),
|
|
profile_source_archive_handle: None,
|
|
}
|
|
.with_computed_digest();
|
|
Ok(Some(bundle))
|
|
}
|
|
|
|
pub fn project_profile_candidates(workspace_root: &Path) -> Vec<WorkspaceProfileSummary> {
|
|
load_profile_settings("workspace", workspace_root)
|
|
.profiles
|
|
.into_iter()
|
|
.filter(|profile| profile.source_kind == "project" && !has_error(&profile.diagnostics))
|
|
.collect()
|
|
}
|
|
|
|
pub fn is_profile_candidate(workspace_root: &Path, profile_id: &str) -> bool {
|
|
BUILTIN_PROFILE_IDS.contains(&profile_id)
|
|
|| profile_id == "runtime_default"
|
|
|| project_profile_candidates(workspace_root)
|
|
.into_iter()
|
|
.any(|profile| profile.profile_id == profile_id)
|
|
}
|
|
|
|
fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec<WorkspaceProfileSummary> {
|
|
let labels = [
|
|
("builtin:default", "Default", "Bundled default Yoi profile"),
|
|
(
|
|
"builtin:companion",
|
|
"Companion",
|
|
"Bundled Companion role profile",
|
|
),
|
|
("builtin:intake", "Intake", "Bundled Intake role profile"),
|
|
(
|
|
"builtin:orchestrator",
|
|
"Orchestrator",
|
|
"Bundled Orchestrator role profile",
|
|
),
|
|
("builtin:coder", "Coder", "Bundled Coder role profile"),
|
|
(
|
|
"builtin:reviewer",
|
|
"Reviewer",
|
|
"Bundled Reviewer role profile",
|
|
),
|
|
];
|
|
labels
|
|
.into_iter()
|
|
.map(|(id, label, description)| WorkspaceProfileSummary {
|
|
profile_id: id.to_string(),
|
|
selector: id.to_string(),
|
|
label: label.to_string(),
|
|
source_kind: "builtin".to_string(),
|
|
profile_source_id: None,
|
|
description: Some(description.to_string()),
|
|
editable: false,
|
|
is_default: default_profile == Some(id),
|
|
diagnostics: Vec::new(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn validate_project_profile_entry(
|
|
workspace_root: &Path,
|
|
entry: &ProjectProfileEntry,
|
|
) -> Vec<RuntimeDiagnostic> {
|
|
let full = match checked_source_path(workspace_root, &entry.relative_path) {
|
|
Ok(path) => path,
|
|
Err(err) => return vec![diagnostic_from_error(&err)],
|
|
};
|
|
match fs::read_to_string(&full) {
|
|
Ok(content) => {
|
|
validate_source_content(workspace_root, &entry.name, &entry.relative_path, &content)
|
|
.err()
|
|
.map(|err| vec![diagnostic_from_error(&err)])
|
|
.unwrap_or_default()
|
|
}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => vec![diagnostic(
|
|
"profile_source_missing",
|
|
DiagnosticSeverity::Error,
|
|
format!("Profile source '{}' is missing.", entry.name),
|
|
)],
|
|
Err(err) => vec![diagnostic(
|
|
"profile_source_read_failed",
|
|
DiagnosticSeverity::Error,
|
|
sanitize_error(&err.to_string()),
|
|
)],
|
|
}
|
|
}
|
|
|
|
fn diagnostic_from_error(err: &Error) -> RuntimeDiagnostic {
|
|
match err {
|
|
Error::RuntimeOperationFailed { code, message, .. } => diagnostic(
|
|
code.clone(),
|
|
DiagnosticSeverity::Error,
|
|
sanitize_error(message),
|
|
),
|
|
other => diagnostic(
|
|
"profile_settings_failed",
|
|
DiagnosticSeverity::Error,
|
|
sanitize_error(&other.to_string()),
|
|
),
|
|
}
|
|
}
|
|
|
|
fn has_error(diagnostics: &[RuntimeDiagnostic]) -> bool {
|
|
diagnostics
|
|
.iter()
|
|
.any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
|
|
}
|
|
|
|
fn validate_all_project_profiles(
|
|
workspace_root: &Path,
|
|
registry: &ProfileRegistryDocument,
|
|
) -> Result<()> {
|
|
let mut diagnostics = Vec::new();
|
|
let entries = project_entries(registry, &mut diagnostics);
|
|
if let Some(diagnostic) = diagnostics
|
|
.into_iter()
|
|
.find(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
|
|
{
|
|
return Err(profile_validation_error(
|
|
diagnostic.code,
|
|
diagnostic.message,
|
|
));
|
|
}
|
|
for entry in entries {
|
|
if BUILTIN_PROFILE_SLUGS.contains(&entry.name.as_str()) {
|
|
return Err(profile_validation_error(
|
|
"profile_selector_duplicate",
|
|
"Project profile conflicts with a builtin selector",
|
|
));
|
|
}
|
|
if let Some(diagnostic) = validate_project_profile_entry(workspace_root, &entry)
|
|
.into_iter()
|
|
.find(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
|
|
{
|
|
return Err(profile_validation_error(
|
|
diagnostic.code,
|
|
diagnostic.message,
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn profile_validation_error(code: impl Into<String>, message: impl Into<String>) -> Error {
|
|
Error::RuntimeOperationFailed {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: code.into(),
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
fn profile_registry_error(message: String) -> Error {
|
|
profile_validation_error("profile_registry_schema_invalid", sanitize_error(&message))
|
|
}
|
|
|
|
fn validate_registry_default(registry: &ProfileRegistryDocument) -> Result<()> {
|
|
let Some(value) = registry.default.as_deref() else {
|
|
return Ok(());
|
|
};
|
|
if BUILTIN_PROFILE_IDS.contains(&value) {
|
|
return Ok(());
|
|
}
|
|
if let Some(name) = value.strip_prefix("project:") {
|
|
let name = validate_profile_name(name)?;
|
|
if registry.profile.contains_key(&name) {
|
|
return Ok(());
|
|
}
|
|
return Err(profile_validation_error(
|
|
"profile_default_unknown",
|
|
"Default project profile selector is not present in the workspace profile registry",
|
|
));
|
|
}
|
|
Err(profile_validation_error(
|
|
"profile_default_invalid",
|
|
"Default profile must be a Backend-published builtin or project selector",
|
|
))
|
|
}
|
|
|
|
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(),
|
|
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,
|
|
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 Some(source_root) = existing_profile_source_root(workspace_root)? else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let mut files = Vec::new();
|
|
collect_profile_tree_files(workspace_root, &source_root, &source_root.path, &mut files)?;
|
|
files.sort_by(|a, b| a.path.cmp(&b.path));
|
|
Ok(files)
|
|
}
|
|
|
|
fn collect_profile_tree_files(
|
|
workspace_root: &Path,
|
|
source_root: &ProfileSourceRoot,
|
|
dir: &Path,
|
|
files: &mut Vec<WorkspaceProfileSourceTreeFileSummary>,
|
|
) -> Result<()> {
|
|
let canonical_dir = fs::canonicalize(dir)?;
|
|
if !canonical_dir.starts_with(&source_root.canonical_path) {
|
|
return Err(profile_source_symlink_escape(
|
|
"Profile source directory resolves outside the workspace profile source root",
|
|
));
|
|
}
|
|
for entry in fs::read_dir(dir)? {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
let file_type = entry.file_type()?;
|
|
if file_type.is_symlink() {
|
|
return Err(profile_source_symlink_escape(
|
|
"Profile source tree entries must not be symlinks",
|
|
));
|
|
}
|
|
if file_type.is_dir() {
|
|
collect_profile_tree_files(workspace_root, source_root, &path, files)?;
|
|
} else if file_type.is_file()
|
|
&& path.extension().and_then(|value| value.to_str()) == Some("dcdl")
|
|
{
|
|
let canonical_file = fs::canonicalize(&path)?;
|
|
if !canonical_file.starts_with(&source_root.canonical_path)
|
|
|| !canonical_file.starts_with(&source_root.canonical_workspace)
|
|
{
|
|
return Err(profile_source_symlink_escape(
|
|
"Profile source file resolves outside the workspace profile source root",
|
|
));
|
|
}
|
|
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(),
|
|
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)
|
|
.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,
|
|
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);
|
|
}
|
|
}
|
|
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 {
|
|
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}"
|
|
),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
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,
|
|
sources,
|
|
})
|
|
.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(®istry, 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,
|
|
relative_path: &Path,
|
|
content: &str,
|
|
) -> Result<()> {
|
|
if content.as_bytes().len() as u64 > MAX_PROFILE_SOURCE_BYTES {
|
|
return Err(Error::RuntimeOperationFailed {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: "profile_source_too_large".to_string(),
|
|
message: "Profile source exceeds the browser editing size limit".to_string(),
|
|
});
|
|
}
|
|
validate_source_candidate_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 (archive, _) = build_profile_archive_from_tree_sources(®istry, sources)?;
|
|
let verified = archive
|
|
.verify()
|
|
.map_err(|err| Error::RuntimeOperationFailed {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: "profile_source_archive_invalid".to_string(),
|
|
message: err.to_string(),
|
|
})?;
|
|
verified
|
|
.resolve_profile(&selector, workspace_root, "workspace-settings-validation")
|
|
.map_err(|err| Error::RuntimeOperationFailed {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: "profile_source_syntax_invalid".to_string(),
|
|
message: err.to_string(),
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
fn summarize_source(
|
|
workspace_root: &Path,
|
|
source_id: &str,
|
|
relative_path: &Path,
|
|
) -> WorkspaceProfileSourceSummary {
|
|
let mut diagnostics = Vec::new();
|
|
let display_path = display_source_path(relative_path);
|
|
let mut revision = "missing".to_string();
|
|
let mut size_bytes = 0;
|
|
match checked_source_path(workspace_root, relative_path) {
|
|
Ok(full) => match source_metadata(&full) {
|
|
Ok(metadata) => {
|
|
size_bytes = metadata.len();
|
|
revision = file_revision(&full);
|
|
if size_bytes > MAX_PROFILE_SOURCE_BYTES {
|
|
diagnostics.push(diagnostic(
|
|
"profile_source_too_large",
|
|
DiagnosticSeverity::Error,
|
|
"Profile source is too large for browser editing.",
|
|
));
|
|
}
|
|
}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => diagnostics.push(diagnostic(
|
|
"profile_source_missing",
|
|
DiagnosticSeverity::Error,
|
|
"Profile source file is missing.",
|
|
)),
|
|
Err(err) => diagnostics.push(diagnostic(
|
|
"profile_source_metadata_failed",
|
|
DiagnosticSeverity::Error,
|
|
sanitize_error(&err.to_string()),
|
|
)),
|
|
},
|
|
Err(err) => diagnostics.push(diagnostic(
|
|
"profile_source_path_escape",
|
|
DiagnosticSeverity::Error,
|
|
sanitize_error(&err.to_string()),
|
|
)),
|
|
}
|
|
WorkspaceProfileSourceSummary {
|
|
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),
|
|
revision,
|
|
size_bytes,
|
|
diagnostics,
|
|
}
|
|
}
|
|
|
|
fn read_registry(workspace_root: &Path) -> std::result::Result<ProfileRegistryDocument, String> {
|
|
let path = registry_path(workspace_root);
|
|
match fs::read_to_string(&path) {
|
|
Ok(raw) => {
|
|
toml::from_str(&raw).map_err(|err| format!("invalid profile registry schema: {err}"))
|
|
}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
|
Ok(ProfileRegistryDocument::default())
|
|
}
|
|
Err(err) => Err(format!(
|
|
"failed to read profile registry: {}",
|
|
sanitize_error(&err.to_string())
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn write_registry(workspace_root: &Path, registry: &ProfileRegistryDocument) -> Result<()> {
|
|
let path = registry_path(workspace_root);
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
let raw = toml::to_string_pretty(registry)
|
|
.map_err(|err| Error::Config(format!("failed to serialize profile registry: {err}")))?;
|
|
fs::write(path, raw)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn project_entries(
|
|
registry: &ProfileRegistryDocument,
|
|
diagnostics: &mut Vec<RuntimeDiagnostic>,
|
|
) -> Vec<ProjectProfileEntry> {
|
|
registry
|
|
.profile
|
|
.iter()
|
|
.filter_map(|(name, entry)| match validate_profile_name(name) {
|
|
Ok(name) => {
|
|
if BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) {
|
|
diagnostics.push(diagnostic(
|
|
"profile_selector_duplicate",
|
|
DiagnosticSeverity::Error,
|
|
format!("Project profile '{name}' conflicts with a builtin selector."),
|
|
));
|
|
}
|
|
let (path, description) = match entry {
|
|
ProfileEntryFile::Path(path) => (path.clone(), None),
|
|
ProfileEntryFile::Table(table) => {
|
|
(table.path.clone(), table.description.clone())
|
|
}
|
|
};
|
|
match registry_relative_source_path(&path) {
|
|
Ok(relative_path) => Some(ProjectProfileEntry {
|
|
name,
|
|
description,
|
|
relative_path,
|
|
}),
|
|
Err(err) => {
|
|
diagnostics.push(diagnostic(
|
|
"profile_source_path_escape",
|
|
DiagnosticSeverity::Error,
|
|
err,
|
|
));
|
|
None
|
|
}
|
|
}
|
|
}
|
|
Err(err) => {
|
|
diagnostics.push(diagnostic(
|
|
"profile_selector_invalid",
|
|
DiagnosticSeverity::Error,
|
|
err.to_string(),
|
|
));
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn entry_for_source_id(
|
|
registry: &ProfileRegistryDocument,
|
|
source_id: &str,
|
|
) -> Result<(String, ProjectProfileEntry)> {
|
|
let (name, _) = parse_project_source_id(source_id)?;
|
|
let mut diagnostics = Vec::new();
|
|
let entry = project_entries(registry, &mut diagnostics)
|
|
.into_iter()
|
|
.find(|entry| entry.name == name)
|
|
.ok_or_else(|| Error::RuntimeOperationFailed {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: "unknown_profile_source".to_string(),
|
|
message: "Unknown profile source id".to_string(),
|
|
})?;
|
|
Ok((name, entry))
|
|
}
|
|
|
|
fn registry_relative_source_path(raw: &str) -> std::result::Result<PathBuf, String> {
|
|
let path = Path::new(raw);
|
|
if path.is_absolute() {
|
|
return Err("Profile source path must be workspace-relative and safe.".to_string());
|
|
}
|
|
let path = if path.starts_with(".yoi") {
|
|
path.to_path_buf()
|
|
} else {
|
|
PathBuf::from(".yoi").join(path)
|
|
};
|
|
validate_relative_source_path(&path)?;
|
|
Ok(path)
|
|
}
|
|
|
|
fn validate_relative_source_path(path: &Path) -> std::result::Result<(), String> {
|
|
if !path.starts_with(PROFILE_SOURCE_ROOT_RELATIVE_PATH) {
|
|
return Err(
|
|
"Profile source path must be under the workspace profile source root.".to_string(),
|
|
);
|
|
}
|
|
for component in path.components() {
|
|
if !matches!(component, Component::Normal(_)) {
|
|
return Err(
|
|
"Profile source path must not contain absolute, parent, or prefix components."
|
|
.to_string(),
|
|
);
|
|
}
|
|
}
|
|
if path.extension().and_then(|value| value.to_str()) != Some("dcdl") {
|
|
return Err("Profile source path must use the .dcdl extension.".to_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
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ProfileSourceRoot {
|
|
path: PathBuf,
|
|
canonical_path: PathBuf,
|
|
canonical_workspace: PathBuf,
|
|
}
|
|
|
|
fn profile_source_symlink_escape(message: impl Into<String>) -> Error {
|
|
Error::RuntimeOperationFailed {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: "profile_source_symlink_escape".to_string(),
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
fn existing_profile_source_root(workspace_root: &Path) -> Result<Option<ProfileSourceRoot>> {
|
|
let source_root = workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH);
|
|
let metadata = match fs::symlink_metadata(&source_root) {
|
|
Ok(metadata) => metadata,
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
Err(err) => return Err(err.into()),
|
|
};
|
|
validate_profile_source_root_metadata(workspace_root, source_root, metadata).map(Some)
|
|
}
|
|
|
|
fn prepare_profile_source_root_for_write(workspace_root: &Path) -> Result<ProfileSourceRoot> {
|
|
let canonical_workspace = fs::canonicalize(workspace_root)?;
|
|
let yoi_dir = workspace_root.join(".yoi");
|
|
match fs::symlink_metadata(&yoi_dir) {
|
|
Ok(metadata) => {
|
|
if metadata.file_type().is_symlink() {
|
|
return Err(profile_source_symlink_escape(
|
|
"Workspace .yoi directory must not be a symlink",
|
|
));
|
|
}
|
|
if !metadata.is_dir() {
|
|
return Err(profile_validation_error(
|
|
"profile_source_path_invalid",
|
|
"Workspace .yoi path is not a directory",
|
|
));
|
|
}
|
|
let canonical_yoi = fs::canonicalize(&yoi_dir)?;
|
|
if !canonical_yoi.starts_with(&canonical_workspace) {
|
|
return Err(profile_source_symlink_escape(
|
|
"Workspace .yoi directory resolves outside the workspace root",
|
|
));
|
|
}
|
|
}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => fs::create_dir(&yoi_dir)?,
|
|
Err(err) => return Err(err.into()),
|
|
}
|
|
|
|
let source_root = workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH);
|
|
match fs::symlink_metadata(&source_root) {
|
|
Ok(metadata) => {
|
|
validate_profile_source_root_metadata(workspace_root, source_root, metadata)
|
|
}
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
|
fs::create_dir(&source_root)?;
|
|
let metadata = fs::symlink_metadata(&source_root)?;
|
|
validate_profile_source_root_metadata(workspace_root, source_root, metadata)
|
|
}
|
|
Err(err) => Err(err.into()),
|
|
}
|
|
}
|
|
|
|
fn validate_profile_source_root_metadata(
|
|
workspace_root: &Path,
|
|
source_root: PathBuf,
|
|
metadata: std::fs::Metadata,
|
|
) -> Result<ProfileSourceRoot> {
|
|
if metadata.file_type().is_symlink() {
|
|
return Err(profile_source_symlink_escape(
|
|
"Workspace profile source root must not be a symlink",
|
|
));
|
|
}
|
|
if !metadata.is_dir() {
|
|
return Err(profile_validation_error(
|
|
"profile_source_path_invalid",
|
|
"Workspace profile source root is not a directory",
|
|
));
|
|
}
|
|
let canonical_workspace = fs::canonicalize(workspace_root)?;
|
|
let canonical_path = fs::canonicalize(&source_root)?;
|
|
if !canonical_path.starts_with(&canonical_workspace) {
|
|
return Err(profile_source_symlink_escape(
|
|
"Workspace profile source root resolves outside the workspace root",
|
|
));
|
|
}
|
|
Ok(ProfileSourceRoot {
|
|
path: source_root,
|
|
canonical_path,
|
|
canonical_workspace,
|
|
})
|
|
}
|
|
|
|
fn validate_source_candidate_path(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 Some(_) = existing_profile_source_root(workspace_root)? else {
|
|
return Ok(());
|
|
};
|
|
let full = workspace_root.join(relative_path);
|
|
if full.exists() || full.parent().is_some_and(Path::exists) {
|
|
checked_source_path(workspace_root, relative_path)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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 = prepare_profile_source_root_for_write(workspace_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.path).map_err(|_| {
|
|
profile_validation_error(
|
|
"profile_source_path_escape",
|
|
"Profile source parent must remain inside the source tree",
|
|
)
|
|
})?;
|
|
let mut current = source_root.path.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(profile_source_symlink_escape(
|
|
"Profile source parent contains a symlink",
|
|
));
|
|
}
|
|
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(&source_root.canonical_path)
|
|
|| !canonical_next.starts_with(&source_root.canonical_workspace)
|
|
{
|
|
return Err(profile_source_symlink_escape(
|
|
"Profile source parent resolves outside the workspace profile source root",
|
|
));
|
|
}
|
|
}
|
|
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 {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: "profile_source_path_escape".to_string(),
|
|
message,
|
|
}
|
|
})?;
|
|
let source_root = existing_profile_source_root(workspace_root)?.ok_or_else(|| {
|
|
profile_validation_error(
|
|
"profile_source_missing",
|
|
"Workspace profile source root does not exist",
|
|
)
|
|
})?;
|
|
let full = workspace_root.join(relative_path);
|
|
if let Ok(canonical_full) = fs::canonicalize(&full) {
|
|
if !canonical_full.starts_with(&source_root.canonical_path)
|
|
|| !canonical_full.starts_with(&source_root.canonical_workspace)
|
|
{
|
|
return Err(profile_source_symlink_escape(
|
|
"Profile source resolves outside the workspace profile source root",
|
|
));
|
|
}
|
|
} else if let Some(parent) = full.parent() {
|
|
let canonical_parent = fs::canonicalize(parent)?;
|
|
if !canonical_parent.starts_with(&source_root.canonical_path)
|
|
|| !canonical_parent.starts_with(&source_root.canonical_workspace)
|
|
{
|
|
return Err(Error::RuntimeOperationFailed {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: "profile_source_path_escape".to_string(),
|
|
message: "Profile source parent resolves outside the workspace profile source root"
|
|
.to_string(),
|
|
});
|
|
}
|
|
}
|
|
Ok(full)
|
|
}
|
|
|
|
fn validate_profile_name(value: &str) -> Result<String> {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty()
|
|
|| trimmed.len() > 64
|
|
|| !trimmed
|
|
.chars()
|
|
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
|
|
{
|
|
return Err(Error::RuntimeOperationFailed {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: "profile_selector_invalid".to_string(),
|
|
message: "Profile selector must contain only ASCII letters, digits, '-' or '_'"
|
|
.to_string(),
|
|
});
|
|
}
|
|
Ok(trimmed.to_string())
|
|
}
|
|
|
|
fn sanitize_display_name(value: &str) -> Result<String> {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() || trimmed.chars().any(char::is_control) || trimmed.len() > 120 {
|
|
return Err(Error::RuntimeOperationFailed {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: "workspace_display_name_invalid".to_string(),
|
|
message: "Workspace display name must be non-empty, bounded, and must not contain control characters".to_string(),
|
|
});
|
|
}
|
|
Ok(trimmed.to_string())
|
|
}
|
|
|
|
fn parse_project_source_id(source_id: &str) -> Result<(String, String)> {
|
|
let Some(name) = source_id.strip_prefix("project:") else {
|
|
return Err(Error::RuntimeOperationFailed {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: "unsupported_profile_source_id".to_string(),
|
|
message: "Profile source id is not a project profile source".to_string(),
|
|
});
|
|
};
|
|
let name = validate_profile_name(name)?;
|
|
Ok((name.clone(), project_source_id(&name)))
|
|
}
|
|
|
|
pub fn project_selector(name: &str) -> String {
|
|
format!("project:{name}")
|
|
}
|
|
|
|
pub fn project_source_id(name: &str) -> String {
|
|
format!("project:{name}")
|
|
}
|
|
|
|
pub fn selector_for_builtin_candidate(
|
|
id: &str,
|
|
) -> Option<worker_runtime::catalog::ProfileSelector> {
|
|
match id {
|
|
"runtime_default" => Some(worker_runtime::catalog::ProfileSelector::RuntimeDefault),
|
|
"builtin:default"
|
|
| "builtin:companion"
|
|
| "builtin:intake"
|
|
| "builtin:orchestrator"
|
|
| "builtin:coder"
|
|
| "builtin:reviewer" => Some(worker_runtime::catalog::ProfileSelector::Builtin(
|
|
id.to_string(),
|
|
)),
|
|
value if value.starts_with("project:") => Some(
|
|
worker_runtime::catalog::ProfileSelector::Named(value.to_string()),
|
|
),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn registry_path(workspace_root: &Path) -> PathBuf {
|
|
workspace_root.join(PROFILE_REGISTRY_RELATIVE_PATH)
|
|
}
|
|
|
|
fn file_revision(path: &Path) -> String {
|
|
let Ok(metadata) = fs::metadata(path) else {
|
|
return "missing".to_string();
|
|
};
|
|
let modified = metadata
|
|
.modified()
|
|
.ok()
|
|
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
|
.map(|duration| duration.as_nanos())
|
|
.unwrap_or_default();
|
|
format!("rev:{modified}:{}", metadata.len())
|
|
}
|
|
|
|
fn source_metadata(path: &Path) -> std::io::Result<std::fs::Metadata> {
|
|
fs::symlink_metadata(path)
|
|
}
|
|
|
|
fn ensure_revision(path: &Path, expected: &str, code: &'static str) -> Result<()> {
|
|
let actual = file_revision(path);
|
|
if expected != actual {
|
|
return Err(Error::RuntimeOperationFailed {
|
|
runtime_id: "workspace-backend".to_string(),
|
|
code: code.to_string(),
|
|
message: "Settings changed before this update was applied".to_string(),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn optional_trim(value: &str) -> Option<String> {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(trimmed.to_string())
|
|
}
|
|
}
|
|
|
|
fn diagnostic(
|
|
code: impl Into<String>,
|
|
severity: DiagnosticSeverity,
|
|
message: impl Into<String>,
|
|
) -> RuntimeDiagnostic {
|
|
RuntimeDiagnostic {
|
|
code: code.into(),
|
|
severity,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
fn sanitize_error(value: &str) -> String {
|
|
value
|
|
.split_whitespace()
|
|
.map(|token| {
|
|
if token.starts_with('/') || token.contains("/.yoi/") || token.contains(".yoi/sessions")
|
|
{
|
|
"<redacted-path>"
|
|
} else {
|
|
token
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn valid_decodal(slug: &str) -> String {
|
|
format!(
|
|
r#"{{
|
|
slug = "{slug}";
|
|
description = "Test";
|
|
scope = "workspace_read";
|
|
}}"#
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn profile_settings_create_update_and_discover_project_profile() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
|
fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap();
|
|
let revision = file_revision(&dir.path().join(".yoi/profiles.toml"));
|
|
let created = create_profile_source(
|
|
"workspace-test",
|
|
dir.path(),
|
|
CreateWorkspaceProfileSourceRequest {
|
|
name: "alpha".to_string(),
|
|
description: Some("Alpha".to_string()),
|
|
content: valid_decodal("alpha"),
|
|
registry_revision: revision,
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
created
|
|
.settings
|
|
.profiles
|
|
.iter()
|
|
.any(|profile| profile.profile_id == "project:alpha")
|
|
);
|
|
assert!(
|
|
build_workspace_profile_archive(dir.path(), "project:alpha")
|
|
.unwrap()
|
|
.is_some()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_source_rejects_path_escape_and_revision_conflict() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
|
fs::write(
|
|
dir.path().join(".yoi/profiles.toml"),
|
|
"[profile.bad]\npath = \"../bad.dcdl\"\n",
|
|
)
|
|
.unwrap();
|
|
let settings = load_profile_settings("workspace-test", dir.path());
|
|
assert!(
|
|
settings
|
|
.diagnostics
|
|
.iter()
|
|
.any(|diagnostic| diagnostic.code == "profile_source_path_escape")
|
|
);
|
|
|
|
let err = update_profile_registry(
|
|
"workspace-test",
|
|
dir.path(),
|
|
UpdateWorkspaceProfileRegistryRequest {
|
|
registry_revision: "stale".to_string(),
|
|
default_profile: None,
|
|
profiles: Vec::new(),
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
assert!(
|
|
err.to_string()
|
|
.contains("profile_registry_revision_conflict")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_source_rejects_invalid_decodal() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
|
fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap();
|
|
let revision = file_revision(&dir.path().join(".yoi/profiles.toml"));
|
|
let err = create_profile_source(
|
|
"workspace-test",
|
|
dir.path(),
|
|
CreateWorkspaceProfileSourceRequest {
|
|
name: "bad".to_string(),
|
|
description: None,
|
|
content: "not decodal".to_string(),
|
|
registry_revision: revision,
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
assert!(
|
|
err.to_string().contains("profile_source_syntax_invalid")
|
|
|| err.to_string().contains("profile_source_archive_invalid")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_project_profile_is_not_a_launch_candidate() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap();
|
|
fs::write(
|
|
dir.path().join(".yoi/profiles.toml"),
|
|
"[profile.bad]\npath = \"profiles/bad.dcdl\"\n",
|
|
)
|
|
.unwrap();
|
|
fs::write(dir.path().join(".yoi/profiles/bad.dcdl"), "not decodal").unwrap();
|
|
|
|
let settings = load_profile_settings("workspace-test", dir.path());
|
|
let bad = settings
|
|
.profiles
|
|
.iter()
|
|
.find(|profile| profile.profile_id == "project:bad")
|
|
.expect("bad profile summary is still visible for repair");
|
|
assert!(
|
|
bad.diagnostics
|
|
.iter()
|
|
.any(|diagnostic| diagnostic.code.starts_with("profile_source_"))
|
|
);
|
|
assert!(project_profile_candidates(dir.path()).is_empty());
|
|
assert!(!is_profile_candidate(dir.path(), "project:bad"));
|
|
}
|
|
|
|
#[test]
|
|
fn registry_update_rejects_missing_source_and_unknown_default() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
|
fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap();
|
|
let revision = file_revision(&dir.path().join(".yoi/profiles.toml"));
|
|
|
|
let err = update_profile_registry(
|
|
"workspace-test",
|
|
dir.path(),
|
|
UpdateWorkspaceProfileRegistryRequest {
|
|
registry_revision: revision.clone(),
|
|
default_profile: Some("project:missing".to_string()),
|
|
profiles: Vec::new(),
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
assert!(err.to_string().contains("profile_default_unknown"));
|
|
|
|
let err = update_profile_registry(
|
|
"workspace-test",
|
|
dir.path(),
|
|
UpdateWorkspaceProfileRegistryRequest {
|
|
registry_revision: revision,
|
|
default_profile: None,
|
|
profiles: vec![WorkspaceProfileRegistryEntryUpdate {
|
|
name: "missing".to_string(),
|
|
description: None,
|
|
profile_source_id: None,
|
|
}],
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
assert!(err.to_string().contains("profile_source_missing"));
|
|
assert_eq!(
|
|
fs::read_to_string(dir.path().join(".yoi/profiles.toml")).unwrap(),
|
|
""
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_source_rejects_too_large_content() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
|
fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap();
|
|
let revision = file_revision(&dir.path().join(".yoi/profiles.toml"));
|
|
let err = create_profile_source(
|
|
"workspace-test",
|
|
dir.path(),
|
|
CreateWorkspaceProfileSourceRequest {
|
|
name: "large".to_string(),
|
|
description: None,
|
|
content: "x".repeat((MAX_PROFILE_SOURCE_BYTES + 1) as usize),
|
|
registry_revision: revision,
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
assert!(err.to_string().contains("profile_source_too_large"));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn registry_update_rejects_symlink_escape() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap();
|
|
fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap();
|
|
let outside = dir.path().join("outside.dcdl");
|
|
fs::write(&outside, valid_decodal("escape")).unwrap();
|
|
std::os::unix::fs::symlink(&outside, dir.path().join(".yoi/profiles/escape.dcdl")).unwrap();
|
|
let revision = file_revision(&dir.path().join(".yoi/profiles.toml"));
|
|
let err = update_profile_registry(
|
|
"workspace-test",
|
|
dir.path(),
|
|
UpdateWorkspaceProfileRegistryRequest {
|
|
registry_revision: revision,
|
|
default_profile: None,
|
|
profiles: vec![WorkspaceProfileRegistryEntryUpdate {
|
|
name: "escape".to_string(),
|
|
description: None,
|
|
profile_source_id: None,
|
|
}],
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
let rendered = err.to_string();
|
|
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());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn source_root_symlink_is_rejected_before_tree_side_effects() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
|
let outside = tempfile::tempdir().unwrap();
|
|
std::os::unix::fs::symlink(outside.path(), dir.path().join(".yoi/profiles")).unwrap();
|
|
|
|
let err = write_profile_tree_file(
|
|
"workspace-test",
|
|
dir.path(),
|
|
PROFILE_SOURCE_TREE_ID,
|
|
WriteWorkspaceProfileTreeFileRequest {
|
|
path: "profiles/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());
|
|
assert!(!outside.path().join("new.dcdl").exists());
|
|
|
|
let err = read_profile_source_tree("workspace-test", dir.path(), PROFILE_SOURCE_TREE_ID)
|
|
.unwrap_err();
|
|
assert!(err.to_string().contains("profile_source_symlink_escape"));
|
|
|
|
let err = build_workspace_profile_archive(dir.path(), "project:any").unwrap_err();
|
|
assert!(err.to_string().contains("profile_source_symlink_escape"));
|
|
}
|
|
}
|