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