flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/repos/src/registry.rs

850 lines32,085 bytesCodeBlame
1//! Repository metadata in D1.
2
3use std::cell::RefCell;
4use std::collections::HashMap;
5
6use g1t_contracts::Viewer;
7use g1t_contracts::access::{self, Capability, RepoRole};
8use g1t_contracts::repos::{Repo, RepoPath};
9use serde::Deserialize;
10use worker::wasm_bindgen::JsValue;
11use worker::{D1Database, Result};
12
13#[derive(Deserialize)]
14pub(crate) struct RepoRow {
15 id: String,
16 namespace: String,
17 name: String,
18 description: Option<String>,
19 is_private: u8,
20 owner_id: String,
21 default_branch: String,
22 fork_of: Option<String>,
23 protected: u8,
24 created_at: String,
25 /// Null only on rows written before the column existed and not yet
26 /// migrated; their key is the one worked out from the path.
27 #[serde(default)]
28 store: Option<String>,
29 /// JSON; absent on rows read before the column existed.
30 #[serde(default)]
31 topics: Option<String>,
32 #[serde(default)]
33 website: Option<String>,
34 #[serde(default)]
35 archived_at: Option<String>,
36 #[serde(default)]
37 deleted_at: Option<String>,
38 /// Bumped by everything that changes the repository's refs; see
39 /// [`RefsState`]. Absent on rows read before the column existed.
40 #[serde(default)]
41 refs_version: Option<f64>,
42 #[serde(default)]
43 refs_open_until: Option<f64>,
44}
45
46/// Where a repository's refs stand, as its row last said: `version` goes up
47/// with every change g1t makes to them, so an answer that lists them (see
48/// refs_cache.rs) is kept under the version it was made at, and a change
49/// leaves it behind. Until `open_until` (milliseconds) a credential that
50/// can change them is out of g1t's hands, and nothing is kept.
51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
52pub struct RefsState {
53 pub version: u64,
54 pub open_until: u64,
55}
56
57/// The newest [`RefsState`] this isolate has read or written, by
58/// repository id. A version only goes up, so an older read finishing late
59/// never takes a newer one back.
60#[derive(Default)]
61pub struct RefsStates {
62 states: HashMap<String, RefsState>,
63}
64
65impl RefsStates {
66 pub fn note(&mut self, id: &str, state: RefsState) {
67 let kept = self.states.entry(id.to_owned()).or_default();
68 kept.version = kept.version.max(state.version);
69 kept.open_until = kept.open_until.max(state.open_until);
70 }
71
72 pub fn get(&self, id: &str) -> Option<RefsState> {
73 self.states.get(id).copied()
74 }
75}
76
77thread_local! {
78 static REFS: RefCell<RefsStates> = RefCell::new(RefsStates::default());
79}
80
81/// Where the refs of the repository with this id stand, as this isolate
82/// last read them; `None` before the column existed or before its row was
83/// read here.
84pub fn refs_state(id: &str) -> Option<RefsState> {
85 REFS.with(|refs| refs.borrow().get(id))
86}
87
88fn note_refs(id: &str, version: Option<f64>, open_until: Option<f64>) {
89 if let Some(version) = version {
90 let state = RefsState {
91 version: version as u64,
92 open_until: open_until.unwrap_or(0.0) as u64,
93 };
94 REFS.with(|refs| refs.borrow_mut().note(id, state));
95 }
96}
97
98thread_local! {
99 /// Store keys that differ from the one a repository's path gives: those
100 /// of repositories whose workspace was renamed after they were made.
101 /// Filled whenever a row is read or written, so every `Repo` this
102 /// service holds has its key here. A key never changes once given, so
103 /// requests sharing the isolate can share the map.
104 static MOVED: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new());
105}
106
107/// How long a fetch may go by a repository's row as it was read a moment
108/// ago: a clone is two or three requests in quick succession, and each
109/// would otherwise read the same row. Short enough that making a repository
110/// private, archiving or deleting it applies within seconds.
111pub const RECENT_MS: u64 = 5_000;
112
113/// Repositories read in the last [`RECENT_MS`], by path. Only rows that
114/// were found are kept, so a repository just made is never missed.
115#[derive(Default)]
116pub struct Recent {
117 rows: HashMap<(String, String), (Repo, u64)>,
118}
119
120impl Recent {
121 fn key(path: &RepoPath) -> (String, String) {
122 (path.namespace.to_lowercase(), path.name.to_lowercase())
123 }
124
125 pub fn get(&self, path: &RepoPath, now: u64) -> Option<Repo> {
126 self.rows
127 .get(&Self::key(path))
128 .filter(|(_, read)| now.saturating_sub(*read) < RECENT_MS)
129 .map(|(repo, _)| repo.clone())
130 }
131
132 pub fn keep(&mut self, path: &RepoPath, repo: &Repo, now: u64) {
133 self.rows.retain(|_, (_, read)| now.saturating_sub(*read) < RECENT_MS);
134 self.rows.insert(Self::key(path), (repo.clone(), now));
135 }
136}
137
138thread_local! {
139 static RECENT: RefCell<Recent> = RefCell::new(Recent::default());
140}
141
142/// The key a repository's path gives: what every repository was stored
143/// under before workspaces could be renamed.
144pub fn path_key(repo: &Repo) -> String {
145 format!("{}--{}", repo.namespace, repo.name)
146}
147
148/// Records where a repository is stored, when its path does not say.
149pub fn remember_store(repo: &Repo, store: &str) {
150 if store != path_key(repo) {
151 MOVED.with(|moved| moved.borrow_mut().insert(repo.id.clone(), store.to_owned()));
152 }
153}
154
155impl From<RepoRow> for Repo {
156 fn from(row: RepoRow) -> Self {
157 let repo = Repo {
158 id: row.id,
159 namespace: row.namespace,
160 name: row.name,
161 description: row.description,
162 is_private: row.is_private != 0,
163 owner_id: row.owner_id,
164 default_branch: row.default_branch,
165 fork_of: row.fork_of,
166 protected: row.protected != 0,
167 created_at: row.created_at,
168 topics: row
169 .topics
170 .as_deref()
171 .and_then(|topics| serde_json::from_str(topics).ok())
172 .unwrap_or_default(),
173 website: row.website,
174 archived_at: row.archived_at,
175 };
176 if let Some(store) = &row.store {
177 remember_store(&repo, store);
178 }
179 note_refs(&repo.id, row.refs_version, row.refs_open_until);
180 repo
181 }
182}
183
184/// The key a repo is stored under in the git store.
185pub fn store_key(repo: &Repo) -> String {
186 MOVED
187 .with(|moved| moved.borrow().get(&repo.id).cloned())
188 .unwrap_or_else(|| path_key(repo))
189}
190
191/// The viewer's role on `repo` (see `g1t_contracts::access`): ownership of
192/// its workspace, the workspace's base permission, a direct grant, or
193/// Read on a public repository. A pull request's fork is its author's to
194/// write; whoever can read the repository it came from can read it too,
195/// which `Repos::may_read` checks.
196pub fn role(repo: &Repo, viewer: &Viewer) -> Option<RepoRole> {
197 if repo.fork_of.is_some() {
198 let author = viewer.as_ref().is_some_and(|user| user.id == repo.owner_id);
199 return if author {
200 Some(RepoRole::Write)
201 } else if repo.is_private {
202 None
203 } else {
204 Some(RepoRole::Read)
205 };
206 }
207 access::permission(viewer.as_ref(), repo)
208}
209
210/// Whether the viewer may read `repo`, going by the repository alone.
211pub fn can_read(repo: &Repo, viewer: &Viewer) -> bool {
212 role(repo, viewer).is_some()
213}
214
215/// Whether the viewer may push to `repo`: Write or higher, or the author
216/// of a pull request's fork.
217pub fn can_write(repo: &Repo, viewer: &Viewer) -> bool {
218 can(repo, viewer, Capability::Push)
219}
220
221/// Whether the viewer may do `capability` in `repo`. A fork has only its
222/// author's Write.
223pub fn can(repo: &Repo, viewer: &Viewer, capability: Capability) -> bool {
224 if repo.fork_of.is_some() {
225 return role(repo, viewer).is_some_and(|role| access::allows(role, capability))
226 && !access::OWNER_ONLY.contains(&capability);
227 }
228 access::can(viewer.as_ref(), repo, capability)
229}
230
231fn optional(value: &Option<String>) -> JsValue {
232 value.as_deref().map_or(JsValue::NULL, JsValue::from)
233}
234
235pub struct Registry {
236 pub db: D1Database,
237}
238
239impl Registry {
240 pub async fn by_path(&self, path: &RepoPath) -> Result<Option<Repo>> {
241 Ok(self
242 .db
243 .prepare("SELECT * FROM repos WHERE namespace = ? AND name = ? AND deleted_at IS NULL")
244 .bind(&[
245 path.namespace.to_lowercase().into(),
246 path.name.to_lowercase().into(),
247 ])?
248 .first::<RepoRow>(None)
249 .await?
250 .map(Repo::from))
251 }
252
253 /// The repository at `path`, as read in the last few seconds if it was
254 /// (see [`RECENT_MS`]). For fetches only: a push always reads the row.
255 pub async fn by_path_recent(&self, path: &RepoPath) -> Result<Option<Repo>> {
256 let now = g1t_kit::now_ms();
257 if let Some(repo) = RECENT.with(|recent| recent.borrow().get(path, now)) {
258 return Ok(Some(repo));
259 }
260 let found = self.by_path(path).await?;
261 if let Some(repo) = &found {
262 RECENT.with(|recent| recent.borrow_mut().keep(path, repo, now));
263 }
264 Ok(found)
265 }
266
267 /// Its details; who can see it changes with `set_private`.
268 pub async fn update(
269 &self,
270 id: &str,
271 description: Option<&str>,
272 protected: bool,
273 topics: &[String],
274 website: Option<&str>,
275 ) -> Result<()> {
276 self.db
277 .prepare("UPDATE repos SET description = ?, protected = ?, topics = ?, website = ? WHERE id = ?")
278 .bind(&[
279 description.map_or(JsValue::NULL, JsValue::from),
280 u32::from(protected).into(),
281 serde_json::to_string(topics)?.into(),
282 website.map_or(JsValue::NULL, JsValue::from),
283 id.into(),
284 ])?
285 .run()
286 .await?;
287 Ok(())
288 }
289
290 pub async fn by_id(&self, id: &str) -> Result<Option<Repo>> {
291 Ok(self
292 .db
293 .prepare("SELECT * FROM repos WHERE id = ? AND deleted_at IS NULL")
294 .bind(&[id.into()])?
295 .first::<RepoRow>(None)
296 .await?
297 .map(Repo::from))
298 }
299
300 /// The repository at `path`, deleted or not: what holds the name.
301 pub async fn by_path_any(&self, path: &RepoPath) -> Result<Option<(Repo, Option<String>)>> {
302 Ok(self
303 .db
304 .prepare("SELECT * FROM repos WHERE namespace = ? AND name = ?")
305 .bind(&[
306 path.namespace.to_lowercase().into(),
307 path.name.to_lowercase().into(),
308 ])?
309 .first::<RepoRow>(None)
310 .await?
311 .map(|mut row| {
312 let deleted_at = row.deleted_at.take();
313 (Repo::from(row), deleted_at)
314 }))
315 }
316
317 /// Repos the viewer may see, newest first. Excludes pull request forks.
318 /// With `member_only`, only repos in the viewer's own workspaces.
319 pub async fn list(
320 &self,
321 viewer: &Viewer,
322 query: Option<&str>,
323 namespace: Option<&str>,
324 member_only: bool,
325 ) -> Result<Vec<Repo>> {
326 let workspaces: Vec<&str> = viewer
327 .iter()
328 .flat_map(|user| &user.workspaces)
329 .map(|membership| membership.slug.as_str())
330 .collect();
331 // The workspaces whose private repositories the viewer reads all
332 // of (an owner, or a base permission other than none), and the
333 // repositories they were given a role on: see access.rs. A probe
334 // repository in each workspace stands for all of them.
335 let reading: Vec<&str> = viewer
336 .iter()
337 .flat_map(|user| {
338 user.workspaces.iter().filter(move |membership| {
339 let probe = access::RepoRef { id: "", namespace: &membership.slug, private: true };
340 access::granted(user, probe).is_some()
341 })
342 })
343 .map(|membership| membership.slug.as_str())
344 .collect();
345 let granted: Vec<&str> = viewer
346 .iter()
347 .flat_map(|user| &user.grants)
348 .map(|grant| grant.repo_id.as_str())
349 .collect();
350 let mut params: Vec<JsValue> = vec![
351 serde_json::to_string(&reading)?.into(),
352 serde_json::to_string(&granted)?.into(),
353 ];
354 let private_ok = "(namespace IN (SELECT value FROM json_each(?)) OR id IN (SELECT value FROM json_each(?)))";
355 let mut conditions = vec![
356 "fork_of IS NULL AND deleted_at IS NULL".to_owned(),
357 format!("(is_private = 0 OR {private_ok})"),
358 ];
359 if member_only {
360 conditions.push("namespace IN (SELECT value FROM json_each(?))".to_owned());
361 params.push(serde_json::to_string(&workspaces)?.into());
362 }
363 if let Some(namespace) = namespace {
364 conditions.push("namespace = ?".to_owned());
365 params.push(namespace.to_lowercase().into());
366 }
367 if let Some(query) = query.map(str::trim).filter(|query| !query.is_empty()) {
368 conditions
369 .push("(name LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\')".to_owned());
370 // LIKE wildcards in the query are matched literally.
371 let escaped: String = query
372 .chars()
373 .flat_map(|c| match c {
374 '\\' | '%' | '_' => vec!['\\', c],
375 _ => vec![c],
376 })
377 .collect();
378 let pattern = format!("%{escaped}%");
379 params.push(pattern.as_str().into());
380 params.push(pattern.into());
381 }
382 let sql = format!(
383 "SELECT * FROM repos WHERE {} ORDER BY created_at DESC, id DESC LIMIT 50",
384 conditions.join(" AND ")
385 );
386 let rows = self
387 .db
388 .prepare(sql)
389 .bind(&params)?
390 .all()
391 .await?
392 .results::<RepoRow>()?;
393 Ok(rows.into_iter().map(Repo::from).collect())
394 }
395
396 /// Of these ids, the repositories (not forks) the viewer may read.
397 pub async fn readable(&self, ids: &[String], viewer: &Viewer) -> Result<Vec<Repo>> {
398 let ids: Vec<&String> = ids.iter().take(g1t_contracts::repos::MAX_READABLE).collect();
399 if ids.is_empty() {
400 return Ok(Vec::new());
401 }
402 // One parameter however many ids: D1 binds at most 100.
403 let rows = self
404 .db
405 .prepare(
406 "SELECT * FROM repos
407 WHERE id IN (SELECT value FROM json_each(?)) AND fork_of IS NULL AND deleted_at IS NULL",
408 )
409 .bind(&[serde_json::to_string(&ids)?.into()])?
410 .all()
411 .await?
412 .results::<RepoRow>()?;
413 Ok(rows
414 .into_iter()
415 .map(Repo::from)
416 .filter(|repo| can_read(repo, viewer))
417 .collect())
418 }
419
420 /// The workspaces in which this account made a public repository.
421 pub async fn public_namespaces(&self, owner_id: &str) -> Result<Vec<String>> {
422 #[derive(Deserialize)]
423 struct Row {
424 namespace: String,
425 }
426 Ok(self
427 .db
428 .prepare(
429 "SELECT DISTINCT namespace FROM repos
430 WHERE owner_id = ? AND is_private = 0 AND fork_of IS NULL AND deleted_at IS NULL
431 ORDER BY namespace",
432 )
433 .bind(&[owner_id.into()])?
434 .all()
435 .await?
436 .results::<Row>()?
437 .into_iter()
438 .map(|row| row.namespace)
439 .collect())
440 }
441
442 /// Repositories that are not forks, by id, a page at a time.
443 pub async fn ids_after(&self, after: Option<&str>, limit: u32) -> Result<Vec<String>> {
444 #[derive(Deserialize)]
445 struct Row {
446 id: String,
447 }
448 Ok(self
449 .db
450 .prepare("SELECT id FROM repos WHERE fork_of IS NULL AND deleted_at IS NULL AND id > ? ORDER BY id LIMIT ?")
451 .bind(&[after.unwrap_or("").into(), limit.into()])?
452 .all()
453 .await?
454 .results::<Row>()?
455 .into_iter()
456 .map(|row| row.id)
457 .collect())
458 }
459
460 /// Adds a pushed pack's bytes to what the repository is counted as
461 /// holding: its own, or, for a pull request's working copy, the
462 /// repository it is a copy of, whose storage it is.
463 pub async fn add_stored_bytes(&self, repo: &Repo, bytes: u64) -> Result<()> {
464 if bytes == 0 {
465 return Ok(());
466 }
467 let root = repo.fork_of.as_deref().unwrap_or(&repo.id);
468 self.db
469 .prepare("UPDATE repos SET stored_bytes = stored_bytes + ? WHERE id = ?")
470 .bind(&[(bytes as f64).into(), root.into()])?
471 .run()
472 .await?;
473 Ok(())
474 }
475
476 /// Which of these `namespace/name` paths are private. A working copy
477 /// answers as its repository. Unknown paths are left out.
478 pub async fn visibility(&self, paths: &[String]) -> Result<Vec<g1t_contracts::repos::RepoVisibility>> {
479 let mut out = Vec::new();
480 for path in paths.iter().take(50) {
481 let Some((namespace, name)) = path.split_once('/') else { continue };
482 let Some(repo) = self
483 .by_path(&RepoPath { namespace: namespace.to_owned(), name: name.to_owned() })
484 .await?
485 else {
486 continue;
487 };
488 let is_private = match &repo.fork_of {
489 Some(parent) => self.by_id(parent).await?.map_or(repo.is_private, |parent| parent.is_private),
490 None => repo.is_private,
491 };
492 out.push(g1t_contracts::repos::RepoVisibility { path: path.clone(), is_private });
493 }
494 Ok(out)
495 }
496
497 /// What each workspace's repositories are counted as holding, private
498 /// and public apart. Working copies count toward their repository.
499 pub async fn storage(&self) -> Result<Vec<g1t_contracts::repos::WorkspaceStorage>> {
500 #[derive(Deserialize)]
501 struct Row {
502 namespace: String,
503 private_bytes: Option<f64>,
504 public_bytes: Option<f64>,
505 }
506 Ok(self
507 .db
508 .prepare(
509 "SELECT namespace,
510 SUM(CASE WHEN is_private = 1 THEN stored_bytes ELSE 0 END) AS private_bytes,
511 SUM(CASE WHEN is_private = 0 THEN stored_bytes ELSE 0 END) AS public_bytes
512 FROM repos WHERE fork_of IS NULL AND deleted_at IS NULL AND stored_bytes > 0 GROUP BY namespace",
513 )
514 .all()
515 .await?
516 .results::<Row>()?
517 .into_iter()
518 .map(|row| g1t_contracts::repos::WorkspaceStorage {
519 namespace: row.namespace,
520 private_bytes: row.private_bytes.unwrap_or(0.0) as i64,
521 public_bytes: row.public_bytes.unwrap_or(0.0) as i64,
522 })
523 .collect())
524 }
525
526 /// What one workspace's private repositories are counted as holding.
527 pub async fn private_bytes(&self, namespace: &str) -> Result<i64> {
528 #[derive(Deserialize)]
529 struct Row {
530 bytes: Option<f64>,
531 }
532 Ok(self
533 .db
534 .prepare("SELECT SUM(stored_bytes) AS bytes FROM repos WHERE namespace = ? AND is_private = 1 AND fork_of IS NULL AND deleted_at IS NULL")
535 .bind(&[namespace.into()])?
536 .first::<Row>(None)
537 .await?
538 .and_then(|row| row.bytes)
539 .unwrap_or(0.0) as i64)
540 }
541
542 /// Forgets a repository that could not be filled.
543 pub async fn remove(&self, id: &str) -> Result<()> {
544 self.db
545 .prepare("DELETE FROM repos WHERE id = ?")
546 .bind(&[id.into()])?
547 .run()
548 .await?;
549 Ok(())
550 }
551
552 /// Picks the store key for a repository about to be made, and
553 /// remembers it: the one its path gives, unless a repository already
554 /// holds that (one made in a workspace that has since been renamed,
555 /// whose old name this workspace now has), when its id.
556 pub async fn claim_store_key(&self, repo: &Repo) -> Result<String> {
557 let wanted = path_key(repo);
558 let held = self
559 .db
560 .prepare("SELECT 1 AS held FROM repos WHERE store = ?")
561 .bind(&[wanted.as_str().into()])?
562 .first::<serde_json::Value>(None)
563 .await?
564 .is_some();
565 let key = if held { repo.id.clone() } else { wanted };
566 remember_store(repo, &key);
567 Ok(key)
568 }
569
570 /// Moves a renamed workspace's repositories to its current slug, from
571 /// any of `stale`. A repository whose name the current slug already has
572 /// (one pushed there in the moment before this ran) stays where it is;
573 /// returns how many did.
574 pub async fn rename_namespace(&self, stale: &[String], current: &str) -> Result<usize> {
575 if stale.is_empty() {
576 return Ok(0);
577 }
578 let marks = vec!["?"; stale.len()].join(", ");
579 let mut moved: Vec<JsValue> = vec![current.into()];
580 moved.extend(stale.iter().map(|slug| JsValue::from(slug.as_str())));
581 let left: Vec<JsValue> = stale.iter().map(|slug| JsValue::from(slug.as_str())).collect();
582 let results = self
583 .db
584 .batch(vec![
585 self.db
586 .prepare(format!(
587 "UPDATE OR IGNORE repos SET namespace = ? WHERE namespace IN ({marks})"
588 ))
589 .bind(&moved)?,
590 self.db
591 .prepare(format!(
592 "SELECT count(*) AS left FROM repos WHERE namespace IN ({marks})"
593 ))
594 .bind(&left)?,
595 // Git operations follow the workspace, added together.
596 self.db
597 .prepare(format!(
598 "INSERT INTO git_operations (namespace, hour, operations)
599 SELECT ?, hour, SUM(operations) FROM git_operations WHERE namespace IN ({marks}) GROUP BY hour
600 ON CONFLICT (namespace, hour) DO UPDATE SET operations = git_operations.operations + excluded.operations"
601 ))
602 .bind(&moved)?,
603 self.db
604 .prepare(format!("DELETE FROM git_operations WHERE namespace IN ({marks})"))
605 .bind(&left)?,
606 // Paths repositories were transferred away from follow the
607 // workspace too, so the old slug's redirect then finds them.
608 self.db
609 .prepare(format!(
610 "UPDATE OR IGNORE repo_redirects SET namespace = ? WHERE namespace IN ({marks})"
611 ))
612 .bind(&moved)?,
613 ])
614 .await?;
615 #[derive(Deserialize)]
616 struct Left {
617 left: usize,
618 }
619 Ok(results
620 .get(1)
621 .map(|result| result.results::<Left>())
622 .transpose()?
623 .and_then(|rows| rows.into_iter().next())
624 .map_or(0, |row| row.left))
625 }
626
627 /// Records that the refs of the repository with this id changed, after
628 /// they did: what anything that lists them keeps goes stale.
629 pub async fn refs_moved(&self, id: &str) -> Result<()> {
630 self.bump_refs(
631 "UPDATE repos SET refs_version = refs_version + 1 WHERE id = ?
632 RETURNING refs_version, refs_open_until",
633 &[id.into()],
634 id,
635 )
636 .await
637 }
638
639 /// Records that a credential able to change the refs of the repository
640 /// with this id was handed out of g1t's hands, until `until`
641 /// (milliseconds): until then, nothing that lists them is kept.
642 pub async fn refs_open(&self, id: &str, until: u64) -> Result<()> {
643 self.bump_refs(
644 "UPDATE repos SET refs_version = refs_version + 1,
645 refs_open_until = max(coalesce(refs_open_until, 0), ?)
646 WHERE id = ? RETURNING refs_version, refs_open_until",
647 &[(until as f64).into(), id.into()],
648 id,
649 )
650 .await
651 }
652
653 async fn bump_refs(&self, sql: &str, params: &[JsValue], id: &str) -> Result<()> {
654 #[derive(Deserialize)]
655 struct Bumped {
656 refs_version: Option<f64>,
657 refs_open_until: Option<f64>,
658 }
659 let bumped = self
660 .db
661 .prepare(sql)
662 .bind(params)?
663 .first::<Bumped>(None)
664 .await?;
665 if let Some(bumped) = bumped {
666 note_refs(id, bumped.refs_version, bumped.refs_open_until);
667 }
668 Ok(())
669 }
670
671 pub async fn insert(&self, repo: &Repo) -> Result<()> {
672 self.db
673 .prepare(
674 "INSERT INTO repos
675 (id, namespace, name, description, is_private, owner_id,
676 default_branch, fork_of, created_at, store)
677 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
678 )
679 .bind(&[
680 repo.id.as_str().into(),
681 repo.namespace.as_str().into(),
682 repo.name.as_str().into(),
683 optional(&repo.description),
684 (repo.is_private as u8).into(),
685 repo.owner_id.as_str().into(),
686 repo.default_branch.as_str().into(),
687 optional(&repo.fork_of),
688 repo.created_at.as_str().into(),
689 store_key(repo).into(),
690 ])?
691 .run()
692 .await?;
693 Ok(())
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use g1t_contracts::access::{BasePermission, RepoGrant};
701 use g1t_contracts::{Membership, Role, User};
702
703 #[test]
704 fn the_refs_state_kept_only_moves_forward() {
705 let mut states = RefsStates::default();
706 assert_eq!(states.get("rep_1"), None);
707 states.note("rep_1", RefsState { version: 3, open_until: 0 });
708 // A read that started before a bump and finished after it.
709 states.note("rep_1", RefsState { version: 2, open_until: 0 });
710 assert_eq!(states.get("rep_1").unwrap().version, 3);
711 states.note("rep_1", RefsState { version: 4, open_until: 9_000 });
712 states.note("rep_1", RefsState { version: 5, open_until: 0 });
713 assert_eq!(states.get("rep_1"), Some(RefsState { version: 5, open_until: 9_000 }));
714 assert_eq!(states.get("rep_2"), None);
715 }
716
717 #[test]
718 fn a_row_from_before_the_column_has_no_refs_state() {
719 let row = |version: Option<f64>| RepoRow {
720 id: format!("rep_row_{}", version.is_some()),
721 namespace: "acme".into(),
722 name: "rocket".into(),
723 description: None,
724 is_private: 0,
725 owner_id: "usr_owner".into(),
726 default_branch: "main".into(),
727 fork_of: None,
728 protected: 0,
729 created_at: String::new(),
730 store: None,
731 topics: None,
732 website: None,
733 archived_at: None,
734 deleted_at: None,
735 refs_version: version,
736 refs_open_until: None,
737 };
738 let old = Repo::from(row(None));
739 assert_eq!(refs_state(&old.id), None);
740 let new = Repo::from(row(Some(7.0)));
741 assert_eq!(refs_state(&new.id), Some(RefsState { version: 7, open_until: 0 }));
742 }
743
744 #[test]
745 fn a_repository_read_a_moment_ago_is_reused_for_a_few_seconds() {
746 let mut recent = Recent::default();
747 let path = RepoPath {
748 namespace: "Acme".into(),
749 name: "Rocket".into(),
750 };
751 recent.keep(&path, &repo(false), 1_000);
752 // Paths are matched as the table matches them, ignoring case.
753 let lower = RepoPath {
754 namespace: "acme".into(),
755 name: "rocket".into(),
756 };
757 assert_eq!(recent.get(&lower, 1_000 + RECENT_MS - 1).unwrap().id, "rep_1");
758 assert!(recent.get(&lower, 1_000 + RECENT_MS).is_none());
759 let other = RepoPath {
760 namespace: "acme".into(),
761 name: "booster".into(),
762 };
763 assert!(recent.get(&other, 1_000).is_none());
764 // Keeping another later drops the stale row.
765 recent.keep(&other, &repo(true), 1_000 + RECENT_MS);
766 assert_eq!(recent.rows.len(), 1);
767 }
768
769 fn repo(private: bool) -> Repo {
770 Repo {
771 id: "rep_1".into(),
772 namespace: "acme".into(),
773 name: "rocket".into(),
774 description: None,
775 is_private: private,
776 owner_id: "usr_owner".into(),
777 default_branch: "main".into(),
778 fork_of: None,
779 protected: false,
780 created_at: String::new(),
781 topics: Vec::new(),
782 website: None,
783 archived_at: None,
784 }
785 }
786
787 fn person(id: &str, memberships: Vec<Membership>, grants: Vec<(&str, RepoRole)>) -> Viewer {
788 Some(User {
789 id: id.into(),
790 username: id.into(),
791 verified: true,
792 workspaces: memberships,
793 grants: grants
794 .into_iter()
795 .map(|(repo_id, role)| RepoGrant { repo_id: repo_id.into(), workspace: "acme".into(), role })
796 .collect(),
797 ..User::default()
798 })
799 }
800
801 /// What git asks: clone and fetch need Read on a private repository,
802 /// push needs Write.
803 #[test]
804 fn git_reads_with_read_and_pushes_with_write() {
805 let private = repo(true);
806 let reader = person("usr_r", vec![], vec![("rep_1", RepoRole::Read)]);
807 assert!(can_read(&private, &reader));
808 assert!(!can_write(&private, &reader));
809 let writer = person("usr_w", vec![], vec![("rep_1", RepoRole::Write)]);
810 assert!(can_read(&private, &writer) && can_write(&private, &writer));
811 let stranger = person("usr_s", vec![], vec![("rep_2", RepoRole::Admin)]);
812 assert!(!can_read(&private, &stranger) && !can_write(&private, &stranger));
813 assert!(!can_read(&private, &None));
814 // A public repository: anyone clones, nobody without Write pushes.
815 let public = repo(false);
816 assert!(can_read(&public, &None) && !can_write(&public, &None));
817 assert!(can_read(&public, &stranger) && !can_write(&public, &stranger));
818 }
819
820 #[test]
821 fn members_follow_the_base_permission_and_owners_have_admin() {
822 let private = repo(true);
823 let default_member = person("usr_m", vec![Membership::member("acme")], vec![]);
824 assert!(can_write(&private, &default_member));
825 assert!(!can(&private, &default_member, Capability::ManageIntegrations));
826 let none = Membership { base_permission: Some(BasePermission::None), ..Membership::member("acme") };
827 let locked_out = person("usr_n", vec![none.clone()], vec![]);
828 assert!(!can_read(&private, &locked_out));
829 let given = person("usr_g", vec![none], vec![("rep_1", RepoRole::Triage)]);
830 assert!(can_read(&private, &given) && !can_write(&private, &given));
831 let owner = person("usr_o", vec![Membership { role: Role::Owner, ..Membership::member("acme") }], vec![]);
832 assert_eq!(role(&private, &owner), Some(RepoRole::Admin));
833 assert!(can(&private, &owner, Capability::Delete));
834 }
835
836 #[test]
837 fn a_pull_requests_fork_is_its_authors() {
838 let fork = Repo {
839 namespace: "pulls".into(),
840 fork_of: Some("rep_1".into()),
841 owner_id: "usr_a".into(),
842 ..repo(true)
843 };
844 let author = person("usr_a", vec![], vec![]);
845 assert!(can_write(&fork, &author));
846 assert!(!can(&fork, &author, Capability::ManageSettings));
847 let other = person("usr_b", vec![Membership::member("acme")], vec![]);
848 assert!(!can_write(&fork, &other));
849 }
850}