chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A copy of a repository shows its commits' authors as the original does
crates/knotless-serve/src/index.rs+86−0
| @@ -653,6 +653,14 @@ pub(crate) fn connect(path: &Path) -> rusqlite::Result<(Connection, bool)> { | ||
CREATE TABLE IF NOT EXISTS home ( | ||
did TEXT PRIMARY KEY, moved INTEGER NOT NULL, own INTEGER NOT NULL, | ||
checked INTEGER NOT NULL); | ||
CREATE TABLE IF NOT EXISTS commit_author ( | ||
sha TEXT NOT NULL, address TEXT NOT NULL, repo_did TEXT NOT NULL, | ||
repo_key TEXT NOT NULL, did TEXT NOT NULL, | ||
PRIMARY KEY (sha, address, repo_did, repo_key)) WITHOUT ROWID; | ||
CREATE INDEX IF NOT EXISTS commit_author_repo ON commit_author (repo_did, repo_key); | ||
CREATE TABLE IF NOT EXISTS authors_walked ( | ||
repo_did TEXT NOT NULL, repo_key TEXT NOT NULL, tip TEXT NOT NULL, | ||
people TEXT NOT NULL, PRIMARY KEY (repo_did, repo_key)); | ||
CREATE TABLE IF NOT EXISTS report ( | ||
id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT NOT NULL); | ||
CREATE TABLE IF NOT EXISTS invite ( | ||
| @@ -3626,6 +3634,76 @@ impl Index { | ||
rows.collect() | ||
} | ||
| ||
/// How far the authors `did`'s repository `key` vouches for were | ||
/// walked: the tip its default branch was at, and who it believed | ||
/// then, as [`Index::vouch_authors`] kept them. | ||
pub fn authors_walked( | ||
&self, | ||
did: &str, | ||
key: &str, | ||
) -> rusqlite::Result<Option<(String, String)>> { | ||
let db = self.readers.take(&self.db); | ||
let mut stmt = db.prepare_cached( | ||
"SELECT tip, people FROM authors_walked WHERE repo_did = ?1 AND repo_key = ?2", | ||
)?; | ||
stmt.query_row([did, key], |row| Ok((row.get(0)?, row.get(1)?))) | ||
.optional() | ||
} | ||
| ||
/// Keeps that `did`'s repository `key` vouches for `rows`, each a | ||
/// commit, an address of it ([`address`]) and the account it is, | ||
/// walked to `tip` believing `people`. `afresh` forgets what it | ||
/// vouched for before, as who it believes has changed. | ||
pub fn vouch_authors( | ||
&self, | ||
(did, key): (&str, &str), | ||
(tip, people): (&str, &str), | ||
rows: &[(String, String, String)], | ||
afresh: bool, | ||
) -> rusqlite::Result<()> { | ||
let mut db = self.db.lock().unwrap(); | ||
let tx = db.transaction()?; | ||
if afresh { | ||
tx.execute( | ||
"DELETE FROM commit_author WHERE repo_did = ?1 AND repo_key = ?2", | ||
[did, key], | ||
)?; | ||
} | ||
{ | ||
let mut put = tx.prepare_cached( | ||
"INSERT OR IGNORE INTO commit_author VALUES (?1, ?2, ?3, ?4, ?5)", | ||
)?; | ||
for (sha, address, by) in rows { | ||
put.execute([sha.as_str(), address, did, key, by])?; | ||
} | ||
} | ||
tx.execute( | ||
"INSERT OR REPLACE INTO authors_walked VALUES (?1, ?2, ?3, ?4)", | ||
[did, key, tip, people], | ||
)?; | ||
tx.commit() | ||
} | ||
| ||
/// The accounts the repositories the index knows, other than `did`'s | ||
/// `key`, vouch for as `sha`'s at `address`. | ||
pub fn authors_elsewhere( | ||
&self, | ||
sha: &str, | ||
address: &str, | ||
(did, key): (&str, &str), | ||
) -> rusqlite::Result<Vec<String>> { | ||
let db = self.readers.take(&self.db); | ||
let mut stmt = db.prepare_cached( | ||
"SELECT DISTINCT c.did FROM commit_author c | ||
JOIN repo r ON r.did = c.repo_did AND r.key = c.repo_key | ||
WHERE c.sha = ?1 AND c.address = ?2 | ||
AND NOT (c.repo_did = ?3 AND c.repo_key = ?4) | ||
AND r.uri NOT IN down AND c.did NOT IN down", | ||
)?; | ||
let found = stmt.query_map([sha, address, did, key], |row| row.get(0))?; | ||
found.collect() | ||
} | ||
| ||
/// The record of `did`'s repository `key`. | ||
pub fn repo_of(&self, did: &str, key: &str) -> rusqlite::Result<Option<Value>> { | ||
let db = self.readers.take(&self.db); | ||
| @@ -4351,6 +4429,14 @@ fn bot_on(author: &str, did: &str, key: &str) -> String { | ||
) | ||
} | ||
| ||
/// How [`Index::vouch_authors`] keeps a commit's address: hashed, as | ||
/// git has it trimmed and lowercased, so the index names no address. | ||
pub fn address(email: &str) -> String { | ||
use sha2::{Digest, Sha256}; | ||
let digest = Sha256::digest(email.trim().to_lowercase()); | ||
digest.iter().map(|b| format!("{b:02x}")).collect() | ||
} | ||
| ||
/// Where a page cut after the record `uri`, made at `at`, starts the next: | ||
/// newest first, ties broken by URI, so the next starts strictly after it. | ||
pub fn cursor_at(at: &str, uri: &str) -> String { | ||
crates/knotless-serve/src/plans.rs+2−1
| @@ -32,7 +32,7 @@ pub const ENV: &str = "KNOTLESS_PLANS"; | ||
/// The tables that grow with what people do, as many rows as prod might | ||
/// hold: a scan of one is a scan of the network. The rest are said to be | ||
/// small, as a scan of them is. | ||
pub const GROWING: [&str; 19] = [ | ||
pub const GROWING: [&str; 20] = [ | ||
"forge", | ||
"check_run", | ||
"comment", | ||
| @@ -52,6 +52,7 @@ pub const GROWING: [&str; 19] = [ | ||
"shared", | ||
"shared_use", | ||
"record_rev", | ||
"commit_author", | ||
]; | ||
| ||
/// The rows a growing table is said to hold, and a small one. | ||
crates/knotless-serve/src/web.rs+111−10
| @@ -1077,20 +1077,31 @@ struct Vouched { | ||
only: Option<HashSet<String>>, | ||
} | ||
| ||
/// `name` at `email`, by account if `email` is one of `people`'s in `sha`. | ||
/// Which of `people` `email` is in `sha`, if any. | ||
fn vouched_as<'a>(people: &'a [Vouched], sha: &str, email: &str) -> Option<&'a str> { | ||
let by = people.iter().find(|p| { | ||
let here = p.only.as_ref().is_none_or(|only| only.contains(sha)); | ||
here && knotless::emails::is(&p.did, email, &p.hashes) | ||
}); | ||
by.map(|p| p.did.as_str()) | ||
} | ||
| ||
/// `name` at `email`, by account if `email` is one of `people`'s in `sha`, | ||
/// or else the one account other repositories with `sha` vouch for as it | ||
/// there ([`walk_authors`]): none when they disagree. | ||
async fn author( | ||
app: &App, | ||
people: &[Vouched], | ||
(repo, people): (&Repo, &[Vouched]), | ||
sha: &str, | ||
name: String, | ||
email: &str, | ||
) -> CommitAuthorView { | ||
let by = people.iter().find(|p| { | ||
let here = p.only.as_ref().is_none_or(|only| only.contains(sha)); | ||
here && knotless::emails::is(&p.did, email, &p.hashes) | ||
}); | ||
let by = match vouched_as(people, sha, email) { | ||
Some(did) => Some(did.to_string()), | ||
None => elsewhere(app, repo, sha, email), | ||
}; | ||
let actor = match by { | ||
Some(p) => Some(actor(app, &p.did).await), | ||
Some(did) => Some(actor(app, &did).await), | ||
None => None, | ||
}; | ||
let agent = email | ||
| @@ -1218,6 +1229,93 @@ async fn vouched( | ||
(people, pulls) | ||
} | ||
| ||
/// The one account other repositories with `sha` vouch for as `email` | ||
/// in it, if they agree on one. | ||
fn elsewhere(app: &App, repo: &Repo, sha: &str, email: &str) -> Option<String> { | ||
let index = app.index.as_ref()?; | ||
let address = crate::index::address(email); | ||
let found = index.authors_elsewhere(sha, &address, (&repo.did, &repo.key)); | ||
let mut found = found.ok()?; | ||
found.pop().filter(|_| found.is_empty()) | ||
} | ||
| ||
/// The repositories whose authors are being walked now. | ||
static WALKING: LazyLock<Mutex<HashSet<(String, String)>>> = LazyLock::new(Mutex::default); | ||
| ||
/// Keeps in the index the account `people` vouch for as each address of | ||
/// each commit on `repo`'s default branch, so that a page of another | ||
/// repository with those commits, as a copy of it, shows them as theirs. | ||
/// Each walk goes on from the tip the last stopped at, and starts over | ||
/// when who `repo` believes changes, as one it no longer believes vouches | ||
/// for nothing. A private repository vouches for nothing elsewhere. | ||
fn walk_authors(app: &App, repo: &Repo, people: Arc<Vec<Vouched>>) { | ||
let index = app.index.clone().filter(|_| repo.in_space.is_none()); | ||
let id = (repo.did.clone(), repo.key.clone()); | ||
// One walk of a repository at a time. | ||
let index = index.filter(|_| WALKING.lock().unwrap().insert(id.clone())); | ||
if let Some(index) = index { | ||
let (dir, head) = (repo.git.dir.clone(), repo.head.clone()); | ||
tokio::task::spawn_blocking(move || { | ||
// One with no tip yet, as an empty repository, has none to walk. | ||
let _ = vouch_walked(&index, &id, &dir, &head, &people); | ||
WALKING.lock().unwrap().remove(&id); | ||
}); | ||
} | ||
} | ||
| ||
/// [`walk_authors`]'s walk. | ||
fn vouch_walked( | ||
index: &Index, | ||
(did, key): &(String, String), | ||
dir: &std::path::Path, | ||
head: &str, | ||
people: &[Vouched], | ||
) -> anyhow::Result<()> { | ||
let believed = believed(people); | ||
let objects = objects::open(dir)?; | ||
let tip = objects.resolve(head)?; | ||
let walked = index.authors_walked(did, key)?; | ||
let from = match &walked { | ||
Some((at, was)) if *at == tip && *was == believed => return Ok(()), | ||
Some((at, was)) if *was == believed => Some(at.as_str()), | ||
_ => None, | ||
}; | ||
let known: Vec<&str> = from.into_iter().collect(); | ||
let mut rows = Vec::new(); | ||
for c in objects.brought(&tip, &known, usize::MAX)? { | ||
let helped = coauthors(&c.message).into_iter().map(|(_, email)| email); | ||
let emails = [c.author.email, c.committer.email] | ||
.into_iter() | ||
.chain(helped); | ||
for email in emails { | ||
if let Some(by) = vouched_as(people, &c.sha, &email) { | ||
rows.push((c.sha.clone(), crate::index::address(&email), by.to_string())); | ||
} | ||
} | ||
} | ||
index.vouch_authors((did, key), (&tip, &believed), &rows, from.is_none())?; | ||
Ok(()) | ||
} | ||
| ||
/// Who `people` are, as a walk of the authors they vouch for keeps it: | ||
/// each account and the addresses it claims, but not the pulls merged, | ||
/// as one merged since brought in only commits a walk on from its tip | ||
/// finds. | ||
fn believed(people: &[Vouched]) -> String { | ||
let mut each: Vec<String> = people | ||
.iter() | ||
.map(|p| { | ||
let mut hashes = p.hashes.clone(); | ||
hashes.sort(); | ||
let everywhere = if p.only.is_none() { "*" } else { "" }; | ||
format!("{}{everywhere}:{}", p.did, hashes.join(",")) | ||
}) | ||
.collect(); | ||
each.sort(); | ||
each.dedup(); | ||
crate::index::address(&each.join("\n")) | ||
} | ||
| ||
/// `commits`, as the pages show them, each with the accounts that wrote it | ||
/// where `repo`'s pages believe their claims (`vouched`). | ||
async fn shown(app: &App, repo: &Repo, commits: Vec<read::Commit>) -> Vec<CommitView> { | ||
| @@ -1225,6 +1323,9 @@ async fn shown(app: &App, repo: &Repo, commits: Vec<read::Commit>) -> Vec<Commit | ||
let made = assembled(app, repo).await.unwrap_or_default(); | ||
let items = &made.items; | ||
let (people, pulls) = vouched(app, repo, record, items).await; | ||
let people = Arc::new(people); | ||
walk_authors(app, repo, people.clone()); | ||
let people = (repo, people.as_slice()); | ||
let mut out = Vec::new(); | ||
for c in commits { | ||
let (pull, message) = match from_pull(items, &c.message) { | ||
| @@ -1233,11 +1334,11 @@ async fn shown(app: &App, repo: &Repo, commits: Vec<read::Commit>) -> Vec<Commit | ||
}; | ||
let mut helped = Vec::new(); | ||
for (name, email) in coauthors(&message) { | ||
helped.push(author(app, &people, &c.sha, name, &email).await); | ||
helped.push(author(app, people, &c.sha, name, &email).await); | ||
} | ||
let (a, m) = (&c.author, &c.committer); | ||
let by = author(app, &people, &c.sha, a.name.clone(), &a.email).await; | ||
let made = author(app, &people, &c.sha, m.name.clone(), &m.email).await; | ||
let by = author(app, people, &c.sha, a.name.clone(), &a.email).await; | ||
let made = author(app, people, &c.sha, m.name.clone(), &m.email).await; | ||
let same = match (&by.actor, &made.actor) { | ||
(Some(x), Some(y)) => x.did == y.did, | ||
_ => (&a.name, &a.email) == (&m.name, &m.email), | ||
crates/knotless-serve/tests/web.rs+51−0
| @@ -964,6 +964,57 @@ fn blaming_a_file() { | ||
} | ||
} | ||
| ||
#[test] | ||
fn a_copy_shows_whom_the_original_vouched_for() { | ||
let site = serve(true); | ||
let owner = person(account(), &site); | ||
let url = owner.url("original"); | ||
ok(&owner, &["create", &url]); | ||
let work = owner.work("w", 2); | ||
// The first push claims git's address as the owner's. | ||
owner.git(&work, &["push", "-q", &url, "main"]); | ||
let first = owner.git(&work, &["rev-parse", "HEAD^"]); | ||
let o = Value::from(owner.account.handle.as_str()); | ||
let (copier, taker) = (person(account(), &site), person(account(), &site)); | ||
let author = |who: &Sandbox, name: &str, sha: &str| { | ||
let at = format!("/{}/{name}/commit/{sha}", who.account.handle); | ||
wait_until(|| site.status(&self::at(&at)).0 == 200); | ||
site.data(&at)["commit"]["author"]["actor"]["handle"].clone() | ||
}; | ||
| ||
// A copy, pushed as a repository of its own rather than forked, by | ||
// one who claims another address, shows the owner's commits as no | ||
// one's until the original's pages have walked them, then as the | ||
// owner's: the original's owner vouched for them there. | ||
ok(&copier, &["email", "add", "copier@example.com"]); | ||
let copy = copier.url("copy"); | ||
ok(&copier, &["create", ©]); | ||
copier.git(&work, &["push", "-q", ©, "main"]); | ||
assert_eq!(author(&copier, "copy", &first), Value::Null); | ||
assert_eq!(author(&owner, "original", &first), o); | ||
wait_until(|| author(&copier, "copy", &first) == o); | ||
// Shown again with nothing new, the walk has nothing to do; a commit | ||
// pushed since is walked on from where the last stopped. | ||
assert_eq!(author(&owner, "original", &first), o); | ||
let tip = owner.commit_file(&work, "g", "g\n"); | ||
owner.git(&work, &["push", "-q", &url, "main"]); | ||
copier.git(&work, &["push", "-q", ©, "main"]); | ||
assert_eq!(author(&owner, "original", &tip), o); | ||
wait_until(|| author(&copier, "copy", &tip) == o); | ||
| ||
// One who copies them and claims the owner's address, as their own | ||
// first push does, has them shown as theirs only in their own | ||
// repository. Elsewhere the two disagree, and the copy shows the | ||
// commits as no one's rather than either's. | ||
let taken = taker.url("taken"); | ||
ok(&taker, &["create", &taken]); | ||
taker.git(&work, &["push", "-q", &taken, "main"]); | ||
let t = Value::from(taker.account.handle.as_str()); | ||
wait_until(|| author(&taker, "taken", &first) == t); | ||
wait_until(|| author(&copier, "copy", &first) == Value::Null); | ||
assert_eq!(author(&owner, "original", &first), o); | ||
} | ||
| ||
#[test] | ||
fn comments_on_lines() { | ||
let site = serve(true); | ||
docs/design/identity.md+25−0
| @@ -154,6 +154,31 @@ The owner and collaborators are matched first, so a pull's author cannot | ||
take their addresses. Someone who claims your address gains nothing beyond | ||
the commits they got merged. | ||
| ||
### Commits in other repositories | ||
| ||
A commit keeps its ID when its history is pushed somewhere else, as in a | ||
fork, a mirror, or a copy pushed as a repository of its own. Where a | ||
page's own repository believes no claim for an address in a commit, it | ||
asks which accounts other repositories with that commit believe there: | ||
| ||
- Exactly one: the page shows that account. | ||
- More than one: the page shows none, rather than pick between them. | ||
- None: the address stays unlinked. | ||
| ||
So a copy shows its commits' authors as the original shows them. Someone | ||
who copies your commits and claims your address is believed only in their | ||
own repository. Everywhere else the claims disagree, and the commits show | ||
as no one's rather than as theirs. | ||
| ||
What each repository believes comes from a walk of its default branch, | ||
kept in the index. The walk runs in the background when a page shows the | ||
repository's commits, and goes on from the tip where the last walk | ||
stopped. It starts over when the people the repository believes change, | ||
or when their claims change, since an address it no longer believes | ||
vouches for nothing. Only public repositories the index knows count, and | ||
not those taken down. A private repository vouches for nothing outside | ||
itself. | ||
| ||
A commit knotless makes for an account that names no author is authored as | ||
`<did>@knotless.invalid`, which needs no claim. | ||
| ||
docs/design/serve.md+3−1
| @@ -541,7 +541,9 @@ it log in again. | ||
| ||
The index records its table version. A build with another version drops the | ||
cache tables and rebuilds them from scratch, keeping only takedowns, reports, | ||
emitted labels, pushes, repositories' [languages](#languages), which accounts are hidden ([Accounts that | ||
emitted labels, pushes, repositories' [languages](#languages), the commit | ||
authors each repository vouches for ([Commits in other | ||
repositories](identity.md#commits-in-other-repositories)), which accounts are hidden ([Accounts that | ||
go](#accounts-that-go)), and, in the index of private repositories, which | ||
members were proven to read which space. Pushes are kept because nothing can bring them | ||
back: each is read from a claim, compaction deletes claims a day old, and the | ||