mirror of
https://github.com/akitaonrails/ai-memory.git
synced 2026-10-02 03:24:46 +08:00
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:
co-authored by
Claude Opus 5.5
parent
bec4b4856f
commit
b2621692b1
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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'",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user