feat(identity): key a project by its repository, not its folder name (#708)

ai-memory-core gains the repository-identity resolution chain (marker
identity, marker project, upstream/origin remote normalised with
credentials stripped, folder name) and a shared fixture of remote-URL
cases. V70 adds projects.identity / identity_source with a partial
unique index per workspace and no backfill (case-only name clashes
would fail the upgrade). resolve_project_by_identity matches, claims in
place when the caller may write (slice 2's resolve_project_authz on
the same transaction), leaves it unclaimed otherwise, or splits a
different repository into owner-named projects; created projects
record created_by.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014aqFKAuGVkuBoewmpA3cx9
This commit is contained in:
luisfnicolau
2026-09-28 09:43:22 -04:00
co-authored by Claude Opus 5.5
parent bec4b4856f
commit b2621692b1
11 changed files with 1526 additions and 2 deletions
@@ -0,0 +1,45 @@
{
"_comment": "Shared by every client that normalises a git remote into a repository identity: the Rust core, hooks/_lib.sh, hooks/lib/ai-memory-hook.ps1 and the generated TypeScript plugins. A case added here is checked against all four, so they cannot drift apart. `identity: null` means the remote must yield no identity.",
"normalize": [
{ "url": "git@github.com:Acme/API.git", "identity": "github.com/acme/api" },
{ "url": "git@github.com:acme/api", "identity": "github.com/acme/api" },
{ "url": "https://github.com/acme/api", "identity": "github.com/acme/api" },
{ "url": "https://github.com/acme/api/", "identity": "github.com/acme/api" },
{ "url": "https://github.com/acme/api.git", "identity": "github.com/acme/api" },
{ "url": "https://github.com/Acme/API.git/", "identity": "github.com/acme/api" },
{ "url": "https://user@github.com/acme/api", "identity": "github.com/acme/api" },
{ "url": "https://user:secret@github.com/acme/api", "identity": "github.com/acme/api" },
{ "url": "https://user:p@ss@github.com/acme/api", "identity": "github.com/acme/api" },
{ "url": "https://user@github.com:443/Acme/API", "identity": "github.com/acme/api" },
{ "url": "ssh://git@github.com:22/Acme/API.git", "identity": "github.com/acme/api" },
{ "url": "ssh://git@github.com/acme/api.git", "identity": "github.com/acme/api" },
{ "url": "git://github.com/acme/api", "identity": "github.com/acme/api" },
{ "url": " https://github.com/acme/api ", "identity": "github.com/acme/api" },
{ "url": "https://github.com//acme//api", "identity": "github.com/acme/api" },
{ "url": "://github.com/acme/api", "identity": "github.com/acme/api" },
{ "url": "git@git.internal.acme.dev:platform/tools/api.git", "identity": "git.internal.acme.dev/platform/tools/api" },
{ "url": "https://gitlab.com/acme/group/subgroup/api.git", "identity": "gitlab.com/acme/group/subgroup/api" },
{ "url": "ssh://git@ssh.dev.azure.com:22/v3/acme/proj/api", "identity": "ssh.dev.azure.com/v3/acme/proj/api" },
{ "url": "https://github.com/acme/api.git.git", "identity": "github.com/acme/api.git" },
{ "url": "/srv/git/api.git", "identity": null },
{ "url": "../sibling", "identity": null },
{ "url": "./api", "identity": null },
{ "url": "file:///srv/git/api.git", "identity": null },
{ "url": "file://localhost/srv/git/api.git", "identity": null },
{ "url": "C:\\repos\\api", "identity": null },
{ "url": "c:\\repos\\api", "identity": null },
{ "url": "~/repos/api", "identity": null },
{ "url": "https://github.com", "identity": null },
{ "url": "https://github.com/", "identity": null },
{ "url": "", "identity": null },
{ "url": " ", "identity": null }
],
"split_name": [
{ "identity": "github.com/orgb/api", "name": "orgb-api" },
{ "identity": "gitlab.com/acme/group/subgroup/api", "name": "subgroup-api" },
{ "identity": "git.internal.acme.dev/platform/tools/api", "name": "tools-api" },
{ "identity": "github.com/acme/api.git", "name": "acme-api.git" },
{ "identity": "acme platform", "name": "acme-platform" },
{ "identity": "ana/personal notes", "name": "ana-personal-notes" }
]
}
+2
View File
@@ -17,6 +17,8 @@ pub mod message;
pub mod observation;
pub mod okf;
pub mod page;
pub mod repository_identity;
pub use repository_identity::{MARKER_FILENAME, MARKER_FILENAMES};
pub mod routing_skills;
pub mod scaffolding;
pub use scaffolding::looks_like_scaffolding;
@@ -0,0 +1,792 @@
//! Repository identity: what names a repository independently of where it
//! sits on any one disk (#708).
//!
//! ## Why this exists
//!
//! A project is keyed by `(workspace_id, name)`, and an undeclared checkout
//! gets its name from its folder. Folder names are not unique: two unrelated
//! repositories both checked out as `api/` land in one project. On a
//! multi-user server with per-project grants that is an access problem, not
//! untidiness — whatever resolves a working directory to a project decides
//! which grant applies. And a path cannot fix it, because a path is
//! per-machine by construction: keying on one would split the same repository
//! into a different project on every device.
//!
//! So the client resolves an identity from what a repository carries with it,
//! first rung wins:
//!
//! 1. **explicit** — `identity = "…"` in `.ai-memory.toml`, written by a person;
//! 2. **manifest** — `project = "…"` in `.ai-memory.toml`, also written by a
//! person;
//! 3. **git remote** — `upstream` when present, else `origin`, normalised;
//! 4. **folder name** — the basename.
//!
//! The two declarations rank above the remote because a statement beats an
//! inference. A fork that is its own product is the common case: its
//! `upstream` names the project it forked from, while its marker names what
//! it is. Letting the remote win would key the fork's memory under its
//! parent's identity.
//!
//! ## Which rungs route by identity
//!
//! Only [`IdentitySource::Explicit`] and [`IdentitySource::GitRemote`] — see
//! [`IdentitySource::routes_by_identity`]. A declared `project` already routes
//! by name, as it always has: the person chose that name, and two checkouts
//! declaring it share it by agreement. A folder name adds nothing a name
//! lookup does not already do. What is left is exactly the undeclared
//! checkout with a remote, which is the case that collided.
//!
//! ## Why the chain stops rather than searching
//!
//! When neither `upstream` nor `origin` exists, the chain falls to the next
//! rung instead of picking some other remote. Remote names are personal —
//! one person's `fork`, another's `mine` — so choosing among them would give
//! the same repository a different identity per person. `upstream` wins over
//! `origin` because in fork workflows every contributor's `origin` is their
//! own fork, which would give each of them a private memory.
//!
//! ## Why local-path remotes are rejected
//!
//! A remote can legitimately be a filesystem path — `/srv/git/api.git`,
//! `../sibling`, `file:///srv/git/api.git`, `C:\repos\api`. Those are paths,
//! with every problem paths have, so they yield no identity and the chain
//! moves on.
//!
//! ## Where this runs
//!
//! On the client, which is the only side that can see the checkout — a
//! remote server cannot read the user's disk. Normalising there also means a
//! remote with embedded credentials (`https://user:token@host/…`) never
//! leaves the machine. The native hook client calls this module; the shell,
//! PowerShell and TypeScript clients port [`normalize_remote_url`], and all
//! four are checked against `fixtures/remote_identity_cases.json`.
use serde::{Deserialize, Serialize};
/// The marker filename, matching `crates/ai-memory-cli/src/marker.rs`.
pub const MARKER_FILENAME: &str = ".ai-memory.toml";
/// Every marker name that is read, highest precedence first.
///
/// One entry today. The list stays because dropping a name that is still on
/// somebody's disk sends resolution down to the next rung — usually the
/// folder name — which silently re-homes that repository's memory. Any future
/// rename adds a name here rather than replacing one.
pub const MARKER_FILENAMES: &[&str] = &[MARKER_FILENAME];
/// Longest identity accepted from the wire. Real remotes are far shorter; the
/// bound exists so a client cannot park an arbitrary blob in a unique index.
pub const MAX_IDENTITY_LEN: usize = 512;
/// Which rung of the chain produced an identity.
///
/// Stored alongside the identity so an operator can tell a globally unique
/// identity from a merely local one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IdentitySource {
/// `identity = "…"` in the marker. Outranks everything below it.
///
/// Every rung beneath this one is either a name or an inference. This one
/// is the answer to the cases neither can reach: a directory with no
/// remote that must not collide with every other `notes/`, two checkouts
/// that should share one memory, a monorepo subdirectory that deserves its
/// own.
Explicit,
/// `project = "…"` in the marker. Unique by agreement.
Manifest,
/// A normalised `upstream` or `origin` URL. Globally unique.
GitRemote,
/// The directory's basename. **Not** globally unique.
FolderName,
}
impl IdentitySource {
/// The stored and wire spelling. Kept explicit rather than derived from the
/// variant name so a rename in Rust cannot silently rewrite what is
/// already in the database.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Explicit => "explicit",
Self::Manifest => "manifest",
Self::GitRemote => "git_remote",
Self::FolderName => "folder_name",
}
}
/// Parse the stored spelling back. Unknown values are `None` rather than a
/// default, so a row written by a newer version is visibly unreadable
/// instead of quietly mis-typed.
#[must_use]
pub fn from_str_opt(s: &str) -> Option<Self> {
match s {
"explicit" => Some(Self::Explicit),
"manifest" => Some(Self::Manifest),
"git_remote" => Some(Self::GitRemote),
"folder_name" => Some(Self::FolderName),
_ => None,
}
}
/// Whether an identity from this rung is unique beyond the local machine.
#[must_use]
pub fn is_globally_unique(self) -> bool {
// An explicit declaration is as unique as the person writing it meant
// it to be — which is the point of writing one.
matches!(self, Self::Explicit | Self::GitRemote)
}
/// Whether the server routes a capture by this identity rather than by
/// project name. See the module docs: a declared `project` and a folder
/// name keep name routing, so only the two rungs that carry information a
/// name does not are routed by identity.
#[must_use]
pub fn routes_by_identity(self) -> bool {
matches!(self, Self::Explicit | Self::GitRemote)
}
}
/// A resolved repository identity and the rung it came from.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryIdentity {
/// The identity itself, e.g. `github.com/acme/api`.
pub identity: String,
/// Which rung produced it.
pub source: IdentitySource,
}
/// Everything the chain needs, gathered by the caller.
///
/// Taking these as data rather than reading git and the filesystem here keeps
/// this module pure: the whole chain is testable without a repository on disk,
/// and the crate stays free of a git dependency.
#[derive(Debug, Default, Clone)]
pub struct IdentityInputs<'a> {
/// `identity = "…"` from the nearest `.ai-memory.toml`.
pub explicit_identity: Option<&'a str>,
/// `project = "…"` from the nearest `.ai-memory.toml`.
pub manifest_name: Option<&'a str>,
/// URL of the `upstream` remote, if any.
pub upstream_remote: Option<&'a str>,
/// URL of the `origin` remote, if any.
pub origin_remote: Option<&'a str>,
/// Basename of the repository root (or of the cwd when there is no repo).
pub folder_name: Option<&'a str>,
}
/// Walk the chain and return the first rung that yields an identity.
///
/// Returns `None` only when every rung is empty.
#[must_use]
pub fn resolve(inputs: &IdentityInputs<'_>) -> Option<RepositoryIdentity> {
// Rungs 1 and 2 are statements. Nothing inferred below overrides either —
// including a git remote, because the reasons to write one down are
// exactly the cases where the remote gives the wrong answer.
if let Some(declared) = inputs.explicit_identity.and_then(non_empty) {
return Some(RepositoryIdentity {
identity: declared.to_lowercase(),
source: IdentitySource::Explicit,
});
}
if let Some(name) = inputs.manifest_name.and_then(non_empty) {
return Some(RepositoryIdentity {
identity: name.to_lowercase(),
source: IdentitySource::Manifest,
});
}
// Rung 3. `upstream` first. A remote that normalises to nothing — a local
// path, or a string git accepted but we cannot key on — does not stop the
// chain; it simply yields nothing and we continue.
for remote in [inputs.upstream_remote, inputs.origin_remote]
.into_iter()
.flatten()
{
if let Some(identity) = normalize_remote_url(remote) {
return Some(RepositoryIdentity {
identity,
source: IdentitySource::GitRemote,
});
}
}
// Rung 4. The folder name. Not globally unique; see `IdentitySource`.
if let Some(name) = inputs.folder_name.and_then(non_empty) {
return Some(RepositoryIdentity {
identity: name.to_lowercase(),
source: IdentitySource::FolderName,
});
}
None
}
/// Accept an identity a client sent over the wire, or `None` to ignore it.
///
/// The server cannot re-run the chain — it never sees the checkout — so it
/// checks that what arrived has the shape the chain produces: case-folded,
/// trimmed, bounded, free of control characters, and for a remote, a
/// `host/path` with no empty segment. Anything else is dropped and the capture
/// routes by name, exactly as a client that sent nothing would. Only the rungs
/// that route by identity are accepted; the others carry nothing the server
/// uses.
#[must_use]
pub fn accept_wire_identity(identity: &str, source: &str) -> Option<RepositoryIdentity> {
let source = IdentitySource::from_str_opt(source.trim())?;
if !source.routes_by_identity() {
return None;
}
let identity = identity.trim();
if identity.is_empty()
|| identity.len() > MAX_IDENTITY_LEN
|| identity.chars().any(char::is_control)
|| identity != identity.to_lowercase()
{
return None;
}
if source == IdentitySource::GitRemote
&& (!identity.contains('/')
|| identity.split('/').any(str::is_empty)
|| identity.chars().any(char::is_whitespace))
{
return None;
}
Some(RepositoryIdentity {
identity: identity.to_owned(),
source,
})
}
/// The project name to give a repository whose folder name is already held by
/// a different identity, before any numeric suffix.
///
/// For a path-shaped identity it is the last two segments joined with `-`
/// (`github.com/orgb/api` → `orgb-api`): the owner is what tells two `api`s
/// apart in a listing. Characters a project name cannot carry — `/` appears in
/// URL paths — become `-`.
#[must_use]
pub fn split_name_base(identity: &str) -> String {
let segments: Vec<&str> = identity.split('/').filter(|s| !s.is_empty()).collect();
let tail = if segments.len() >= 2 {
segments[segments.len() - 2..].join("-")
} else {
segments.join("-")
};
let mut out = String::with_capacity(tail.len());
for c in tail.chars() {
let keep = c.is_alphanumeric() || matches!(c, '-' | '_' | '.');
let c = if keep { c } else { '-' };
if !(c == '-' && out.ends_with('-')) {
out.push(c);
}
}
let out = out.trim_matches('-').to_owned();
if out.is_empty() {
"repository".to_owned()
} else {
out
}
}
fn non_empty(s: &str) -> Option<&str> {
let t = s.trim();
(!t.is_empty()).then_some(t)
}
/// Normalise a git remote URL to a stable identity, or `None` if the URL does
/// not name a network-reachable repository.
///
/// All of these produce `github.com/acme/api`:
///
/// ```text
/// git@github.com:Acme/API.git
/// https://github.com/acme/api/
/// https://user@github.com:443/Acme/API
/// ssh://git@github.com:22/Acme/API.git
/// git://github.com/acme/api
/// ```
///
/// These produce `None`, because they are filesystem paths (hard rule #4):
///
/// ```text
/// /srv/git/api.git
/// ../sibling
/// file:///srv/git/api.git
/// C:\repos\api
/// ```
#[must_use]
pub fn normalize_remote_url(raw: &str) -> Option<String> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
// Split off a scheme if there is one. Whether a scheme was present decides
// how `:` is read further down, which is the subtle part of this function.
let (scheme, rest) = match raw.find("://") {
Some(idx) => {
let scheme = raw[..idx].to_ascii_lowercase();
(Some(scheme), &raw[idx + 3..])
}
None => (None, raw),
};
// `file://` names a path however it is dressed up.
if scheme.as_deref() == Some("file") {
return None;
}
// Credentials: strip up to the last `@` that precedes the path. Splitting
// on the last one rather than the first keeps passwords containing `@`
// from leaking a fragment of themselves into the identity.
let host_and_path = {
let path_start = rest.find('/').unwrap_or(rest.len());
match rest[..path_start].rfind('@') {
Some(at) => &rest[at + 1..],
None => rest,
}
};
let normalized = if scheme.is_some() {
// URL form: `host[:port]/path`.
let (host, path) = split_once_or_all(host_and_path, '/');
let host = strip_port(host);
if host.is_empty() || path.is_empty() {
return None;
}
format!("{host}/{path}")
} else {
// No scheme. Either scp-like `host:path`, or a filesystem path.
//
// `:` before any `/` is what distinguishes them. `/srv/git/api.git`
// has no `:` at all; `../sibling` likewise. A Windows drive letter
// (`C:\repos\api`) does have one, hence the single-character guard —
// no real hostname is one character long.
let colon = host_and_path.find(':')?;
let slash = host_and_path.find('/');
if slash.is_some_and(|s| s < colon) {
return None;
}
let (host, path) = host_and_path.split_at(colon);
let path = &path[1..];
if host.len() <= 1 || host.is_empty() || path.is_empty() {
return None;
}
// A scp-like path is never absolute in practice, and a backslash means
// we are looking at Windows rather than a repository path.
if path.starts_with('\\') || path.contains('\\') {
return None;
}
format!("{host}/{path}")
};
let identity = tidy(&normalized);
// An identity with no `/` is a bare hostname, not a repository.
if identity.is_empty() || !identity.contains('/') {
return None;
}
Some(identity)
}
/// Strip a `:port` suffix from a host. Only ever called on the URL form, where
/// `:` unambiguously introduces a port — in scp-like syntax the same character
/// separates host from path.
fn strip_port(host: &str) -> &str {
match host.rfind(':') {
Some(idx) if host[idx + 1..].chars().all(|c| c.is_ascii_digit()) => &host[..idx],
_ => host,
}
}
fn split_once_or_all(s: &str, sep: char) -> (&str, &str) {
match s.split_once(sep) {
Some((a, b)) => (a, b),
None => (s, ""),
}
}
/// Case-fold, drop a trailing `.git`, and collapse the slash noise that
/// distinguishes otherwise identical URLs.
fn tidy(s: &str) -> String {
let mut out = s.to_lowercase();
while out.ends_with('/') {
out.pop();
}
if let Some(stripped) = out.strip_suffix(".git") {
out = stripped.to_string();
}
while out.ends_with('/') {
out.pop();
}
// `https://host//acme//api` and `https://host/acme/api` are the same
// repository as far as any server is concerned.
while out.contains("//") {
out = out.replace("//", "/");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// The property that defines the whole task: every spelling of the same
/// repository has to land on one identity, or memory fragments per person.
#[test]
fn every_spelling_of_one_repository_agrees() {
let expected = "github.com/acme/api";
for url in [
"git@github.com:Acme/API.git",
"git@github.com:acme/api",
"https://github.com/acme/api",
"https://github.com/acme/api/",
"https://github.com/acme/api.git",
"https://github.com/Acme/API.git/",
"https://user@github.com/acme/api",
"https://user:secret@github.com/acme/api",
"https://user@github.com:443/Acme/API",
"ssh://git@github.com:22/Acme/API.git",
"ssh://git@github.com/acme/api.git",
"git://github.com/acme/api",
" https://github.com/acme/api ",
"https://github.com//acme//api",
] {
assert_eq!(
normalize_remote_url(url).as_deref(),
Some(expected),
"normalising {url}"
);
}
}
/// Hard rule #4. These are all paths, however git dresses them up, and a
/// path must never become an identity.
#[test]
fn filesystem_paths_yield_no_identity() {
for url in [
"/srv/git/api.git",
"../sibling",
"./api",
"file:///srv/git/api.git",
"file://localhost/srv/git/api.git",
r"C:\repos\api",
r"c:\repos\api",
"~/repos/api",
"",
" ",
] {
assert_eq!(normalize_remote_url(url), None, "rejecting {url}");
}
}
#[test]
fn a_bare_host_is_not_a_repository() {
assert_eq!(normalize_remote_url("https://github.com"), None);
assert_eq!(normalize_remote_url("https://github.com/"), None);
}
#[test]
fn self_hosted_forges_and_deep_paths_survive() {
assert_eq!(
normalize_remote_url("git@git.internal.acme.dev:platform/tools/api.git").as_deref(),
Some("git.internal.acme.dev/platform/tools/api")
);
assert_eq!(
normalize_remote_url("https://gitlab.com/acme/group/subgroup/api.git").as_deref(),
Some("gitlab.com/acme/group/subgroup/api")
);
assert_eq!(
normalize_remote_url("ssh://git@ssh.dev.azure.com:22/v3/acme/proj/api").as_deref(),
Some("ssh.dev.azure.com/v3/acme/proj/api")
);
}
/// A repository whose name genuinely ends in `.git` would be mangled, but
/// the suffix is stripped only once, so `api.git.git` keeps one.
#[test]
fn dot_git_is_stripped_once() {
assert_eq!(
normalize_remote_url("https://github.com/acme/api.git.git").as_deref(),
Some("github.com/acme/api.git")
);
}
#[test]
fn upstream_wins_over_origin() {
let got = resolve(&IdentityInputs {
upstream_remote: Some("git@github.com:acme/api.git"),
origin_remote: Some("git@github.com:contributor/api-fork.git"),
..Default::default()
})
.expect("an identity");
assert_eq!(got.identity, "github.com/acme/api");
assert_eq!(got.source, IdentitySource::GitRemote);
}
#[test]
fn origin_is_used_when_there_is_no_upstream() {
let got = resolve(&IdentityInputs {
origin_remote: Some("https://github.com/acme/api"),
..Default::default()
})
.expect("an identity");
assert_eq!(got.identity, "github.com/acme/api");
}
/// An unusable remote does not halt the chain, and no *other* remote is
/// substituted — the caller never offers one.
#[test]
fn a_path_remote_falls_through_to_the_folder() {
let got = resolve(&IdentityInputs {
origin_remote: Some("/srv/git/api.git"),
folder_name: Some("whatever"),
..Default::default()
})
.expect("an identity");
assert_eq!(got.identity, "whatever");
assert_eq!(got.source, IdentitySource::FolderName);
}
/// A declared project is a statement, and a statement beats the remote.
/// This is the fork that is its own product: its `upstream` names what it
/// forked from, its marker names what it is.
#[test]
fn a_declared_project_beats_the_git_remote() {
let got = resolve(&IdentityInputs {
manifest_name: Some("Lore"),
upstream_remote: Some("https://github.com/upstream-owner/tool"),
origin_remote: Some("git@github.com:fork-owner/lore.git"),
folder_name: Some("my-checkout"),
..Default::default()
})
.expect("an identity");
assert_eq!(got.identity, "lore");
assert_eq!(got.source, IdentitySource::Manifest);
assert!(
!got.source.routes_by_identity(),
"a declared name routes by name"
);
}
#[test]
fn the_chain_runs_in_order() {
let remote_over_folder = resolve(&IdentityInputs {
origin_remote: Some("https://github.com/acme/api"),
folder_name: Some("on-disk"),
..Default::default()
})
.expect("an identity");
assert_eq!(remote_over_folder.source, IdentitySource::GitRemote);
let manifest_only = resolve(&IdentityInputs {
manifest_name: Some("Declared"),
folder_name: Some("on-disk"),
..Default::default()
})
.expect("an identity");
assert_eq!(manifest_only.identity, "declared");
assert_eq!(manifest_only.source, IdentitySource::Manifest);
let folder_only = resolve(&IdentityInputs {
folder_name: Some("On-Disk"),
..Default::default()
})
.expect("an identity");
assert_eq!(folder_only.identity, "on-disk");
assert_eq!(folder_only.source, IdentitySource::FolderName);
assert!(resolve(&IdentityInputs::default()).is_none());
}
/// Blank strings are not declarations. A marker with `project = ""` should
/// behave as if the field were absent rather than pin every repository to
/// one empty identity.
#[test]
fn blank_rungs_are_skipped() {
let got = resolve(&IdentityInputs {
origin_remote: Some(" "),
manifest_name: Some(""),
folder_name: Some("api"),
..Default::default()
})
.expect("an identity");
assert_eq!(got.identity, "api");
assert_eq!(got.source, IdentitySource::FolderName);
}
/// The two clones from the task description: same repository, different
/// directories, one identity. This is the regression that matters.
#[test]
fn two_clones_in_different_directories_share_an_identity() {
let mac = resolve(&IdentityInputs {
origin_remote: Some("git@github.com:acme/api.git"),
folder_name: Some("api"),
..Default::default()
})
.expect("an identity");
let linux = resolve(&IdentityInputs {
origin_remote: Some("https://github.com/Acme/API"),
folder_name: Some("acme-api"),
..Default::default()
})
.expect("an identity");
assert_eq!(mac.identity, linux.identity);
}
/// The point of a declaration is to override what would otherwise be
/// inferred. One that lost to a git remote would be useless in exactly the
/// case people reach for it: two checkouts that should share one memory.
#[test]
fn an_explicit_declaration_beats_every_inferred_rung() {
let got = resolve(&IdentityInputs {
explicit_identity: Some("Acme Platform"),
upstream_remote: Some("git@github.com:acme/api.git"),
origin_remote: Some("git@github.com:contributor/api.git"),
manifest_name: Some("declared"),
folder_name: Some("on-disk"),
})
.expect("an identity");
assert_eq!(got.identity, "acme platform");
assert_eq!(got.source, IdentitySource::Explicit);
}
/// A folder with no git at all is the case the feature exists for: the
/// chain would otherwise land on a basename that collides with every other
/// `notes` folder in the world.
#[test]
fn a_declaration_rescues_a_folder_with_no_repository() {
let inferred = resolve(&IdentityInputs {
folder_name: Some("notes"),
..Default::default()
})
.expect("an identity");
assert_eq!(inferred.source, IdentitySource::FolderName);
assert!(!inferred.source.is_globally_unique());
let declared = resolve(&IdentityInputs {
explicit_identity: Some("ana/personal-notes"),
folder_name: Some("notes"),
..Default::default()
})
.expect("an identity");
assert_eq!(declared.identity, "ana/personal-notes");
assert!(
declared.source.is_globally_unique(),
"having answered the question, the operator should stop being asked it"
);
}
/// Two directories pointed at the same declared identity share a project —
/// which is what "link a folder to a project" means.
#[test]
fn two_folders_can_be_linked_to_one_identity() {
let a = resolve(&IdentityInputs {
explicit_identity: Some("acme/platform"),
folder_name: Some("frontend"),
..Default::default()
})
.unwrap();
let b = resolve(&IdentityInputs {
explicit_identity: Some("acme/platform"),
origin_remote: Some("git@github.com:acme/backend.git"),
folder_name: Some("backend"),
..Default::default()
})
.unwrap();
assert_eq!(a.identity, b.identity);
}
/// A blank declaration is not a declaration. An empty `identity = ""` must
/// fall through rather than pin every folder to one empty identity.
#[test]
fn a_blank_declaration_falls_through() {
let got = resolve(&IdentityInputs {
explicit_identity: Some(" "),
origin_remote: Some("https://github.com/acme/api"),
..Default::default()
})
.unwrap();
assert_eq!(got.source, IdentitySource::GitRemote);
}
#[test]
fn source_round_trips_through_its_stored_spelling() {
for source in [
IdentitySource::Explicit,
IdentitySource::GitRemote,
IdentitySource::Manifest,
IdentitySource::FolderName,
] {
assert_eq!(IdentitySource::from_str_opt(source.as_str()), Some(source));
}
assert_eq!(IdentitySource::from_str_opt("something_new"), None);
assert!(IdentitySource::GitRemote.is_globally_unique());
assert!(!IdentitySource::Manifest.is_globally_unique());
assert!(!IdentitySource::FolderName.is_globally_unique());
assert!(IdentitySource::Explicit.routes_by_identity());
assert!(IdentitySource::GitRemote.routes_by_identity());
assert!(!IdentitySource::Manifest.routes_by_identity());
assert!(!IdentitySource::FolderName.routes_by_identity());
}
const CASES: &str = include_str!("../fixtures/remote_identity_cases.json");
/// The fixture every client normaliser is checked against. The Rust core
/// is the reference: a case that fails here is a wrong fixture, a case
/// that fails in a script client is a drifted port.
#[test]
fn the_shared_fixture_holds_for_the_reference_normaliser() {
let cases: serde_json::Value = serde_json::from_str(CASES).unwrap();
let normalize = cases["normalize"].as_array().unwrap();
assert!(normalize.len() >= 20);
for case in normalize {
let url = case["url"].as_str().unwrap();
let expected = case["identity"].as_str();
assert_eq!(
normalize_remote_url(url).as_deref(),
expected,
"normalising {url:?}"
);
}
for case in cases["split_name"].as_array().unwrap() {
let identity = case["identity"].as_str().unwrap();
let expected = case["name"].as_str().unwrap();
assert_eq!(
split_name_base(identity),
expected,
"splitting {identity:?}"
);
}
}
/// What a client produces, the server accepts; what it cannot produce, or
/// what does not route, is ignored rather than trusted.
#[test]
fn the_server_accepts_only_what_the_chain_can_produce() {
let ok = accept_wire_identity("github.com/acme/api", "git_remote").unwrap();
assert_eq!(ok.source, IdentitySource::GitRemote);
assert!(accept_wire_identity("acme platform", "explicit").is_some());
for (identity, source) in [
("github.com/acme/api", "manifest"),
("github.com/acme/api", "folder_name"),
("github.com/acme/api", "something_new"),
("GitHub.com/Acme/API", "git_remote"),
("github.com", "git_remote"),
("github.com//api", "git_remote"),
("github.com/acme/api/", "git_remote"),
("github.com/acme api", "git_remote"),
("", "explicit"),
(" ", "explicit"),
("line\nbreak", "explicit"),
] {
assert!(
accept_wire_identity(identity, source).is_none(),
"accepted {identity:?} as {source}"
);
}
assert!(accept_wire_identity(&"a/".repeat(MAX_IDENTITY_LEN), "explicit").is_none());
}
}
@@ -0,0 +1,44 @@
-- V70: give a project an identity that is not its folder name (#708).
--
-- A project is keyed by `(workspace_id, name)`, and the name is the basename
-- of whatever directory the agent happened to run in. That fails in three ways
-- that matter on a shared server:
--
-- * Two repositories called `api`, in different organisations, collapse into
-- one project and read each other's memory.
-- * Renaming a folder orphans its memory under the old name.
-- * `repo_path` is an absolute path, so it cannot be the thing two people on
-- two machines agree on.
--
-- With per-project authorization (V68/V69) the first is an access-control
-- hole, not untidiness: grants are held against a project, so an unrelated
-- `api/` checkout resolving to the same row reaches the same grants.
--
-- Additive on purpose. `identity` defaults to empty, and every existing query
-- keeps working untouched: nothing reads this column until the resolution path
-- is taught to, and the partial index below ignores rows that never are.
--
-- Existing rows are deliberately NOT backfilled. Identities are case-folded,
-- so `API` and `api` are one repository to the resolver, while upstream's
-- `UNIQUE (workspace_id, name)` is case-sensitive and may already hold both.
-- Backfilling `lower(name)` failed the whole upgrade on such an install, and
-- keeping one of the pair would have the migration silently decide which
-- project — and so which grants — a future `api/` checkout lands in. That is
-- the resolution path's decision to make on first sighting, not this file's.
ALTER TABLE projects ADD COLUMN identity TEXT NOT NULL DEFAULT '';
-- Which rung of the resolution chain produced it: `explicit`, `git_remote`,
-- `manifest`, or `folder_name`. Kept because the rungs are ranked — a later
-- sighting may only ever upgrade an identity to a more trustworthy source,
-- never downgrade it, and that comparison needs to know where this one came
-- from. An empty string means no rung has claimed the row yet.
ALTER TABLE projects ADD COLUMN identity_source TEXT NOT NULL DEFAULT '';
-- Partial, so rows with no identity do not collide with each other on the
-- empty string. Two projects may share a name across workspaces, and within a
-- workspace an identity is the thing that must be unique — that is the point
-- of having it.
CREATE UNIQUE INDEX idx_projects_identity
ON projects(workspace_id, identity)
WHERE identity <> '';
@@ -411,7 +411,7 @@ mod tests {
|row| row.get(0),
)
.unwrap();
assert_eq!(version, 69, "update the pin when adding a migration");
assert_eq!(version, 70, "update the pin when adding a migration");
let cols: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('users') WHERE name = 'token_hash'",
+1 -1
View File
@@ -58,7 +58,7 @@ pub use grants::{GrantFilter, GrantListing, GrantOutcome, ProjectGrant};
pub use maintenance::MaintenanceJob;
pub use ops::{
AdmittedSession, BootstrapChunkRecord, CompactSummary, Compaction, DeleteWorkspaceSummary,
EmbedOutcome, EmbeddingWrite, EntityBackfillSummary, HookSessionAdmission,
EmbedOutcome, EmbeddingWrite, EntityBackfillSummary, HookSessionAdmission, IdentityResolution,
IngestObservationOutcome, LifecycleOnlyEndOutcome, MAX_PENDING_INBOX_MESSAGES,
MoveSessionSummary, MoveSummary, ObservationPruneOutcome, OkfMigratedPage,
PAGE_WINDOW_BACKFILL_BATCH, PageWindowBackfillSummary, PagesMode, PurgeMode,
+59
View File
@@ -961,4 +961,63 @@ mod tests {
assert!(after.contains("superseded_at IS NOT NULL"), "{after}");
assert!(!after.contains("supersedes IS NULL"), "{after}");
}
/// Upstream's `UNIQUE (workspace_id, name)` is case-sensitive, so one
/// workspace may hold both `API` and `api`. The identity index is on
/// case-folded values, and backfilling `lower(name)` into it failed the
/// whole upgrade on such an install. Looked up by name, not number: this
/// migration is renumbered on every upstream sync that adds one of its own.
#[test]
fn project_identity_upgrades_a_workspace_with_names_differing_only_in_case() {
let identity_version = migrations::runner()
.get_migrations()
.iter()
.find(|m| m.name() == "project_identity")
.map(refinery::Migration::version)
.expect("the project_identity migration is embedded");
let mut conn = Connection::open_in_memory().unwrap();
run_to(&mut conn, identity_version - 1).unwrap();
let workspace_id = [7_u8; 16];
conn.execute(
"INSERT INTO workspaces (id, name, created_at) VALUES (?1, 'acme', 1)",
params![workspace_id.as_slice()],
)
.unwrap();
for (id, name) in [([1_u8; 16], "API"), ([2_u8; 16], "api")] {
conn.execute(
"INSERT INTO projects (id, workspace_id, name, created_at) VALUES (?1, ?2, ?3, 1)",
params![id.as_slice(), workspace_id.as_slice(), name],
)
.unwrap();
}
run(&mut conn).expect("names differing only in case must not fail the upgrade");
// Both projects survive, and neither is claimed: which one a future
// `api/` checkout resolves to is not the migration's decision.
let unclaimed: i64 = conn
.query_row(
"SELECT COUNT(*) FROM projects WHERE identity = '' AND identity_source = ''",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(unclaimed, 2);
// The index still does its job once the resolver claims an identity.
conn.execute(
"UPDATE projects SET identity = 'api', identity_source = 'folder_name' WHERE name = 'api'",
[],
)
.unwrap();
let duplicate = conn.execute(
"UPDATE projects SET identity = 'api', identity_source = 'folder_name' WHERE name = 'API'",
[],
);
assert!(
duplicate.is_err(),
"a claimed identity must stay unique per workspace"
);
}
}
+205
View File
@@ -379,6 +379,211 @@ pub fn get_or_create_project_as(
Ok((id, created))
}
/// How [`resolve_project_by_identity`] reached the project it returns.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdentityResolution {
/// A project already carried this identity.
Matched,
/// The name-matched project carried no identity and the caller may write
/// to it, so it took this one. Existing projects migrate this way.
Claimed,
/// The name-matched project carried no identity, but the caller may not
/// write to it. It is returned unclaimed, for the caller's grant check to
/// refuse — never split, which would let the first outsider after an
/// upgrade take the identity away from the team whose project it is.
Unclaimed,
/// No project existed under the name; one was created with the identity.
Created,
/// The name belonged to a project with a different identity, so a new one
/// was created under a distinct name.
Split,
}
impl IdentityResolution {
/// Whether this call created the project — and so recorded its creator.
#[must_use]
pub fn created(self) -> bool {
matches!(self, Self::Created | Self::Split)
}
}
/// Resolve the project a repository identity routes to, creating it if
/// needed, in one transaction (#708).
///
/// 1. A project in the workspace already carrying `identity` wins, whatever
/// it is called.
/// 2. Otherwise the candidate is `candidate` (the cwd-prefix parent the hook
/// router found) or the project named `name`:
/// - none → create `name` with the identity;
/// - it carries no identity → claim it when `creator` may write to it (or
/// there is no creator: no database users, or root), else
/// return it unclaimed;
/// - it carries a different identity → create a new project named from
/// the identity ([`ai_memory_core::repository_identity::split_name_base`],
/// then `-2`, `-3`, …).
///
/// An identity already on a project is never overwritten. A created project
/// records its creator in `created_by`, as [`get_or_create_project_as`] does. A
/// split
/// project gets no `repo_path`: the path belongs to the project the name
/// matched, and sharing it would let prefix matching route that project's
/// other captures here.
///
/// # Errors
/// Propagates SQLite failures.
#[allow(clippy::too_many_arguments)]
pub fn resolve_project_by_identity(
conn: &mut Connection,
workspace_id: &ai_memory_core::WorkspaceId,
identity: &ai_memory_core::repository_identity::RepositoryIdentity,
name: &str,
repo_path: Option<&str>,
candidate: Option<ai_memory_core::ProjectId>,
creator: Option<ai_memory_core::UserId>,
new_project_mode: crate::AccessMode,
) -> StoreResult<(ai_memory_core::ProjectId, IdentityResolution)> {
let repo_path = repo_path.map(normalize_repo_path_key);
let now = Timestamp::now().as_microsecond();
let tx = conn.transaction()?;
let matched: Option<Vec<u8>> = tx
.query_row(
"SELECT id FROM projects WHERE workspace_id = ?1 AND identity = ?2",
params![workspace_id.as_bytes(), identity.identity],
|row| row.get(0),
)
.optional()?;
let (id, resolution) = if let Some(bytes) = matched {
(
ai_memory_core::ProjectId::from_slice(&bytes)?,
IdentityResolution::Matched,
)
} else {
let candidate_row: Option<(Vec<u8>, String)> = match candidate {
Some(candidate) => tx
.query_row(
"SELECT id, identity FROM projects WHERE workspace_id = ?1 AND id = ?2",
params![workspace_id.as_bytes(), candidate.as_bytes()],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?,
None => tx
.query_row(
"SELECT id, identity FROM projects WHERE workspace_id = ?1 AND name = ?2",
params![workspace_id.as_bytes(), name],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?,
};
match candidate_row {
None => {
let id = insert_project_with_identity(
&tx,
workspace_id,
name,
repo_path.as_deref(),
identity,
initial_access_mode(name, new_project_mode),
creator,
now,
)?;
(id, IdentityResolution::Created)
}
Some((bytes, held)) if held.is_empty() => {
let id = ai_memory_core::ProjectId::from_slice(&bytes)?;
// The same decision the choke point makes, on this
// transaction, so the claim cannot race a grant change.
let may_write = match creator {
None => true,
Some(user) => crate::project_authz::resolve_project_authz(
&tx,
*workspace_id,
id,
&crate::ProjectPrincipal::user(user),
true,
)?
.authorize(crate::ProjectAccess::Write)
.is_ok(),
};
if may_write {
tx.execute(
"UPDATE projects SET identity = ?1, identity_source = ?2 WHERE id = ?3",
params![identity.identity, identity.source.as_str(), id.as_bytes()],
)?;
(id, IdentityResolution::Claimed)
} else {
(id, IdentityResolution::Unclaimed)
}
}
Some(_) => {
let base = ai_memory_core::repository_identity::split_name_base(&identity.identity);
let mut split_name = base.clone();
let mut n = 2_u32;
while tx
.query_row(
"SELECT 1 FROM projects WHERE workspace_id = ?1 AND name = ?2",
params![workspace_id.as_bytes(), split_name],
|_| Ok(()),
)
.optional()?
.is_some()
{
split_name = format!("{base}-{n}");
n += 1;
}
let id = insert_project_with_identity(
&tx,
workspace_id,
&split_name,
None,
identity,
initial_access_mode(&split_name, new_project_mode),
creator,
now,
)?;
(id, IdentityResolution::Split)
}
}
};
tx.commit()?;
if resolution.created() && scheduler_state_table_exists(conn)? {
crate::auto_improve::ensure_scheduler_state(conn, *workspace_id, id)?;
}
Ok((id, resolution))
}
#[allow(clippy::too_many_arguments)]
fn insert_project_with_identity(
tx: &rusqlite::Transaction<'_>,
workspace_id: &ai_memory_core::WorkspaceId,
name: &str,
repo_path: Option<&str>,
identity: &ai_memory_core::repository_identity::RepositoryIdentity,
mode: crate::AccessMode,
creator: Option<ai_memory_core::UserId>,
now: i64,
) -> StoreResult<ai_memory_core::ProjectId> {
let id = ai_memory_core::ProjectId::new();
tx.execute(
"INSERT INTO projects \
(id, workspace_id, name, repo_path, created_at, identity, identity_source, access_mode, \
created_by) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
id.as_bytes(),
workspace_id.as_bytes(),
name,
repo_path,
now,
identity.identity,
identity.source.as_str(),
mode.as_str(),
creator.map(|creator| creator.as_bytes().to_vec()),
],
)?;
Ok(id)
}
/// Delete "hollow" project rows: zero pages (any version), zero sessions,
/// zero observations, zero handoffs, zero managed workstreams, zero
/// auto-improve runs/proposals/rejections, and older than `min_age_days`.
+59
View File
@@ -77,6 +77,15 @@ pub(crate) enum WriteCmd {
mode: crate::AccessMode,
reply: oneshot::Sender<StoreResult<Option<crate::AccessMode>>>,
},
ResolveProjectByIdentity {
workspace_id: WorkspaceId,
identity: ai_memory_core::repository_identity::RepositoryIdentity,
name: String,
repo_path: Option<String>,
candidate: Option<ProjectId>,
creator: Option<ai_memory_core::UserId>,
reply: oneshot::Sender<StoreResult<(ProjectId, ops::IdentityResolution)>>,
},
EnsureProjectWorkspace {
workspace_id: WorkspaceId,
project_id: ProjectId,
@@ -882,6 +891,35 @@ impl WriterHandle {
rx.await.map_err(|_| StoreError::WriterClosed)?
}
/// Resolve the project a repository identity routes to, creating it when
/// needed — see [`ops::resolve_project_by_identity`] for the rules.
///
/// # Errors
/// Returns [`StoreError::WriterClosed`] if the actor has shut down, or
/// propagates the SQL error.
pub async fn resolve_project_by_identity(
&self,
workspace_id: WorkspaceId,
identity: ai_memory_core::repository_identity::RepositoryIdentity,
name: impl Into<String>,
repo_path: Option<String>,
candidate: Option<ProjectId>,
creator: Option<ai_memory_core::UserId>,
) -> StoreResult<(ProjectId, ops::IdentityResolution)> {
let (tx, rx) = oneshot::channel();
self.send(WriteCmd::ResolveProjectByIdentity {
workspace_id,
identity,
name: name.into(),
repo_path,
candidate,
creator,
reply: tx,
})
.await?;
rx.await.map_err(|_| StoreError::WriterClosed)?
}
/// Assert that a project still belongs to the supplied workspace.
///
/// # Errors
@@ -3039,6 +3077,27 @@ fn worker_loop(mut conn: Connection, mut rx: mpsc::Receiver<WriteCmd>) {
);
send_or_warn(reply, result, "get_or_create_project_as");
}
WriteCmd::ResolveProjectByIdentity {
workspace_id,
identity,
name,
repo_path,
candidate,
creator,
reply,
} => {
let result = ops::resolve_project_by_identity(
&mut conn,
&workspace_id,
&identity,
&name,
repo_path.as_deref(),
candidate,
creator,
new_project_mode,
);
send_or_warn(reply, result, "resolve_project_by_identity");
}
WriteCmd::EnsureProjectWorkspace {
workspace_id,
project_id,
@@ -0,0 +1,317 @@
//! Routing a capture by repository identity (#708).
//!
//! A project name comes from a folder, and folder names collide: two unrelated
//! repositories both checked out as `api/` would share one project, and so one
//! grant. `resolve_project_by_identity` routes by the identity the client
//! resolved instead. These pin each way it can answer, and the two properties
//! that make it safe to switch on for installs that already hold data: an
//! existing project is claimed in place rather than split away, and somebody
//! who may not write to it cannot take its identity.
use ai_memory_core::repository_identity::{IdentitySource, RepositoryIdentity};
use ai_memory_core::{NewUser, ProjectId, UserId, WorkspaceId};
use ai_memory_store::{
AccessMode, GrantLevel, IdentityResolution, ProjectAccess, ProjectPrincipal, Store,
};
fn remote(identity: &str) -> RepositoryIdentity {
RepositoryIdentity {
identity: identity.to_owned(),
source: IdentitySource::GitRemote,
}
}
async fn workspace(store: &Store) -> WorkspaceId {
store
.writer
.get_or_create_workspace("default".to_string())
.await
.unwrap()
}
async fn user(store: &Store, name: &str, byte: u8) -> UserId {
store
.writer
.create_user(
NewUser {
username: name.to_owned(),
name: None,
email: None,
},
[byte; ai_memory_store::TOKEN_HASH_LEN],
)
.await
.unwrap()
}
/// `(name, identity, identity_source, repo_path)` straight from the row.
fn row(store: &Store, id: ProjectId) -> (String, String, String, Option<String>) {
let conn = rusqlite::Connection::open(store.db_path()).unwrap();
conn.query_row(
"SELECT name, identity, identity_source, repo_path FROM projects WHERE id = ?1",
[id.as_bytes().to_vec()],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.unwrap()
}
async fn resolve(
store: &Store,
ws: WorkspaceId,
identity: &RepositoryIdentity,
name: &str,
creator: Option<UserId>,
) -> (ProjectId, IdentityResolution) {
store
.writer
.resolve_project_by_identity(
ws,
identity.clone(),
name,
Some("/work/api".to_owned()),
None,
creator,
)
.await
.unwrap()
}
/// The collision the feature exists for: two unrelated `api` checkouts land
/// in two projects, the second named after its owner — and the same
/// repository, wherever it is checked out and whatever the folder is called,
/// lands back in its own.
#[tokio::test]
async fn two_unrelated_repositories_with_one_folder_name_stay_apart() {
let tmp = tempfile::TempDir::new().unwrap();
let store = Store::open(tmp.path()).unwrap();
let ws = workspace(&store).await;
let (a, how) = resolve(&store, ws, &remote("github.com/orga/api"), "api", None).await;
assert_eq!(how, IdentityResolution::Created);
assert_eq!(
row(&store, a),
(
"api".into(),
"github.com/orga/api".into(),
"git_remote".into(),
Some("/work/api".into())
)
);
let (b, how) = resolve(&store, ws, &remote("github.com/orgb/api"), "api", None).await;
assert_eq!(how, IdentityResolution::Split);
assert_ne!(a, b);
let (name, identity, _, repo_path) = row(&store, b);
assert_eq!(
(name.as_str(), identity.as_str()),
("orgb-api", "github.com/orgb/api")
);
assert_eq!(
repo_path, None,
"a split project must not share the other's path"
);
// A third `api` whose owner-repo name is also taken gets a suffix.
let (c, how) = resolve(&store, ws, &remote("gitlab.com/orgb/api"), "api", None).await;
assert_eq!(how, IdentityResolution::Split);
assert_eq!(row(&store, c).0, "orgb-api-2");
// Same repository, another folder name: its own project, by identity.
let (again, how) = resolve(
&store,
ws,
&remote("github.com/orga/api"),
"acme-api-clone",
None,
)
.await;
assert_eq!(how, IdentityResolution::Matched);
assert_eq!(again, a);
}
/// An install upgrading with data: the project that already exists under the
/// folder name takes the identity in place, so its memory does not move.
#[tokio::test]
async fn an_existing_project_is_claimed_in_place() {
let tmp = tempfile::TempDir::new().unwrap();
let store = Store::open(tmp.path()).unwrap();
let ws = workspace(&store).await;
let existing = store
.writer
.get_or_create_project(ws, "api", None)
.await
.unwrap();
let (id, how) = resolve(&store, ws, &remote("github.com/orga/api"), "api", None).await;
assert_eq!(how, IdentityResolution::Claimed);
assert_eq!(id, existing);
assert_eq!(row(&store, id).1, "github.com/orga/api");
// Claimed identities are never overwritten: a different repository with
// the same folder name splits instead of re-pointing this one.
let (other, how) = resolve(&store, ws, &remote("github.com/orgb/api"), "api", None).await;
assert_eq!(how, IdentityResolution::Split);
assert_ne!(other, existing);
assert_eq!(row(&store, existing).1, "github.com/orga/api");
}
/// With authorization on, claiming is a write. A user with no grant on the
/// unclaimed project must neither claim it nor split off a project carrying
/// its identity — either would let the first outsider after an upgrade own
/// the repository the team has been working in. They get the project back
/// unclaimed, for their grant check to refuse.
#[tokio::test]
async fn an_outsider_cannot_take_an_unclaimed_projects_identity() {
let tmp = tempfile::TempDir::new().unwrap();
let store = Store::open(tmp.path()).unwrap();
let ws = workspace(&store).await;
let team_project = store
.writer
.get_or_create_project(ws, "api", None)
.await
.unwrap();
// An open project admits everyone, outsider included; the question only
// arises for a restricted one.
store
.writer
.set_access_mode(team_project, ai_memory_store::AccessMode::Restricted)
.await
.unwrap();
let member = user(&store, "member", 1).await;
let outsider = user(&store, "outsider", 2).await;
store
.writer
.grant_memory(member, team_project, GrantLevel::Write, None)
.await
.unwrap();
let (id, how) = resolve(
&store,
ws,
&remote("github.com/orga/api"),
"api",
Some(outsider),
)
.await;
assert_eq!(how, IdentityResolution::Unclaimed);
assert_eq!(id, team_project);
assert_eq!(
row(&store, team_project).1,
"",
"the outsider claimed nothing"
);
assert!(
store
.reader
.grants_for(outsider, team_project)
.await
.unwrap()
.is_empty()
);
let (id, how) = resolve(
&store,
ws,
&remote("github.com/orga/api"),
"api",
Some(member),
)
.await;
assert_eq!(how, IdentityResolution::Claimed);
assert_eq!(id, team_project);
}
/// A project this call creates — directly or by splitting — records its
/// creator, as every other creation path does, and the choke point admits them
/// to it without a grant while refusing the other user.
#[tokio::test]
async fn a_created_or_split_project_admits_its_creator() {
let tmp = tempfile::TempDir::new().unwrap();
let store = Store::open(tmp.path()).unwrap();
store
.writer
.set_new_project_mode(AccessMode::Restricted)
.await
.unwrap();
let ws = workspace(&store).await;
let alice = user(&store, "alice", 1).await;
let bob = user(&store, "bob", 2).await;
let (a, how) = resolve(
&store,
ws,
&remote("github.com/orga/api"),
"api",
Some(alice),
)
.await;
assert_eq!(how, IdentityResolution::Created);
let (b, how) = resolve(&store, ws, &remote("github.com/orgb/api"), "api", Some(bob)).await;
assert_eq!(how, IdentityResolution::Split);
let admits = |who, id| {
let reader = store.reader.clone();
async move {
reader
.authorize_project(
ws,
id,
ProjectPrincipal::user(who),
true,
ProjectAccess::Write,
)
.await
.unwrap()
.is_ok()
}
};
for (who, own, other) in [(alice, a, b), (bob, b, a)] {
assert!(
store.reader.grants_for(who, own).await.unwrap().is_empty(),
"the creator needs no grant"
);
assert!(admits(who, own).await, "the creator is admitted");
assert!(
!admits(who, other).await,
"the other user's project refuses"
);
}
}
/// The cwd-prefix parent the router found is the candidate, not the name: a
/// capture from a subdirectory claims the repository it sits in.
#[tokio::test]
async fn the_prefix_parent_is_the_candidate_when_given() {
let tmp = tempfile::TempDir::new().unwrap();
let store = Store::open(tmp.path()).unwrap();
let ws = workspace(&store).await;
let parent = store
.writer
.get_or_create_project(ws, "monorepo", Some("/work/monorepo".to_owned()))
.await
.unwrap();
let (id, how) = store
.writer
.resolve_project_by_identity(
ws,
remote("github.com/acme/monorepo"),
"src",
None,
Some(parent),
None,
)
.await
.unwrap();
assert_eq!(how, IdentityResolution::Claimed);
assert_eq!(id, parent);
assert!(
store
.reader
.find_project(ws, "src".into())
.await
.unwrap()
.is_none(),
"no fragment project for the subdirectory"
);
}
@@ -12,6 +12,7 @@ mod belief_authority;
mod client_activity;
mod fts_drift_status;
mod handoff_ownership;
mod identity_resolution;
mod most_recently_active_scope;
mod multi_session;
mod pinned_pages;