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