Skip to main content

bamboo_projects/
lib.rs

1//! Crash-safe, revisioned storage for first-class Bamboo Projects.
2//!
3//! `project.json` is authoritative. `projects/index.json` is a derived cache
4//! rebuilt from manifests on startup or after every mutation.
5
6use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
7use std::fs::{File, OpenOptions};
8use std::io::{Read, Write};
9use std::path::{Component, Path, PathBuf};
10use std::process::Command;
11
12use bamboo_domain::{
13    LegacyProjectAssignment, LegacyProjectDryRunReport, LegacyProjectMatchBasis,
14    LegacyProjectSuggestion, LegacyProjectUnassigned, LegacySessionProjectInput, ProjectId,
15    ProjectIndex, ProjectIndexEntry, ProjectManifest, ProjectPathStatus, ProjectResourceEntry,
16    ProjectResourceKind, ProjectResourceSummary, ProjectStatus, WorkspaceBinding,
17    PROJECT_INDEX_SCHEMA_VERSION, PROJECT_MANIFEST_SCHEMA_VERSION,
18};
19use chrono::Utc;
20use fs2::FileExt;
21use serde::Serialize;
22use thiserror::Error;
23use uuid::Uuid;
24
25const PROJECT_MANIFEST_FILE: &str = "project.json";
26const PROJECT_MANIFEST_BACKUP_FILE: &str = "project.json.bak";
27const PROJECT_MANIFEST_REVISION_FILE: &str = "manifest-revision";
28const PROJECT_INDEX_FILE: &str = "index.json";
29
30#[derive(Debug, Error)]
31pub enum ProjectStoreError {
32    #[error("project store I/O failed")]
33    Io(#[from] std::io::Error),
34    #[error("project document is invalid")]
35    Json(#[from] serde_json::Error),
36    #[error("project {0} was not found")]
37    NotFound(ProjectId),
38    #[error("project {0} already exists")]
39    AlreadyExists(ProjectId),
40    #[error("project revision conflict: expected {expected}, actual {actual}")]
41    Conflict { expected: u64, actual: u64 },
42    #[error("project {0} is not archived")]
43    NotArchived(ProjectId),
44    #[error(
45        "project_path '{project_path}' cannot be unbound from Project '{project_id}'; select another Project path first"
46    )]
47    ProjectPathUnbindConflict {
48        project_id: ProjectId,
49        project_path: String,
50    },
51    #[error("project validation failed: {0}")]
52    Validation(String),
53    #[error("invalid project path component: {0}")]
54    InvalidPathComponent(String),
55}
56
57pub type ProjectStoreResult<T> = Result<T, ProjectStoreError>;
58
59/// Centralized paths rooted at `${BAMBOO_DATA_DIR}`.
60#[derive(Debug, Clone)]
61pub struct ProjectPaths {
62    data_dir: PathBuf,
63}
64
65impl ProjectPaths {
66    pub fn new(data_dir: impl Into<PathBuf>) -> Self {
67        Self {
68            data_dir: data_dir.into(),
69        }
70    }
71
72    pub fn data_dir(&self) -> &Path {
73        &self.data_dir
74    }
75
76    pub fn projects_dir(&self) -> PathBuf {
77        self.data_dir.join("projects")
78    }
79
80    pub fn index_path(&self) -> PathBuf {
81        self.projects_dir().join(PROJECT_INDEX_FILE)
82    }
83
84    pub fn project_home(&self, project_id: &ProjectId) -> PathBuf {
85        self.projects_dir().join(project_id.as_str())
86    }
87
88    pub fn manifest_path(&self, project_id: &ProjectId) -> PathBuf {
89        self.project_home(project_id).join(PROJECT_MANIFEST_FILE)
90    }
91
92    pub fn settings_path(&self, project_id: &ProjectId) -> PathBuf {
93        self.project_home(project_id).join("settings.json")
94    }
95
96    pub fn skills_dir(
97        &self,
98        project_id: &ProjectId,
99        mode: Option<&str>,
100    ) -> ProjectStoreResult<PathBuf> {
101        let name = match mode {
102            None => "skills".to_string(),
103            Some(mode) => {
104                validate_component(mode)?;
105                format!("skills-{mode}")
106            }
107        };
108        Ok(self.project_home(project_id).join(name))
109    }
110
111    pub fn commands_dir(&self, project_id: &ProjectId) -> PathBuf {
112        self.project_home(project_id).join("commands")
113    }
114
115    pub fn memory_v1_dir(&self, project_id: &ProjectId) -> PathBuf {
116        self.project_home(project_id).join("memory").join("v1")
117    }
118
119    pub fn artifacts_dir(&self, project_id: &ProjectId) -> PathBuf {
120        self.project_home(project_id).join("artifacts")
121    }
122
123    pub fn state_dir(&self, project_id: &ProjectId) -> PathBuf {
124        self.project_home(project_id).join("state")
125    }
126
127    pub fn manifest_revision_path(&self, project_id: &ProjectId) -> PathBuf {
128        self.state_dir(project_id)
129            .join(PROJECT_MANIFEST_REVISION_FILE)
130    }
131}
132
133fn validate_component(value: &str) -> ProjectStoreResult<()> {
134    let valid = !value.is_empty()
135        && value.len() <= 64
136        && value
137            .bytes()
138            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_');
139    if valid {
140        Ok(())
141    } else {
142        Err(ProjectStoreError::InvalidPathComponent(value.to_string()))
143    }
144}
145
146fn prepare_data_dir(data_dir: PathBuf) -> ProjectStoreResult<PathBuf> {
147    let mut requested = if data_dir.is_absolute() {
148        data_dir
149    } else {
150        std::env::current_dir()?.join(data_dir)
151    };
152    let mut missing = Vec::new();
153    loop {
154        match std::fs::symlink_metadata(&requested) {
155            Ok(metadata) => {
156                if metadata.file_type().is_symlink() || !metadata.is_dir() {
157                    return Err(ProjectStoreError::Validation(format!(
158                        "data directory component is not a plain directory: {}",
159                        requested.display()
160                    )));
161                }
162                break;
163            }
164            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
165                let component = requested.file_name().ok_or_else(|| {
166                    ProjectStoreError::Validation(format!(
167                        "data directory has no creatable component: {}",
168                        requested.display()
169                    ))
170                })?;
171                missing.push(component.to_os_string());
172                requested = requested
173                    .parent()
174                    .ok_or_else(|| {
175                        ProjectStoreError::Validation(
176                            "data directory has no existing ancestor".to_string(),
177                        )
178                    })?
179                    .to_path_buf();
180            }
181            Err(error) => return Err(error.into()),
182        }
183    }
184
185    let mut current = std::fs::canonicalize(&requested)?;
186    for component in missing.into_iter().rev() {
187        assert_plain_directory(&current)?;
188        let next = current.join(component);
189        match std::fs::symlink_metadata(&next) {
190            Ok(metadata) => {
191                if metadata.file_type().is_symlink() || !metadata.is_dir() {
192                    return Err(ProjectStoreError::Validation(format!(
193                        "data directory component is not a plain directory: {}",
194                        next.display()
195                    )));
196                }
197            }
198            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
199                assert_plain_directory(&current)?;
200                std::fs::create_dir(&next)?;
201                assert_plain_directory(&next)?;
202                sync_directory(&current)?;
203            }
204            Err(error) => return Err(error.into()),
205        }
206        let resolved = std::fs::canonicalize(&next)?;
207        if !resolved.starts_with(&current) {
208            return Err(ProjectStoreError::Validation(format!(
209                "data directory component escaped its parent: {}",
210                next.display()
211            )));
212        }
213        current = resolved;
214    }
215    Ok(current)
216}
217
218pub(crate) fn ensure_confined_directory(
219    trusted_base: &Path,
220    directory: &Path,
221) -> ProjectStoreResult<PathBuf> {
222    walk_confined_directory(trusted_base, directory, true)
223}
224
225pub(crate) fn validate_existing_confined_directory(
226    trusted_base: &Path,
227    directory: &Path,
228) -> ProjectStoreResult<PathBuf> {
229    walk_confined_directory(trusted_base, directory, false)
230}
231
232/// Walk from an already trusted base without following symlinks. Missing
233/// components are created one at a time after their parent is revalidated.
234fn walk_confined_directory(
235    trusted_base: &Path,
236    directory: &Path,
237    create_missing: bool,
238) -> ProjectStoreResult<PathBuf> {
239    assert_plain_directory(trusted_base)?;
240    let canonical_base = std::fs::canonicalize(trusted_base)?;
241    let relative = directory
242        .strip_prefix(trusted_base)
243        .or_else(|_| directory.strip_prefix(&canonical_base))
244        .map_err(|_| {
245            ProjectStoreError::Validation(format!(
246                "project store directory escapes trusted base: {}",
247                directory.display()
248            ))
249        })?;
250    let mut current = canonical_base.clone();
251    for component in relative.components() {
252        let std::path::Component::Normal(component) = component else {
253            return Err(ProjectStoreError::Validation(
254                "project store directory has an invalid component".to_string(),
255            ));
256        };
257        current.push(component);
258        match std::fs::symlink_metadata(&current) {
259            Ok(metadata) => {
260                if metadata.file_type().is_symlink() || !metadata.is_dir() {
261                    return Err(ProjectStoreError::Validation(format!(
262                        "project store directory component is not a plain directory: {}",
263                        current.display()
264                    )));
265                }
266            }
267            Err(error) if error.kind() == std::io::ErrorKind::NotFound && create_missing => {
268                let parent = current.parent().ok_or_else(|| {
269                    ProjectStoreError::Validation(
270                        "project store directory has no parent".to_string(),
271                    )
272                })?;
273                assert_plain_directory(parent)?;
274                std::fs::create_dir(&current)?;
275                assert_plain_directory(&current)?;
276                sync_directory(parent)?;
277            }
278            Err(error) => return Err(error.into()),
279        }
280    }
281    let resolved = std::fs::canonicalize(&current)?;
282    if !resolved.starts_with(&canonical_base) {
283        return Err(ProjectStoreError::Validation(format!(
284            "project store directory resolves outside trusted base: {}",
285            resolved.display()
286        )));
287    }
288    Ok(resolved)
289}
290
291pub(crate) fn assert_plain_directory(path: &Path) -> ProjectStoreResult<()> {
292    let metadata = std::fs::symlink_metadata(path)?;
293    if metadata.file_type().is_symlink() || !metadata.is_dir() {
294        return Err(ProjectStoreError::Validation(format!(
295            "expected a plain directory: {}",
296            path.display()
297        )));
298    }
299    Ok(())
300}
301
302fn validate_regular_file_if_exists(path: &Path, label: &str) -> ProjectStoreResult<bool> {
303    match std::fs::symlink_metadata(path) {
304        Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => Ok(true),
305        Ok(_) => Err(ProjectStoreError::Validation(format!(
306            "{label} is not a plain regular file: {}",
307            path.display()
308        ))),
309        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
310        Err(error) => Err(error.into()),
311    }
312}
313
314fn validate_required_regular_file(path: &Path, label: &str) -> ProjectStoreResult<()> {
315    if validate_regular_file_if_exists(path, label)? {
316        Ok(())
317    } else {
318        Err(std::io::Error::new(
319            std::io::ErrorKind::NotFound,
320            format!("{label} was not found: {}", path.display()),
321        )
322        .into())
323    }
324}
325
326#[derive(Debug, Clone)]
327pub struct ProjectStore {
328    paths: ProjectPaths,
329}
330
331impl ProjectStore {
332    /// Open the registry and rebuild its derived index. Corrupt individual
333    /// manifests are recovered from their last-known-good backup when possible;
334    /// otherwise they are quarantined and skipped rather than blocking startup.
335    pub fn open(data_dir: impl Into<PathBuf>) -> ProjectStoreResult<Self> {
336        let data_dir = prepare_data_dir(data_dir.into())?;
337        let paths = ProjectPaths::new(data_dir);
338        ensure_confined_directory(paths.data_dir(), &paths.projects_dir())?;
339        let store = Self { paths };
340        store.remove_orphan_temps()?;
341        store.rebuild_index()?;
342        Ok(store)
343    }
344
345    pub fn paths(&self) -> &ProjectPaths {
346        &self.paths
347    }
348
349    pub fn create(
350        &self,
351        name: impl Into<String>,
352        description: Option<String>,
353    ) -> ProjectStoreResult<ProjectManifest> {
354        self.create_with_bindings(name, description, Vec::new())
355    }
356
357    pub fn create_with_bindings(
358        &self,
359        name: impl Into<String>,
360        description: Option<String>,
361        workspace_bindings: Vec<WorkspaceBinding>,
362    ) -> ProjectStoreResult<ProjectManifest> {
363        let mut manifest = ProjectManifest::new(ProjectId::new(), name, description, Utc::now());
364        manifest.workspace_bindings = workspace_bindings;
365        self.create_manifest(manifest)
366    }
367
368    /// Create a configured active Project with one authoritative user source
369    /// folder plus zero or more additional workspaces/worktrees.
370    pub fn create_with_project_path(
371        &self,
372        name: impl Into<String>,
373        description: Option<String>,
374        project_path: impl Into<String>,
375        workspace_bindings: Vec<WorkspaceBinding>,
376    ) -> ProjectStoreResult<ProjectManifest> {
377        let mut manifest = ProjectManifest::new(ProjectId::new(), name, description, Utc::now());
378        manifest.project_path = Some(project_path.into());
379        manifest.project_path_status = ProjectPathStatus::Configured;
380        manifest.workspace_bindings = workspace_bindings;
381        self.create_manifest(manifest)
382    }
383
384    pub fn create_with_id(
385        &self,
386        project_id: ProjectId,
387        name: impl Into<String>,
388        description: Option<String>,
389    ) -> ProjectStoreResult<ProjectManifest> {
390        let manifest = ProjectManifest::new(project_id, name, description, Utc::now());
391        self.create_manifest(manifest)
392    }
393
394    pub fn create_manifest(
395        &self,
396        mut manifest: ProjectManifest,
397    ) -> ProjectStoreResult<ProjectManifest> {
398        canonicalize_manifest_paths(&mut manifest)?;
399        validate_manifest(&manifest)?;
400        if manifest.revision != 1 {
401            return Err(ProjectStoreError::Validation(
402                "a new project must start at revision 1".to_string(),
403            ));
404        }
405        let projects_dir = validate_existing_confined_directory(
406            self.paths.data_dir(),
407            &self.paths.projects_dir(),
408        )?;
409        let _registry_lock = lock_exclusive(projects_dir.join(".registry.lock"))?;
410        validate_existing_confined_directory(self.paths.data_dir(), &projects_dir)?;
411        let existing_projects = self.load_registry_manifests()?;
412        validate_new_workspace_roots(
413            &manifest.id,
414            manifest.project_path.as_deref(),
415            &manifest.workspace_bindings,
416            &existing_projects,
417        )?;
418        let home = self.paths.project_home(&manifest.id);
419        ensure_confined_directory(&projects_dir, &home)?;
420        {
421            let _lock = lock_exclusive(home.join(".project.lock"))?;
422            validate_existing_confined_directory(&projects_dir, &home)?;
423            let path = self.paths.manifest_path(&manifest.id);
424            if validate_regular_file_if_exists(&path, "project manifest")? {
425                return Err(ProjectStoreError::AlreadyExists(manifest.id));
426            }
427            self.write_manifest_revision_floor(&manifest.id, manifest.revision)?;
428            write_json_atomic(&path, &manifest)?;
429        }
430        self.rebuild_index()?;
431        Ok(manifest)
432    }
433
434    pub fn get(&self, project_id: &ProjectId) -> ProjectStoreResult<ProjectManifest> {
435        let home = self.paths.project_home(project_id);
436        self.validate_project_home(project_id)?;
437        let _lock = lock_exclusive(home.join(".project.lock"))?;
438        self.validate_project_home(project_id)?;
439        self.load_manifest_locked(project_id)
440    }
441
442    pub fn list(&self) -> ProjectStoreResult<Vec<ProjectManifest>> {
443        let index = self.index()?;
444        let mut projects = Vec::with_capacity(index.projects.len());
445        for project_id in index.projects.keys() {
446            match self.get(project_id) {
447                Ok(manifest) => projects.push(manifest),
448                Err(error) => {
449                    tracing::warn!(project_id = %project_id, %error, "skipping unavailable project");
450                }
451            }
452        }
453        Ok(projects)
454    }
455
456    pub fn index(&self) -> ProjectStoreResult<ProjectIndex> {
457        validate_existing_confined_directory(self.paths.data_dir(), &self.paths.projects_dir())?;
458        let path = self.paths.index_path();
459        let bytes = read_regular_file(&path, "project index")?;
460        let index: ProjectIndex = serde_json::from_slice(&bytes)?;
461        validate_index(&index)?;
462        Ok(index)
463    }
464
465    /// CAS update under the per-Project cross-process lock.
466    pub fn update<F>(
467        &self,
468        project_id: &ProjectId,
469        expected_revision: u64,
470        mutate: F,
471    ) -> ProjectStoreResult<ProjectManifest>
472    where
473        F: FnOnce(&mut ProjectManifest) -> ProjectStoreResult<()>,
474    {
475        self.update_inner(project_id, expected_revision, false, false, mutate)
476    }
477
478    /// Atomically update the authoritative Project path and other metadata
479    /// under the registry lock. The new path is canonicalized and checked
480    /// against every Project root before the CAS write becomes visible.
481    pub fn update_with_project_path<F>(
482        &self,
483        project_id: &ProjectId,
484        expected_revision: u64,
485        project_path: &str,
486        mutate: F,
487    ) -> ProjectStoreResult<ProjectManifest>
488    where
489        F: FnOnce(&mut ProjectManifest) -> ProjectStoreResult<()>,
490    {
491        let project_path = canonicalize_project_path(project_path)?;
492        let projects_dir = validate_existing_confined_directory(
493            self.paths.data_dir(),
494            &self.paths.projects_dir(),
495        )?;
496        let _registry_lock = lock_exclusive(projects_dir.join(".registry.lock"))?;
497        validate_existing_confined_directory(self.paths.data_dir(), &projects_dir)?;
498
499        let current = self.get(project_id)?;
500        let remaining_bindings = current
501            .workspace_bindings
502            .iter()
503            .filter(|binding| binding.path != project_path)
504            .cloned()
505            .collect::<Vec<_>>();
506        let existing_projects = self
507            .load_registry_manifests()?
508            .into_iter()
509            .filter(|project| project.id != *project_id)
510            .collect::<Vec<_>>();
511        validate_new_workspace_roots(
512            project_id,
513            Some(&project_path),
514            &remaining_bindings,
515            &existing_projects,
516        )?;
517
518        self.update_inner(project_id, expected_revision, true, true, move |manifest| {
519            mutate(manifest)?;
520            manifest.project_path = Some(project_path);
521            manifest.project_path_status = ProjectPathStatus::Configured;
522            manifest.workspace_bindings = remaining_bindings;
523            Ok(())
524        })
525    }
526
527    fn update_inner<F>(
528        &self,
529        project_id: &ProjectId,
530        expected_revision: u64,
531        allow_project_path_change: bool,
532        allow_workspace_binding_change: bool,
533        mutate: F,
534    ) -> ProjectStoreResult<ProjectManifest>
535    where
536        F: FnOnce(&mut ProjectManifest) -> ProjectStoreResult<()>,
537    {
538        let home = self.paths.project_home(project_id);
539        self.validate_project_home(project_id)?;
540        let updated = {
541            let _lock = lock_exclusive(home.join(".project.lock"))?;
542            self.validate_project_home(project_id)?;
543            let current = self.load_manifest_locked(project_id)?;
544            if current.revision != expected_revision {
545                return Err(ProjectStoreError::Conflict {
546                    expected: expected_revision,
547                    actual: current.revision,
548                });
549            }
550            let mut candidate = current.clone();
551            mutate(&mut candidate)?;
552            if candidate.id != current.id
553                || candidate.schema_version != current.schema_version
554                || candidate.created_at != current.created_at
555            {
556                return Err(ProjectStoreError::Validation(
557                    "project id, schema version, and created_at are immutable".to_string(),
558                ));
559            }
560            if !allow_workspace_binding_change
561                && candidate.workspace_bindings != current.workspace_bindings
562            {
563                return Err(ProjectStoreError::Validation(
564                    "workspace bindings must be changed through bind/unbind APIs".to_string(),
565                ));
566            }
567            if !allow_project_path_change
568                && (candidate.project_path != current.project_path
569                    || candidate.project_path_status != current.project_path_status)
570            {
571                return Err(ProjectStoreError::Validation(
572                    "project_path must be changed through the Project path CAS API".to_string(),
573                ));
574            }
575            candidate.revision = current
576                .revision
577                .checked_add(1)
578                .ok_or_else(|| ProjectStoreError::Validation("revision exhausted".to_string()))?;
579            candidate.updated_at = Utc::now();
580            validate_manifest(&candidate)?;
581            self.write_manifest_locked(&current, &candidate)?;
582            candidate
583        };
584        self.rebuild_index()?;
585        Ok(updated)
586    }
587
588    pub fn archive(
589        &self,
590        project_id: &ProjectId,
591        expected_revision: u64,
592    ) -> ProjectStoreResult<ProjectManifest> {
593        self.update(project_id, expected_revision, |manifest| {
594            manifest.status = ProjectStatus::Archived;
595            Ok(())
596        })
597    }
598
599    /// Restore an archived Project under the same per-Project CAS lock used by
600    /// every manifest mutation. The status check is intentionally performed
601    /// inside the update closure so two concurrent restores cannot both
602    /// succeed.
603    pub fn unarchive(
604        &self,
605        project_id: &ProjectId,
606        expected_revision: u64,
607    ) -> ProjectStoreResult<ProjectManifest> {
608        self.update(project_id, expected_revision, |manifest| {
609            if manifest.status != ProjectStatus::Archived {
610                return Err(ProjectStoreError::NotArchived(manifest.id.clone()));
611            }
612            manifest.status = ProjectStatus::Active;
613            Ok(())
614        })
615    }
616
617    /// Register an exact canonical workspace path under a registry-wide lock.
618    /// A path already owned by another Project is rejected.
619    pub fn bind_workspace(
620        &self,
621        project_id: &ProjectId,
622        expected_revision: u64,
623        binding: WorkspaceBinding,
624    ) -> ProjectStoreResult<ProjectManifest> {
625        let binding = canonicalize_binding(binding)?;
626        let projects_dir = validate_existing_confined_directory(
627            self.paths.data_dir(),
628            &self.paths.projects_dir(),
629        )?;
630        let _registry_lock = lock_exclusive(projects_dir.join(".registry.lock"))?;
631        validate_existing_confined_directory(self.paths.data_dir(), &projects_dir)?;
632        let existing_projects = self.load_registry_manifests()?;
633        validate_new_workspace_roots(
634            project_id,
635            None,
636            std::slice::from_ref(&binding),
637            &existing_projects,
638        )?;
639        self.update_inner(
640            project_id,
641            expected_revision,
642            false,
643            true,
644            move |manifest| {
645                if manifest.status != ProjectStatus::Active {
646                    return Err(ProjectStoreError::Validation(
647                        "cannot bind a workspace to an archived project".to_string(),
648                    ));
649                }
650                manifest.workspace_bindings.push(binding);
651                Ok(())
652            },
653        )
654    }
655
656    /// Remove only the exact binding; no session or Project resource is
657    /// deleted or moved.
658    pub fn unbind_workspace(
659        &self,
660        project_id: &ProjectId,
661        expected_revision: u64,
662        workspace_path: &str,
663    ) -> ProjectStoreResult<ProjectManifest> {
664        validate_absolute_path(workspace_path, "workspace binding")?;
665        let requested_path = workspace_path.to_string();
666        let projects_dir = validate_existing_confined_directory(
667            self.paths.data_dir(),
668            &self.paths.projects_dir(),
669        )?;
670        let _registry_lock = lock_exclusive(projects_dir.join(".registry.lock"))?;
671        validate_existing_confined_directory(self.paths.data_dir(), &projects_dir)?;
672        self.update_inner(
673            project_id,
674            expected_revision,
675            false,
676            true,
677            move |manifest| {
678                if manifest.project_path.as_deref() == Some(requested_path.as_str()) {
679                    return Err(ProjectStoreError::ProjectPathUnbindConflict {
680                        project_id: manifest.id.clone(),
681                        project_path: requested_path.clone(),
682                    });
683                }
684                // The exact manifest string is authoritative for DELETE. In
685                // particular, do not canonicalize it first: the workspace may be
686                // stale, missing, or replaced by a symlink after it was bound.
687                let matched_path = if manifest
688                    .workspace_bindings
689                    .iter()
690                    .any(|binding| binding.path == requested_path)
691                {
692                    requested_path.clone()
693                } else {
694                    // Compatibility for callers that send an existing path alias
695                    // (`nested/..`, platform spelling, and similar). This fallback
696                    // is reached only when the raw stored identity did not match.
697                    let canonical_path =
698                        canonicalize_utf8(Path::new(&requested_path), "workspace binding")
699                            .unwrap_or_else(|_| requested_path.clone());
700                    if manifest.project_path.as_deref() == Some(canonical_path.as_str()) {
701                        return Err(ProjectStoreError::ProjectPathUnbindConflict {
702                            project_id: manifest.id.clone(),
703                            project_path: canonical_path,
704                        });
705                    }
706                    if manifest
707                        .workspace_bindings
708                        .iter()
709                        .any(|binding| binding.path == canonical_path)
710                    {
711                        canonical_path
712                    } else {
713                        return Err(ProjectStoreError::Validation(format!(
714                            "workspace binding was not found: {requested_path}"
715                        )));
716                    }
717                };
718                let before = manifest.workspace_bindings.len();
719                manifest
720                    .workspace_bindings
721                    .retain(|binding| binding.path != matched_path);
722                if manifest.workspace_bindings.len() == before {
723                    return Err(ProjectStoreError::Validation(format!(
724                        "workspace binding was not found: {requested_path}"
725                    )));
726                }
727                Ok(())
728            },
729        )
730    }
731
732    pub fn bump_resource_revision(
733        &self,
734        project_id: &ProjectId,
735        expected_revision: u64,
736    ) -> ProjectStoreResult<ProjectManifest> {
737        self.update(project_id, expected_revision, |manifest| {
738            manifest.resource_revision =
739                manifest.resource_revision.checked_add(1).ok_or_else(|| {
740                    ProjectStoreError::Validation("resource revision exhausted".to_string())
741                })?;
742            Ok(())
743        })
744    }
745
746    /// Resolve an exact registered workspace owner. Multiple owners are a
747    /// corrupt/ambiguous registry state and return a validation error.
748    pub fn find_workspace_owner(
749        &self,
750        workspace_path: &str,
751    ) -> ProjectStoreResult<Option<ProjectManifest>> {
752        let canonical_path = canonicalize_utf8(Path::new(workspace_path), "workspace binding")?;
753        let matches = self
754            .list()?
755            .into_iter()
756            .filter(|project| project.workspace_roots().any(|root| root == canonical_path))
757            .collect::<Vec<_>>();
758        match matches.len() {
759            0 => Ok(None),
760            1 => Ok(matches.into_iter().next()),
761            _ => Err(ProjectStoreError::Validation(format!(
762                "workspace is bound to multiple projects: {canonical_path}"
763            ))),
764        }
765    }
766
767    /// Resolve the owner of an existing candidate path using component-aware
768    /// containment below registered canonical workspace bindings.
769    ///
770    /// A candidate that is equal to a binding or is a descendant of one
771    /// matches. If nested bindings make more than one Project match, resolution
772    /// fails closed instead of picking the longest prefix or registry order.
773    pub fn find_workspace_owner_for_path(
774        &self,
775        candidate_path: &str,
776    ) -> ProjectStoreResult<Option<ProjectManifest>> {
777        let canonical_path =
778            canonicalize_candidate_utf8(Path::new(candidate_path), "workspace candidate")?;
779        let candidate = Path::new(&canonical_path);
780        let matches = self
781            .list()?
782            .into_iter()
783            .filter(|project| {
784                project.workspace_roots().any(|root| {
785                    let root = Path::new(root);
786                    candidate == root || candidate.starts_with(root)
787                })
788            })
789            .collect::<Vec<_>>();
790        match matches.len() {
791            0 => Ok(None),
792            1 => Ok(matches.into_iter().next()),
793            _ => Err(ProjectStoreError::Validation(format!(
794                "workspace candidate is contained by multiple project bindings: {canonical_path}"
795            ))),
796        }
797    }
798
799    pub fn find_workspace_binding(
800        &self,
801        workspace_path: &str,
802    ) -> ProjectStoreResult<Option<(ProjectManifest, WorkspaceBinding)>> {
803        let canonical_path = canonicalize_utf8(Path::new(workspace_path), "workspace binding")?;
804        let Some(project) = self.find_workspace_owner(&canonical_path)? else {
805            return Ok(None);
806        };
807        if project.project_path.as_deref() == Some(canonical_path.as_str()) {
808            return Ok(Some((
809                project,
810                WorkspaceBinding {
811                    path: canonical_path,
812                    label: Some("Project path".to_string()),
813                    git_common_dir: None,
814                },
815            )));
816        }
817        let binding = project
818            .workspace_bindings
819            .iter()
820            .find(|binding| binding.path == canonical_path)
821            .cloned()
822            .ok_or_else(|| {
823                ProjectStoreError::Validation(
824                    "workspace owner disappeared during lookup".to_string(),
825                )
826            })?;
827        Ok(Some((project, binding)))
828    }
829
830    /// Return only counts/presence and revisions; never read resource contents.
831    pub fn resource_summary(
832        &self,
833        project_id: &ProjectId,
834    ) -> ProjectStoreResult<ProjectResourceSummary> {
835        let manifest = self.get(project_id)?;
836        let home = self.paths.project_home(project_id);
837        let settings = self.paths.settings_path(project_id);
838        let skills = count_skills_layers(&home)?;
839        let resources = vec![
840            ProjectResourceEntry {
841                kind: ProjectResourceKind::Settings,
842                present: settings.is_file(),
843                item_count: u64::from(settings.is_file()),
844            },
845            ProjectResourceEntry {
846                kind: ProjectResourceKind::Skills,
847                present: skills > 0,
848                item_count: skills,
849            },
850            resource_dir_summary(
851                ProjectResourceKind::Commands,
852                &self.paths.commands_dir(project_id),
853            )?,
854            resource_dir_summary(
855                ProjectResourceKind::Memory,
856                &self.paths.memory_v1_dir(project_id),
857            )?,
858            resource_dir_summary(
859                ProjectResourceKind::Artifacts,
860                &self.paths.artifacts_dir(project_id),
861            )?,
862            resource_dir_summary(
863                ProjectResourceKind::State,
864                &self.paths.state_dir(project_id),
865            )?,
866        ];
867        Ok(ProjectResourceSummary {
868            project_id: project_id.clone(),
869            resource_revision: manifest.resource_revision,
870            resources,
871        })
872    }
873
874    /// Rebuild the derived index from authoritative manifests.
875    pub fn rebuild_index(&self) -> ProjectStoreResult<ProjectIndex> {
876        let projects_dir = validate_existing_confined_directory(
877            self.paths.data_dir(),
878            &self.paths.projects_dir(),
879        )?;
880        let _index_lock = lock_exclusive(projects_dir.join(".index.lock"))?;
881        validate_existing_confined_directory(self.paths.data_dir(), &projects_dir)?;
882        let old_revision = self.read_or_quarantine_index_revision()?;
883        let mut projects = BTreeMap::new();
884
885        for entry in std::fs::read_dir(&projects_dir)? {
886            let entry = match entry {
887                Ok(entry) => entry,
888                Err(error) => {
889                    tracing::warn!(%error, "project index rebuild skipped unreadable entry");
890                    continue;
891                }
892            };
893            if !entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
894                continue;
895            }
896            let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
897                continue;
898            };
899            let Ok(project_id) = name.parse::<ProjectId>() else {
900                tracing::warn!(directory = %name, "project index rebuild skipped invalid id directory");
901                continue;
902            };
903            if let Err(error) = validate_existing_confined_directory(&projects_dir, &entry.path()) {
904                tracing::warn!(project_id = %project_id, %error, "project index rebuild skipped unsafe project home");
905                continue;
906            }
907            let manifest = {
908                let _project_lock = match lock_exclusive(entry.path().join(".project.lock")) {
909                    Ok(lock) => lock,
910                    Err(error) => {
911                        tracing::warn!(project_id = %project_id, %error, "project index rebuild could not lock manifest");
912                        continue;
913                    }
914                };
915                if let Err(error) =
916                    validate_existing_confined_directory(&projects_dir, &entry.path())
917                {
918                    tracing::warn!(project_id = %project_id, %error, "project index rebuild skipped project home changed after lock");
919                    continue;
920                }
921                match self.load_manifest_locked(&project_id) {
922                    Ok(manifest) => manifest,
923                    Err(error) => {
924                        tracing::warn!(project_id = %project_id, %error, "project index rebuild skipped invalid manifest");
925                        continue;
926                    }
927                }
928            };
929            projects.insert(project_id, ProjectIndexEntry::from(&manifest));
930        }
931
932        let index = ProjectIndex {
933            schema_version: PROJECT_INDEX_SCHEMA_VERSION,
934            revision: old_revision.saturating_add(1),
935            updated_at: Utc::now(),
936            projects,
937        };
938        write_json_atomic(&self.paths.index_path(), &index)?;
939        Ok(index)
940    }
941
942    fn validate_project_home(&self, project_id: &ProjectId) -> ProjectStoreResult<PathBuf> {
943        let projects_dir = validate_existing_confined_directory(
944            self.paths.data_dir(),
945            &self.paths.projects_dir(),
946        )?;
947        let home = self.paths.project_home(project_id);
948        match validate_existing_confined_directory(&projects_dir, &home) {
949            Ok(home) => Ok(home),
950            Err(ProjectStoreError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
951                Err(ProjectStoreError::NotFound(project_id.clone()))
952            }
953            Err(error) => Err(error),
954        }
955    }
956
957    /// Load valid authoritative manifests directly from Project homes while
958    /// the caller holds the registry lock. The derived index is deliberately
959    /// not identity evidence for overlap enforcement.
960    fn load_registry_manifests(&self) -> ProjectStoreResult<Vec<ProjectManifest>> {
961        let projects_dir = validate_existing_confined_directory(
962            self.paths.data_dir(),
963            &self.paths.projects_dir(),
964        )?;
965        let mut manifests = Vec::new();
966        for entry in std::fs::read_dir(&projects_dir)? {
967            let entry = entry?;
968            if !entry.file_type()?.is_dir() {
969                continue;
970            }
971            let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
972                continue;
973            };
974            let Ok(project_id) = name.parse::<ProjectId>() else {
975                continue;
976            };
977            validate_existing_confined_directory(&projects_dir, &entry.path())?;
978            let _project_lock = lock_exclusive(entry.path().join(".project.lock"))?;
979            validate_existing_confined_directory(&projects_dir, &entry.path())?;
980            match self.load_manifest_locked(&project_id) {
981                Ok(manifest) => manifests.push(manifest),
982                Err(ProjectStoreError::NotFound(_)) | Err(ProjectStoreError::Json(_)) => {
983                    tracing::warn!(
984                        project_id = %project_id,
985                        "registry overlap scan skipped Project without a recoverable manifest"
986                    );
987                }
988                Err(error) => return Err(error),
989            }
990        }
991        Ok(manifests)
992    }
993
994    fn load_manifest_locked(&self, project_id: &ProjectId) -> ProjectStoreResult<ProjectManifest> {
995        self.validate_project_home(project_id)?;
996        let path = self.paths.manifest_path(project_id);
997        if !validate_regular_file_if_exists(&path, "project manifest")? {
998            return Err(ProjectStoreError::NotFound(project_id.clone()));
999        }
1000        let primary = match read_regular_file(&path, "project manifest") {
1001            Ok(bytes) => bytes,
1002            Err(ProjectStoreError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
1003                return Err(ProjectStoreError::NotFound(project_id.clone()));
1004            }
1005            Err(error) => return Err(error),
1006        };
1007        match decode_manifest(&primary, project_id) {
1008            Ok(decoded) if decoded.migrated_from_v1 => {
1009                self.persist_migrated_manifest_locked(project_id, &primary, decoded.manifest)
1010            }
1011            Ok(decoded) => self.normalize_manifest_revision_locked(decoded.manifest),
1012            Err(primary_error) => {
1013                let quarantine =
1014                    path.with_file_name(format!("project.json.corrupt.{}", Uuid::new_v4()));
1015                write_bytes_atomic(&quarantine, &primary)?;
1016                let backup = self
1017                    .paths
1018                    .project_home(project_id)
1019                    .join(PROJECT_MANIFEST_BACKUP_FILE);
1020                let recovered =
1021                    if validate_regular_file_if_exists(&backup, "project manifest backup")? {
1022                        read_regular_file(&backup, "project manifest backup")
1023                            .ok()
1024                            .and_then(|bytes| decode_manifest(&bytes, project_id).ok())
1025                    } else {
1026                        None
1027                    };
1028                if let Some(decoded) = recovered {
1029                    let manifest = decoded.manifest;
1030                    let revision_floor = self.read_manifest_revision_floor(project_id)?;
1031                    let mut candidate = manifest.clone();
1032                    candidate.revision = candidate
1033                        .revision
1034                        .max(revision_floor)
1035                        .checked_add(1)
1036                        .ok_or_else(|| {
1037                            ProjectStoreError::Validation("revision exhausted".to_string())
1038                        })?;
1039                    candidate.updated_at = Utc::now();
1040                    self.write_manifest_locked(&manifest, &candidate)?;
1041                    tracing::warn!(
1042                        project_id = %project_id,
1043                        quarantine = %quarantine.display(),
1044                        "recovered corrupt project manifest from backup"
1045                    );
1046                    Ok(candidate)
1047                } else {
1048                    Err(primary_error)
1049                }
1050            }
1051        }
1052    }
1053
1054    fn persist_migrated_manifest_locked(
1055        &self,
1056        project_id: &ProjectId,
1057        v1_bytes: &[u8],
1058        manifest: ProjectManifest,
1059    ) -> ProjectStoreResult<ProjectManifest> {
1060        let revision_floor = self.read_manifest_revision_floor(project_id)?;
1061        let mut candidate = manifest;
1062        candidate.revision = candidate
1063            .revision
1064            .max(revision_floor)
1065            .checked_add(1)
1066            .ok_or_else(|| ProjectStoreError::Validation("revision exhausted".to_string()))?;
1067        candidate.updated_at = Utc::now();
1068
1069        let home = self.validate_project_home(project_id)?;
1070        write_bytes_atomic(&home.join(PROJECT_MANIFEST_BACKUP_FILE), v1_bytes)?;
1071        self.write_manifest_revision_floor(project_id, candidate.revision)?;
1072        write_json_atomic(&self.paths.manifest_path(project_id), &candidate)?;
1073        Ok(candidate)
1074    }
1075
1076    fn write_manifest_locked(
1077        &self,
1078        previous: &ProjectManifest,
1079        candidate: &ProjectManifest,
1080    ) -> ProjectStoreResult<()> {
1081        let home = self.validate_project_home(&previous.id)?;
1082        let backup = home.join(PROJECT_MANIFEST_BACKUP_FILE);
1083        write_json_atomic(&backup, previous)?;
1084        // Persist the monotonic floor before publishing the new manifest. If
1085        // the process dies between these writes, the next load advances past
1086        // the issued revision instead of allowing a stale CAS token to win.
1087        self.write_manifest_revision_floor(&previous.id, candidate.revision)?;
1088        write_json_atomic(&self.paths.manifest_path(&previous.id), candidate)
1089    }
1090
1091    fn normalize_manifest_revision_locked(
1092        &self,
1093        manifest: ProjectManifest,
1094    ) -> ProjectStoreResult<ProjectManifest> {
1095        let floor = self.read_manifest_revision_floor(&manifest.id)?;
1096        if manifest.revision < floor {
1097            let mut candidate = manifest.clone();
1098            candidate.revision = floor
1099                .checked_add(1)
1100                .ok_or_else(|| ProjectStoreError::Validation("revision exhausted".to_string()))?;
1101            candidate.updated_at = Utc::now();
1102            self.write_manifest_locked(&manifest, &candidate)?;
1103            Ok(candidate)
1104        } else {
1105            if manifest.revision > floor {
1106                self.write_manifest_revision_floor(&manifest.id, manifest.revision)?;
1107            }
1108            Ok(manifest)
1109        }
1110    }
1111
1112    fn read_manifest_revision_floor(&self, project_id: &ProjectId) -> ProjectStoreResult<u64> {
1113        let home = self.validate_project_home(project_id)?;
1114        let state = self.paths.state_dir(project_id);
1115        match validate_existing_confined_directory(&home, &state) {
1116            Ok(_) => {}
1117            Err(ProjectStoreError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
1118                return Ok(0);
1119            }
1120            Err(error) => return Err(error),
1121        }
1122        let path = self.paths.manifest_revision_path(project_id);
1123        if !validate_regular_file_if_exists(&path, "project manifest revision floor")? {
1124            return Ok(0);
1125        }
1126        let bytes = match read_regular_file(&path, "project manifest revision floor") {
1127            Ok(bytes) => bytes,
1128            Err(ProjectStoreError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
1129                return Ok(0);
1130            }
1131            Err(error) => return Err(error),
1132        };
1133        let value = std::str::from_utf8(&bytes)
1134            .ok()
1135            .and_then(|value| value.trim().parse::<u64>().ok())
1136            .ok_or_else(|| {
1137                ProjectStoreError::Validation(format!(
1138                    "project manifest revision floor is invalid: {}",
1139                    path.display()
1140                ))
1141            })?;
1142        Ok(value)
1143    }
1144
1145    fn write_manifest_revision_floor(
1146        &self,
1147        project_id: &ProjectId,
1148        revision: u64,
1149    ) -> ProjectStoreResult<()> {
1150        let home = self.validate_project_home(project_id)?;
1151        ensure_confined_directory(&home, &self.paths.state_dir(project_id))?;
1152        write_bytes_atomic(
1153            &self.paths.manifest_revision_path(project_id),
1154            format!("{revision}\n").as_bytes(),
1155        )
1156    }
1157
1158    fn read_or_quarantine_index_revision(&self) -> ProjectStoreResult<u64> {
1159        validate_existing_confined_directory(self.paths.data_dir(), &self.paths.projects_dir())?;
1160        let path = self.paths.index_path();
1161        if !validate_regular_file_if_exists(&path, "project index")? {
1162            return Ok(0);
1163        }
1164        let bytes = match read_regular_file(&path, "project index") {
1165            Ok(bytes) => bytes,
1166            Err(ProjectStoreError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
1167                return Ok(0);
1168            }
1169            Err(error) => return Err(error),
1170        };
1171        match serde_json::from_slice::<ProjectIndex>(&bytes)
1172            .map_err(ProjectStoreError::from)
1173            .and_then(|index| match index.schema_version {
1174                1 => Ok(index),
1175                PROJECT_INDEX_SCHEMA_VERSION => {
1176                    validate_index(&index)?;
1177                    Ok(index)
1178                }
1179                schema_version => Err(ProjectStoreError::Validation(format!(
1180                    "unsupported project index schema {schema_version}"
1181                ))),
1182            }) {
1183            Ok(index) => Ok(index.revision),
1184            Err(error) => {
1185                let quarantine =
1186                    path.with_file_name(format!("index.json.corrupt.{}", Uuid::new_v4()));
1187                write_bytes_atomic(&quarantine, &bytes)?;
1188                tracing::warn!(%error, quarantine = %quarantine.display(), "rebuilding corrupt project index");
1189                Ok(0)
1190            }
1191        }
1192    }
1193
1194    fn remove_orphan_temps(&self) -> ProjectStoreResult<()> {
1195        let projects_dir = validate_existing_confined_directory(
1196            self.paths.data_dir(),
1197            &self.paths.projects_dir(),
1198        )?;
1199        {
1200            let _index_lock = lock_exclusive(projects_dir.join(".index.lock"))?;
1201            validate_existing_confined_directory(self.paths.data_dir(), &projects_dir)?;
1202            remove_temp_files_in(&projects_dir)?;
1203        }
1204        for entry in std::fs::read_dir(&projects_dir)? {
1205            let entry = entry?;
1206            if entry.file_type()?.is_dir() {
1207                validate_existing_confined_directory(&projects_dir, &entry.path())?;
1208                let _project_lock = lock_exclusive(entry.path().join(".project.lock"))?;
1209                validate_existing_confined_directory(&projects_dir, &entry.path())?;
1210                remove_temp_files_in(&entry.path())?;
1211            }
1212        }
1213        Ok(())
1214    }
1215}
1216
1217struct DecodedManifest {
1218    manifest: ProjectManifest,
1219    migrated_from_v1: bool,
1220}
1221
1222fn decode_manifest(bytes: &[u8], expected_id: &ProjectId) -> ProjectStoreResult<DecodedManifest> {
1223    let mut manifest: ProjectManifest = serde_json::from_slice(bytes)?;
1224    let migrated_from_v1 = match manifest.schema_version {
1225        1 => {
1226            // A sole v1 binding is the only deterministic primary-folder
1227            // signal. Zero or multiple bindings deliberately remain
1228            // unconfigured instead of choosing by ordering.
1229            if manifest.workspace_bindings.len() == 1 {
1230                let binding = manifest.workspace_bindings.remove(0);
1231                manifest.project_path = Some(binding.path);
1232                manifest.project_path_status = ProjectPathStatus::Configured;
1233            } else if manifest.workspace_bindings.is_empty() {
1234                manifest.project_path_status = ProjectPathStatus::NeedsConfiguration;
1235            } else {
1236                manifest.project_path_status = ProjectPathStatus::NeedsSelection;
1237            }
1238            manifest.schema_version = PROJECT_MANIFEST_SCHEMA_VERSION;
1239            true
1240        }
1241        PROJECT_MANIFEST_SCHEMA_VERSION => false,
1242        schema_version => {
1243            return Err(ProjectStoreError::Validation(format!(
1244                "unsupported project manifest schema {schema_version}"
1245            )));
1246        }
1247    };
1248    validate_manifest(&manifest)?;
1249    if &manifest.id != expected_id {
1250        return Err(ProjectStoreError::Validation(format!(
1251            "manifest id {} does not match directory {}",
1252            manifest.id, expected_id
1253        )));
1254    }
1255    Ok(DecodedManifest {
1256        manifest,
1257        migrated_from_v1,
1258    })
1259}
1260
1261fn canonicalize_manifest_paths(manifest: &mut ProjectManifest) -> ProjectStoreResult<()> {
1262    if let Some(project_path) = manifest.project_path.as_deref() {
1263        manifest.project_path = Some(canonicalize_project_path(project_path)?);
1264        manifest.project_path_status = ProjectPathStatus::Configured;
1265    }
1266    for binding in &mut manifest.workspace_bindings {
1267        *binding = canonicalize_binding(binding.clone())?;
1268    }
1269    Ok(())
1270}
1271
1272fn canonicalize_project_path(project_path: &str) -> ProjectStoreResult<String> {
1273    validate_absolute_path(project_path, "project_path")?;
1274    let canonical = canonicalize_utf8(Path::new(project_path), "project_path")?;
1275    let metadata = std::fs::symlink_metadata(&canonical)?;
1276    if metadata.file_type().is_symlink() || !metadata.is_dir() {
1277        return Err(ProjectStoreError::Validation(format!(
1278            "project_path must be a plain directory: {canonical}"
1279        )));
1280    }
1281    Ok(canonical)
1282}
1283
1284fn validate_new_workspace_roots(
1285    project_id: &ProjectId,
1286    project_path: Option<&str>,
1287    incoming: &[WorkspaceBinding],
1288    existing_projects: &[ProjectManifest],
1289) -> ProjectStoreResult<()> {
1290    let incoming_roots = project_path
1291        .into_iter()
1292        .chain(incoming.iter().map(|binding| binding.path.as_str()))
1293        .collect::<Vec<_>>();
1294    for (index, root) in incoming_roots.iter().enumerate() {
1295        for other in incoming_roots.iter().skip(index + 1) {
1296            if workspace_paths_overlap(root, other) {
1297                return Err(ProjectStoreError::Validation(format!(
1298                    "project {project_id} contains overlapping workspace roots: {root} and {other}"
1299                )));
1300            }
1301        }
1302    }
1303    for root in incoming_roots {
1304        for project in existing_projects {
1305            for existing in project.workspace_roots() {
1306                if workspace_paths_overlap(root, existing) {
1307                    return Err(ProjectStoreError::Validation(format!(
1308                        "workspace root {root} overlaps project {} root {existing}",
1309                        project.id
1310                    )));
1311                }
1312            }
1313        }
1314    }
1315    Ok(())
1316}
1317
1318fn workspace_paths_overlap(left: &str, right: &str) -> bool {
1319    let left = Path::new(left);
1320    let right = Path::new(right);
1321    left == right || left.starts_with(right) || right.starts_with(left)
1322}
1323
1324fn canonicalize_binding(mut binding: WorkspaceBinding) -> ProjectStoreResult<WorkspaceBinding> {
1325    binding.path = canonicalize_workspace_path(Path::new(&binding.path))?;
1326    let actual_git_common_dir = resolve_git_common_dir(Path::new(&binding.path))?;
1327    if let Some(supplied) = binding.git_common_dir.as_deref() {
1328        let supplied = canonicalize_utf8(Path::new(supplied), "git common dir")?;
1329        if actual_git_common_dir.as_deref() != Some(supplied.as_str()) {
1330            return Err(ProjectStoreError::Validation(format!(
1331                "supplied git common dir does not match workspace {}",
1332                binding.path
1333            )));
1334        }
1335    }
1336    binding.git_common_dir = actual_git_common_dir;
1337    Ok(binding)
1338}
1339
1340/// Canonicalize a Workspace path with the exact filesystem semantics used
1341/// when persisting Workspace bindings.
1342pub fn canonicalize_workspace_path(workspace: &Path) -> ProjectStoreResult<String> {
1343    canonicalize_utf8(workspace, "workspace binding")
1344}
1345
1346/// Discover the canonical Git common directory for a Workspace without
1347/// mutating the repository. Linked worktrees and their main checkout resolve
1348/// to the same directory. A missing Git executable or non-repository
1349/// Workspace yields `None`.
1350pub fn resolve_git_common_dir(workspace: &Path) -> ProjectStoreResult<Option<String>> {
1351    let absolute = run_git_common_dir(
1352        workspace,
1353        &["rev-parse", "--path-format=absolute", "--git-common-dir"],
1354    )?;
1355    let value = match absolute {
1356        Some(value) => Some(value),
1357        None => run_git_common_dir(workspace, &["rev-parse", "--git-common-dir"])?,
1358    };
1359    let Some(value) = value else {
1360        return Ok(None);
1361    };
1362    let path = PathBuf::from(value);
1363    let path = if path.is_absolute() {
1364        path
1365    } else {
1366        workspace.join(path)
1367    };
1368    let canonical = canonicalize_utf8(&path, "git common dir")?;
1369    let metadata = std::fs::symlink_metadata(&canonical)?;
1370    if !metadata.is_dir() || metadata.file_type().is_symlink() {
1371        return Err(ProjectStoreError::Validation(
1372            "resolved git common dir is not a plain directory".to_string(),
1373        ));
1374    }
1375    Ok(Some(canonical))
1376}
1377
1378fn run_git_common_dir(workspace: &Path, args: &[&str]) -> ProjectStoreResult<Option<String>> {
1379    let output = match Command::new("git")
1380        .current_dir(workspace)
1381        .args(args)
1382        .env_remove("GIT_DIR")
1383        .env_remove("GIT_WORK_TREE")
1384        .env_remove("GIT_COMMON_DIR")
1385        .env_remove("GIT_CEILING_DIRECTORIES")
1386        .output()
1387    {
1388        Ok(output) => output,
1389        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1390        Err(error) => return Err(error.into()),
1391    };
1392    if !output.status.success() {
1393        return Ok(None);
1394    }
1395    let output = String::from_utf8(output.stdout).map_err(|_| {
1396        ProjectStoreError::Validation("git common dir output is not valid UTF-8".to_string())
1397    })?;
1398    let output = output.trim();
1399    if output.is_empty() || output.contains('\0') || output.lines().count() != 1 {
1400        return Err(ProjectStoreError::Validation(
1401            "git common dir output is invalid".to_string(),
1402        ));
1403    }
1404    Ok(Some(output.to_string()))
1405}
1406
1407fn canonicalize_utf8(path: &Path, field: &str) -> ProjectStoreResult<String> {
1408    let canonical = std::fs::canonicalize(path).map_err(|error| {
1409        ProjectStoreError::Validation(format!(
1410            "{field} could not be canonicalized ({}): {error}",
1411            path.display()
1412        ))
1413    })?;
1414    canonical
1415        .into_os_string()
1416        .into_string()
1417        .map_err(|_| ProjectStoreError::Validation(format!("{field} must be valid UTF-8")))
1418}
1419
1420/// Canonicalize an existing candidate, or canonicalize its deepest existing
1421/// ancestor and append the missing suffix lexically. Project-aware preflight
1422/// uses this for a confinement relocation target before authorization has
1423/// materialized that directory.
1424fn canonicalize_candidate_utf8(path: &Path, field: &str) -> ProjectStoreResult<String> {
1425    if let Ok(canonical) = std::fs::canonicalize(path) {
1426        return canonical
1427            .into_os_string()
1428            .into_string()
1429            .map_err(|_| ProjectStoreError::Validation(format!("{field} must be valid UTF-8")));
1430    }
1431
1432    let mut missing = Vec::new();
1433    let mut probe = path;
1434    loop {
1435        if let Ok(mut canonical) = std::fs::canonicalize(probe) {
1436            for component in missing.into_iter().rev() {
1437                canonical.push(component);
1438            }
1439            let canonical = lexically_clean_candidate(&canonical);
1440            return canonical.into_os_string().into_string().map_err(|_| {
1441                ProjectStoreError::Validation(format!("{field} must be valid UTF-8"))
1442            });
1443        }
1444        let Some(parent) = probe.parent() else {
1445            return Err(ProjectStoreError::Validation(format!(
1446                "{field} has no existing ancestor ({})",
1447                path.display()
1448            )));
1449        };
1450        if let Some(component) = probe.components().next_back() {
1451            match component {
1452                Component::Normal(_) | Component::ParentDir | Component::CurDir => {
1453                    missing.push(component.as_os_str().to_os_string());
1454                }
1455                Component::Prefix(_) | Component::RootDir => {}
1456            }
1457        }
1458        probe = parent;
1459    }
1460}
1461
1462fn lexically_clean_candidate(path: &Path) -> PathBuf {
1463    let mut clean = PathBuf::new();
1464    for component in path.components() {
1465        match component {
1466            Component::ParentDir => {
1467                clean.pop();
1468            }
1469            Component::CurDir => {}
1470            other => clean.push(other.as_os_str()),
1471        }
1472    }
1473    clean
1474}
1475
1476fn validate_manifest(manifest: &ProjectManifest) -> ProjectStoreResult<()> {
1477    if manifest.schema_version != PROJECT_MANIFEST_SCHEMA_VERSION {
1478        return Err(ProjectStoreError::Validation(format!(
1479            "unsupported project manifest schema {}",
1480            manifest.schema_version
1481        )));
1482    }
1483    if manifest.name.trim().is_empty() || manifest.name.len() > 200 {
1484        return Err(ProjectStoreError::Validation(
1485            "project name must be 1..=200 bytes".to_string(),
1486        ));
1487    }
1488    if manifest
1489        .description
1490        .as_ref()
1491        .is_some_and(|description| description.len() > 4096)
1492    {
1493        return Err(ProjectStoreError::Validation(
1494            "project description exceeds 4096 bytes".to_string(),
1495        ));
1496    }
1497    if manifest.revision == 0 || manifest.resource_revision == 0 {
1498        return Err(ProjectStoreError::Validation(
1499            "project revisions must be positive".to_string(),
1500        ));
1501    }
1502    let mut paths = HashSet::new();
1503    if let Some(project_path) = manifest.project_path.as_deref() {
1504        validate_absolute_path(project_path, "project_path")?;
1505        paths.insert(project_path);
1506    }
1507    match (manifest.project_path.as_ref(), manifest.project_path_status) {
1508        (Some(_), ProjectPathStatus::Configured)
1509        | (None, ProjectPathStatus::NeedsConfiguration | ProjectPathStatus::NeedsSelection) => {}
1510        (Some(_), status) => {
1511            return Err(ProjectStoreError::Validation(format!(
1512                "configured project_path has incompatible status {status:?}"
1513            )));
1514        }
1515        (None, ProjectPathStatus::Configured) => {
1516            return Err(ProjectStoreError::Validation(
1517                "project_path status is configured but no path is present".to_string(),
1518            ));
1519        }
1520    }
1521    for binding in &manifest.workspace_bindings {
1522        validate_absolute_path(&binding.path, "workspace binding")?;
1523        if !paths.insert(binding.path.as_str()) {
1524            return Err(ProjectStoreError::Validation(format!(
1525                "duplicate workspace root: {}",
1526                binding.path
1527            )));
1528        }
1529        if manifest
1530            .workspace_roots()
1531            .any(|other| other != binding.path && workspace_paths_overlap(&binding.path, other))
1532        {
1533            return Err(ProjectStoreError::Validation(format!(
1534                "overlapping workspace root: {}",
1535                binding.path
1536            )));
1537        }
1538        if binding
1539            .label
1540            .as_ref()
1541            .is_some_and(|label| label.is_empty() || label.len() > 100)
1542        {
1543            return Err(ProjectStoreError::Validation(
1544                "workspace label must be 1..=100 bytes".to_string(),
1545            ));
1546        }
1547        if let Some(git_common_dir) = &binding.git_common_dir {
1548            validate_absolute_path(git_common_dir, "git common dir")?;
1549        }
1550    }
1551    Ok(())
1552}
1553
1554fn validate_absolute_path(value: &str, field: &str) -> ProjectStoreResult<()> {
1555    if value.is_empty() || !Path::new(value).is_absolute() {
1556        return Err(ProjectStoreError::Validation(format!(
1557            "{field} must be an absolute path"
1558        )));
1559    }
1560    Ok(())
1561}
1562
1563fn validate_index(index: &ProjectIndex) -> ProjectStoreResult<()> {
1564    if index.schema_version != PROJECT_INDEX_SCHEMA_VERSION {
1565        return Err(ProjectStoreError::Validation(format!(
1566            "unsupported project index schema {}",
1567            index.schema_version
1568        )));
1569    }
1570    for (id, entry) in &index.projects {
1571        if id != &entry.id {
1572            return Err(ProjectStoreError::Validation(
1573                "project index key/id mismatch".to_string(),
1574            ));
1575        }
1576    }
1577    Ok(())
1578}
1579
1580struct FileLock(File);
1581
1582impl Drop for FileLock {
1583    fn drop(&mut self) {
1584        let _ = FileExt::unlock(&self.0);
1585    }
1586}
1587
1588fn lock_exclusive(path: PathBuf) -> ProjectStoreResult<FileLock> {
1589    let parent = path.parent().ok_or_else(|| {
1590        ProjectStoreError::Validation("project lock has no parent directory".to_string())
1591    })?;
1592    assert_plain_directory(parent)?;
1593    validate_regular_file_if_exists(&path, "project lock")?;
1594    let mut options = OpenOptions::new();
1595    options.create(true).truncate(false).read(true).write(true);
1596    configure_open_no_follow(&mut options);
1597    let file = options.open(&path)?;
1598    validate_open_regular_file(&file, &path, "project lock")?;
1599    file.lock_exclusive()?;
1600    assert_plain_directory(parent)?;
1601    validate_open_regular_file(&file, &path, "project lock")?;
1602    Ok(FileLock(file))
1603}
1604
1605fn read_regular_file(path: &Path, label: &str) -> ProjectStoreResult<Vec<u8>> {
1606    validate_required_regular_file(path, label)?;
1607    let mut options = OpenOptions::new();
1608    options.read(true);
1609    configure_open_no_follow(&mut options);
1610    let mut file = options.open(path)?;
1611    validate_open_regular_file(&file, path, label)?;
1612    let mut bytes = Vec::new();
1613    file.read_to_end(&mut bytes)?;
1614    validate_open_regular_file(&file, path, label)?;
1615    Ok(bytes)
1616}
1617
1618#[cfg(unix)]
1619fn configure_open_no_follow(options: &mut OpenOptions) {
1620    use std::os::unix::fs::OpenOptionsExt;
1621    options.custom_flags(libc::O_NOFOLLOW);
1622}
1623
1624#[cfg(windows)]
1625fn configure_open_no_follow(options: &mut OpenOptions) {
1626    use std::os::windows::fs::OpenOptionsExt;
1627    use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT;
1628    options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
1629}
1630
1631#[cfg(not(any(unix, windows)))]
1632fn configure_open_no_follow(_options: &mut OpenOptions) {}
1633
1634fn validate_open_regular_file(file: &File, path: &Path, label: &str) -> ProjectStoreResult<()> {
1635    let opened = file.metadata()?;
1636    let current = std::fs::symlink_metadata(path)?;
1637    if !opened.is_file()
1638        || current.file_type().is_symlink()
1639        || !current.is_file()
1640        || !same_open_file(&opened, &current)
1641    {
1642        return Err(ProjectStoreError::Validation(format!(
1643            "{label} changed during no-follow open: {}",
1644            path.display()
1645        )));
1646    }
1647    Ok(())
1648}
1649
1650#[cfg(unix)]
1651fn same_open_file(opened: &std::fs::Metadata, current: &std::fs::Metadata) -> bool {
1652    use std::os::unix::fs::MetadataExt;
1653    opened.dev() == current.dev() && opened.ino() == current.ino()
1654}
1655
1656#[cfg(not(unix))]
1657fn same_open_file(_opened: &std::fs::Metadata, _current: &std::fs::Metadata) -> bool {
1658    true
1659}
1660
1661fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> ProjectStoreResult<()> {
1662    let bytes = serde_json::to_vec_pretty(value)?;
1663    write_bytes_atomic(path, &bytes)
1664}
1665
1666fn write_bytes_atomic(path: &Path, bytes: &[u8]) -> ProjectStoreResult<()> {
1667    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1668    assert_plain_directory(parent)?;
1669    validate_regular_file_if_exists(path, "project store destination")?;
1670    let file_name = path
1671        .file_name()
1672        .and_then(|name| name.to_str())
1673        .unwrap_or("project.json");
1674    let temp = parent.join(format!(".{file_name}.tmp.{}", Uuid::new_v4()));
1675    let mut cleanup = TempCleanup(Some(temp.clone()));
1676    let mut file = OpenOptions::new()
1677        .create_new(true)
1678        .write(true)
1679        .open(&temp)?;
1680    file.write_all(bytes)?;
1681    file.sync_all()?;
1682    drop(file);
1683    assert_plain_directory(parent)?;
1684    validate_regular_file_if_exists(path, "project store destination")?;
1685    sync_directory(parent)?;
1686    replace_path(&temp, path)?;
1687    cleanup.0 = None;
1688    sync_directory(parent)?;
1689    Ok(())
1690}
1691
1692#[cfg(windows)]
1693fn replace_path(source: &Path, target: &Path) -> std::io::Result<()> {
1694    use std::os::windows::ffi::OsStrExt;
1695    use windows_sys::Win32::Storage::FileSystem::{
1696        MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
1697    };
1698
1699    let source = source
1700        .as_os_str()
1701        .encode_wide()
1702        .chain(std::iter::once(0))
1703        .collect::<Vec<_>>();
1704    let target = target
1705        .as_os_str()
1706        .encode_wide()
1707        .chain(std::iter::once(0))
1708        .collect::<Vec<_>>();
1709    // SAFETY: both buffers are stable, NUL-terminated UTF-16 strings for the
1710    // duration of the call. MoveFileExW does not retain their pointers.
1711    let result = unsafe {
1712        MoveFileExW(
1713            source.as_ptr(),
1714            target.as_ptr(),
1715            MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
1716        )
1717    };
1718    if result == 0 {
1719        Err(std::io::Error::last_os_error())
1720    } else {
1721        Ok(())
1722    }
1723}
1724
1725#[cfg(unix)]
1726fn replace_path(source: &Path, target: &Path) -> std::io::Result<()> {
1727    std::fs::rename(source, target)
1728}
1729
1730#[cfg(not(any(unix, windows)))]
1731fn replace_path(source: &Path, target: &Path) -> std::io::Result<()> {
1732    std::fs::rename(source, target)
1733}
1734
1735#[cfg(unix)]
1736fn sync_directory(path: &Path) -> std::io::Result<()> {
1737    use std::os::unix::fs::OpenOptionsExt;
1738
1739    let file = OpenOptions::new()
1740        .read(true)
1741        .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY)
1742        .open(path)?;
1743    let opened = file.metadata()?;
1744    let current = std::fs::symlink_metadata(path)?;
1745    if !opened.is_dir()
1746        || current.file_type().is_symlink()
1747        || !current.is_dir()
1748        || !same_open_file(&opened, &current)
1749    {
1750        return Err(std::io::Error::other(format!(
1751            "directory changed during no-follow sync: {}",
1752            path.display()
1753        )));
1754    }
1755    file.sync_all()
1756}
1757
1758#[cfg(not(unix))]
1759fn sync_directory(_path: &Path) -> std::io::Result<()> {
1760    Ok(())
1761}
1762
1763struct TempCleanup(Option<PathBuf>);
1764
1765impl Drop for TempCleanup {
1766    fn drop(&mut self) {
1767        if let Some(path) = self.0.take() {
1768            let _ = std::fs::remove_file(path);
1769        }
1770    }
1771}
1772
1773fn remove_temp_files_in(directory: &Path) -> ProjectStoreResult<()> {
1774    if !directory.exists() {
1775        return Ok(());
1776    }
1777    for entry in std::fs::read_dir(directory)? {
1778        let entry = entry?;
1779        let name = entry.file_name();
1780        let name = name.to_string_lossy();
1781        if name.starts_with('.') && name.contains(".tmp.") && entry.file_type()?.is_file() {
1782            std::fs::remove_file(entry.path())?;
1783        }
1784    }
1785    Ok(())
1786}
1787
1788fn count_direct_entries(path: &Path) -> ProjectStoreResult<u64> {
1789    let entries = match std::fs::read_dir(path) {
1790        Ok(entries) => entries,
1791        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
1792        Err(error) => return Err(error.into()),
1793    };
1794    Ok(entries.filter_map(Result::ok).count() as u64)
1795}
1796
1797fn count_skills_layers(home: &Path) -> ProjectStoreResult<u64> {
1798    let mut count = 0;
1799    for entry in std::fs::read_dir(home)? {
1800        let entry = entry?;
1801        let name = entry.file_name();
1802        let name = name.to_string_lossy();
1803        if (name == "skills" || name.starts_with("skills-")) && entry.file_type()?.is_dir() {
1804            count += count_direct_entries(&entry.path())?;
1805        }
1806    }
1807    Ok(count)
1808}
1809
1810fn resource_dir_summary(
1811    kind: ProjectResourceKind,
1812    path: &Path,
1813) -> ProjectStoreResult<ProjectResourceEntry> {
1814    let item_count = count_direct_entries(path)?;
1815    Ok(ProjectResourceEntry {
1816        kind,
1817        present: path.is_dir(),
1818        item_count,
1819    })
1820}
1821
1822/// Produce a read-only legacy migration report. No Project, session, or memory
1823/// data is written. Basenames, remotes, missing paths, and path hashes never
1824/// become identity evidence.
1825pub fn plan_legacy_migration(
1826    inputs: &[LegacySessionProjectInput],
1827    projects: &[ProjectManifest],
1828) -> LegacyProjectDryRunReport {
1829    let mut report = LegacyProjectDryRunReport::default();
1830    let mut by_path: HashMap<&str, Vec<&ProjectManifest>> = HashMap::new();
1831    let mut by_git: HashMap<String, Vec<&ProjectManifest>> = HashMap::new();
1832    for project in projects {
1833        if let Some(project_path) = project.project_path.as_deref() {
1834            by_path.entry(project_path).or_default().push(project);
1835            let project_path = Path::new(project_path);
1836            let is_stable_plain_directory = std::fs::symlink_metadata(project_path)
1837                .ok()
1838                .is_some_and(|metadata| {
1839                    !metadata.file_type().is_symlink()
1840                        && metadata.is_dir()
1841                        && std::fs::canonicalize(project_path).ok().as_deref() == Some(project_path)
1842                });
1843            if is_stable_plain_directory {
1844                if let Ok(Some(git_common_dir)) = resolve_git_common_dir(project_path) {
1845                    by_git.entry(git_common_dir).or_default().push(project);
1846                }
1847            }
1848        }
1849        for binding in &project.workspace_bindings {
1850            by_path.entry(&binding.path).or_default().push(project);
1851            if let Some(git_common_dir) = binding.git_common_dir.as_deref() {
1852                by_git
1853                    .entry(git_common_dir.to_string())
1854                    .or_default()
1855                    .push(project);
1856            }
1857        }
1858    }
1859
1860    let mut pending = Vec::new();
1861    for input in inputs {
1862        let exact = input
1863            .canonical_path
1864            .as_deref()
1865            .and_then(|path| by_path.get(path));
1866        if let Some(matches) = exact {
1867            if let Some(project) = unique_project(matches) {
1868                report.assignments.push(LegacyProjectAssignment {
1869                    session_id: input.session_id.clone(),
1870                    project_id: project.id.clone(),
1871                    basis: LegacyProjectMatchBasis::ExactCanonicalBinding,
1872                });
1873            } else {
1874                report.unassigned.push(LegacyProjectUnassigned {
1875                    session_id: input.session_id.clone(),
1876                    reason: "canonical workspace is bound to multiple Projects".to_string(),
1877                });
1878                report.diagnostics.push(format!(
1879                    "session {} has an ambiguous canonical workspace binding",
1880                    input.session_id
1881                ));
1882            }
1883            continue;
1884        }
1885
1886        let git = input
1887            .git_common_dir
1888            .as_deref()
1889            .and_then(|path| by_git.get(path));
1890        if let Some(matches) = git {
1891            if let Some(project) = unique_project(matches) {
1892                report.assignments.push(LegacyProjectAssignment {
1893                    session_id: input.session_id.clone(),
1894                    project_id: project.id.clone(),
1895                    basis: LegacyProjectMatchBasis::GitCommonDir,
1896                });
1897            } else {
1898                report.unassigned.push(LegacyProjectUnassigned {
1899                    session_id: input.session_id.clone(),
1900                    reason: "git common dir is registered by multiple Projects".to_string(),
1901                });
1902                report.diagnostics.push(format!(
1903                    "session {} has an ambiguous git common dir",
1904                    input.session_id
1905                ));
1906            }
1907            continue;
1908        }
1909        pending.push(input);
1910    }
1911
1912    let mut suggested = HashSet::new();
1913    suggest_groups(
1914        &pending,
1915        |input| input.canonical_path.as_deref(),
1916        LegacyProjectMatchBasis::ExactCanonicalBinding,
1917        &mut suggested,
1918        &mut report,
1919    );
1920    suggest_groups(
1921        &pending,
1922        |input| input.git_common_dir.as_deref(),
1923        LegacyProjectMatchBasis::GitCommonDir,
1924        &mut suggested,
1925        &mut report,
1926    );
1927
1928    for input in pending {
1929        if !suggested.contains(&input.session_id) {
1930            report.unassigned.push(LegacyProjectUnassigned {
1931                session_id: input.session_id.clone(),
1932                reason: "no exact canonical binding or shared git common dir".to_string(),
1933            });
1934        }
1935    }
1936    report
1937}
1938
1939fn unique_project<'a>(matches: &[&'a ProjectManifest]) -> Option<&'a ProjectManifest> {
1940    let mut ids = matches
1941        .iter()
1942        .map(|project| &project.id)
1943        .collect::<BTreeSet<_>>();
1944    if ids.len() == 1 {
1945        let id = ids.pop_first()?;
1946        matches.iter().copied().find(|project| &project.id == id)
1947    } else {
1948        None
1949    }
1950}
1951
1952fn suggest_groups<'a>(
1953    pending: &[&'a LegacySessionProjectInput],
1954    key: impl Fn(&'a LegacySessionProjectInput) -> Option<&'a str>,
1955    basis: LegacyProjectMatchBasis,
1956    suggested: &mut HashSet<String>,
1957    report: &mut LegacyProjectDryRunReport,
1958) {
1959    let mut groups: BTreeMap<&str, Vec<&LegacySessionProjectInput>> = BTreeMap::new();
1960    for input in pending {
1961        if !suggested.contains(&input.session_id) {
1962            if let Some(key) = key(input) {
1963                groups.entry(key).or_default().push(input);
1964            }
1965        }
1966    }
1967    for group in groups.into_values().filter(|group| group.len() >= 2) {
1968        let mut session_ids = BTreeSet::new();
1969        let mut workspace_paths = BTreeSet::new();
1970        for input in group {
1971            session_ids.insert(input.session_id.clone());
1972            if let Some(workspace_path) = &input.workspace_path {
1973                workspace_paths.insert(workspace_path.clone());
1974            }
1975        }
1976        suggested.extend(session_ids.iter().cloned());
1977        report.suggestions.push(LegacyProjectSuggestion {
1978            basis,
1979            session_ids: session_ids.into_iter().collect(),
1980            workspace_paths: workspace_paths.into_iter().collect(),
1981        });
1982    }
1983}
1984
1985#[cfg(test)]
1986mod tests {
1987    use super::*;
1988    use bamboo_domain::WorkspaceBinding;
1989    use tempfile::TempDir;
1990
1991    fn store() -> (TempDir, ProjectStore) {
1992        let temp = tempfile::tempdir().unwrap();
1993        let store = ProjectStore::open(temp.path()).unwrap();
1994        (temp, store)
1995    }
1996
1997    fn binding(path: &Path) -> WorkspaceBinding {
1998        WorkspaceBinding {
1999            path: path.to_string_lossy().into_owned(),
2000            label: None,
2001            git_common_dir: None,
2002        }
2003    }
2004
2005    fn retired_memory_key_field() -> String {
2006        ["legacy", "project", "keys"].join("_")
2007    }
2008
2009    #[test]
2010    fn paths_never_use_name_and_reject_traversal_components() {
2011        let paths = ProjectPaths::new("/tmp/bamboo-data");
2012        let id: ProjectId = "01JPROJECT00000000000000000".parse().unwrap();
2013        assert_eq!(
2014            paths.project_home(&id),
2015            Path::new("/tmp/bamboo-data/projects/01JPROJECT00000000000000000")
2016        );
2017        assert!(paths.skills_dir(&id, Some("../escape")).is_err());
2018        assert!(paths.skills_dir(&id, Some("ask")).is_ok());
2019    }
2020
2021    #[test]
2022    fn create_update_cas_and_rename_keep_home_stable() {
2023        let (_temp, store) = store();
2024        let created = store.create("Zenith", None).unwrap();
2025        let home = store.paths().project_home(&created.id);
2026        let updated = store
2027            .update(&created.id, created.revision, |project| {
2028                project.name = "Zenith renamed".to_string();
2029                Ok(())
2030            })
2031            .unwrap();
2032        assert_eq!(updated.revision, 2);
2033        assert_eq!(store.paths().project_home(&updated.id), home);
2034        assert!(matches!(
2035            store.update(&created.id, 1, |_| Ok(())),
2036            Err(ProjectStoreError::Conflict {
2037                expected: 1,
2038                actual: 2
2039            })
2040        ));
2041    }
2042
2043    #[test]
2044    fn current_manifest_ignores_retired_memory_key_without_rewrite() {
2045        let (temp, store) = store();
2046        let project = store.create("Legacy v2", None).unwrap();
2047        let manifest_path = store.paths().manifest_path(&project.id);
2048        let mut value: serde_json::Value =
2049            serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap();
2050        value.as_object_mut().unwrap().insert(
2051            retired_memory_key_field(),
2052            serde_json::json!(["legacy-project-alias"]),
2053        );
2054        let original_bytes = serde_json::to_vec_pretty(&value).unwrap();
2055        std::fs::write(&manifest_path, &original_bytes).unwrap();
2056        drop(store);
2057
2058        let reopened = ProjectStore::open(temp.path()).unwrap();
2059        assert_eq!(std::fs::read(&manifest_path).unwrap(), original_bytes);
2060
2061        let listed = reopened.list().unwrap();
2062        assert_eq!(listed.len(), 1);
2063        assert_eq!(listed[0].id, project.id);
2064        assert_eq!(listed[0].revision, project.revision);
2065        assert_eq!(std::fs::read(&manifest_path).unwrap(), original_bytes);
2066
2067        let loaded = reopened.get(&project.id).unwrap();
2068        assert_eq!(loaded.revision, project.revision);
2069        assert_eq!(std::fs::read(&manifest_path).unwrap(), original_bytes);
2070    }
2071
2072    #[test]
2073    fn unarchive_is_atomic_and_preserves_project_identity_and_resources() {
2074        let (temp, store) = store();
2075        let project_path = temp.path().join("project");
2076        let workspace_path = temp.path().join("workspace");
2077        std::fs::create_dir_all(&project_path).unwrap();
2078        std::fs::create_dir_all(&workspace_path).unwrap();
2079
2080        let project = store
2081            .create_with_project_path(
2082                "Zenith",
2083                Some("Project restore contract".to_string()),
2084                project_path.to_string_lossy(),
2085                vec![WorkspaceBinding {
2086                    path: workspace_path.to_string_lossy().into_owned(),
2087                    label: Some("Issue worktree".to_string()),
2088                    git_common_dir: None,
2089                }],
2090            )
2091            .unwrap();
2092        assert!(matches!(
2093            store.unarchive(&project.id, project.revision),
2094            Err(ProjectStoreError::NotArchived(project_id)) if project_id == project.id
2095        ));
2096        assert_eq!(
2097            store.get(&project.id).unwrap().revision,
2098            project.revision,
2099            "rejected restore must not bump the manifest"
2100        );
2101
2102        let project = store
2103            .bump_resource_revision(&project.id, project.revision)
2104            .unwrap();
2105        let settings_path = store.paths().settings_path(&project.id);
2106        let memory_path = store.paths().memory_v1_dir(&project.id).join("index.json");
2107        std::fs::create_dir_all(memory_path.parent().unwrap()).unwrap();
2108        std::fs::write(&settings_path, br#"{"theme":"dark"}"#).unwrap();
2109        std::fs::write(&memory_path, br#"{"entries":["stable"]}"#).unwrap();
2110
2111        let archived = store.archive(&project.id, project.revision).unwrap();
2112        let settings_before = std::fs::read(&settings_path).unwrap();
2113        let memory_before = std::fs::read(&memory_path).unwrap();
2114        let restored = store.unarchive(&archived.id, archived.revision).unwrap();
2115
2116        assert_eq!(restored.status, ProjectStatus::Active);
2117        assert_eq!(restored.revision, archived.revision + 1);
2118        assert!(restored.updated_at >= archived.updated_at);
2119        assert_eq!(restored.id, archived.id);
2120        assert_eq!(restored.project_path, archived.project_path);
2121        assert_eq!(restored.project_path_status, archived.project_path_status);
2122        assert_eq!(restored.workspace_bindings, archived.workspace_bindings);
2123        assert_eq!(restored.resource_revision, archived.resource_revision);
2124        assert_eq!(restored.created_at, archived.created_at);
2125        assert_eq!(std::fs::read(&settings_path).unwrap(), settings_before);
2126        assert_eq!(std::fs::read(&memory_path).unwrap(), memory_before);
2127
2128        assert!(matches!(
2129            store.unarchive(&restored.id, restored.revision),
2130            Err(ProjectStoreError::NotArchived(project_id)) if project_id == restored.id
2131        ));
2132        assert_eq!(
2133            store.get(&restored.id).unwrap(),
2134            restored,
2135            "repeated restore must leave the canonical manifest unchanged"
2136        );
2137    }
2138
2139    #[test]
2140    fn project_path_is_canonical_owned_and_cas_update_keeps_identity() {
2141        let (temp, store) = store();
2142        let first = temp.path().join("first");
2143        let second = temp.path().join("second");
2144        std::fs::create_dir_all(first.join("nested")).unwrap();
2145        std::fs::create_dir_all(&second).unwrap();
2146
2147        let project = store
2148            .create_with_project_path(
2149                "Zenith",
2150                None,
2151                first.join("nested").join("..").to_string_lossy(),
2152                Vec::new(),
2153            )
2154            .unwrap();
2155        let first = first.canonicalize().unwrap().to_string_lossy().into_owned();
2156        assert_eq!(project.project_path.as_deref(), Some(first.as_str()));
2157        assert_eq!(
2158            store
2159                .find_workspace_owner(&first)
2160                .unwrap()
2161                .map(|owner| owner.id),
2162            Some(project.id.clone())
2163        );
2164
2165        let attempted_override = first.clone();
2166        let updated = store
2167            .update_with_project_path(
2168                &project.id,
2169                project.revision,
2170                second.to_string_lossy().as_ref(),
2171                move |manifest| {
2172                    manifest.project_path = Some(attempted_override);
2173                    manifest.project_path_status = ProjectPathStatus::NeedsSelection;
2174                    Ok(())
2175                },
2176            )
2177            .unwrap();
2178        let second = second
2179            .canonicalize()
2180            .unwrap()
2181            .to_string_lossy()
2182            .into_owned();
2183        assert_eq!(updated.id, project.id);
2184        assert_eq!(updated.project_path.as_deref(), Some(second.as_str()));
2185        assert!(store.find_workspace_owner(&first).unwrap().is_none());
2186        assert_eq!(
2187            store
2188                .find_workspace_owner(&second)
2189                .unwrap()
2190                .map(|owner| owner.id),
2191            Some(project.id.clone())
2192        );
2193        assert!(matches!(
2194            store.unbind_workspace(&project.id, updated.revision, &second),
2195            Err(ProjectStoreError::ProjectPathUnbindConflict {
2196                project_id,
2197                project_path,
2198            }) if project_id == project.id && project_path == second
2199        ));
2200
2201        let reopened = ProjectStore::open(temp.path()).unwrap();
2202        let indexed = reopened.index().unwrap();
2203        assert_eq!(
2204            indexed.projects[&project.id].project_path.as_deref(),
2205            Some(second.as_str())
2206        );
2207        assert_eq!(
2208            indexed.projects[&project.id].project_path_status,
2209            ProjectPathStatus::Configured
2210        );
2211    }
2212
2213    #[test]
2214    fn project_path_create_and_cas_update_reject_cross_project_overlap() {
2215        let (temp, store) = store();
2216        let owner_root = temp.path().join("owner");
2217        let nested = owner_root.join("nested");
2218        std::fs::create_dir_all(&nested).unwrap();
2219        let owner = store
2220            .create_with_project_path("Owner", None, owner_root.to_string_lossy(), Vec::new())
2221            .unwrap();
2222        let project_count = store.list().unwrap().len();
2223
2224        assert!(matches!(
2225            store.create_with_project_path(
2226                "Overlapping create",
2227                None,
2228                nested.to_string_lossy(),
2229                Vec::new(),
2230            ),
2231            Err(ProjectStoreError::Validation(message)) if message.contains("overlaps")
2232        ));
2233        assert_eq!(store.list().unwrap().len(), project_count);
2234
2235        let target = store.create("Target", None).unwrap();
2236        assert!(matches!(
2237            store.update_with_project_path(
2238                &target.id,
2239                target.revision,
2240                nested.to_string_lossy().as_ref(),
2241                |_| Ok(()),
2242            ),
2243            Err(ProjectStoreError::Validation(message)) if message.contains("overlaps")
2244        ));
2245        let unchanged = store.get(&target.id).unwrap();
2246        assert_eq!(unchanged.revision, target.revision);
2247        assert!(unchanged.project_path.is_none());
2248        assert_eq!(
2249            store
2250                .find_workspace_owner_for_path(nested.to_string_lossy().as_ref())
2251                .unwrap()
2252                .map(|project| project.id),
2253            Some(owner.id)
2254        );
2255    }
2256
2257    #[test]
2258    fn v1_manifest_migration_promotes_only_one_binding() {
2259        fn rewrite_as_v1(store: &ProjectStore, project: &ProjectManifest) {
2260            let path = store.paths().manifest_path(&project.id);
2261            let mut value: serde_json::Value =
2262                serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
2263            value["schema_version"] = serde_json::json!(1);
2264            value.as_object_mut().unwrap().remove("project_path");
2265            value.as_object_mut().unwrap().insert(
2266                retired_memory_key_field(),
2267                serde_json::json!(["legacy-project-alias"]),
2268            );
2269            std::fs::write(path, serde_json::to_vec_pretty(&value).unwrap()).unwrap();
2270        }
2271
2272        let (temp, store) = store();
2273        let single = temp.path().join("single");
2274        let multiple_a = temp.path().join("multiple-a");
2275        let multiple_b = temp.path().join("multiple-b");
2276        std::fs::create_dir_all(&single).unwrap();
2277        std::fs::create_dir_all(&multiple_a).unwrap();
2278        std::fs::create_dir_all(&multiple_b).unwrap();
2279
2280        let one = store
2281            .create_with_bindings("one", None, vec![binding(&single)])
2282            .unwrap();
2283        rewrite_as_v1(&store, &one);
2284        let migrated = store.get(&one.id).unwrap();
2285        assert_eq!(migrated.schema_version, PROJECT_MANIFEST_SCHEMA_VERSION);
2286        assert_eq!(migrated.revision, one.revision + 1);
2287        assert_eq!(
2288            migrated.project_path.as_deref(),
2289            Some(single.canonicalize().unwrap().to_string_lossy().as_ref())
2290        );
2291        assert_eq!(migrated.project_path_status, ProjectPathStatus::Configured);
2292        assert!(migrated.workspace_bindings.is_empty());
2293        let backup: serde_json::Value = serde_json::from_slice(
2294            &std::fs::read(
2295                store
2296                    .paths()
2297                    .project_home(&one.id)
2298                    .join(PROJECT_MANIFEST_BACKUP_FILE),
2299            )
2300            .unwrap(),
2301        )
2302        .unwrap();
2303        assert_eq!(backup["schema_version"], 1);
2304        assert!(backup.get(retired_memory_key_field().as_str()).is_some());
2305
2306        let zero = store.create("zero", None).unwrap();
2307        rewrite_as_v1(&store, &zero);
2308        let migrated = store.get(&zero.id).unwrap();
2309        assert!(migrated.project_path.is_none());
2310        assert_eq!(
2311            migrated.project_path_status,
2312            ProjectPathStatus::NeedsConfiguration
2313        );
2314        assert!(migrated.workspace_bindings.is_empty());
2315
2316        let many = store
2317            .create_with_bindings(
2318                "many",
2319                None,
2320                vec![binding(&multiple_a), binding(&multiple_b)],
2321            )
2322            .unwrap();
2323        rewrite_as_v1(&store, &many);
2324        let migrated = store.get(&many.id).unwrap();
2325        assert!(migrated.project_path.is_none());
2326        assert_eq!(
2327            migrated.project_path_status,
2328            ProjectPathStatus::NeedsSelection
2329        );
2330        assert_eq!(migrated.workspace_bindings.len(), 2);
2331    }
2332
2333    #[test]
2334    fn atomic_writer_replaces_existing_target_without_remove_window() {
2335        let temp = tempfile::tempdir().unwrap();
2336        let target = temp.path().join("project.json");
2337        write_bytes_atomic(&target, b"old").unwrap();
2338        write_bytes_atomic(&target, b"new").unwrap();
2339        assert_eq!(std::fs::read(&target).unwrap(), b"new");
2340        assert!(
2341            std::fs::read_dir(temp.path())
2342                .unwrap()
2343                .filter_map(Result::ok)
2344                .all(|entry| !entry.file_name().to_string_lossy().contains(".tmp.")),
2345            "atomic replacement must not leave a temp file"
2346        );
2347    }
2348
2349    #[cfg(unix)]
2350    #[test]
2351    fn projects_symlink_is_rejected_without_external_writes() {
2352        use std::os::unix::fs::symlink;
2353
2354        let temp = tempfile::tempdir().unwrap();
2355        let outside = tempfile::tempdir().unwrap();
2356        symlink(outside.path(), temp.path().join("projects")).unwrap();
2357
2358        assert!(ProjectStore::open(temp.path()).is_err());
2359        assert_eq!(
2360            std::fs::read_dir(outside.path()).unwrap().count(),
2361            0,
2362            "opening a registry must not create locks or index files through a projects symlink"
2363        );
2364    }
2365
2366    #[cfg(unix)]
2367    #[test]
2368    fn project_home_symlink_is_rejected_without_external_writes() {
2369        use std::os::unix::fs::symlink;
2370
2371        let temp = tempfile::tempdir().unwrap();
2372        let outside = tempfile::tempdir().unwrap();
2373        let store = ProjectStore::open(temp.path()).unwrap();
2374        let project_id: ProjectId = "01JPROJECTHOMESYMLINK00000".parse().unwrap();
2375        symlink(outside.path(), store.paths().project_home(&project_id)).unwrap();
2376
2377        assert!(store
2378            .create_with_id(project_id, "Unsafe home", None)
2379            .is_err());
2380        assert_eq!(
2381            std::fs::read_dir(outside.path()).unwrap().count(),
2382            0,
2383            "creating a Project must not create a lock, state, or manifest through a home symlink"
2384        );
2385    }
2386
2387    #[cfg(unix)]
2388    #[test]
2389    fn manifest_symlink_is_rejected_without_external_writes() {
2390        use std::os::unix::fs::symlink;
2391
2392        let (_temp, store) = store();
2393        let project = store.create("Manifest safety", None).unwrap();
2394        let outside = tempfile::tempdir().unwrap();
2395        let outside_manifest = outside.path().join("external.json");
2396        let sentinel = b"external sentinel";
2397        std::fs::write(&outside_manifest, sentinel).unwrap();
2398        let manifest = store.paths().manifest_path(&project.id);
2399        std::fs::remove_file(&manifest).unwrap();
2400        symlink(&outside_manifest, &manifest).unwrap();
2401
2402        assert!(store.get(&project.id).is_err());
2403        assert_eq!(std::fs::read(&outside_manifest).unwrap(), sentinel);
2404        assert_eq!(
2405            std::fs::read_dir(outside.path()).unwrap().count(),
2406            1,
2407            "manifest recovery must not quarantine or replace an external symlink target"
2408        );
2409    }
2410
2411    #[cfg(unix)]
2412    #[test]
2413    fn index_and_lock_symlinks_are_rejected_without_external_writes() {
2414        use std::os::unix::fs::symlink;
2415
2416        let temp = tempfile::tempdir().unwrap();
2417        let projects = temp.path().join("projects");
2418        std::fs::create_dir(&projects).unwrap();
2419        let outside = tempfile::tempdir().unwrap();
2420        let outside_lock = outside.path().join("external.lock");
2421        std::fs::write(&outside_lock, b"lock sentinel").unwrap();
2422        symlink(&outside_lock, projects.join(".index.lock")).unwrap();
2423        assert!(ProjectStore::open(temp.path()).is_err());
2424        assert_eq!(
2425            std::fs::read(&outside_lock).unwrap(),
2426            b"lock sentinel",
2427            "registry locking must not open an external symlink target"
2428        );
2429
2430        std::fs::remove_file(projects.join(".index.lock")).unwrap();
2431        let store = ProjectStore::open(temp.path()).unwrap();
2432        let outside_index = outside.path().join("external-index.json");
2433        std::fs::write(&outside_index, b"index sentinel").unwrap();
2434        std::fs::remove_file(store.paths().index_path()).unwrap();
2435        symlink(&outside_index, store.paths().index_path()).unwrap();
2436        assert!(store.rebuild_index().is_err());
2437        assert_eq!(
2438            std::fs::read(&outside_index).unwrap(),
2439            b"index sentinel",
2440            "index rebuild must not read, quarantine, or replace an external symlink target"
2441        );
2442    }
2443
2444    #[test]
2445    fn binding_is_canonicalized_and_cross_project_conflicts() {
2446        let (temp, store) = store();
2447        let project_a = store.create("A", None).unwrap();
2448        let project_b = store.create("B", None).unwrap();
2449        let workspace = temp.path().join("workspace");
2450        std::fs::create_dir_all(workspace.join("nested")).unwrap();
2451        let non_canonical = workspace.join("nested").join("..");
2452
2453        let bound = store
2454            .bind_workspace(
2455                &project_a.id,
2456                project_a.revision,
2457                WorkspaceBinding {
2458                    path: non_canonical.to_string_lossy().into_owned(),
2459                    label: Some("main".to_string()),
2460                    git_common_dir: None,
2461                },
2462            )
2463            .unwrap();
2464        let canonical = std::fs::canonicalize(&workspace)
2465            .unwrap()
2466            .to_string_lossy()
2467            .into_owned();
2468        assert_eq!(bound.workspace_bindings[0].path, canonical);
2469        assert_eq!(bound.workspace_bindings[0].git_common_dir, None);
2470        assert_eq!(
2471            store
2472                .find_workspace_owner(non_canonical.to_string_lossy().as_ref())
2473                .unwrap()
2474                .map(|project| project.id),
2475            Some(project_a.id.clone())
2476        );
2477        assert!(store
2478            .bind_workspace(
2479                &project_b.id,
2480                project_b.revision,
2481                WorkspaceBinding {
2482                    path: workspace.to_string_lossy().into_owned(),
2483                    label: None,
2484                    git_common_dir: None,
2485                },
2486            )
2487            .is_err());
2488
2489        let projects_before = store.list().unwrap().len();
2490        assert!(store
2491            .create_with_bindings(
2492                "conflicting-create",
2493                None,
2494                vec![WorkspaceBinding {
2495                    path: workspace.to_string_lossy().into_owned(),
2496                    label: None,
2497                    git_common_dir: None,
2498                }],
2499            )
2500            .is_err());
2501        assert_eq!(
2502            store.list().unwrap().len(),
2503            projects_before,
2504            "a binding conflict must not leave a partially created Project"
2505        );
2506    }
2507
2508    #[test]
2509    fn exact_stored_binding_can_be_unbound_after_workspace_disappears() {
2510        let (temp, store) = store();
2511        let project = store.create("A", None).unwrap();
2512        let workspace = temp.path().join("workspace");
2513        std::fs::create_dir(&workspace).unwrap();
2514        let bound = store
2515            .bind_workspace(&project.id, project.revision, binding(&workspace))
2516            .unwrap();
2517        let stored_path = bound.workspace_bindings[0].path.clone();
2518        std::fs::remove_dir(&workspace).unwrap();
2519
2520        let unbound = store
2521            .unbind_workspace(&project.id, bound.revision, &stored_path)
2522            .unwrap();
2523        assert!(unbound.workspace_bindings.is_empty());
2524        assert!(!workspace.exists());
2525    }
2526
2527    #[test]
2528    fn unbind_uses_canonical_alias_only_after_raw_path_misses() {
2529        let (temp, store) = store();
2530        let project = store.create("A", None).unwrap();
2531        let workspace = temp.path().join("workspace");
2532        let nested = workspace.join("nested");
2533        std::fs::create_dir_all(&nested).unwrap();
2534        let bound = store
2535            .bind_workspace(&project.id, project.revision, binding(&workspace))
2536            .unwrap();
2537        let alias = nested.join("..");
2538
2539        let unbound = store
2540            .unbind_workspace(
2541                &project.id,
2542                bound.revision,
2543                alias.to_string_lossy().as_ref(),
2544            )
2545            .unwrap();
2546        assert!(unbound.workspace_bindings.is_empty());
2547    }
2548
2549    #[cfg(unix)]
2550    #[test]
2551    fn exact_unbind_does_not_follow_replaced_workspace_symlink() {
2552        use std::os::unix::fs::symlink;
2553
2554        let (temp, store) = store();
2555        let project = store.create("A", None).unwrap();
2556        let workspace = temp.path().join("workspace");
2557        std::fs::create_dir(&workspace).unwrap();
2558        let bound = store
2559            .bind_workspace(&project.id, project.revision, binding(&workspace))
2560            .unwrap();
2561        let stored_path = bound.workspace_bindings[0].path.clone();
2562
2563        std::fs::remove_dir(&workspace).unwrap();
2564        let outside = tempfile::tempdir().unwrap();
2565        let sentinel = outside.path().join("sentinel");
2566        std::fs::write(&sentinel, b"external data").unwrap();
2567        symlink(outside.path(), &workspace).unwrap();
2568
2569        let unbound = store
2570            .unbind_workspace(&project.id, bound.revision, &stored_path)
2571            .unwrap();
2572        assert!(unbound.workspace_bindings.is_empty());
2573        assert!(std::fs::symlink_metadata(&workspace)
2574            .unwrap()
2575            .file_type()
2576            .is_symlink());
2577        assert_eq!(std::fs::read(&sentinel).unwrap(), b"external data");
2578        assert_eq!(
2579            std::fs::read_dir(outside.path()).unwrap().count(),
2580            1,
2581            "exact unbind must not follow or write through the replacement symlink"
2582        );
2583    }
2584
2585    #[test]
2586    fn workspace_descendant_resolves_registered_owner() {
2587        let (temp, store) = store();
2588        let project_a = store.create("A", None).unwrap();
2589        let project_b = store.create("B", None).unwrap();
2590        let workspace_a = temp.path().join("workspace-a");
2591        let workspace_b = temp.path().join("workspace-b");
2592        let nested_b = workspace_b.join("nested").join("deeper");
2593        std::fs::create_dir_all(&workspace_a).unwrap();
2594        std::fs::create_dir_all(&nested_b).unwrap();
2595        store
2596            .bind_workspace(
2597                &project_a.id,
2598                project_a.revision,
2599                WorkspaceBinding {
2600                    path: workspace_a.to_string_lossy().into_owned(),
2601                    label: None,
2602                    git_common_dir: None,
2603                },
2604            )
2605            .unwrap();
2606        store
2607            .bind_workspace(
2608                &project_b.id,
2609                project_b.revision,
2610                WorkspaceBinding {
2611                    path: workspace_b.to_string_lossy().into_owned(),
2612                    label: None,
2613                    git_common_dir: None,
2614                },
2615            )
2616            .unwrap();
2617
2618        let owner = store
2619            .find_workspace_owner_for_path(nested_b.to_string_lossy().as_ref())
2620            .unwrap()
2621            .unwrap();
2622        assert_eq!(owner.id, project_b.id);
2623    }
2624
2625    #[test]
2626    fn missing_descendant_parent_escape_resolves_sibling_owner() {
2627        let (temp, store) = store();
2628        let project_a = store.create("A", None).unwrap();
2629        let project_b = store.create("B", None).unwrap();
2630        let workspace_a = temp.path().join("workspace-a");
2631        let workspace_b = temp.path().join("workspace-b");
2632        std::fs::create_dir_all(&workspace_a).unwrap();
2633        std::fs::create_dir_all(&workspace_b).unwrap();
2634        store
2635            .bind_workspace(&project_a.id, project_a.revision, binding(&workspace_a))
2636            .unwrap();
2637        store
2638            .bind_workspace(&project_b.id, project_b.revision, binding(&workspace_b))
2639            .unwrap();
2640
2641        let escaped_missing = workspace_a
2642            .join("missing")
2643            .join("..")
2644            .join("..")
2645            .join("workspace-b")
2646            .join("new");
2647        assert!(!escaped_missing.exists());
2648        assert_eq!(
2649            canonicalize_candidate_utf8(&escaped_missing, "test").unwrap(),
2650            workspace_b
2651                .canonicalize()
2652                .unwrap()
2653                .join("new")
2654                .to_string_lossy()
2655        );
2656        let owner = store
2657            .find_workspace_owner_for_path(escaped_missing.to_string_lossy().as_ref())
2658            .unwrap()
2659            .expect("sibling owner");
2660        assert_eq!(owner.id, project_b.id);
2661    }
2662
2663    #[test]
2664    fn outer_then_inner_cross_project_binding_is_rejected() {
2665        let (temp, store) = store();
2666        let project_a = store.create("A", None).unwrap();
2667        let project_b = store.create("B", None).unwrap();
2668        let outer = temp.path().join("outer");
2669        let inner = outer.join("inner");
2670        let candidate = inner.join("src");
2671        std::fs::create_dir_all(&candidate).unwrap();
2672        store
2673            .bind_workspace(&project_a.id, project_a.revision, binding(&outer))
2674            .unwrap();
2675        let error = store
2676            .bind_workspace(&project_b.id, project_b.revision, binding(&inner))
2677            .unwrap_err();
2678        assert!(
2679            matches!(error, ProjectStoreError::Validation(message) if message.contains("overlaps"))
2680        );
2681        let owner = store
2682            .find_workspace_owner_for_path(candidate.to_string_lossy().as_ref())
2683            .unwrap()
2684            .unwrap();
2685        assert_eq!(owner.id, project_a.id);
2686    }
2687
2688    #[test]
2689    fn inner_then_outer_cross_project_binding_is_rejected() {
2690        let (temp, store) = store();
2691        let project_a = store.create("A", None).unwrap();
2692        let project_b = store.create("B", None).unwrap();
2693        let outer = temp.path().join("outer");
2694        let inner = outer.join("inner");
2695        let candidate = inner.join("src");
2696        std::fs::create_dir_all(&candidate).unwrap();
2697        store
2698            .bind_workspace(&project_b.id, project_b.revision, binding(&inner))
2699            .unwrap();
2700        let error = store
2701            .bind_workspace(&project_a.id, project_a.revision, binding(&outer))
2702            .unwrap_err();
2703        assert!(
2704            matches!(error, ProjectStoreError::Validation(message) if message.contains("overlaps"))
2705        );
2706        let owner = store
2707            .find_workspace_owner_for_path(candidate.to_string_lossy().as_ref())
2708            .unwrap()
2709            .unwrap();
2710        assert_eq!(owner.id, project_b.id);
2711    }
2712
2713    #[test]
2714    fn create_rejects_external_and_internal_binding_overlap() {
2715        let (temp, store) = store();
2716        let outer = temp.path().join("outer");
2717        let inner = outer.join("inner");
2718        std::fs::create_dir_all(&inner).unwrap();
2719        let existing = store
2720            .create_with_bindings("existing", None, vec![binding(&inner)])
2721            .unwrap();
2722        let count = store.list().unwrap().len();
2723
2724        assert!(store
2725            .create_with_bindings("external overlap", None, vec![binding(&outer)])
2726            .is_err());
2727        assert!(store
2728            .create_with_bindings(
2729                "internal overlap",
2730                None,
2731                vec![binding(&outer), binding(&inner)],
2732            )
2733            .is_err());
2734        assert_eq!(store.list().unwrap().len(), count);
2735        assert_eq!(
2736            store
2737                .find_workspace_owner(inner.to_string_lossy().as_ref())
2738                .unwrap()
2739                .unwrap()
2740                .id,
2741            existing.id
2742        );
2743    }
2744
2745    #[test]
2746    fn same_project_overlap_and_generic_update_bypass_are_rejected() {
2747        let (temp, store) = store();
2748        let project = store.create("A", None).unwrap();
2749        let outer = temp.path().join("outer");
2750        let inner = outer.join("inner");
2751        std::fs::create_dir_all(&inner).unwrap();
2752        let bound = store
2753            .bind_workspace(&project.id, project.revision, binding(&outer))
2754            .unwrap();
2755
2756        assert!(store
2757            .bind_workspace(&project.id, bound.revision, binding(&inner))
2758            .is_err());
2759        assert!(store
2760            .update(&project.id, bound.revision, |manifest| {
2761                manifest.workspace_bindings.push(binding(&inner));
2762                Ok(())
2763            })
2764            .is_err());
2765        let unchanged = store.get(&project.id).unwrap();
2766        assert_eq!(unchanged.revision, bound.revision);
2767        assert_eq!(unchanged.workspace_bindings.len(), 1);
2768    }
2769
2770    #[test]
2771    fn component_boundary_paths_do_not_overlap() {
2772        let (temp, store) = store();
2773        let project_a = store.create("A", None).unwrap();
2774        let project_b = store.create("B", None).unwrap();
2775        let repo = temp.path().join("repo");
2776        let repo2 = temp.path().join("repo2");
2777        let repo2_child = repo2.join("src");
2778        std::fs::create_dir_all(&repo).unwrap();
2779        std::fs::create_dir_all(&repo2_child).unwrap();
2780        store
2781            .bind_workspace(&project_a.id, project_a.revision, binding(&repo))
2782            .unwrap();
2783        store
2784            .bind_workspace(&project_b.id, project_b.revision, binding(&repo2))
2785            .unwrap();
2786
2787        let owner = store
2788            .find_workspace_owner_for_path(repo2_child.to_string_lossy().as_ref())
2789            .unwrap()
2790            .unwrap();
2791        assert_eq!(owner.id, project_b.id);
2792    }
2793
2794    fn run_git(cwd: &Path, args: &[&str]) {
2795        let output = Command::new("git")
2796            .current_dir(cwd)
2797            .args(args)
2798            .output()
2799            .expect("git must be installed for repository identity tests");
2800        assert!(
2801            output.status.success(),
2802            "git {args:?} failed: {}",
2803            String::from_utf8_lossy(&output.stderr)
2804        );
2805    }
2806
2807    fn initialize_git_repository(root: &Path) {
2808        std::fs::create_dir_all(root).unwrap();
2809        run_git(root, &["init"]);
2810        run_git(
2811            root,
2812            &["config", "user.email", "project-store@example.test"],
2813        );
2814        run_git(root, &["config", "user.name", "Project Store Test"]);
2815        std::fs::write(root.join("README.md"), "project identity\n").unwrap();
2816        run_git(root, &["add", "README.md"]);
2817        run_git(root, &["commit", "-m", "initial"]);
2818    }
2819
2820    #[test]
2821    fn repository_and_linked_worktree_use_the_actual_common_dir() {
2822        let temp = tempfile::tempdir().unwrap();
2823        let repository = temp.path().join("repository");
2824        let linked_worktree = temp.path().join("linked-worktree");
2825        initialize_git_repository(&repository);
2826        let linked_worktree_arg = linked_worktree.to_string_lossy().into_owned();
2827        run_git(
2828            &repository,
2829            &["worktree", "add", "-b", "linked", &linked_worktree_arg],
2830        );
2831
2832        let store = ProjectStore::open(temp.path().join("data")).unwrap();
2833        let project = store.create("Git project", None).unwrap();
2834        let bound_repository = store
2835            .bind_workspace(
2836                &project.id,
2837                project.revision,
2838                WorkspaceBinding {
2839                    path: repository.to_string_lossy().into_owned(),
2840                    label: Some("main".to_string()),
2841                    git_common_dir: None,
2842                },
2843            )
2844            .unwrap();
2845        let expected_common_dir = std::fs::canonicalize(repository.join(".git"))
2846            .unwrap()
2847            .to_string_lossy()
2848            .into_owned();
2849        assert_eq!(
2850            bound_repository.workspace_bindings[0]
2851                .git_common_dir
2852                .as_deref(),
2853            Some(expected_common_dir.as_str())
2854        );
2855
2856        let bound_linked_worktree = store
2857            .bind_workspace(
2858                &project.id,
2859                bound_repository.revision,
2860                WorkspaceBinding {
2861                    path: linked_worktree.to_string_lossy().into_owned(),
2862                    label: Some("linked".to_string()),
2863                    git_common_dir: None,
2864                },
2865            )
2866            .unwrap();
2867        assert_eq!(bound_linked_worktree.workspace_bindings.len(), 2);
2868        assert!(bound_linked_worktree
2869            .workspace_bindings
2870            .iter()
2871            .all(|binding| {
2872                binding.git_common_dir.as_deref() == Some(expected_common_dir.as_str())
2873            }));
2874    }
2875
2876    #[test]
2877    fn migrated_primary_project_path_retains_git_evidence_for_linked_worktree() {
2878        let temp = tempfile::tempdir().unwrap();
2879        let repository = temp.path().join("repository");
2880        let linked_worktree = temp.path().join("linked-worktree");
2881        initialize_git_repository(&repository);
2882        let linked_worktree_arg = linked_worktree.to_string_lossy().into_owned();
2883        run_git(
2884            &repository,
2885            &[
2886                "worktree",
2887                "add",
2888                "-b",
2889                "linked-migration",
2890                &linked_worktree_arg,
2891            ],
2892        );
2893
2894        let store = ProjectStore::open(temp.path().join("data")).unwrap();
2895        let legacy = store
2896            .create_with_bindings(
2897                "Legacy Git Project",
2898                None,
2899                vec![WorkspaceBinding {
2900                    path: repository.to_string_lossy().into_owned(),
2901                    label: Some("main".to_string()),
2902                    git_common_dir: None,
2903                }],
2904            )
2905            .unwrap();
2906        let manifest_path = store.paths().manifest_path(&legacy.id);
2907        let mut raw: serde_json::Value =
2908            serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap();
2909        raw["schema_version"] = serde_json::json!(1);
2910        raw.as_object_mut().unwrap().remove("project_path");
2911        raw.as_object_mut().unwrap().remove("project_path_status");
2912        std::fs::write(&manifest_path, serde_json::to_vec_pretty(&raw).unwrap()).unwrap();
2913
2914        let migrated = store.get(&legacy.id).unwrap();
2915        assert_eq!(
2916            migrated.project_path.as_deref(),
2917            Some(
2918                repository
2919                    .canonicalize()
2920                    .unwrap()
2921                    .to_string_lossy()
2922                    .as_ref()
2923            )
2924        );
2925        assert!(migrated.workspace_bindings.is_empty());
2926        let linked_canonical = linked_worktree.canonicalize().unwrap();
2927        let linked_git_common_dir = resolve_git_common_dir(&linked_canonical)
2928            .unwrap()
2929            .expect("linked worktree common dir");
2930        let report = plan_legacy_migration(
2931            &[LegacySessionProjectInput {
2932                session_id: "linked-legacy-session".to_string(),
2933                workspace_path: Some(linked_canonical.to_string_lossy().into_owned()),
2934                canonical_path: Some(linked_canonical.to_string_lossy().into_owned()),
2935                git_common_dir: Some(linked_git_common_dir),
2936            }],
2937            &[migrated],
2938        );
2939        assert_eq!(report.assignments.len(), 1);
2940        assert_eq!(report.assignments[0].project_id, legacy.id);
2941        assert_eq!(
2942            report.assignments[0].basis,
2943            LegacyProjectMatchBasis::GitCommonDir
2944        );
2945    }
2946
2947    #[cfg(unix)]
2948    #[test]
2949    fn legacy_dry_run_rejects_git_evidence_from_replaced_project_path_symlink() {
2950        use std::os::unix::fs::symlink;
2951
2952        let temp = tempfile::tempdir().unwrap();
2953        let configured_path = temp.path().join("configured-project");
2954        let replacement_repository = temp.path().join("replacement-repository");
2955        std::fs::create_dir_all(&configured_path).unwrap();
2956        let configured_path = configured_path.canonicalize().unwrap();
2957        initialize_git_repository(&replacement_repository);
2958        let replacement_common_dir = resolve_git_common_dir(&replacement_repository)
2959            .unwrap()
2960            .expect("replacement common dir");
2961        let mut project = ProjectManifest::new(
2962            "01JREPLACED000000000000000".parse().unwrap(),
2963            "Replaced Project path",
2964            None,
2965            Utc::now(),
2966        );
2967        project.project_path = Some(configured_path.to_string_lossy().into_owned());
2968        project.project_path_status = ProjectPathStatus::Configured;
2969
2970        std::fs::remove_dir(&configured_path).unwrap();
2971        symlink(&replacement_repository, &configured_path).unwrap();
2972
2973        let report = plan_legacy_migration(
2974            &[LegacySessionProjectInput {
2975                session_id: "replacement-session".to_string(),
2976                workspace_path: Some(replacement_repository.to_string_lossy().into_owned()),
2977                canonical_path: Some(
2978                    replacement_repository
2979                        .canonicalize()
2980                        .unwrap()
2981                        .to_string_lossy()
2982                        .into_owned(),
2983                ),
2984                git_common_dir: Some(replacement_common_dir),
2985            }],
2986            &[project],
2987        );
2988        assert!(report.assignments.is_empty());
2989        assert!(report
2990            .unassigned
2991            .iter()
2992            .any(|entry| entry.session_id == "replacement-session"));
2993    }
2994
2995    #[test]
2996    fn forged_git_common_dir_is_rejected() {
2997        let temp = tempfile::tempdir().unwrap();
2998        let repository = temp.path().join("repository");
2999        let forged_common_dir = temp.path().join("forged-common-dir");
3000        initialize_git_repository(&repository);
3001        std::fs::create_dir_all(&forged_common_dir).unwrap();
3002
3003        let store = ProjectStore::open(temp.path().join("data")).unwrap();
3004        let project = store.create("Git project", None).unwrap();
3005        let error = store
3006            .bind_workspace(
3007                &project.id,
3008                project.revision,
3009                WorkspaceBinding {
3010                    path: repository.to_string_lossy().into_owned(),
3011                    label: None,
3012                    git_common_dir: Some(forged_common_dir.to_string_lossy().into_owned()),
3013                },
3014            )
3015            .unwrap_err();
3016        assert!(
3017            matches!(error, ProjectStoreError::Validation(message) if message.contains(
3018                "supplied git common dir does not match workspace"
3019            ))
3020        );
3021        assert!(store
3022            .get(&project.id)
3023            .unwrap()
3024            .workspace_bindings
3025            .is_empty());
3026    }
3027
3028    #[test]
3029    fn concurrent_cas_allows_exactly_one_writer() {
3030        let (_temp, store) = store();
3031        let project = store.create("CAS", None).unwrap();
3032        let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
3033        let mut threads = Vec::new();
3034        for name in ["winner-a", "winner-b"] {
3035            let store = store.clone();
3036            let id = project.id.clone();
3037            let barrier = barrier.clone();
3038            threads.push(std::thread::spawn(move || {
3039                barrier.wait();
3040                store.update(&id, 1, |manifest| {
3041                    manifest.name = name.to_string();
3042                    Ok(())
3043                })
3044            }));
3045        }
3046        barrier.wait();
3047        let results = threads
3048            .into_iter()
3049            .map(|thread| thread.join().unwrap())
3050            .collect::<Vec<_>>();
3051        assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
3052        assert_eq!(
3053            results
3054                .iter()
3055                .filter(|result| matches!(result, Err(ProjectStoreError::Conflict { .. })))
3056                .count(),
3057            1
3058        );
3059        assert_eq!(store.get(&project.id).unwrap().revision, 2);
3060    }
3061
3062    #[test]
3063    fn corrupt_primary_recovers_from_backup_and_index_rebuild_skips_bad_record() {
3064        let (temp, store) = store();
3065        let created = store.create("Recover", None).unwrap();
3066        let updated = store
3067            .update(&created.id, 1, |project| {
3068                project.description = Some("new".to_string());
3069                Ok(())
3070            })
3071            .unwrap();
3072        std::fs::write(store.paths().manifest_path(&created.id), b"{broken").unwrap();
3073        let recovered = store.get(&created.id).unwrap();
3074        assert_eq!(
3075            recovered.revision, 3,
3076            "recovery must advance past the issued revision floor"
3077        );
3078        assert_eq!(recovered.description, None);
3079        assert!(matches!(
3080            store.update(&created.id, updated.revision, |_| Ok(())),
3081            Err(ProjectStoreError::Conflict {
3082                expected: 2,
3083                actual: 3
3084            })
3085        ));
3086
3087        let bad_id: ProjectId = "01JBADPROJECT000000000000000".parse().unwrap();
3088        let bad_home = store.paths().project_home(&bad_id);
3089        std::fs::create_dir_all(&bad_home).unwrap();
3090        std::fs::write(bad_home.join(PROJECT_MANIFEST_FILE), b"{broken").unwrap();
3091        let reopened = ProjectStore::open(temp.path()).unwrap();
3092        let index = reopened.index().unwrap();
3093        assert!(index.projects.contains_key(&created.id));
3094        assert!(!index.projects.contains_key(&bad_id));
3095        assert!(recovered.revision > updated.revision);
3096    }
3097
3098    #[test]
3099    fn corrupt_derived_index_is_quarantined_and_rebuilt() {
3100        let (temp, store) = store();
3101        let created = store.create("Indexed", None).unwrap();
3102        std::fs::write(store.paths().index_path(), b"{broken-index").unwrap();
3103
3104        let reopened = ProjectStore::open(temp.path()).unwrap();
3105        assert!(reopened.index().unwrap().projects.contains_key(&created.id));
3106        assert!(
3107            std::fs::read_dir(reopened.paths().projects_dir())
3108                .unwrap()
3109                .filter_map(Result::ok)
3110                .any(|entry| entry
3111                    .file_name()
3112                    .to_string_lossy()
3113                    .starts_with("index.json.corrupt.")),
3114            "corrupt derived index bytes should be retained for diagnostics"
3115        );
3116    }
3117
3118    #[test]
3119    fn resource_summary_is_redacted_counts_only() {
3120        let (_temp, store) = store();
3121        let created = store.create("Resources", None).unwrap();
3122        let skills = store.paths().skills_dir(&created.id, None).unwrap();
3123        std::fs::create_dir_all(&skills).unwrap();
3124        std::fs::write(skills.join("secret-token-skill"), "super-secret").unwrap();
3125        std::fs::write(
3126            store.paths().settings_path(&created.id),
3127            r#"{"api_key":"never-return"}"#,
3128        )
3129        .unwrap();
3130        let summary = store.resource_summary(&created.id).unwrap();
3131        let encoded = serde_json::to_string(&summary).unwrap();
3132        assert!(!encoded.contains("super-secret"));
3133        assert!(!encoded.contains("never-return"));
3134        assert_eq!(
3135            summary
3136                .resources
3137                .iter()
3138                .find(|entry| entry.kind == ProjectResourceKind::Skills)
3139                .map(|entry| entry.item_count),
3140            Some(1)
3141        );
3142    }
3143
3144    #[test]
3145    fn legacy_dry_run_only_uses_safe_evidence() {
3146        let now = Utc::now();
3147        let mut existing = ProjectManifest::new(
3148            "01JEXISTING0000000000000000".parse().unwrap(),
3149            "Existing",
3150            None,
3151            now,
3152        );
3153        existing.workspace_bindings.push(WorkspaceBinding {
3154            path: "/work/main".to_string(),
3155            label: None,
3156            git_common_dir: Some("/work/repo/.git".to_string()),
3157        });
3158        let inputs = vec![
3159            LegacySessionProjectInput {
3160                session_id: "exact".to_string(),
3161                workspace_path: Some("/work/main".to_string()),
3162                canonical_path: Some("/work/main".to_string()),
3163                git_common_dir: None,
3164            },
3165            LegacySessionProjectInput {
3166                session_id: "linked-a".to_string(),
3167                workspace_path: Some("/other/a".to_string()),
3168                canonical_path: Some("/other/a".to_string()),
3169                git_common_dir: Some("/other/repo/.git".to_string()),
3170            },
3171            LegacySessionProjectInput {
3172                session_id: "linked-b".to_string(),
3173                workspace_path: Some("/other/b".to_string()),
3174                canonical_path: Some("/other/b".to_string()),
3175                git_common_dir: Some("/other/repo/.git".to_string()),
3176            },
3177            LegacySessionProjectInput {
3178                session_id: "basename-only".to_string(),
3179                workspace_path: Some("/missing/zenith".to_string()),
3180                canonical_path: None,
3181                git_common_dir: None,
3182            },
3183        ];
3184        let report = plan_legacy_migration(&inputs, &[existing]);
3185        assert_eq!(report.assignments.len(), 1);
3186        assert_eq!(report.assignments[0].session_id, "exact");
3187        assert_eq!(report.suggestions.len(), 1);
3188        assert_eq!(
3189            report.suggestions[0].basis,
3190            LegacyProjectMatchBasis::GitCommonDir
3191        );
3192        assert!(report
3193            .unassigned
3194            .iter()
3195            .any(|entry| entry.session_id == "basename-only"));
3196    }
3197}