Skip to main content

bamboo_server/
project_context.rs

1//! Adapter from the authoritative `bamboo-projects` store into the engine's
2//! secret-free Project context seam.
3
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use bamboo_domain::ProjectId;
8use bamboo_engine::project_context::{
9    ProjectContextError, ProjectContextSource, ProjectDescriptor, ProjectMemoryReadRoots,
10};
11use bamboo_memory::memory_store::LegacyProjectMemoryReadRoot;
12use bamboo_projects::{ProjectStore, ProjectStoreError};
13
14#[derive(Debug, thiserror::Error)]
15pub enum ProjectWorkspaceValidationError {
16    #[error("{message}")]
17    Invalid {
18        code: &'static str,
19        workspace: String,
20        message: String,
21    },
22    #[error(
23        "workspace '{workspace}' belongs to Project '{owner_project_id}', not session Project '{session_project_id}'"
24    )]
25    Conflict {
26        workspace: String,
27        owner_project_id: ProjectId,
28        session_project_id: String,
29    },
30    #[error("failed to resolve workspace Project ownership: {0}")]
31    Store(#[from] ProjectStoreError),
32}
33
34#[derive(Debug, thiserror::Error)]
35pub(crate) enum SessionWorkspaceValidationError {
36    #[error("{message}")]
37    Invalid { workspace: String, message: String },
38    #[error(
39        "workspace '{workspace}' belongs to Project '{owner_project_id}', not session Project '{session_project_id}'"
40    )]
41    Conflict {
42        workspace: String,
43        owner_project_id: ProjectId,
44        session_project_id: String,
45    },
46    #[error("workspace '{workspace}' is not bound to assigned Project '{session_project_id}'")]
47    Unbound {
48        workspace: String,
49        session_project_id: ProjectId,
50    },
51    #[error("assigned Project '{project_id}' is archived")]
52    ProjectArchived { project_id: ProjectId },
53    #[error("assigned Project '{project_id}' is unavailable")]
54    ProjectUnavailable { project_id: ProjectId },
55    #[error("failed to resolve workspace Project ownership: {0}")]
56    Store(#[from] ProjectStoreError),
57}
58
59pub(crate) fn session_workspace_error_response(
60    error: SessionWorkspaceValidationError,
61) -> actix_web::HttpResponse {
62    match error {
63        SessionWorkspaceValidationError::Invalid { workspace, message } => {
64            actix_web::HttpResponse::BadRequest().json(serde_json::json!({
65                "error": {
66                    "type": "api_error",
67                    "code": "workspace_invalid",
68                    "message": message
69                },
70                "workspace": workspace,
71            }))
72        }
73        SessionWorkspaceValidationError::Conflict {
74            workspace,
75            owner_project_id,
76            session_project_id,
77        } => actix_web::HttpResponse::Conflict().json(serde_json::json!({
78            "error": {
79                "type": "api_error",
80                "code": "project_workspace_conflict",
81                "message": "Workspace belongs to another Project"
82            },
83            "workspace": workspace,
84            "owner_project_id": owner_project_id,
85            "session_project_id": session_project_id,
86        })),
87        SessionWorkspaceValidationError::Unbound {
88            workspace,
89            session_project_id,
90        } => actix_web::HttpResponse::Conflict().json(serde_json::json!({
91            "error": {
92                "type": "api_error",
93                "code": "project_workspace_unbound",
94                "message": "Workspace must be bound to the session Project before switching"
95            },
96            "workspace": workspace,
97            "session_project_id": session_project_id,
98        })),
99        SessionWorkspaceValidationError::ProjectArchived { project_id } => {
100            actix_web::HttpResponse::Conflict().json(serde_json::json!({
101                "error": {
102                    "type": "api_error",
103                    "code": "project_archived",
104                    "message": "Archived Projects cannot switch session workspaces"
105                },
106                "project_id": project_id,
107            }))
108        }
109        SessionWorkspaceValidationError::ProjectUnavailable { project_id } => {
110            actix_web::HttpResponse::Conflict().json(serde_json::json!({
111                "error": {
112                    "type": "api_error",
113                    "code": "project_unavailable",
114                    "message": "Assigned Project is unavailable"
115                },
116                "project_id": project_id,
117            }))
118        }
119        SessionWorkspaceValidationError::Store(error) => {
120            tracing::error!(%error, "failed to validate session workspace Project ownership");
121            crate::error::json_error(
122                actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
123                "Failed to validate workspace Project ownership",
124            )
125        }
126    }
127}
128
129pub(crate) fn project_context_error_response(
130    error: ProjectContextError,
131) -> actix_web::HttpResponse {
132    match error {
133        ProjectContextError::WorkspaceConflict {
134            workspace,
135            owner_project_id,
136            session_project_id,
137        } => actix_web::HttpResponse::Conflict().json(serde_json::json!({
138            "error": {
139                "type": "api_error",
140                "code": "project_workspace_conflict",
141                "message": "Workspace belongs to another Project"
142            },
143            "workspace": workspace,
144            "owner_project_id": owner_project_id,
145            "session_project_id": session_project_id,
146        })),
147        ProjectContextError::UnassignedWorkspaceConflict {
148            workspace,
149            owner_project_id,
150        } => actix_web::HttpResponse::Conflict().json(serde_json::json!({
151            "error": {
152                "type": "api_error",
153                "code": "project_workspace_conflict",
154                "message": "Workspace belongs to another Project"
155            },
156            "workspace": workspace,
157            "owner_project_id": owner_project_id,
158            "session_project_id": "unassigned",
159        })),
160        ProjectContextError::WorkspaceInvalid { workspace, message } => {
161            actix_web::HttpResponse::BadRequest().json(serde_json::json!({
162                "error": {
163                    "type": "api_error",
164                    "code": "workspace_invalid",
165                    "message": message
166                },
167                "workspace": workspace,
168            }))
169        }
170        ProjectContextError::InvalidProjectIdentity { raw, message } => {
171            actix_web::HttpResponse::BadRequest().json(serde_json::json!({
172                "error": {
173                    "type": "api_error",
174                    "code": "invalid_project_identity",
175                    "message": format!(
176                        "Session carries an invalid Project identity '{raw}': {message}"
177                    )
178                }
179            }))
180        }
181        ProjectContextError::ProjectUnavailable { project_id } => {
182            actix_web::HttpResponse::Conflict().json(serde_json::json!({
183                "error": {
184                    "type": "api_error",
185                    "code": "project_unavailable",
186                    "message": "Assigned Project is unavailable"
187                },
188                "project_id": project_id,
189            }))
190        }
191        ProjectContextError::ProjectPathMissing { project_id } => {
192            actix_web::HttpResponse::Conflict().json(serde_json::json!({
193                "error": {
194                    "type": "api_error",
195                    "code": "project_path_missing",
196                    "message": "Assigned Project has no configured project_path"
197                },
198                "project_id": project_id,
199            }))
200        }
201        ProjectContextError::ProjectPathUnavailable {
202            project_id,
203            project_path,
204            message,
205        } => actix_web::HttpResponse::Conflict().json(serde_json::json!({
206            "error": {
207                "type": "api_error",
208                "code": "project_path_unavailable",
209                "message": message
210            },
211            "project_id": project_id,
212            "project_path": project_path,
213        })),
214        error @ (ProjectContextError::Source(_) | ProjectContextError::IdentityMismatch { .. }) => {
215            tracing::error!(%error, "failed to resolve Project context");
216            crate::error::json_error(
217                actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
218                "Failed to resolve Project context",
219            )
220        }
221    }
222}
223
224/// Resolve confinement before checking ownership, without mutating the global
225/// workspace registry or session metadata. HTTP creation paths must call this
226/// before any workspace/session side effect.
227pub fn validate_workspace_assignment(
228    store: &ProjectStore,
229    session_project_id: Option<&ProjectId>,
230    requested_workspace: Option<&str>,
231) -> Result<Option<std::path::PathBuf>, ProjectWorkspaceValidationError> {
232    validate_workspace_assignment_with(
233        store,
234        session_project_id,
235        requested_workspace,
236        bamboo_agent_core::workspace_state::preview_workspace_path,
237        false,
238    )
239}
240
241pub fn validate_workspace_assignment_with_resolver(
242    store: &ProjectStore,
243    session_project_id: Option<&ProjectId>,
244    requested_workspace: Option<&str>,
245    workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
246) -> Result<Option<std::path::PathBuf>, ProjectWorkspaceValidationError> {
247    validate_workspace_assignment_with(
248        store,
249        session_project_id,
250        requested_workspace,
251        |workspace| workspace_resolver.preview_workspace_path(workspace),
252        false,
253    )
254}
255
256/// Validate an explicit session Workspace switch.
257///
258/// This is stricter than the generic creation/runtime resolver: an assigned
259/// session may switch only to a path already registered to its active Project.
260/// The API never binds a path as a side effect. Unassigned sessions may switch
261/// to an unregistered path, but still cannot borrow another Project's binding.
262pub(crate) fn validate_explicit_session_workspace_with_resolver(
263    store: &ProjectStore,
264    session_project_id: Option<&ProjectId>,
265    requested_workspace: &str,
266    workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
267) -> Result<std::path::PathBuf, SessionWorkspaceValidationError> {
268    let requested_workspace = requested_workspace.trim();
269    if requested_workspace.is_empty() {
270        return Err(SessionWorkspaceValidationError::Invalid {
271            workspace: String::new(),
272            message: "Workspace path must be a non-empty existing directory".to_string(),
273        });
274    }
275
276    if let Some(project_id) = session_project_id {
277        match store.get(project_id) {
278            Ok(project) if project.status == bamboo_domain::ProjectStatus::Active => {}
279            Ok(_) => {
280                return Err(SessionWorkspaceValidationError::ProjectArchived {
281                    project_id: project_id.clone(),
282                });
283            }
284            Err(ProjectStoreError::NotFound(_)) => {
285                return Err(SessionWorkspaceValidationError::ProjectUnavailable {
286                    project_id: project_id.clone(),
287                });
288            }
289            Err(error) => return Err(SessionWorkspaceValidationError::Store(error)),
290        }
291    }
292
293    let final_workspace = validate_workspace_assignment_with_resolver(
294        store,
295        session_project_id,
296        Some(requested_workspace),
297        workspace_resolver,
298    )
299    .map_err(|error| match error {
300        ProjectWorkspaceValidationError::Invalid {
301            workspace, message, ..
302        } => SessionWorkspaceValidationError::Invalid { workspace, message },
303        ProjectWorkspaceValidationError::Conflict {
304            workspace,
305            owner_project_id,
306            session_project_id,
307        } => SessionWorkspaceValidationError::Conflict {
308            workspace,
309            owner_project_id,
310            session_project_id,
311        },
312        ProjectWorkspaceValidationError::Store(error) => {
313            SessionWorkspaceValidationError::Store(error)
314        }
315    })?
316    .ok_or_else(|| SessionWorkspaceValidationError::Invalid {
317        workspace: requested_workspace.to_string(),
318        message: "Workspace path must be a non-empty existing directory".to_string(),
319    })?;
320
321    let display = bamboo_config::paths::path_to_display_string(&final_workspace);
322    let owner = match store.find_workspace_owner_for_path(&display) {
323        Ok(owner) => owner,
324        Err(ProjectStoreError::Validation(message))
325        | Err(ProjectStoreError::InvalidPathComponent(message)) => {
326            return Err(SessionWorkspaceValidationError::Invalid {
327                workspace: display,
328                message,
329            });
330        }
331        Err(error) => return Err(SessionWorkspaceValidationError::Store(error)),
332    };
333    match (session_project_id, owner) {
334        (Some(session_project_id), Some(owner)) if owner.id == *session_project_id => {
335            Ok(final_workspace)
336        }
337        (Some(session_project_id), Some(owner)) => Err(SessionWorkspaceValidationError::Conflict {
338            workspace: display,
339            owner_project_id: owner.id,
340            session_project_id: session_project_id.to_string(),
341        }),
342        (Some(session_project_id), None) => Err(SessionWorkspaceValidationError::Unbound {
343            workspace: display,
344            session_project_id: session_project_id.clone(),
345        }),
346        (None, Some(owner)) => Err(SessionWorkspaceValidationError::Conflict {
347            workspace: display,
348            owner_project_id: owner.id,
349            session_project_id: "unassigned".to_string(),
350        }),
351        (None, None) => Ok(final_workspace),
352    }
353}
354
355/// Validate a proposed authoritative Project path with the same
356/// canonicalization, confinement, and ownership rules used at session
357/// resolution time. `project_id` is `None` during create and the existing
358/// stable ID during a CAS update.
359pub fn validate_project_path_candidate_with_resolver(
360    store: &ProjectStore,
361    project_id: Option<&ProjectId>,
362    project_path: &str,
363    workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
364) -> Result<std::path::PathBuf, ProjectWorkspaceValidationError> {
365    validate_workspace_assignment_with(
366        store,
367        project_id,
368        Some(project_path),
369        |workspace| workspace_resolver.preview_workspace_path(workspace),
370        true,
371    )?
372    .ok_or_else(|| ProjectWorkspaceValidationError::Invalid {
373        code: "project_path_missing",
374        workspace: project_path.to_string(),
375        message: "Project path must be a non-empty existing directory".to_string(),
376    })
377}
378
379fn validate_workspace_assignment_with(
380    store: &ProjectStore,
381    session_project_id: Option<&ProjectId>,
382    requested_workspace: Option<&str>,
383    resolve_workspace: impl FnOnce(std::path::PathBuf) -> std::path::PathBuf,
384    explicit_is_project_path: bool,
385) -> Result<Option<std::path::PathBuf>, ProjectWorkspaceValidationError> {
386    let explicit_workspace = requested_workspace
387        .map(str::trim)
388        .filter(|workspace| !workspace.is_empty());
389    if explicit_is_project_path && explicit_workspace.is_none() {
390        return Err(ProjectWorkspaceValidationError::Invalid {
391            code: "project_path_missing",
392            workspace: requested_workspace.unwrap_or_default().to_string(),
393            message: "Project path must be a non-empty existing directory".to_string(),
394        });
395    }
396    let (requested_workspace, is_project_default, is_persisted_project_default) =
397        if let Some(workspace) = explicit_workspace {
398            (workspace.to_string(), explicit_is_project_path, false)
399        } else if let Some(project_id) = session_project_id {
400            let project = store.get(project_id)?;
401            let Some(project_path) = project.project_path else {
402                return Err(ProjectWorkspaceValidationError::Invalid {
403                    code: "project_path_missing",
404                    workspace: String::new(),
405                    message: format!("Project '{project_id}' has no configured project_path"),
406                });
407            };
408            (project_path, true, true)
409        } else {
410            return Ok(None);
411        };
412    let requested_path = std::path::Path::new(&requested_workspace);
413    if !requested_path.exists() {
414        return Err(ProjectWorkspaceValidationError::Invalid {
415            code: if is_project_default {
416                "project_path_unavailable"
417            } else {
418                "workspace_not_found"
419            },
420            workspace: requested_workspace,
421            message: if is_project_default {
422                "Project path does not exist".to_string()
423            } else {
424                "Workspace path does not exist".to_string()
425            },
426        });
427    }
428    if !requested_path.is_dir() {
429        return Err(ProjectWorkspaceValidationError::Invalid {
430            code: if is_project_default {
431                "project_path_unavailable"
432            } else {
433                "workspace_not_directory"
434            },
435            workspace: requested_workspace,
436            message: if is_project_default {
437                "Project path is not a directory".to_string()
438            } else {
439                "Workspace path is not a directory".to_string()
440            },
441        });
442    }
443    if is_persisted_project_default {
444        let metadata = std::fs::symlink_metadata(requested_path).map_err(|error| {
445            ProjectWorkspaceValidationError::Invalid {
446                code: "project_path_unavailable",
447                workspace: requested_workspace.clone(),
448                message: format!("Project path metadata is unavailable: {error}"),
449            }
450        })?;
451        if metadata.file_type().is_symlink() || !metadata.is_dir() {
452            return Err(ProjectWorkspaceValidationError::Invalid {
453                code: "project_path_unavailable",
454                workspace: requested_workspace,
455                message: "Configured Project path is no longer a plain directory".to_string(),
456            });
457        }
458    }
459    let canonical =
460        requested_path
461            .canonicalize()
462            .map_err(|_| ProjectWorkspaceValidationError::Invalid {
463                code: if is_project_default {
464                    "project_path_unavailable"
465                } else {
466                    "workspace_invalid"
467                },
468                workspace: requested_workspace.clone(),
469                message: if is_project_default {
470                    "Project path could not be canonicalized".to_string()
471                } else {
472                    "Workspace path could not be canonicalized".to_string()
473                },
474            })?;
475    if is_persisted_project_default && canonical != requested_path {
476        return Err(ProjectWorkspaceValidationError::Invalid {
477            code: "project_path_unavailable",
478            workspace: requested_workspace,
479            message:
480                "Configured Project path no longer resolves to its registered canonical directory"
481                    .to_string(),
482        });
483    }
484    let final_workspace = resolve_workspace(canonical.clone());
485    let final_workspace = final_workspace.canonicalize().unwrap_or(final_workspace);
486    let display = bamboo_config::paths::path_to_display_string(&final_workspace);
487    if is_project_default && final_workspace != canonical {
488        return Err(ProjectWorkspaceValidationError::Invalid {
489            code: "project_path_unavailable",
490            workspace: requested_workspace,
491            message: format!(
492                "Workspace confinement redirected the Project path to '{}'",
493                final_workspace.display()
494            ),
495        });
496    }
497    let owner = match store.find_workspace_owner_for_path(&display) {
498        Ok(owner) => owner,
499        Err(ProjectStoreError::Validation(message))
500        | Err(ProjectStoreError::InvalidPathComponent(message)) => {
501            return Err(ProjectWorkspaceValidationError::Invalid {
502                code: if is_project_default {
503                    "project_path_unavailable"
504                } else {
505                    "workspace_invalid"
506                },
507                workspace: display,
508                message,
509            });
510        }
511        Err(error) => return Err(ProjectWorkspaceValidationError::Store(error)),
512    };
513    let Some(owner) = owner else {
514        return Ok(Some(final_workspace));
515    };
516    if session_project_id == Some(&owner.id) {
517        return Ok(Some(final_workspace));
518    }
519    Err(ProjectWorkspaceValidationError::Conflict {
520        workspace: display,
521        owner_project_id: owner.id,
522        session_project_id: session_project_id
523            .map(ToString::to_string)
524            .unwrap_or_else(|| "unassigned".to_string()),
525    })
526}
527
528pub struct ProjectStoreContextSource {
529    store: Arc<ProjectStore>,
530}
531
532impl ProjectStoreContextSource {
533    pub fn new(store: Arc<ProjectStore>) -> Self {
534        Self { store }
535    }
536}
537
538#[async_trait]
539impl ProjectContextSource for ProjectStoreContextSource {
540    async fn find_project(
541        &self,
542        project_id: &ProjectId,
543    ) -> Result<Option<ProjectDescriptor>, ProjectContextError> {
544        let manifest = match self.store.get(project_id) {
545            Ok(project) => project,
546            Err(ProjectStoreError::NotFound(_)) => return Ok(None),
547            Err(error) => return Err(ProjectContextError::Source(error.to_string())),
548        };
549        let resources = self
550            .store
551            .resource_summary(project_id)
552            .map_err(|error| ProjectContextError::Source(error.to_string()))?;
553        let memory_read_roots = self
554            .store
555            .project_memory_read_roots(project_id)
556            .map_err(|error| ProjectContextError::Source(error.to_string()))?;
557        Ok(Some(ProjectDescriptor {
558            id: manifest.id.clone(),
559            name: manifest.name,
560            project_path: manifest.project_path.map(std::path::PathBuf::from),
561            home: self.store.paths().project_home(project_id),
562            workspace_bindings: manifest.workspace_bindings,
563            resources,
564            memory_read_roots: ProjectMemoryReadRoots {
565                primary: memory_read_roots.primary,
566                legacy_aliases: memory_read_roots
567                    .legacy_aliases
568                    .into_iter()
569                    .map(|legacy| LegacyProjectMemoryReadRoot {
570                        project_key: legacy.legacy_project_key,
571                        root: legacy.root,
572                    })
573                    .collect(),
574            },
575        }))
576    }
577
578    async fn list_projects(&self) -> Result<Vec<ProjectDescriptor>, ProjectContextError> {
579        let project_ids = self
580            .store
581            .list()
582            .map_err(|error| ProjectContextError::Source(error.to_string()))?
583            .into_iter()
584            .map(|project| project.id)
585            .collect::<Vec<_>>();
586        let mut projects = Vec::with_capacity(project_ids.len());
587        for project_id in project_ids {
588            if let Some(project) = self.find_project(&project_id).await? {
589                projects.push(project);
590            }
591        }
592        Ok(projects)
593    }
594
595    async fn find_workspace_owner(
596        &self,
597        workspace: &std::path::Path,
598    ) -> Result<Option<ProjectId>, ProjectContextError> {
599        let display = bamboo_config::paths::path_to_display_string(workspace);
600        self.store
601            .find_workspace_owner_for_path(&display)
602            .map(|owner| owner.map(|project| project.id))
603            .map_err(|error| ProjectContextError::Source(error.to_string()))
604    }
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    #[test]
612    fn confinement_final_path_is_the_authority_for_ownership_and_persistence() {
613        let data = tempfile::tempdir().expect("data");
614        let raw = tempfile::tempdir().expect("raw workspace");
615        let confined = tempfile::tempdir().expect("confined workspace");
616        std::fs::write(raw.path().join("raw-only.txt"), "RAW MUST NOT BE USED")
617            .expect("raw fixture");
618        std::fs::write(confined.path().join("final-only.txt"), "FINAL").expect("final fixture");
619        let store = ProjectStore::open(data.path()).expect("Project store");
620        let owner = store
621            .create_with_bindings(
622                "Owner",
623                None,
624                vec![bamboo_domain::WorkspaceBinding {
625                    path: confined.path().to_string_lossy().into_owned(),
626                    label: None,
627                    git_common_dir: None,
628                }],
629            )
630            .expect("owner Project");
631        let other = store.create("Other", None).expect("other Project");
632
633        let resolved = validate_workspace_assignment_with(
634            &store,
635            Some(&owner.id),
636            Some(raw.path().to_string_lossy().as_ref()),
637            |_| confined.path().to_path_buf(),
638            false,
639        )
640        .expect("same owner");
641        assert_eq!(
642            resolved.as_deref(),
643            Some(confined.path().canonicalize().unwrap().as_path())
644        );
645        assert!(resolved
646            .as_ref()
647            .is_some_and(|path| path.join("final-only.txt").exists()));
648        assert!(!resolved
649            .as_ref()
650            .is_some_and(|path| path.join("raw-only.txt").exists()));
651
652        let conflict = validate_workspace_assignment_with(
653            &store,
654            Some(&other.id),
655            Some(raw.path().to_string_lossy().as_ref()),
656            |_| confined.path().to_path_buf(),
657            false,
658        )
659        .expect_err("final workspace owner must win over the raw request");
660        assert!(matches!(
661            conflict,
662            ProjectWorkspaceValidationError::Conflict {
663                owner_project_id,
664                ..
665            } if owner_project_id == owner.id
666        ));
667    }
668
669    #[test]
670    fn assigned_omission_uses_project_path_and_reports_unconfigured_legacy_project() {
671        let data = tempfile::tempdir().expect("data");
672        let project_path = tempfile::tempdir().expect("Project path");
673        let foreign_default = tempfile::tempdir().expect("foreign default");
674        let store = ProjectStore::open(data.path()).expect("Project store");
675        let project = store
676            .create_with_project_path(
677                "Configured",
678                None,
679                project_path.path().to_string_lossy(),
680                Vec::new(),
681            )
682            .expect("configured Project");
683
684        let resolved = validate_workspace_assignment_with(
685            &store,
686            Some(&project.id),
687            None,
688            |_| foreign_default.path().to_path_buf(),
689            false,
690        )
691        .expect_err("confinement must not silently relocate project_path");
692        assert!(matches!(
693            resolved,
694            ProjectWorkspaceValidationError::Invalid {
695                code: "project_path_unavailable",
696                ..
697            }
698        ));
699
700        let resolved = validate_workspace_assignment_with(
701            &store,
702            Some(&project.id),
703            None,
704            |workspace| workspace,
705            false,
706        )
707        .expect("Project path fallback");
708        assert_eq!(
709            resolved.as_deref(),
710            Some(project_path.path().canonicalize().unwrap().as_path())
711        );
712
713        let legacy = store.create("Legacy", None).expect("legacy Project");
714        let error = validate_workspace_assignment_with(
715            &store,
716            Some(&legacy.id),
717            None,
718            |workspace| workspace,
719            false,
720        )
721        .expect_err("unconfigured Project must fail closed");
722        assert!(matches!(
723            error,
724            ProjectWorkspaceValidationError::Invalid {
725                code: "project_path_missing",
726                ..
727            }
728        ));
729    }
730
731    #[cfg(unix)]
732    #[test]
733    fn assigned_omission_rejects_project_path_replaced_by_symlink() {
734        use std::os::unix::fs::symlink;
735
736        let root = tempfile::tempdir().expect("root");
737        let data = root.path().join("data");
738        let project_path = root.path().join("project");
739        let replacement = root.path().join("replacement");
740        std::fs::create_dir_all(&project_path).expect("Project path");
741        std::fs::create_dir_all(&replacement).expect("replacement");
742        let store = ProjectStore::open(&data).expect("Project store");
743        let project = store
744            .create_with_project_path(
745                "Configured",
746                None,
747                project_path.to_string_lossy(),
748                Vec::new(),
749            )
750            .expect("configured Project");
751
752        std::fs::remove_dir(&project_path).expect("remove original Project path");
753        symlink(&replacement, &project_path).expect("replace Project path with symlink");
754
755        let error = validate_workspace_assignment_with(
756            &store,
757            Some(&project.id),
758            None,
759            |workspace| workspace,
760            false,
761        )
762        .expect_err("persisted Project path must not follow a replacement symlink");
763        assert!(matches!(
764            error,
765            ProjectWorkspaceValidationError::Invalid {
766                code: "project_path_unavailable",
767                ..
768            }
769        ));
770    }
771}