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