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