fix: harden profile settings validation
This commit is contained in:
parent
3eefd33334
commit
0c2ca1eafb
|
|
@ -2,7 +2,7 @@
|
||||||
title: 'Add Workspace settings API and pages for Workspace/Profile editing'
|
title: 'Add Workspace settings API and pages for Workspace/Profile editing'
|
||||||
state: 'inprogress'
|
state: 'inprogress'
|
||||||
created_at: '2026-07-08T08:34:01Z'
|
created_at: '2026-07-08T08:34:01Z'
|
||||||
updated_at: '2026-07-08T12:37:56Z'
|
updated_at: '2026-07-08T13:02:16Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
queued_by: 'workspace-panel'
|
queued_by: 'workspace-panel'
|
||||||
queued_at: '2026-07-08T10:44:13Z'
|
queued_at: '2026-07-08T10:44:13Z'
|
||||||
|
|
|
||||||
|
|
@ -175,4 +175,27 @@ Validation:
|
||||||
- nix build .#yoi --no-link
|
- nix build .#yoi --no-link
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-08T13:02:16Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Follow-up fixes for external review blockers.
|
||||||
|
|
||||||
|
Summary:
|
||||||
|
- Profile settings RuntimeOperationFailed responses now include typed Browser diagnostics and map profile validation/conflict/not-found codes to bad-request/conflict/not-found HTTP statuses; response messages are redacted while preserving diagnostic codes.
|
||||||
|
- Project profile discovery now attaches source/Decodal validation failures to each project profile summary, excludes invalid project profiles from launch candidates, and rejects invalid project selectors during Worker launch candidate validation.
|
||||||
|
- Registry/default mutations now validate default selector existence, selector/source consistency, missing source files, symlink/path escape, source size, Decodal/ProfileSourceArchive validity, and invalid registry schema before committing registry changes.
|
||||||
|
- Added API/unit coverage for duplicate selector, invalid Decodal/source schema, stale revision conflicts, too-large source content, symlink escape redaction, invalid candidate exclusion/rejection, and missing/default reference rejection.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- git diff --check
|
||||||
|
- cargo test -p yoi-workspace-server
|
||||||
|
- cargo check -p yoi
|
||||||
|
- cd web/workspace && deno task check && deno task test
|
||||||
|
- yoi ticket doctor
|
||||||
|
- nix build .#yoi --no-link
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ pub mod companion;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod hosts;
|
pub mod hosts;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
pub mod profile_settings;
|
|
||||||
pub mod observation;
|
pub mod observation;
|
||||||
|
pub mod profile_settings;
|
||||||
pub mod records;
|
pub mod records;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
pub mod resource_broker;
|
pub mod resource_broker;
|
||||||
|
|
|
||||||
|
|
@ -230,7 +230,10 @@ pub fn workspace_metadata_settings(
|
||||||
diagnostics.push(diagnostic(
|
diagnostics.push(diagnostic(
|
||||||
"workspace_identity_read_failed",
|
"workspace_identity_read_failed",
|
||||||
DiagnosticSeverity::Error,
|
DiagnosticSeverity::Error,
|
||||||
format!("Workspace identity could not be read: {}", sanitize_error(&err.to_string())),
|
format!(
|
||||||
|
"Workspace identity could not be read: {}",
|
||||||
|
sanitize_error(&err.to_string())
|
||||||
|
),
|
||||||
));
|
));
|
||||||
(
|
(
|
||||||
fallback_workspace_id.to_string(),
|
fallback_workspace_id.to_string(),
|
||||||
|
|
@ -311,6 +314,17 @@ pub fn load_profile_settings(workspace_id: &str, workspace_root: &Path) -> Profi
|
||||||
}
|
}
|
||||||
let source_summary = summarize_source(workspace_root, &source_id, &entry.relative_path);
|
let source_summary = summarize_source(workspace_root, &source_id, &entry.relative_path);
|
||||||
entry_diagnostics.extend(source_summary.diagnostics.clone());
|
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 {
|
profiles.push(WorkspaceProfileSummary {
|
||||||
profile_id: selector.clone(),
|
profile_id: selector.clone(),
|
||||||
selector,
|
selector,
|
||||||
|
|
@ -324,7 +338,17 @@ pub fn load_profile_settings(workspace_id: &str, workspace_root: &Path) -> Profi
|
||||||
});
|
});
|
||||||
sources.push(source_summary);
|
sources.push(source_summary);
|
||||||
}
|
}
|
||||||
diagnostics.extend(validate_project_profiles(workspace_root, ®istry));
|
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 {
|
ProfileSettingsResponse {
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
registry_revision,
|
registry_revision,
|
||||||
|
|
@ -340,7 +364,7 @@ pub fn read_profile_source(
|
||||||
workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
source_id: &str,
|
source_id: &str,
|
||||||
) -> Result<WorkspaceProfileSourceDetailResponse> {
|
) -> Result<WorkspaceProfileSourceDetailResponse> {
|
||||||
let registry = read_registry(workspace_root).map_err(Error::Config)?;
|
let registry = read_registry(workspace_root).map_err(profile_registry_error)?;
|
||||||
let (name, entry) = entry_for_source_id(®istry, source_id)?;
|
let (name, entry) = entry_for_source_id(®istry, source_id)?;
|
||||||
let full = checked_source_path(workspace_root, &entry.relative_path)?;
|
let full = checked_source_path(workspace_root, &entry.relative_path)?;
|
||||||
let metadata = source_metadata(&full)?;
|
let metadata = source_metadata(&full)?;
|
||||||
|
|
@ -380,8 +404,12 @@ pub fn create_profile_source(
|
||||||
request: CreateWorkspaceProfileSourceRequest,
|
request: CreateWorkspaceProfileSourceRequest,
|
||||||
) -> Result<ProfileSettingsMutationResponse> {
|
) -> Result<ProfileSettingsMutationResponse> {
|
||||||
let registry_path = registry_path(workspace_root);
|
let registry_path = registry_path(workspace_root);
|
||||||
ensure_revision(®istry_path, &request.registry_revision, "profile_registry_revision_conflict")?;
|
ensure_revision(
|
||||||
let mut registry = read_registry(workspace_root).map_err(Error::Config)?;
|
®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)?;
|
let name = validate_profile_name(&request.name)?;
|
||||||
if registry.profile.contains_key(&name) || BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) {
|
if registry.profile.contains_key(&name) || BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) {
|
||||||
return Err(Error::RuntimeOperationFailed {
|
return Err(Error::RuntimeOperationFailed {
|
||||||
|
|
@ -390,7 +418,9 @@ pub fn create_profile_source(
|
||||||
message: "Profile selector already exists".to_string(),
|
message: "Profile selector already exists".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let relative_path = PathBuf::from(".yoi").join("profiles").join(format!("{name}.dcdl"));
|
let relative_path = PathBuf::from(".yoi")
|
||||||
|
.join("profiles")
|
||||||
|
.join(format!("{name}.dcdl"));
|
||||||
validate_source_content(workspace_root, &name, &relative_path, &request.content)?;
|
validate_source_content(workspace_root, &name, &relative_path, &request.content)?;
|
||||||
let full = checked_source_path(workspace_root, &relative_path)?;
|
let full = checked_source_path(workspace_root, &relative_path)?;
|
||||||
if let Some(parent) = full.parent() {
|
if let Some(parent) = full.parent() {
|
||||||
|
|
@ -401,9 +431,13 @@ pub fn create_profile_source(
|
||||||
name.clone(),
|
name.clone(),
|
||||||
ProfileEntryFile::Table(ProfileEntryTable {
|
ProfileEntryFile::Table(ProfileEntryTable {
|
||||||
path: format!("profiles/{name}.dcdl"),
|
path: format!("profiles/{name}.dcdl"),
|
||||||
description: request.description.and_then(|value| optional_trim(value.as_str())),
|
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)?;
|
write_registry(workspace_root, ®istry)?;
|
||||||
Ok(ProfileSettingsMutationResponse {
|
Ok(ProfileSettingsMutationResponse {
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
|
|
@ -422,8 +456,11 @@ pub fn update_profile_registry(
|
||||||
request: UpdateWorkspaceProfileRegistryRequest,
|
request: UpdateWorkspaceProfileRegistryRequest,
|
||||||
) -> Result<ProfileSettingsMutationResponse> {
|
) -> Result<ProfileSettingsMutationResponse> {
|
||||||
let path = registry_path(workspace_root);
|
let path = registry_path(workspace_root);
|
||||||
ensure_revision(&path, &request.registry_revision, "profile_registry_revision_conflict")?;
|
ensure_revision(
|
||||||
validate_default_profile(request.default_profile.as_deref())?;
|
&path,
|
||||||
|
&request.registry_revision,
|
||||||
|
"profile_registry_revision_conflict",
|
||||||
|
)?;
|
||||||
let mut profile = BTreeMap::new();
|
let mut profile = BTreeMap::new();
|
||||||
let mut seen = BTreeSet::new();
|
let mut seen = BTreeSet::new();
|
||||||
for update in request.profiles {
|
for update in request.profiles {
|
||||||
|
|
@ -452,7 +489,9 @@ pub fn update_profile_registry(
|
||||||
name.clone(),
|
name.clone(),
|
||||||
ProfileEntryFile::Table(ProfileEntryTable {
|
ProfileEntryFile::Table(ProfileEntryTable {
|
||||||
path: format!("profiles/{name}.dcdl"),
|
path: format!("profiles/{name}.dcdl"),
|
||||||
description: update.description.and_then(|value| optional_trim(value.as_str())),
|
description: update
|
||||||
|
.description
|
||||||
|
.and_then(|value| optional_trim(value.as_str())),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -460,13 +499,8 @@ pub fn update_profile_registry(
|
||||||
default: request.default_profile,
|
default: request.default_profile,
|
||||||
profile,
|
profile,
|
||||||
};
|
};
|
||||||
for entry in project_entries(®istry, &mut Vec::new()) {
|
validate_registry_default(®istry)?;
|
||||||
let full = checked_source_path(workspace_root, &entry.relative_path)?;
|
validate_all_project_profiles(workspace_root, ®istry)?;
|
||||||
if full.exists() {
|
|
||||||
let content = fs::read_to_string(&full)?;
|
|
||||||
validate_source_content(workspace_root, &entry.name, &entry.relative_path, &content)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
write_registry(workspace_root, ®istry)?;
|
write_registry(workspace_root, ®istry)?;
|
||||||
Ok(ProfileSettingsMutationResponse {
|
Ok(ProfileSettingsMutationResponse {
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
|
|
@ -485,11 +519,16 @@ pub fn update_profile_source(
|
||||||
source_id: &str,
|
source_id: &str,
|
||||||
request: UpdateWorkspaceProfileSourceRequest,
|
request: UpdateWorkspaceProfileSourceRequest,
|
||||||
) -> Result<ProfileSettingsMutationResponse> {
|
) -> Result<ProfileSettingsMutationResponse> {
|
||||||
let registry = read_registry(workspace_root).map_err(Error::Config)?;
|
let registry = read_registry(workspace_root).map_err(profile_registry_error)?;
|
||||||
let (name, entry) = entry_for_source_id(®istry, source_id)?;
|
let (name, entry) = entry_for_source_id(®istry, source_id)?;
|
||||||
let full = checked_source_path(workspace_root, &entry.relative_path)?;
|
let full = checked_source_path(workspace_root, &entry.relative_path)?;
|
||||||
ensure_revision(&full, &request.revision, "profile_source_revision_conflict")?;
|
ensure_revision(&full, &request.revision, "profile_source_revision_conflict")?;
|
||||||
validate_source_content(workspace_root, &name, &entry.relative_path, &request.content)?;
|
validate_source_content(
|
||||||
|
workspace_root,
|
||||||
|
&name,
|
||||||
|
&entry.relative_path,
|
||||||
|
&request.content,
|
||||||
|
)?;
|
||||||
fs::write(&full, request.content)?;
|
fs::write(&full, request.content)?;
|
||||||
Ok(ProfileSettingsMutationResponse {
|
Ok(ProfileSettingsMutationResponse {
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
|
|
@ -509,11 +548,19 @@ pub fn delete_profile_source(
|
||||||
request: DeleteWorkspaceProfileSourceRequest,
|
request: DeleteWorkspaceProfileSourceRequest,
|
||||||
) -> Result<ProfileSettingsMutationResponse> {
|
) -> Result<ProfileSettingsMutationResponse> {
|
||||||
let registry_path = registry_path(workspace_root);
|
let registry_path = registry_path(workspace_root);
|
||||||
ensure_revision(®istry_path, &request.registry_revision, "profile_registry_revision_conflict")?;
|
ensure_revision(
|
||||||
let mut registry = read_registry(workspace_root).map_err(Error::Config)?;
|
®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 (name, entry) = entry_for_source_id(®istry, source_id)?;
|
||||||
let full = checked_source_path(workspace_root, &entry.relative_path)?;
|
let full = checked_source_path(workspace_root, &entry.relative_path)?;
|
||||||
ensure_revision(&full, &request.source_revision, "profile_source_revision_conflict")?;
|
ensure_revision(
|
||||||
|
&full,
|
||||||
|
&request.source_revision,
|
||||||
|
"profile_source_revision_conflict",
|
||||||
|
)?;
|
||||||
registry.profile.remove(&name);
|
registry.profile.remove(&name);
|
||||||
if registry.default.as_deref() == Some(project_selector(&name).as_str()) {
|
if registry.default.as_deref() == Some(project_selector(&name).as_str()) {
|
||||||
registry.default = None;
|
registry.default = None;
|
||||||
|
|
@ -540,7 +587,7 @@ pub fn build_workspace_profile_archive(
|
||||||
if !selector.starts_with("project:") {
|
if !selector.starts_with("project:") {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let registry = read_registry(workspace_root).map_err(Error::Config)?;
|
let registry = read_registry(workspace_root).map_err(profile_registry_error)?;
|
||||||
let mut entrypoints = BTreeMap::new();
|
let mut entrypoints = BTreeMap::new();
|
||||||
let mut sources = BTreeMap::new();
|
let mut sources = BTreeMap::new();
|
||||||
for entry in project_entries(®istry, &mut Vec::new()) {
|
for entry in project_entries(®istry, &mut Vec::new()) {
|
||||||
|
|
@ -554,10 +601,15 @@ pub fn build_workspace_profile_archive(
|
||||||
return Err(Error::RuntimeOperationFailed {
|
return Err(Error::RuntimeOperationFailed {
|
||||||
runtime_id: "workspace-backend".to_string(),
|
runtime_id: "workspace-backend".to_string(),
|
||||||
code: "unknown_profile_selector".to_string(),
|
code: "unknown_profile_selector".to_string(),
|
||||||
message: "Selected project profile is not present in the workspace profile registry".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(default) = registry
|
||||||
|
.default
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| value.starts_with("project:"))
|
||||||
|
{
|
||||||
if let Some(path) = entrypoints.get(default).cloned() {
|
if let Some(path) = entrypoints.get(default).cloned() {
|
||||||
entrypoints.insert("default".to_string(), path);
|
entrypoints.insert("default".to_string(), path);
|
||||||
}
|
}
|
||||||
|
|
@ -573,11 +625,14 @@ pub fn build_workspace_profile_archive(
|
||||||
code: "profile_source_archive_invalid".to_string(),
|
code: "profile_source_archive_invalid".to_string(),
|
||||||
message: err.to_string(),
|
message: err.to_string(),
|
||||||
})?;
|
})?;
|
||||||
archive.verify().and_then(|verified| {
|
archive
|
||||||
|
.verify()
|
||||||
|
.and_then(|verified| {
|
||||||
verified
|
verified
|
||||||
.resolve_profile(selector, workspace_root, "workspace-settings-validation")
|
.resolve_profile(selector, workspace_root, "workspace-settings-validation")
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
}).map_err(|err| Error::RuntimeOperationFailed {
|
})
|
||||||
|
.map_err(|err| Error::RuntimeOperationFailed {
|
||||||
runtime_id: "workspace-backend".to_string(),
|
runtime_id: "workspace-backend".to_string(),
|
||||||
code: "profile_source_invalid".to_string(),
|
code: "profile_source_invalid".to_string(),
|
||||||
message: err.to_string(),
|
message: err.to_string(),
|
||||||
|
|
@ -622,7 +677,7 @@ pub fn project_profile_candidates(workspace_root: &Path) -> Vec<WorkspaceProfile
|
||||||
load_profile_settings("workspace", workspace_root)
|
load_profile_settings("workspace", workspace_root)
|
||||||
.profiles
|
.profiles
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|profile| profile.source_kind == "project")
|
.filter(|profile| profile.source_kind == "project" && !has_error(&profile.diagnostics))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -637,11 +692,23 @@ pub fn is_profile_candidate(workspace_root: &Path, profile_id: &str) -> bool {
|
||||||
fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec<WorkspaceProfileSummary> {
|
fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec<WorkspaceProfileSummary> {
|
||||||
let labels = [
|
let labels = [
|
||||||
("builtin:default", "Default", "Bundled default Yoi profile"),
|
("builtin:default", "Default", "Bundled default Yoi profile"),
|
||||||
("builtin:companion", "Companion", "Bundled Companion role profile"),
|
(
|
||||||
|
"builtin:companion",
|
||||||
|
"Companion",
|
||||||
|
"Bundled Companion role profile",
|
||||||
|
),
|
||||||
("builtin:intake", "Intake", "Bundled Intake role profile"),
|
("builtin:intake", "Intake", "Bundled Intake role profile"),
|
||||||
("builtin:orchestrator", "Orchestrator", "Bundled Orchestrator role profile"),
|
(
|
||||||
|
"builtin:orchestrator",
|
||||||
|
"Orchestrator",
|
||||||
|
"Bundled Orchestrator role profile",
|
||||||
|
),
|
||||||
("builtin:coder", "Coder", "Bundled Coder role profile"),
|
("builtin:coder", "Coder", "Bundled Coder role profile"),
|
||||||
("builtin:reviewer", "Reviewer", "Bundled Reviewer role profile"),
|
(
|
||||||
|
"builtin:reviewer",
|
||||||
|
"Reviewer",
|
||||||
|
"Bundled Reviewer role profile",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
labels
|
labels
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -659,52 +726,123 @@ fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec<WorkspaceProf
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_project_profiles(workspace_root: &Path, registry: &ProfileRegistryDocument) -> Vec<RuntimeDiagnostic> {
|
fn validate_project_profile_entry(
|
||||||
let mut diagnostics = Vec::new();
|
workspace_root: &Path,
|
||||||
for entry in project_entries(registry, &mut diagnostics) {
|
entry: &ProjectProfileEntry,
|
||||||
|
) -> Vec<RuntimeDiagnostic> {
|
||||||
let full = match checked_source_path(workspace_root, &entry.relative_path) {
|
let full = match checked_source_path(workspace_root, &entry.relative_path) {
|
||||||
Ok(path) => path,
|
Ok(path) => path,
|
||||||
Err(err) => {
|
Err(err) => return vec![diagnostic_from_error(&err)],
|
||||||
diagnostics.push(diagnostic(
|
|
||||||
"profile_source_path_escape",
|
|
||||||
DiagnosticSeverity::Error,
|
|
||||||
err.to_string(),
|
|
||||||
));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
match fs::read_to_string(&full) {
|
match fs::read_to_string(&full) {
|
||||||
Ok(content) => {
|
Ok(content) => {
|
||||||
if let Err(err) = validate_source_content(workspace_root, &entry.name, &entry.relative_path, &content) {
|
validate_source_content(workspace_root, &entry.name, &entry.relative_path, &content)
|
||||||
diagnostics.push(diagnostic(
|
.err()
|
||||||
"profile_source_invalid",
|
.map(|err| vec![diagnostic_from_error(&err)])
|
||||||
DiagnosticSeverity::Error,
|
.unwrap_or_default()
|
||||||
sanitize_error(&err.to_string()),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => vec![diagnostic(
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => diagnostics.push(diagnostic(
|
|
||||||
"profile_source_missing",
|
"profile_source_missing",
|
||||||
DiagnosticSeverity::Error,
|
DiagnosticSeverity::Error,
|
||||||
format!("Profile source '{}' is missing.", entry.name),
|
format!("Profile source '{}' is missing.", entry.name),
|
||||||
)),
|
)],
|
||||||
Err(err) => diagnostics.push(diagnostic(
|
Err(err) => vec![diagnostic(
|
||||||
"profile_source_read_failed",
|
"profile_source_read_failed",
|
||||||
DiagnosticSeverity::Error,
|
DiagnosticSeverity::Error,
|
||||||
sanitize_error(&err.to_string()),
|
sanitize_error(&err.to_string()),
|
||||||
)),
|
)],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(default) = registry.default.as_deref() {
|
|
||||||
if let Err(err) = validate_default_profile(Some(default)) {
|
fn diagnostic_from_error(err: &Error) -> RuntimeDiagnostic {
|
||||||
diagnostics.push(diagnostic(
|
match err {
|
||||||
"profile_default_invalid",
|
Error::RuntimeOperationFailed { code, message, .. } => diagnostic(
|
||||||
|
code.clone(),
|
||||||
DiagnosticSeverity::Error,
|
DiagnosticSeverity::Error,
|
||||||
sanitize_error(&err.to_string()),
|
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,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
diagnostics
|
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 validate_source_content(
|
fn validate_source_content(
|
||||||
|
|
@ -738,7 +876,9 @@ fn validate_source_content(
|
||||||
code: "profile_source_archive_invalid".to_string(),
|
code: "profile_source_archive_invalid".to_string(),
|
||||||
message: err.to_string(),
|
message: err.to_string(),
|
||||||
})?;
|
})?;
|
||||||
let verified = archive.verify().map_err(|err| Error::RuntimeOperationFailed {
|
let verified = archive
|
||||||
|
.verify()
|
||||||
|
.map_err(|err| Error::RuntimeOperationFailed {
|
||||||
runtime_id: "workspace-backend".to_string(),
|
runtime_id: "workspace-backend".to_string(),
|
||||||
code: "profile_source_archive_invalid".to_string(),
|
code: "profile_source_archive_invalid".to_string(),
|
||||||
message: err.to_string(),
|
message: err.to_string(),
|
||||||
|
|
@ -797,7 +937,9 @@ fn summarize_source(
|
||||||
profile_source_id: source_id.to_string(),
|
profile_source_id: source_id.to_string(),
|
||||||
display_path,
|
display_path,
|
||||||
kind: "decodal".to_string(),
|
kind: "decodal".to_string(),
|
||||||
editable: diagnostics.iter().all(|d| d.severity != DiagnosticSeverity::Error),
|
editable: diagnostics
|
||||||
|
.iter()
|
||||||
|
.all(|d| d.severity != DiagnosticSeverity::Error),
|
||||||
revision,
|
revision,
|
||||||
size_bytes,
|
size_bytes,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
|
|
@ -807,9 +949,16 @@ fn summarize_source(
|
||||||
fn read_registry(workspace_root: &Path) -> std::result::Result<ProfileRegistryDocument, String> {
|
fn read_registry(workspace_root: &Path) -> std::result::Result<ProfileRegistryDocument, String> {
|
||||||
let path = registry_path(workspace_root);
|
let path = registry_path(workspace_root);
|
||||||
match fs::read_to_string(&path) {
|
match fs::read_to_string(&path) {
|
||||||
Ok(raw) => toml::from_str(&raw).map_err(|err| format!("invalid profile registry schema: {err}")),
|
Ok(raw) => {
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(ProfileRegistryDocument::default()),
|
toml::from_str(&raw).map_err(|err| format!("invalid profile registry schema: {err}"))
|
||||||
Err(err) => Err(format!("failed to read profile registry: {}", sanitize_error(&err.to_string()))),
|
}
|
||||||
|
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())
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -824,7 +973,10 @@ fn write_registry(workspace_root: &Path, registry: &ProfileRegistryDocument) ->
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn project_entries(registry: &ProfileRegistryDocument, diagnostics: &mut Vec<RuntimeDiagnostic>) -> Vec<ProjectProfileEntry> {
|
fn project_entries(
|
||||||
|
registry: &ProfileRegistryDocument,
|
||||||
|
diagnostics: &mut Vec<RuntimeDiagnostic>,
|
||||||
|
) -> Vec<ProjectProfileEntry> {
|
||||||
registry
|
registry
|
||||||
.profile
|
.profile
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -839,7 +991,9 @@ fn project_entries(registry: &ProfileRegistryDocument, diagnostics: &mut Vec<Run
|
||||||
}
|
}
|
||||||
let (path, description) = match entry {
|
let (path, description) = match entry {
|
||||||
ProfileEntryFile::Path(path) => (path.clone(), None),
|
ProfileEntryFile::Path(path) => (path.clone(), None),
|
||||||
ProfileEntryFile::Table(table) => (table.path.clone(), table.description.clone()),
|
ProfileEntryFile::Table(table) => {
|
||||||
|
(table.path.clone(), table.description.clone())
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match registry_relative_source_path(&path) {
|
match registry_relative_source_path(&path) {
|
||||||
Ok(relative_path) => Some(ProjectProfileEntry {
|
Ok(relative_path) => Some(ProjectProfileEntry {
|
||||||
|
|
@ -869,7 +1023,10 @@ fn project_entries(registry: &ProfileRegistryDocument, diagnostics: &mut Vec<Run
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn entry_for_source_id(registry: &ProfileRegistryDocument, source_id: &str) -> Result<(String, ProjectProfileEntry)> {
|
fn entry_for_source_id(
|
||||||
|
registry: &ProfileRegistryDocument,
|
||||||
|
source_id: &str,
|
||||||
|
) -> Result<(String, ProjectProfileEntry)> {
|
||||||
let (name, _) = parse_project_source_id(source_id)?;
|
let (name, _) = parse_project_source_id(source_id)?;
|
||||||
let mut diagnostics = Vec::new();
|
let mut diagnostics = Vec::new();
|
||||||
let entry = project_entries(registry, &mut diagnostics)
|
let entry = project_entries(registry, &mut diagnostics)
|
||||||
|
|
@ -899,11 +1056,16 @@ fn registry_relative_source_path(raw: &str) -> std::result::Result<PathBuf, Stri
|
||||||
|
|
||||||
fn validate_relative_source_path(path: &Path) -> std::result::Result<(), String> {
|
fn validate_relative_source_path(path: &Path) -> std::result::Result<(), String> {
|
||||||
if !path.starts_with(PROFILE_SOURCE_ROOT_RELATIVE_PATH) {
|
if !path.starts_with(PROFILE_SOURCE_ROOT_RELATIVE_PATH) {
|
||||||
return Err("Profile source path must be under the workspace profile source root.".to_string());
|
return Err(
|
||||||
|
"Profile source path must be under the workspace profile source root.".to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for component in path.components() {
|
for component in path.components() {
|
||||||
if !matches!(component, Component::Normal(_)) {
|
if !matches!(component, Component::Normal(_)) {
|
||||||
return Err("Profile source path must not contain absolute, parent, or prefix components.".to_string());
|
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") {
|
if path.extension().and_then(|value| value.to_str()) != Some("dcdl") {
|
||||||
|
|
@ -913,10 +1075,12 @@ fn validate_relative_source_path(path: &Path) -> std::result::Result<(), String>
|
||||||
}
|
}
|
||||||
|
|
||||||
fn checked_source_path(workspace_root: &Path, relative_path: &Path) -> Result<PathBuf> {
|
fn checked_source_path(workspace_root: &Path, relative_path: &Path) -> Result<PathBuf> {
|
||||||
validate_relative_source_path(relative_path).map_err(|message| Error::RuntimeOperationFailed {
|
validate_relative_source_path(relative_path).map_err(|message| {
|
||||||
|
Error::RuntimeOperationFailed {
|
||||||
runtime_id: "workspace-backend".to_string(),
|
runtime_id: "workspace-backend".to_string(),
|
||||||
code: "profile_source_path_escape".to_string(),
|
code: "profile_source_path_escape".to_string(),
|
||||||
message,
|
message,
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
let source_root = workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH);
|
let source_root = workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH);
|
||||||
fs::create_dir_all(&source_root)?;
|
fs::create_dir_all(&source_root)?;
|
||||||
|
|
@ -927,7 +1091,8 @@ fn checked_source_path(workspace_root: &Path, relative_path: &Path) -> Result<Pa
|
||||||
return Err(Error::RuntimeOperationFailed {
|
return Err(Error::RuntimeOperationFailed {
|
||||||
runtime_id: "workspace-backend".to_string(),
|
runtime_id: "workspace-backend".to_string(),
|
||||||
code: "profile_source_symlink_escape".to_string(),
|
code: "profile_source_symlink_escape".to_string(),
|
||||||
message: "Profile source resolves outside the workspace profile source root".to_string(),
|
message: "Profile source resolves outside the workspace profile source root"
|
||||||
|
.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if let Some(parent) = full.parent() {
|
} else if let Some(parent) = full.parent() {
|
||||||
|
|
@ -936,27 +1101,14 @@ fn checked_source_path(workspace_root: &Path, relative_path: &Path) -> Result<Pa
|
||||||
return Err(Error::RuntimeOperationFailed {
|
return Err(Error::RuntimeOperationFailed {
|
||||||
runtime_id: "workspace-backend".to_string(),
|
runtime_id: "workspace-backend".to_string(),
|
||||||
code: "profile_source_path_escape".to_string(),
|
code: "profile_source_path_escape".to_string(),
|
||||||
message: "Profile source parent resolves outside the workspace profile source root".to_string(),
|
message: "Profile source parent resolves outside the workspace profile source root"
|
||||||
|
.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(full)
|
Ok(full)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_default_profile(value: Option<&str>) -> Result<()> {
|
|
||||||
if let Some(value) = value {
|
|
||||||
if BUILTIN_PROFILE_IDS.contains(&value) || value.starts_with("project:") {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
return Err(Error::RuntimeOperationFailed {
|
|
||||||
runtime_id: "workspace-backend".to_string(),
|
|
||||||
code: "profile_default_invalid".to_string(),
|
|
||||||
message: "Default profile must be a Backend-published builtin or project selector".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_profile_name(value: &str) -> Result<String> {
|
fn validate_profile_name(value: &str) -> Result<String> {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if trimmed.is_empty()
|
if trimmed.is_empty()
|
||||||
|
|
@ -968,7 +1120,8 @@ fn validate_profile_name(value: &str) -> Result<String> {
|
||||||
return Err(Error::RuntimeOperationFailed {
|
return Err(Error::RuntimeOperationFailed {
|
||||||
runtime_id: "workspace-backend".to_string(),
|
runtime_id: "workspace-backend".to_string(),
|
||||||
code: "profile_selector_invalid".to_string(),
|
code: "profile_selector_invalid".to_string(),
|
||||||
message: "Profile selector must contain only ASCII letters, digits, '-' or '_'".to_string(),
|
message: "Profile selector must contain only ASCII letters, digits, '-' or '_'"
|
||||||
|
.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(trimmed.to_string())
|
Ok(trimmed.to_string())
|
||||||
|
|
@ -1006,16 +1159,22 @@ pub fn project_source_id(name: &str) -> String {
|
||||||
format!("project:{name}")
|
format!("project:{name}")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn selector_for_builtin_candidate(id: &str) -> Option<worker_runtime::catalog::ProfileSelector> {
|
pub fn selector_for_builtin_candidate(
|
||||||
|
id: &str,
|
||||||
|
) -> Option<worker_runtime::catalog::ProfileSelector> {
|
||||||
match id {
|
match id {
|
||||||
"runtime_default" => Some(worker_runtime::catalog::ProfileSelector::RuntimeDefault),
|
"runtime_default" => Some(worker_runtime::catalog::ProfileSelector::RuntimeDefault),
|
||||||
"builtin:default" | "builtin:companion" | "builtin:intake" | "builtin:orchestrator"
|
"builtin:default"
|
||||||
| "builtin:coder" | "builtin:reviewer" => {
|
| "builtin:companion"
|
||||||
Some(worker_runtime::catalog::ProfileSelector::Builtin(id.to_string()))
|
| "builtin:intake"
|
||||||
}
|
| "builtin:orchestrator"
|
||||||
value if value.starts_with("project:") => {
|
| "builtin:coder"
|
||||||
Some(worker_runtime::catalog::ProfileSelector::Named(value.to_string()))
|
| "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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1090,7 +1249,8 @@ fn sanitize_error(value: &str) -> String {
|
||||||
value
|
value
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
.map(|token| {
|
.map(|token| {
|
||||||
if token.starts_with('/') || token.contains("/.yoi/") || token.contains(".yoi/sessions") {
|
if token.starts_with('/') || token.contains("/.yoi/") || token.contains(".yoi/sessions")
|
||||||
|
{
|
||||||
"<redacted-path>"
|
"<redacted-path>"
|
||||||
} else {
|
} else {
|
||||||
token
|
token
|
||||||
|
|
@ -1131,14 +1291,18 @@ mod tests {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(created
|
assert!(
|
||||||
|
created
|
||||||
.settings
|
.settings
|
||||||
.profiles
|
.profiles
|
||||||
.iter()
|
.iter()
|
||||||
.any(|profile| profile.profile_id == "project:alpha"));
|
.any(|profile| profile.profile_id == "project:alpha")
|
||||||
assert!(build_workspace_profile_archive(dir.path(), "project:alpha")
|
);
|
||||||
|
assert!(
|
||||||
|
build_workspace_profile_archive(dir.path(), "project:alpha")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.is_some());
|
.is_some()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1151,10 +1315,12 @@ mod tests {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let settings = load_profile_settings("workspace-test", dir.path());
|
let settings = load_profile_settings("workspace-test", dir.path());
|
||||||
assert!(settings
|
assert!(
|
||||||
|
settings
|
||||||
.diagnostics
|
.diagnostics
|
||||||
.iter()
|
.iter()
|
||||||
.any(|diagnostic| diagnostic.code == "profile_source_path_escape"));
|
.any(|diagnostic| diagnostic.code == "profile_source_path_escape")
|
||||||
|
);
|
||||||
|
|
||||||
let err = update_profile_registry(
|
let err = update_profile_registry(
|
||||||
"workspace-test",
|
"workspace-test",
|
||||||
|
|
@ -1166,7 +1332,10 @@ mod tests {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(err.to_string().contains("profile_registry_revision_conflict"));
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains("profile_registry_revision_conflict")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1186,6 +1355,124 @@ mod tests {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(err.to_string().contains("profile_source_syntax_invalid") || err.to_string().contains("profile_source_archive_invalid"));
|
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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -856,15 +856,18 @@ async fn scoped_update_workspace_settings(
|
||||||
Json(request): Json<UpdateWorkspaceMetadataRequest>,
|
Json(request): Json<UpdateWorkspaceMetadataRequest>,
|
||||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceMetadataMutationResponse>> {
|
) -> ApiResult<Json<crate::profile_settings::WorkspaceMetadataMutationResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
let workspace = crate::profile_settings::update_workspace_metadata(&api.config.workspace_root, request)?;
|
let workspace =
|
||||||
Ok(Json(crate::profile_settings::WorkspaceMetadataMutationResponse {
|
crate::profile_settings::update_workspace_metadata(&api.config.workspace_root, request)?;
|
||||||
|
Ok(Json(
|
||||||
|
crate::profile_settings::WorkspaceMetadataMutationResponse {
|
||||||
workspace,
|
workspace,
|
||||||
diagnostics: vec![RuntimeDiagnostic {
|
diagnostics: vec![RuntimeDiagnostic {
|
||||||
code: "workspace_metadata_updated".to_string(),
|
code: "workspace_metadata_updated".to_string(),
|
||||||
severity: DiagnosticSeverity::Info,
|
severity: DiagnosticSeverity::Info,
|
||||||
message: "Workspace display metadata was updated.".to_string(),
|
message: "Workspace display metadata was updated.".to_string(),
|
||||||
}],
|
}],
|
||||||
}))
|
},
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_get_profile_settings(
|
async fn scoped_get_profile_settings(
|
||||||
|
|
@ -1769,7 +1772,9 @@ async fn create_workspace_worker(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
Json(request): Json<BrowserCreateWorkerRequest>,
|
Json(request): Json<BrowserCreateWorkerRequest>,
|
||||||
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
|
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
|
||||||
let profile_selector = profile_selector_for_candidate_with_root(&api.config.workspace_root, &request.profile).ok_or_else(|| {
|
let profile_selector =
|
||||||
|
profile_selector_for_candidate_with_root(&api.config.workspace_root, &request.profile)
|
||||||
|
.ok_or_else(|| {
|
||||||
settings_bad_request(
|
settings_bad_request(
|
||||||
"unsupported_worker_profile",
|
"unsupported_worker_profile",
|
||||||
"profile must be selected from Backend-published worker profile candidates",
|
"profile must be selected from Backend-published worker profile candidates",
|
||||||
|
|
@ -3001,12 +3006,14 @@ fn worker_profile_candidates_for_root(workspace_root: &Path) -> Vec<WorkerLaunch
|
||||||
}
|
}
|
||||||
|
|
||||||
fn profile_selector_for_candidate(profile: &str) -> Option<ProfileSelector> {
|
fn profile_selector_for_candidate(profile: &str) -> Option<ProfileSelector> {
|
||||||
crate::profile_settings::selector_for_builtin_candidate(profile).filter(|_| {
|
crate::profile_settings::selector_for_builtin_candidate(profile)
|
||||||
matches!(profile, "builtin:coder" | "runtime_default")
|
.filter(|_| matches!(profile, "builtin:coder" | "runtime_default"))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn profile_selector_for_candidate_with_root(workspace_root: &Path, profile: &str) -> Option<ProfileSelector> {
|
fn profile_selector_for_candidate_with_root(
|
||||||
|
workspace_root: &Path,
|
||||||
|
profile: &str,
|
||||||
|
) -> Option<ProfileSelector> {
|
||||||
if profile_selector_for_candidate(profile).is_some() {
|
if profile_selector_for_candidate(profile).is_some() {
|
||||||
profile_selector_for_candidate(profile)
|
profile_selector_for_candidate(profile)
|
||||||
} else if crate::profile_settings::is_profile_candidate(workspace_root, profile) {
|
} else if crate::profile_settings::is_profile_candidate(workspace_root, profile) {
|
||||||
|
|
@ -3354,10 +3361,15 @@ struct ApiError {
|
||||||
|
|
||||||
impl From<Error> for ApiError {
|
impl From<Error> for ApiError {
|
||||||
fn from(error: Error) -> Self {
|
fn from(error: Error) -> Self {
|
||||||
Self {
|
let diagnostics = match &error {
|
||||||
error,
|
Error::RuntimeOperationFailed { code, message, .. } => vec![RuntimeDiagnostic {
|
||||||
diagnostics: Vec::new(),
|
code: code.clone(),
|
||||||
}
|
severity: DiagnosticSeverity::Error,
|
||||||
|
message: sanitize_backend_error(message),
|
||||||
|
}],
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
Self { error, diagnostics }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3397,6 +3409,23 @@ impl IntoResponse for ApiError {
|
||||||
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => {
|
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => {
|
||||||
StatusCode::NOT_IMPLEMENTED
|
StatusCode::NOT_IMPLEMENTED
|
||||||
}
|
}
|
||||||
|
Error::RuntimeOperationFailed { code, .. }
|
||||||
|
if code == "profile_registry_revision_conflict"
|
||||||
|
|| code == "profile_source_revision_conflict"
|
||||||
|
|| code == "workspace_metadata_revision_conflict" =>
|
||||||
|
{
|
||||||
|
StatusCode::CONFLICT
|
||||||
|
}
|
||||||
|
Error::RuntimeOperationFailed { code, .. }
|
||||||
|
if code == "unknown_profile_source" || code == "unknown_profile_selector" =>
|
||||||
|
{
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
}
|
||||||
|
Error::RuntimeOperationFailed { code, .. }
|
||||||
|
if code == "workspace_display_name_invalid" || code.starts_with("profile_") =>
|
||||||
|
{
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
}
|
||||||
Error::RuntimeOperationFailed { code, .. } if code.ends_with("_blocked") => {
|
Error::RuntimeOperationFailed { code, .. } if code.ends_with("_blocked") => {
|
||||||
StatusCode::CONFLICT
|
StatusCode::CONFLICT
|
||||||
}
|
}
|
||||||
|
|
@ -3414,12 +3443,18 @@ impl IntoResponse for ApiError {
|
||||||
Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY,
|
Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY,
|
||||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
};
|
};
|
||||||
|
let response_message = match &self.error {
|
||||||
|
Error::RuntimeOperationFailed { code, message, .. } => {
|
||||||
|
format!("{code}: {}", sanitize_backend_error(message))
|
||||||
|
}
|
||||||
|
_ => sanitize_backend_error(&self.error.to_string()),
|
||||||
|
};
|
||||||
(
|
(
|
||||||
status,
|
status,
|
||||||
[(CONTENT_TYPE, "application/json")],
|
[(CONTENT_TYPE, "application/json")],
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"error": status.canonical_reason().unwrap_or("error"),
|
"error": status.canonical_reason().unwrap_or("error"),
|
||||||
"message": self.error.to_string(),
|
"message": response_message,
|
||||||
"diagnostics": self.diagnostics,
|
"diagnostics": self.diagnostics,
|
||||||
}))
|
}))
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
|
@ -3435,7 +3470,7 @@ mod tests {
|
||||||
use axum::http::Request;
|
use axum::http::Request;
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::sync::Arc;
|
use std::{fs, sync::Arc};
|
||||||
use tokio_tungstenite::connect_async;
|
use tokio_tungstenite::connect_async;
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
@ -3492,6 +3527,210 @@ mod tests {
|
||||||
assert!(!sanitized.contains("/home/example"));
|
assert!(!sanitized.contains("/home/example"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn profile_settings_api_returns_typed_diagnostics_for_duplicate_selector() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let response = profile_settings_request(
|
||||||
|
dir.path(),
|
||||||
|
"POST",
|
||||||
|
"/settings/profiles",
|
||||||
|
json!({
|
||||||
|
"name": "coder",
|
||||||
|
"content": valid_profile_source("coder"),
|
||||||
|
"registry_revision": "missing"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||||
|
assert_diagnostic(&response.1, "profile_selector_duplicate");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn profile_settings_api_returns_typed_diagnostics_for_invalid_decodal() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let response = profile_settings_request(
|
||||||
|
dir.path(),
|
||||||
|
"POST",
|
||||||
|
"/settings/profiles",
|
||||||
|
json!({
|
||||||
|
"name": "bad",
|
||||||
|
"content": "not decodal",
|
||||||
|
"registry_revision": "missing"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||||
|
assert!(
|
||||||
|
diagnostic_codes(&response.1)
|
||||||
|
.iter()
|
||||||
|
.any(|code| code.starts_with("profile_source_"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn profile_settings_api_returns_typed_diagnostic_for_invalid_registry_schema() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
||||||
|
fs::write(dir.path().join(".yoi/profiles.toml"), "[profile\n").unwrap();
|
||||||
|
let response = profile_settings_request(
|
||||||
|
dir.path(),
|
||||||
|
"POST",
|
||||||
|
"/settings/profiles",
|
||||||
|
json!({
|
||||||
|
"name": "schema",
|
||||||
|
"content": valid_profile_source("schema"),
|
||||||
|
"registry_revision": test_file_revision(&dir.path().join(".yoi/profiles.toml"))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||||
|
assert_diagnostic(&response.1, "profile_registry_schema_invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn profile_settings_api_returns_conflict_diagnostic_for_stale_revision() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let response = profile_settings_request(
|
||||||
|
dir.path(),
|
||||||
|
"POST",
|
||||||
|
"/settings/profiles",
|
||||||
|
json!({
|
||||||
|
"name": "alpha",
|
||||||
|
"content": valid_profile_source("alpha"),
|
||||||
|
"registry_revision": "stale"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.0, StatusCode::CONFLICT);
|
||||||
|
assert_diagnostic(&response.1, "profile_registry_revision_conflict");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn profile_settings_api_returns_typed_diagnostic_for_too_large_source() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let response = profile_settings_request(
|
||||||
|
dir.path(),
|
||||||
|
"POST",
|
||||||
|
"/settings/profiles",
|
||||||
|
json!({
|
||||||
|
"name": "large",
|
||||||
|
"content": "x".repeat((256 * 1024) + 1),
|
||||||
|
"registry_revision": "missing"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||||
|
assert_diagnostic(&response.1, "profile_source_too_large");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn profile_settings_api_redacts_symlink_escape_response() {
|
||||||
|
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_profile_source("escape")).unwrap();
|
||||||
|
std::os::unix::fs::symlink(&outside, dir.path().join(".yoi/profiles/escape.dcdl")).unwrap();
|
||||||
|
let response = profile_settings_request(
|
||||||
|
dir.path(),
|
||||||
|
"PUT",
|
||||||
|
"/settings/profiles/registry",
|
||||||
|
json!({
|
||||||
|
"registry_revision": test_file_revision(&dir.path().join(".yoi/profiles.toml")),
|
||||||
|
"default_profile": null,
|
||||||
|
"profiles": [{ "name": "escape", "profile_source_id": "project:escape" }]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.0, StatusCode::BAD_REQUEST);
|
||||||
|
assert_diagnostic(&response.1, "profile_source_symlink_escape");
|
||||||
|
let rendered = response.1.to_string();
|
||||||
|
assert!(!rendered.contains(dir.path().to_string_lossy().as_ref()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn worker_launch_rejects_invalid_project_profile_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();
|
||||||
|
assert!(
|
||||||
|
worker_profile_candidates_for_root(dir.path())
|
||||||
|
.iter()
|
||||||
|
.all(|candidate| candidate.id != "project:bad")
|
||||||
|
);
|
||||||
|
assert!(profile_selector_for_candidate_with_root(dir.path(), "project:bad").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_profile_source(slug: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{
|
||||||
|
slug = "{slug}";
|
||||||
|
description = "Test";
|
||||||
|
scope = "workspace_read";
|
||||||
|
}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_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(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|duration| duration.as_nanos())
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("rev:{modified}:{}", metadata.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn profile_settings_request(
|
||||||
|
workspace_root: &Path,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: Value,
|
||||||
|
) -> (StatusCode, Value) {
|
||||||
|
let app = test_app(workspace_root.to_path_buf()).await;
|
||||||
|
let request = Request::builder()
|
||||||
|
.method(method)
|
||||||
|
.uri(format!("/api/w/{TEST_WORKSPACE_ID}{path}"))
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(body.to_string()))
|
||||||
|
.unwrap();
|
||||||
|
let response = app.oneshot(request).await.unwrap();
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
|
||||||
|
let json = serde_json::from_slice::<Value>(&bytes).unwrap();
|
||||||
|
(status, json)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnostic_codes(response: &Value) -> Vec<String> {
|
||||||
|
response["diagnostics"]
|
||||||
|
.as_array()
|
||||||
|
.expect("diagnostics array")
|
||||||
|
.iter()
|
||||||
|
.map(|diagnostic| diagnostic["code"].as_str().unwrap().to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_diagnostic(response: &Value, code: &str) {
|
||||||
|
let codes = diagnostic_codes(response);
|
||||||
|
assert!(
|
||||||
|
!codes.is_empty(),
|
||||||
|
"diagnostics must not be empty: {response}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
codes.iter().any(|actual| actual == code),
|
||||||
|
"missing {code}: {codes:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct DeterministicExecutionBackend {
|
struct DeterministicExecutionBackend {
|
||||||
contexts: std::sync::Mutex<
|
contexts: std::sync::Mutex<
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user