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