1use std::path::Path;
4
5use anyhow::{bail, Context, Result};
6use rusqlite::{params, Connection, OptionalExtension};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use sha2::{Digest, Sha256};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum ProjectAliasProofKind {
14 FilesystemCanonicalization,
15 GitRemote,
16 GitCommitMembership,
17}
18
19impl ProjectAliasProofKind {
20 fn as_str(self) -> &'static str {
21 match self {
22 Self::FilesystemCanonicalization => "filesystem_canonicalization",
23 Self::GitRemote => "git_remote",
24 Self::GitCommitMembership => "git_commit_membership",
25 }
26 }
27}
28
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30pub struct ProjectAliasPlanEntry {
31 pub alias_path: String,
32 pub canonical_path: String,
33 pub proof_kind: ProjectAliasProofKind,
34 pub proof_payload: Value,
35 pub proof_sha256: String,
36}
37
38#[derive(Debug, Clone)]
39pub struct ProjectAliasApplyRequest<'a> {
40 pub source_inventory_sha256: &'a str,
41 pub actor: &'a str,
42 pub reason: &'a str,
43 pub now_epoch: i64,
44 pub entries: &'a [ProjectAliasPlanEntry],
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
48pub struct ProjectAliasApplyResult {
49 pub inserted: usize,
50 pub unchanged: usize,
51 pub aliases: Vec<ProjectAliasResolution>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55pub struct ProjectAliasResolution {
56 pub requested_path: String,
57 pub canonical_project_id: Option<i64>,
58 pub canonical_path: String,
59 pub active_aliases: Vec<String>,
60 pub resolved_via_alias: bool,
61}
62
63pub fn proof_sha256(payload: &Value) -> Result<String> {
64 let encoded = serde_json::to_vec(payload).context("serialize project alias proof payload")?;
65 Ok(format!("{:x}", Sha256::digest(encoded)))
66}
67
68pub fn apply_project_alias_plan(
69 conn: &Connection,
70 request: &ProjectAliasApplyRequest<'_>,
71) -> Result<ProjectAliasApplyResult> {
72 validate_request_header(request)?;
73
74 let tx = conn.unchecked_transaction()?;
75 let mut inserted = 0;
76 let mut unchanged = 0;
77 let mut resolved = Vec::new();
78 for entry in request.entries {
79 validate_entry(&tx, entry)?;
80 let canonical_project_id =
81 exact_project_id(&tx, &entry.canonical_path)?.ok_or_else(|| {
82 anyhow::anyhow!("canonical project not found: {}", entry.canonical_path)
83 })?;
84 if active_alias_target(&tx, &entry.canonical_path)?.is_some() {
85 bail!(
86 "project alias chains are forbidden: target {} is itself an active alias",
87 entry.canonical_path
88 );
89 }
90
91 match active_alias_target(&tx, &entry.alias_path)? {
92 Some(existing) if existing == canonical_project_id => {
93 unchanged += 1;
94 }
95 Some(existing) => bail!(
96 "project alias collision: {} already targets project id {}, requested {}",
97 entry.alias_path,
98 existing,
99 canonical_project_id
100 ),
101 None => {
102 let payload_json = serde_json::to_string(&entry.proof_payload)?;
103 tx.execute(
104 "INSERT INTO project_identity_alias_events(
105 alias_path, canonical_project_id, action, proof_kind,
106 proof_payload_json, proof_sha256, source_inventory_sha256,
107 actor, reason, created_at_epoch
108 ) VALUES(?1, ?2, 'activate', ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
109 params![
110 entry.alias_path,
111 canonical_project_id,
112 entry.proof_kind.as_str(),
113 payload_json,
114 entry.proof_sha256,
115 request.source_inventory_sha256,
116 request.actor,
117 request.reason,
118 request.now_epoch,
119 ],
120 )?;
121 let event_id = tx.last_insert_rowid();
122 tx.execute(
123 "INSERT INTO project_identity_aliases(
124 alias_path, canonical_project_id, status, last_event_id,
125 created_at_epoch, updated_at_epoch
126 ) VALUES(?1, ?2, 'active', ?3, ?4, ?4)
127 ON CONFLICT(alias_path) DO UPDATE SET
128 canonical_project_id = excluded.canonical_project_id,
129 status = 'active',
130 last_event_id = excluded.last_event_id,
131 updated_at_epoch = excluded.updated_at_epoch",
132 params![
133 entry.alias_path,
134 canonical_project_id,
135 event_id,
136 request.now_epoch
137 ],
138 )?;
139 inserted += 1;
140 }
141 }
142 resolved.push(resolve_project_identity(&tx, &entry.alias_path)?);
143 }
144 tx.commit()?;
145 resolved.sort_by(|a, b| a.requested_path.cmp(&b.requested_path));
146 Ok(ProjectAliasApplyResult {
147 inserted,
148 unchanged,
149 aliases: resolved,
150 })
151}
152
153pub fn preview_project_alias_plan(
154 conn: &Connection,
155 request: &ProjectAliasApplyRequest<'_>,
156) -> Result<ProjectAliasApplyResult> {
157 validate_request_header(request)?;
158 let mut inserted = 0;
159 let mut unchanged = 0;
160 let mut resolved = Vec::new();
161 for entry in request.entries {
162 validate_entry(conn, entry)?;
163 let canonical_project_id =
164 exact_project_id(conn, &entry.canonical_path)?.ok_or_else(|| {
165 anyhow::anyhow!("canonical project not found: {}", entry.canonical_path)
166 })?;
167 if active_alias_target(conn, &entry.canonical_path)?.is_some() {
168 bail!(
169 "project alias chains are forbidden: target {} is itself an active alias",
170 entry.canonical_path
171 );
172 }
173 match active_alias_target(conn, &entry.alias_path)? {
174 Some(existing) if existing == canonical_project_id => {
175 unchanged += 1;
176 resolved.push(resolve_project_identity(conn, &entry.alias_path)?);
177 }
178 Some(existing) => bail!(
179 "project alias collision: {} already targets project id {}, requested {}",
180 entry.alias_path,
181 existing,
182 canonical_project_id
183 ),
184 None => {
185 inserted += 1;
186 let mut active_aliases = active_aliases_for_project(conn, canonical_project_id)?;
187 active_aliases.push(entry.alias_path.clone());
188 active_aliases.sort();
189 active_aliases.dedup();
190 resolved.push(ProjectAliasResolution {
191 requested_path: entry.alias_path.clone(),
192 canonical_project_id: Some(canonical_project_id),
193 canonical_path: entry.canonical_path.clone(),
194 active_aliases,
195 resolved_via_alias: true,
196 });
197 }
198 }
199 }
200 resolved.sort_by(|a, b| a.requested_path.cmp(&b.requested_path));
201 Ok(ProjectAliasApplyResult {
202 inserted,
203 unchanged,
204 aliases: resolved,
205 })
206}
207
208fn validate_request_header(request: &ProjectAliasApplyRequest<'_>) -> Result<()> {
209 validate_digest(request.source_inventory_sha256, "source_inventory_sha256")?;
210 if request.actor.trim().is_empty() {
211 bail!("project alias apply requires a non-empty actor");
212 }
213 if request.reason.trim().is_empty() {
214 bail!("project alias apply requires a non-empty reason");
215 }
216 if request.entries.is_empty() {
217 bail!("project alias apply requires at least one entry");
218 }
219 Ok(())
220}
221
222pub fn resolve_project_identity(
223 conn: &Connection,
224 requested_path: &str,
225) -> Result<ProjectAliasResolution> {
226 if !alias_registry_available(conn)? {
227 return Ok(ProjectAliasResolution {
228 requested_path: requested_path.to_string(),
229 canonical_project_id: None,
230 canonical_path: requested_path.to_string(),
231 active_aliases: Vec::new(),
232 resolved_via_alias: false,
233 });
234 }
235 let alias_target = conn
236 .query_row(
237 "SELECT aliases.canonical_project_id, projects.project_path
238 FROM project_identity_aliases aliases
239 JOIN projects ON projects.id = aliases.canonical_project_id
240 WHERE aliases.alias_path = ?1 AND aliases.status = 'active'",
241 [requested_path],
242 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
243 )
244 .optional()?;
245 let (canonical_project_id, canonical_path, resolved_via_alias) = match alias_target {
246 Some((id, path)) => (Some(id), path, true),
247 None => match exact_project_id(conn, requested_path)? {
248 Some(id) => (Some(id), requested_path.to_string(), false),
249 None => (None, requested_path.to_string(), false),
250 },
251 };
252 let active_aliases = match canonical_project_id {
253 Some(id) => active_aliases_for_project(conn, id)?,
254 None => Vec::new(),
255 };
256 Ok(ProjectAliasResolution {
257 requested_path: requested_path.to_string(),
258 canonical_project_id,
259 canonical_path,
260 active_aliases,
261 resolved_via_alias,
262 })
263}
264
265fn active_aliases_for_project(conn: &Connection, id: i64) -> Result<Vec<String>> {
266 let mut statement = conn.prepare(
267 "SELECT alias_path FROM project_identity_aliases
268 WHERE canonical_project_id = ?1 AND status = 'active'
269 ORDER BY alias_path",
270 )?;
271 let aliases = statement
272 .query_map([id], |row| row.get::<_, String>(0))?
273 .collect::<rusqlite::Result<Vec<_>>>()?;
274 Ok(aliases)
275}
276
277pub fn project_filter_values(conn: &Connection, requested_path: &str) -> Result<Vec<String>> {
278 let resolution = resolve_project_identity(conn, requested_path)?;
279 let mut values = resolution.active_aliases;
280 values.push(resolution.canonical_path);
281 values.sort();
282 values.dedup();
283 Ok(values)
284}
285
286pub fn push_project_value_filter(
290 conn: &Connection,
291 column: &str,
292 requested_path: &str,
293 mut idx: usize,
294 params: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
295) -> Result<(String, usize)> {
296 let values = project_filter_values(conn, requested_path)?;
297 let values_json = serde_json::to_string(&values)?;
298 params.push(Box::new(values_json));
299 let predicate = format!("{column} IN (SELECT value FROM json_each(?{idx}))");
300 idx += 1;
301 Ok((predicate, idx))
302}
303
304pub fn canonical_project_path_for_write(conn: &Connection, requested_path: &str) -> Result<String> {
308 Ok(resolve_project_identity(conn, requested_path)?.canonical_path)
309}
310
311pub(crate) fn alias_registry_available(conn: &Connection) -> Result<bool> {
312 let count: i64 = conn.query_row(
313 "SELECT COUNT(*) FROM sqlite_schema
314 WHERE type = 'table'
315 AND name IN ('projects', 'project_identity_aliases')",
316 [],
317 |row| row.get(0),
318 )?;
319 Ok(count == 2)
320}
321
322fn validate_entry(conn: &Connection, entry: &ProjectAliasPlanEntry) -> Result<()> {
323 if !Path::new(&entry.alias_path).is_absolute()
324 || !Path::new(&entry.canonical_path).is_absolute()
325 {
326 bail!("project alias paths must be absolute");
327 }
328 if entry.alias_path == entry.canonical_path {
329 bail!("project alias source and canonical path must differ");
330 }
331 validate_digest(&entry.proof_sha256, "proof_sha256")?;
332 let actual = proof_sha256(&entry.proof_payload)?;
333 if actual != entry.proof_sha256 {
334 bail!("project alias proof digest does not match payload");
335 }
336 let payload = entry
337 .proof_payload
338 .as_object()
339 .ok_or_else(|| anyhow::anyhow!("project alias proof payload must be an object"))?;
340 if payload.get("from_path").and_then(Value::as_str) != Some(entry.alias_path.as_str())
341 || payload.get("to_path").and_then(Value::as_str) != Some(entry.canonical_path.as_str())
342 {
343 bail!("project alias proof payload path binding does not match plan entry");
344 }
345 match entry.proof_kind {
346 ProjectAliasProofKind::FilesystemCanonicalization => {
347 if payload.get("canonicalized").and_then(Value::as_bool) != Some(true) {
348 bail!("filesystem alias proof requires canonicalized=true");
349 }
350 }
351 ProjectAliasProofKind::GitRemote => {
352 if payload
353 .get("target_remote")
354 .and_then(Value::as_str)
355 .is_none_or(str::is_empty)
356 {
357 bail!("git remote alias proof requires target_remote");
358 }
359 }
360 ProjectAliasProofKind::GitCommitMembership => {
361 if payload
362 .get("shared_commit_count")
363 .and_then(Value::as_u64)
364 .is_none_or(|count| count == 0)
365 {
366 bail!("git commit alias proof requires shared_commit_count > 0");
367 }
368 }
369 }
370 if exact_project_id(conn, &entry.canonical_path)?.is_none() {
371 bail!("canonical project not found: {}", entry.canonical_path);
372 }
373 Ok(())
374}
375
376fn exact_project_id(conn: &Connection, path: &str) -> Result<Option<i64>> {
377 let mut statement =
378 conn.prepare("SELECT id FROM projects WHERE project_path = ?1 ORDER BY id LIMIT 2")?;
379 let ids = statement
380 .query_map([path], |row| row.get::<_, i64>(0))?
381 .collect::<rusqlite::Result<Vec<_>>>()?;
382 match ids.as_slice() {
383 [] => Ok(None),
384 [id] => Ok(Some(*id)),
385 _ => bail!("canonical project path is ambiguous: {path}"),
386 }
387}
388
389fn active_alias_target(conn: &Connection, alias_path: &str) -> Result<Option<i64>> {
390 conn.query_row(
391 "SELECT canonical_project_id FROM project_identity_aliases
392 WHERE alias_path = ?1 AND status = 'active'",
393 [alias_path],
394 |row| row.get(0),
395 )
396 .optional()
397 .map_err(Into::into)
398}
399
400fn validate_digest(value: &str, field: &str) -> Result<()> {
401 if value.len() != 64
402 || !value
403 .bytes()
404 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
405 {
406 bail!("{field} must be 64 lowercase hexadecimal characters");
407 }
408 Ok(())
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 fn setup() -> Result<Connection> {
416 let conn = Connection::open_in_memory()?;
417 conn.execute_batch("PRAGMA foreign_keys = ON")?;
418 crate::migrate::run_migrations(&conn)?;
419 conn.execute(
420 "INSERT INTO workspaces(
421 root_path, git_remote, git_branch, created_at_epoch, updated_at_epoch
422 ) VALUES('/new/repo', 'https://github.com/o/r.git', 'main', 1, 1)",
423 [],
424 )?;
425 let workspace_id = conn.last_insert_rowid();
426 conn.execute(
427 "INSERT INTO projects(
428 workspace_id, project_path, project_key, created_at_epoch, updated_at_epoch
429 ) VALUES(?1, '/new/repo', '/new/repo', 1, 1)",
430 [workspace_id],
431 )?;
432 Ok(conn)
433 }
434
435 fn entry(alias: &str, target: &str) -> ProjectAliasPlanEntry {
436 let payload = serde_json::json!({
437 "from_path": alias,
438 "to_path": target,
439 "target_remote": "github.com/o/r",
440 "shared_commit_count": 2
441 });
442 ProjectAliasPlanEntry {
443 alias_path: alias.to_string(),
444 canonical_path: target.to_string(),
445 proof_kind: ProjectAliasProofKind::GitCommitMembership,
446 proof_sha256: proof_sha256(&payload).unwrap(),
447 proof_payload: payload,
448 }
449 }
450
451 fn request<'a>(entries: &'a [ProjectAliasPlanEntry]) -> ProjectAliasApplyRequest<'a> {
452 ProjectAliasApplyRequest {
453 source_inventory_sha256:
454 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
455 actor: "test",
456 reason: "fixture",
457 now_epoch: 10,
458 entries,
459 }
460 }
461
462 #[test]
463 fn apply_and_resolve_alias_without_rewriting_source_rows() -> Result<()> {
464 let conn = setup()?;
465 conn.execute(
466 "INSERT INTO memories(project, scope, memory_type, title, content, status,
467 created_at_epoch, updated_at_epoch)
468 VALUES('/old/repo', 'project', 'decision', 't', 'c', 'active', 1, 1)",
469 [],
470 )?;
471 let entries = [entry("/old/repo", "/new/repo")];
472 let result = apply_project_alias_plan(&conn, &request(&entries))?;
473 assert_eq!(result.inserted, 1);
474 let resolution = resolve_project_identity(&conn, "/old/repo")?;
475 assert_eq!(resolution.canonical_path, "/new/repo");
476 assert!(resolution.resolved_via_alias);
477 assert_eq!(
478 project_filter_values(&conn, "/new/repo")?,
479 vec!["/new/repo".to_string(), "/old/repo".to_string()]
480 );
481 let historical: String =
482 conn.query_row("SELECT project FROM memories LIMIT 1", [], |row| row.get(0))?;
483 assert_eq!(historical, "/old/repo");
484 Ok(())
485 }
486
487 #[test]
488 fn identical_reapply_is_idempotent() -> Result<()> {
489 let conn = setup()?;
490 let entries = [entry("/old/repo", "/new/repo")];
491 apply_project_alias_plan(&conn, &request(&entries))?;
492 let result = apply_project_alias_plan(&conn, &request(&entries))?;
493 assert_eq!((result.inserted, result.unchanged), (0, 1));
494 let events: i64 = conn.query_row(
495 "SELECT COUNT(*) FROM project_identity_alias_events",
496 [],
497 |row| row.get(0),
498 )?;
499 assert_eq!(events, 1);
500 Ok(())
501 }
502
503 #[test]
504 fn proof_drift_and_collision_fail_closed() -> Result<()> {
505 let conn = setup()?;
506 let mut bad = entry("/old/repo", "/new/repo");
507 bad.proof_sha256 = "b".repeat(64);
508 assert!(apply_project_alias_plan(&conn, &request(&[bad])).is_err());
509
510 let good = [entry("/old/repo", "/new/repo")];
511 apply_project_alias_plan(&conn, &request(&good))?;
512 conn.execute(
513 "INSERT INTO workspaces(
514 root_path, git_remote, git_branch, created_at_epoch, updated_at_epoch
515 ) VALUES('/other/repo', NULL, NULL, 1, 1)",
516 [],
517 )?;
518 let workspace_id = conn.last_insert_rowid();
519 conn.execute(
520 "INSERT INTO projects(
521 workspace_id, project_path, project_key, created_at_epoch, updated_at_epoch
522 ) VALUES(?1, '/other/repo', '/other/repo', 1, 1)",
523 [workspace_id],
524 )?;
525 let collision = [entry("/old/repo", "/other/repo")];
526 assert!(apply_project_alias_plan(&conn, &request(&collision)).is_err());
527 Ok(())
528 }
529
530 #[test]
531 fn batch_rolls_back_when_later_entry_is_invalid() -> Result<()> {
532 let conn = setup()?;
533 let entries = [
534 entry("/old/repo", "/new/repo"),
535 entry("relative", "/new/repo"),
536 ];
537 assert!(apply_project_alias_plan(&conn, &request(&entries)).is_err());
538 let aliases: i64 =
539 conn.query_row("SELECT COUNT(*) FROM project_identity_aliases", [], |row| {
540 row.get(0)
541 })?;
542 assert_eq!(aliases, 0);
543 Ok(())
544 }
545
546 #[test]
547 fn project_value_filter_expands_canonical_and_alias_paths() -> Result<()> {
548 let conn = setup()?;
549 let entries = [entry("/old/repo", "/new/repo")];
550 apply_project_alias_plan(&conn, &request(&entries))?;
551 let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
552 let (clause, next) =
553 push_project_value_filter(&conn, "project", "/new/repo", 1, &mut values)?;
554 assert_eq!(clause, "project IN (SELECT value FROM json_each(?1))");
555 assert_eq!(next, 2);
556 assert_eq!(values.len(), 1);
557 Ok(())
558 }
559
560 #[test]
561 fn resolver_falls_back_on_partial_test_schema() -> Result<()> {
562 let conn = Connection::open_in_memory()?;
563 conn.execute("CREATE TABLE memories(project TEXT NOT NULL)", [])?;
564 assert_eq!(
565 project_filter_values(&conn, "/repo")?,
566 vec!["/repo".to_string()]
567 );
568 Ok(())
569 }
570
571 #[test]
572 fn capture_retrieval_review_state_and_status_share_alias_identity() -> Result<()> {
573 let conn = setup()?;
574 let entries = [entry("/old/repo", "/new/repo")];
575 apply_project_alias_plan(&conn, &request(&entries))?;
576
577 crate::db::record_captured_event(
578 &conn,
579 &crate::db::CaptureEventInput {
580 host: "codex-cli",
581 session_id: "alias-boundary-session",
582 project: "/old/repo",
583 cwd: None,
584 event_type: "user_prompt",
585 role: Some("user"),
586 tool_name: None,
587 content: "alias boundary capture",
588 task_kind: None,
589 },
590 )?;
591 let legacy_project_rows: i64 = conn.query_row(
592 "SELECT COUNT(*) FROM projects WHERE project_path = '/old/repo'",
593 [],
594 |row| row.get(0),
595 )?;
596 assert_eq!(legacy_project_rows, 0, "capture must write canonically");
597
598 conn.execute(
599 "INSERT INTO memories(
600 id, project, scope, memory_type, title, content, status,
601 created_at_epoch, updated_at_epoch
602 ) VALUES(101, '/old/repo', 'project', 'decision', 'historical', 'body',
603 'active', 1, 1)",
604 [],
605 )?;
606 let retrieved = crate::memory::store::get_recent_project_memories_excluding_types(
607 &conn,
608 "/new/repo",
609 &[],
610 10,
611 )?;
612 assert_eq!(
613 retrieved.iter().map(|row| row.id).collect::<Vec<_>>(),
614 [101]
615 );
616
617 conn.execute(
618 "INSERT INTO memory_state_keys(
619 owner_scope, owner_key, memory_type, state_key, state_status,
620 current_memory_id, created_at_epoch, updated_at_epoch
621 ) VALUES('repo', '/old/repo', 'decision', 'alias-state', 'active', 101, 1, 1)",
622 [],
623 )?;
624 assert_eq!(
625 crate::memory::state_key::current_memory_id(
626 &conn,
627 "repo",
628 "/new/repo",
629 "decision",
630 "alias-state",
631 2,
632 )?,
633 Some(101)
634 );
635
636 let canonical_project_id = exact_project_id(&conn, "/new/repo")?.unwrap();
637 conn.execute(
638 "INSERT INTO memory_candidates(
639 project_id, source_project, target_project, owner_scope, owner_key,
640 scope, memory_type, topic_key, text, evidence_event_ids,
641 confidence, risk_class, review_status, created_at_epoch, updated_at_epoch
642 ) VALUES(?1, '/old/repo', '/old/repo', 'repo', '/old/repo',
643 'project', 'decision', 'alias-review', 'review me', '[]',
644 0.8, 'medium', 'pending_review', 1, 1)",
645 [canonical_project_id],
646 )?;
647 let pending = crate::memory_candidate::review::list_pending(&conn, Some("/new/repo"), 10)?;
648 assert_eq!(pending.len(), 1);
649
650 let top = crate::db::query_top_projects(&conn, 5)?;
651 assert_eq!(top[0].project, "/new/repo");
652 assert_eq!(top[0].count, 1);
653 Ok(())
654 }
655}