Skip to main content

remem/
project_alias.rs

1//! Audited project path aliases over the canonical `projects` capture identity.
2
3use 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
286/// Build a bound `IN` predicate containing the canonical project path and all
287/// active historical aliases. The registry lookup falls back to the requested
288/// value when called against a deliberately partial test schema.
289pub 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
304/// Resolve a project-bearing value before a new row is written. Historical
305/// rows remain untouched; only subsequent writes converge on the canonical
306/// project registered in `projects`.
307pub 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}