cf0e3384
Serve issue leases over a collab-lease SSH verb
a73x 2026-09-06 07:59
Commit message
src/server/governance/delegate.rs
| Old | New | ||
|---|---|---|---|
| @@ -16,7 +16,7 @@ use russh::keys::ssh_key::certificate::CertType; | |||
| 16 | use russh::keys::ssh_key::Certificate; | 16 | use russh::keys::ssh_key::Certificate; |
| 17 | 17 | ||
| 18 | use super::Governance; | 18 | use super::Governance; |
| 19 | use crate::ssh::session::{ExecCommand, GitCmd, ReleaseCmd}; | 19 | use crate::ssh::session::{ExecCommand, GitCmd, LeaseCmd, ReleaseCmd}; |
| 20 | 20 | ||
| 21 | #[derive(Debug)] | 21 | #[derive(Debug)] |
| 22 | pub struct Delegate { | 22 | pub struct Delegate { |
| @@ -107,6 +107,10 @@ pub enum Action { | |||
| 107 | ReleaseList, | 107 | ReleaseList, |
| 108 | /// `collab-release upload` or `delete`. | 108 | /// `collab-release upload` or `delete`. |
| 109 | ReleaseMutate, | 109 | ReleaseMutate, |
| 110 | /// `collab-lease list`: read who holds what. | ||
| 111 | LeaseList, | ||
| 112 | /// `collab-lease acquire`, `renew` or `release`: claim or yield work. | ||
| 113 | LeaseMutate, | ||
| 110 | } | 114 | } |
| 111 | 115 | ||
| 112 | /// Classify an exec request, given whether its repository exists yet. | 116 | /// Classify an exec request, given whether its repository exists yet. |
| @@ -132,6 +136,10 @@ pub fn action_of(command: &ExecCommand, repo_exists: bool) -> Action { | |||
| 132 | ExecCommand::Release(ReleaseCmd::Upload { .. } | ReleaseCmd::Delete { .. }) => { | 136 | ExecCommand::Release(ReleaseCmd::Upload { .. } | ReleaseCmd::Delete { .. }) => { |
| 133 | Action::ReleaseMutate | 137 | Action::ReleaseMutate |
| 134 | } | 138 | } |
| 139 | ExecCommand::Lease(LeaseCmd::List { .. }) => Action::LeaseList, | ||
| 140 | ExecCommand::Lease( | ||
| 141 | LeaseCmd::Acquire { .. } | LeaseCmd::Renew { .. } | LeaseCmd::Release { .. }, | ||
| 142 | ) => Action::LeaseMutate, | ||
| 135 | } | 143 | } |
| 136 | } | 144 | } |
| 137 | 145 | ||
| @@ -149,6 +157,9 @@ pub fn action_of(command: &ExecCommand, repo_exists: bool) -> Action { | |||
| 149 | pub fn permits(action: Action) -> bool { | 157 | pub fn permits(action: Action) -> bool { |
| 150 | match action { | 158 | match action { |
| 151 | Action::Fetch | Action::Push | Action::ReleaseList => true, | 159 | Action::Fetch | Action::Push | Action::ReleaseList => true, |
| 160 | // Claiming and yielding work is a collaboration act — the same kind | ||
| 161 | // of thing as writing refs/collab/* — so it sits under the ceiling. | ||
| 162 | Action::LeaseList | Action::LeaseMutate => true, | ||
| 152 | Action::CreateRepo | Action::ReleaseMutate => false, | 163 | Action::CreateRepo | Action::ReleaseMutate => false, |
| 153 | } | 164 | } |
| 154 | } | 165 | } |
src/server/leases.rs
| Old | New | ||
|---|---|---|---|
| @@ -2,6 +2,16 @@ | |||
| 2 | //! express (atomic claim with TTL). One SQLite database per server, | 2 | //! express (atomic claim with TTL). One SQLite database per server, |
| 3 | //! `(repo, issue_id)` primary key, lazy expiry. See | 3 | //! `(repo, issue_id)` primary key, lazy expiry. See |
| 4 | //! docs/superpowers/specs/2026-09-05-server-authoritative-collab-design.md. | 4 | //! docs/superpowers/specs/2026-09-05-server-authoritative-collab-design.md. |
| 5 | //! | ||
| 6 | //! **A row is a tenure ledger for one issue, and is never deleted.** Freeing | ||
| 7 | //! a lease — by release or by expiry — clears `holder`, leaving `token` | ||
| 8 | //! behind as a high-water mark that only ever increments. Deleting the row | ||
| 9 | //! instead (an earlier draft did, and this plan's Task 1 told it to) resets | ||
| 10 | //! the counter, so tenure 1's zombie could later present token 1 to a fresh | ||
| 11 | //! tenure 1 and pass a fencing check it should fail. Monotonicity per issue | ||
| 12 | //! is the whole property a fencing token has, so nothing may reclaim these | ||
| 13 | //! rows: not release, and not the expiry sweep that `current`/`list` were | ||
| 14 | //! doing on every read. | ||
| 5 | 15 | ||
| 6 | use std::path::Path; | 16 | use std::path::Path; |
| 7 | 17 | ||
| @@ -19,9 +29,35 @@ pub struct Lease { | |||
| 19 | pub expires_at: Option<i64>, | 29 | pub expires_at: Option<i64>, |
| 20 | } | 30 | } |
| 21 | 31 | ||
| 22 | impl Lease { | 32 | /// A row as stored: `holder: None` is a free issue that has been leased |
| 23 | pub fn live(&self, now: i64) -> bool { | 33 | /// before, and whose `token` must outlive the tenure that freed it. |
| 24 | self.expires_at.map(|e| e > now).unwrap_or(true) | 34 | /// |
| 35 | /// Liveness is defined once, here, by `held` — an earlier draft also had a | ||
| 36 | /// `Lease::live`, and two rules for the same question is how they drift. | ||
| 37 | #[derive(Debug, Clone)] | ||
| 38 | struct Row { | ||
| 39 | holder: Option<String>, | ||
| 40 | token: i64, | ||
| 41 | acquired_at: i64, | ||
| 42 | expires_at: Option<i64>, | ||
| 43 | } | ||
| 44 | |||
| 45 | impl Row { | ||
| 46 | /// Held right now: someone owns it and the clock has not run out. | ||
| 47 | fn held(&self, now: i64) -> bool { | ||
| 48 | self.holder.is_some() && self.expires_at.map(|e| e > now).unwrap_or(true) | ||
| 49 | } | ||
| 50 | |||
| 51 | /// The public form, for a row known to be held. | ||
| 52 | fn into_lease(self, repo: &str, issue_id: &str) -> Option<Lease> { | ||
| 53 | Some(Lease { | ||
| 54 | repo: repo.to_string(), | ||
| 55 | issue_id: issue_id.to_string(), | ||
| 56 | holder: self.holder?, | ||
| 57 | token: self.token, | ||
| 58 | acquired_at: self.acquired_at, | ||
| 59 | expires_at: self.expires_at, | ||
| 60 | }) | ||
| 25 | } | 61 | } |
| 26 | } | 62 | } |
| 27 | 63 | ||
| @@ -65,11 +101,13 @@ fn init(conn: &Connection) -> rusqlite::Result<()> { | |||
| 65 | // busy_timeout so two SSH sessions serialize instead of erroring. | 101 | // busy_timeout so two SSH sessions serialize instead of erroring. |
| 66 | conn.pragma_update(None, "journal_mode", "WAL")?; | 102 | conn.pragma_update(None, "journal_mode", "WAL")?; |
| 67 | conn.pragma_update(None, "busy_timeout", 5000)?; | 103 | conn.pragma_update(None, "busy_timeout", 5000)?; |
| 104 | // `holder` is nullable because a freed row keeps its token; see the | ||
| 105 | // module docs on why rows are never deleted. | ||
| 68 | conn.execute_batch( | 106 | conn.execute_batch( |
| 69 | "CREATE TABLE IF NOT EXISTS leases ( | 107 | "CREATE TABLE IF NOT EXISTS leases ( |
| 70 | repo TEXT NOT NULL, | 108 | repo TEXT NOT NULL, |
| 71 | issue_id TEXT NOT NULL, | 109 | issue_id TEXT NOT NULL, |
| 72 | holder TEXT NOT NULL, | 110 | holder TEXT, |
| 73 | token INTEGER NOT NULL, | 111 | token INTEGER NOT NULL, |
| 74 | acquired_at INTEGER NOT NULL, | 112 | acquired_at INTEGER NOT NULL, |
| 75 | expires_at INTEGER, | 113 | expires_at INTEGER, |
| @@ -78,19 +116,17 @@ fn init(conn: &Connection) -> rusqlite::Result<()> { | |||
| 78 | ) | 116 | ) |
| 79 | } | 117 | } |
| 80 | 118 | ||
| 81 | fn get_row(conn: &Connection, repo: &str, issue_id: &str) -> rusqlite::Result<Option<Lease>> { | 119 | fn get_row(conn: &Connection, repo: &str, issue_id: &str) -> rusqlite::Result<Option<Row>> { |
| 82 | conn.query_row( | 120 | conn.query_row( |
| 83 | "SELECT repo, issue_id, holder, token, acquired_at, expires_at | 121 | "SELECT holder, token, acquired_at, expires_at |
| 84 | FROM leases WHERE repo = ?1 AND issue_id = ?2", | 122 | FROM leases WHERE repo = ?1 AND issue_id = ?2", |
| 85 | (repo, issue_id), | 123 | (repo, issue_id), |
| 86 | |row| { | 124 | |row| { |
| 87 | Ok(Lease { | 125 | Ok(Row { |
| 88 | repo: row.get(0)?, | 126 | holder: row.get(0)?, |
| 89 | issue_id: row.get(1)?, | 127 | token: row.get(1)?, |
| 90 | holder: row.get(2)?, | 128 | acquired_at: row.get(2)?, |
| 91 | token: row.get(3)?, | 129 | expires_at: row.get(3)?, |
| 92 | acquired_at: row.get(4)?, | ||
| 93 | expires_at: row.get(5)?, | ||
| 94 | }) | 130 | }) |
| 95 | }, | 131 | }, |
| 96 | ) | 132 | ) |
| @@ -109,8 +145,8 @@ pub fn acquire( | |||
| 109 | let existing = get_row(&tx, repo, issue_id)?; | 145 | let existing = get_row(&tx, repo, issue_id)?; |
| 110 | let expires_at = ttl_secs.map(|t| now + t); | 146 | let expires_at = ttl_secs.map(|t| now + t); |
| 111 | let outcome = match existing { | 147 | let outcome = match existing { |
| 112 | Some(lease) if lease.live(now) => { | 148 | Some(row) if row.held(now) => { |
| 113 | if lease.holder == holder { | 149 | if row.holder.as_deref() == Some(holder) { |
| 114 | // Idempotent re-acquire: same tenure, same token, fresh expiry | 150 | // Idempotent re-acquire: same tenure, same token, fresh expiry |
| 115 | // from THIS call's ttl. A retrying client must not deadlock | 151 | // from THIS call's ttl. A retrying client must not deadlock |
| 116 | // against itself. | 152 | // against itself. |
| @@ -119,20 +155,23 @@ pub fn acquire( | |||
| 119 | (repo, issue_id, expires_at), | 155 | (repo, issue_id, expires_at), |
| 120 | )?; | 156 | )?; |
| 121 | Acquire::Acquired { | 157 | Acquire::Acquired { |
| 122 | token: lease.token, | 158 | token: row.token, |
| 123 | expires_at, | 159 | expires_at, |
| 124 | } | 160 | } |
| 125 | } else { | 161 | } else { |
| 126 | Acquire::Held { | 162 | Acquire::Held { |
| 127 | holder: lease.holder, | 163 | // held(now) proved this is Some. |
| 128 | expires_at: lease.expires_at, | 164 | holder: row.holder.unwrap_or_default(), |
| 165 | expires_at: row.expires_at, | ||
| 129 | } | 166 | } |
| 130 | } | 167 | } |
| 131 | } | 168 | } |
| 132 | other => { | 169 | other => { |
| 133 | // Free, or expired: take tenure. The token increments per tenure | 170 | // Free, released, or expired: take a new tenure. The token |
| 134 | // change; it is the fencing token later phases will enforce. | 171 | // increments off whatever the row remembers — never off nothing, |
| 135 | let token = other.map(|l| l.token + 1).unwrap_or(1); | 172 | // because the row is never deleted — and is the fencing token |
| 173 | // later phases will enforce. | ||
| 174 | let token = other.map(|r| r.token + 1).unwrap_or(1); | ||
| 136 | tx.execute( | 175 | tx.execute( |
| 137 | "INSERT OR REPLACE INTO leases | 176 | "INSERT OR REPLACE INTO leases |
| 138 | (repo, issue_id, holder, token, acquired_at, expires_at) | 177 | (repo, issue_id, holder, token, acquired_at, expires_at) |
| @@ -157,20 +196,18 @@ pub fn renew( | |||
| 157 | let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; | 196 | let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; |
| 158 | let existing = get_row(&tx, repo, issue_id)?; | 197 | let existing = get_row(&tx, repo, issue_id)?; |
| 159 | let outcome = match existing { | 198 | let outcome = match existing { |
| 160 | Some(lease) if lease.live(now) && lease.holder == holder => { | 199 | Some(row) if row.held(now) && row.holder.as_deref() == Some(holder) => { |
| 161 | let expires_at = ttl_secs.map(|t| now + t); | 200 | let expires_at = ttl_secs.map(|t| now + t); |
| 162 | tx.execute( | 201 | tx.execute( |
| 163 | "UPDATE leases SET expires_at = ?3 WHERE repo = ?1 AND issue_id = ?2", | 202 | "UPDATE leases SET expires_at = ?3 WHERE repo = ?1 AND issue_id = ?2", |
| 164 | (repo, issue_id, expires_at), | 203 | (repo, issue_id, expires_at), |
| 165 | )?; | 204 | )?; |
| 166 | Renew::Renewed { | 205 | Renew::Renewed { |
| 167 | token: lease.token, | 206 | token: row.token, |
| 168 | expires_at, | 207 | expires_at, |
| 169 | } | 208 | } |
| 170 | } | 209 | } |
| 171 | Some(lease) if lease.live(now) => Renew::NotHolder { | 210 | Some(row) if row.held(now) => Renew::NotHolder { holder: row.holder }, |
| 172 | holder: Some(lease.holder), | ||
| 173 | }, | ||
| 174 | _ => Renew::NotHolder { holder: None }, | 211 | _ => Renew::NotHolder { holder: None }, |
| 175 | }; | 212 | }; |
| 176 | tx.commit()?; | 213 | tx.commit()?; |
| @@ -187,14 +224,17 @@ pub fn release( | |||
| 187 | let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; | 224 | let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; |
| 188 | let existing = get_row(&tx, repo, issue_id)?; | 225 | let existing = get_row(&tx, repo, issue_id)?; |
| 189 | let outcome = match existing { | 226 | let outcome = match existing { |
| 190 | Some(lease) if lease.live(now) && lease.holder != holder => { | 227 | Some(row) if row.held(now) && row.holder.as_deref() != Some(holder) => { |
| 191 | Release::NotHolder { | 228 | Release::NotHolder { |
| 192 | holder: lease.holder, | 229 | holder: row.holder.unwrap_or_default(), |
| 193 | } | 230 | } |
| 194 | } | 231 | } |
| 195 | Some(_) => { | 232 | Some(_) => { |
| 233 | // Free the row, keep the token. Deleting would reset the tenure | ||
| 234 | // counter — see the module docs. | ||
| 196 | tx.execute( | 235 | tx.execute( |
| 197 | "DELETE FROM leases WHERE repo = ?1 AND issue_id = ?2", | 236 | "UPDATE leases SET holder = NULL, expires_at = NULL |
| 237 | WHERE repo = ?1 AND issue_id = ?2", | ||
| 198 | (repo, issue_id), | 238 | (repo, issue_id), |
| 199 | )?; | 239 | )?; |
| 200 | Release::Released | 240 | Release::Released |
| @@ -207,44 +247,38 @@ pub fn release( | |||
| 207 | Ok(outcome) | 247 | Ok(outcome) |
| 208 | } | 248 | } |
| 209 | 249 | ||
| 210 | /// A live lease on the issue, if any. Reaps an expired row it encounters. | 250 | /// The lease held on the issue right now, if any. |
| 251 | /// | ||
| 252 | /// Expiry is a *filter*, not a sweep: an expired row stays, so its token | ||
| 253 | /// survives to fence the tenure that owned it. | ||
| 211 | pub fn current( | 254 | pub fn current( |
| 212 | conn: &mut Connection, | 255 | conn: &Connection, |
| 213 | repo: &str, | 256 | repo: &str, |
| 214 | issue_id: &str, | 257 | issue_id: &str, |
| 215 | now: i64, | 258 | now: i64, |
| 216 | ) -> rusqlite::Result<Option<Lease>> { | 259 | ) -> rusqlite::Result<Option<Lease>> { |
| 217 | match get_row(conn, repo, issue_id)? { | 260 | Ok(get_row(conn, repo, issue_id)? |
| 218 | Some(lease) if lease.live(now) => Ok(Some(lease)), | 261 | .filter(|row| row.held(now)) |
| 219 | Some(_) => { | 262 | .and_then(|row| row.into_lease(repo, issue_id))) |
| 220 | conn.execute( | ||
| 221 | "DELETE FROM leases WHERE repo = ?1 AND issue_id = ?2 AND expires_at <= ?3", | ||
| 222 | (repo, issue_id, now), | ||
| 223 | )?; | ||
| 224 | Ok(None) | ||
| 225 | } | ||
| 226 | None => Ok(None), | ||
| 227 | } | ||
| 228 | } | 263 | } |
| 229 | 264 | ||
| 230 | /// All live leases in a repo, oldest first. Reaps expired rows it encounters. | 265 | /// Every lease held in a repo right now, oldest tenure first. |
| 231 | pub fn list(conn: &mut Connection, repo: &str, now: i64) -> rusqlite::Result<Vec<Lease>> { | 266 | pub fn list(conn: &Connection, repo: &str, now: i64) -> rusqlite::Result<Vec<Lease>> { |
| 232 | conn.execute( | ||
| 233 | "DELETE FROM leases WHERE repo = ?1 AND expires_at IS NOT NULL AND expires_at <= ?2", | ||
| 234 | (repo, now), | ||
| 235 | )?; | ||
| 236 | let mut stmt = conn.prepare( | 267 | let mut stmt = conn.prepare( |
| 237 | "SELECT repo, issue_id, holder, token, acquired_at, expires_at | 268 | "SELECT issue_id, holder, token, acquired_at, expires_at |
| 238 | FROM leases WHERE repo = ?1 ORDER BY acquired_at, issue_id", | 269 | FROM leases |
| 270 | WHERE repo = ?1 AND holder IS NOT NULL | ||
| 271 | AND (expires_at IS NULL OR expires_at > ?2) | ||
| 272 | ORDER BY acquired_at, issue_id", | ||
| 239 | )?; | 273 | )?; |
| 240 | let rows = stmt.query_map((repo,), |row| { | 274 | let rows = stmt.query_map((repo, now), |row| { |
| 241 | Ok(Lease { | 275 | Ok(Lease { |
| 242 | repo: row.get(0)?, | 276 | repo: repo.to_string(), |
| 243 | issue_id: row.get(1)?, | 277 | issue_id: row.get(0)?, |
| 244 | holder: row.get(2)?, | 278 | holder: row.get(1)?, |
| 245 | token: row.get(3)?, | 279 | token: row.get(2)?, |
| 246 | acquired_at: row.get(4)?, | 280 | acquired_at: row.get(3)?, |
| 247 | expires_at: row.get(5)?, | 281 | expires_at: row.get(4)?, |
| 248 | }) | 282 | }) |
| 249 | })?; | 283 | })?; |
| 250 | rows.collect() | 284 | rows.collect() |
| @@ -291,7 +325,7 @@ mod tests { | |||
| 291 | let mut c = mem(); | 325 | let mut c = mem(); |
| 292 | acquire(&mut c, "r", "i", "alice", None, 100).unwrap(); | 326 | acquire(&mut c, "r", "i", "alice", None, 100).unwrap(); |
| 293 | // Far future: still held. | 327 | // Far future: still held. |
| 294 | let lease = current(&mut c, "r", "i", i64::MAX - 1).unwrap().unwrap(); | 328 | let lease = current(&c, "r", "i", i64::MAX - 1).unwrap().unwrap(); |
| 295 | assert_eq!(lease.holder, "alice"); | 329 | assert_eq!(lease.holder, "alice"); |
| 296 | assert_eq!(lease.expires_at, None); | 330 | assert_eq!(lease.expires_at, None); |
| 297 | } | 331 | } |
| @@ -374,14 +408,14 @@ mod tests { | |||
| 374 | } | 408 | } |
| 375 | 409 | ||
| 376 | #[test] | 410 | #[test] |
| 377 | fn release_by_holder_deletes() { | 411 | fn release_by_holder_frees_the_issue() { |
| 378 | let mut c = mem(); | 412 | let mut c = mem(); |
| 379 | acquire(&mut c, "r", "i", "alice", Some(300), 100).unwrap(); | 413 | acquire(&mut c, "r", "i", "alice", Some(300), 100).unwrap(); |
| 380 | assert_eq!( | 414 | assert_eq!( |
| 381 | release(&mut c, "r", "i", "alice", 200).unwrap(), | 415 | release(&mut c, "r", "i", "alice", 200).unwrap(), |
| 382 | Release::Released | 416 | Release::Released |
| 383 | ); | 417 | ); |
| 384 | assert_eq!(current(&mut c, "r", "i", 200).unwrap(), None); | 418 | assert_eq!(current(&c, "r", "i", 200).unwrap(), None); |
| 385 | } | 419 | } |
| 386 | 420 | ||
| 387 | #[test] | 421 | #[test] |
| @@ -422,7 +456,7 @@ mod tests { | |||
| 422 | acquire(&mut c, "r", "b", "bob", Some(100), 100).unwrap(); | 456 | acquire(&mut c, "r", "b", "bob", Some(100), 100).unwrap(); |
| 423 | acquire(&mut c, "r", "c", "carol", None, 150).unwrap(); | 457 | acquire(&mut c, "r", "c", "carol", None, 150).unwrap(); |
| 424 | acquire(&mut c, "other", "a", "dave", None, 100).unwrap(); | 458 | acquire(&mut c, "other", "a", "dave", None, 100).unwrap(); |
| 425 | let live = list(&mut c, "r", 250).unwrap(); | 459 | let live = list(&c, "r", 250).unwrap(); |
| 426 | let holders: Vec<&str> = live.iter().map(|l| l.holder.as_str()).collect(); | 460 | let holders: Vec<&str> = live.iter().map(|l| l.holder.as_str()).collect(); |
| 427 | assert_eq!(holders, vec!["alice", "carol"]); | 461 | assert_eq!(holders, vec!["alice", "carol"]); |
| 428 | } | 462 | } |
| @@ -431,8 +465,55 @@ mod tests { | |||
| 431 | fn current_none_after_expiry() { | 465 | fn current_none_after_expiry() { |
| 432 | let mut c = mem(); | 466 | let mut c = mem(); |
| 433 | acquire(&mut c, "r", "i", "alice", Some(300), 100).unwrap(); | 467 | acquire(&mut c, "r", "i", "alice", Some(300), 100).unwrap(); |
| 434 | assert!(current(&mut c, "r", "i", 200).unwrap().is_some()); | 468 | assert!(current(&c, "r", "i", 200).unwrap().is_some()); |
| 435 | assert_eq!(current(&mut c, "r", "i", 400).unwrap(), None); | 469 | assert_eq!(current(&c, "r", "i", 400).unwrap(), None); |
| 470 | } | ||
| 471 | |||
| 472 | #[test] | ||
| 473 | fn token_does_not_reset_after_release() { | ||
| 474 | let mut c = mem(); | ||
| 475 | acquire(&mut c, "r", "i", "alice", Some(300), 100).unwrap(); | ||
| 476 | release(&mut c, "r", "i", "alice", 200).unwrap(); | ||
| 477 | // A released issue is free, but its tenure history is not forgotten: | ||
| 478 | // the next holder must not be handed alice's token back. | ||
| 479 | let got = acquire(&mut c, "r", "i", "bob", Some(300), 300).unwrap(); | ||
| 480 | assert_eq!( | ||
| 481 | got, | ||
| 482 | Acquire::Acquired { | ||
| 483 | token: 2, | ||
| 484 | expires_at: Some(600) | ||
| 485 | } | ||
| 486 | ); | ||
| 487 | } | ||
| 488 | |||
| 489 | #[test] | ||
| 490 | fn token_does_not_reset_after_expiry_and_reads() { | ||
| 491 | let mut c = mem(); | ||
| 492 | acquire(&mut c, "r", "i", "alice", Some(100), 0).unwrap(); | ||
| 493 | // Reads once swept expired rows, which reset the counter. They must | ||
| 494 | // filter instead. | ||
| 495 | assert_eq!(current(&c, "r", "i", 500).unwrap(), None); | ||
| 496 | assert_eq!(list(&c, "r", 500).unwrap().len(), 0); | ||
| 497 | let got = acquire(&mut c, "r", "i", "bob", Some(100), 500).unwrap(); | ||
| 498 | assert_eq!( | ||
| 499 | got, | ||
| 500 | Acquire::Acquired { | ||
| 501 | token: 2, | ||
| 502 | expires_at: Some(600) | ||
| 503 | } | ||
| 504 | ); | ||
| 505 | } | ||
| 506 | |||
| 507 | #[test] | ||
| 508 | fn released_issue_is_free_to_anyone() { | ||
| 509 | let mut c = mem(); | ||
| 510 | acquire(&mut c, "r", "i", "alice", Some(300), 100).unwrap(); | ||
| 511 | release(&mut c, "r", "i", "alice", 200).unwrap(); | ||
| 512 | assert_eq!(current(&c, "r", "i", 200).unwrap(), None); | ||
| 513 | assert!(matches!( | ||
| 514 | acquire(&mut c, "r", "i", "bob", None, 200).unwrap(), | ||
| 515 | Acquire::Acquired { .. } | ||
| 516 | )); | ||
| 436 | } | 517 | } |
| 437 | 518 | ||
| 438 | #[test] | 519 | #[test] |
| @@ -465,7 +546,7 @@ mod tests { | |||
| 465 | let mut conn = open(&path).unwrap(); | 546 | let mut conn = open(&path).unwrap(); |
| 466 | acquire(&mut conn, "r", "i", "alice", None, 100).unwrap(); | 547 | acquire(&mut conn, "r", "i", "alice", None, 100).unwrap(); |
| 467 | } | 548 | } |
| 468 | let mut conn = open(&path).unwrap(); | 549 | let conn = open(&path).unwrap(); |
| 469 | assert!(current(&mut conn, "r", "i", 200).unwrap().is_some()); | 550 | assert!(current(&conn, "r", "i", 200).unwrap().is_some()); |
| 470 | } | 551 | } |
| 471 | } | 552 | } |
src/server/main.rs
| Old | New | ||
|---|---|---|---|
| @@ -237,6 +237,7 @@ async fn main() { | |||
| 237 | repos_dir: config.repos_dir.clone(), | 237 | repos_dir: config.repos_dir.clone(), |
| 238 | authorized_keys_path: config.authorized_keys.clone(), | 238 | authorized_keys_path: config.authorized_keys.clone(), |
| 239 | max_release_size: config.max_release_size, | 239 | max_release_size: config.max_release_size, |
| 240 | collab_db: config.collab_db_path(), | ||
| 240 | }; | 241 | }; |
| 241 | 242 | ||
| 242 | let http_bind = config.http_bind; | 243 | let http_bind = config.http_bind; |
src/server/ssh/session.rs
| Old | New | ||
|---|---|---|---|
| @@ -61,6 +61,8 @@ pub struct SshServerConfig { | |||
| 61 | pub repos_dir: PathBuf, | 61 | pub repos_dir: PathBuf, |
| 62 | pub authorized_keys_path: PathBuf, | 62 | pub authorized_keys_path: PathBuf, |
| 63 | pub max_release_size: u64, | 63 | pub max_release_size: u64, |
| 64 | /// The collaboration database backing `collab-lease`. | ||
| 65 | pub collab_db: PathBuf, | ||
| 64 | } | 66 | } |
| 65 | 67 | ||
| 66 | /// Which credential this connection authenticated with. | 68 | /// Which credential this connection authenticated with. |
| @@ -361,6 +363,259 @@ impl SshHandler { | |||
| 361 | }, | 363 | }, |
| 362 | } | 364 | } |
| 363 | } | 365 | } |
| 366 | |||
| 367 | /// Handle a lease verb. | ||
| 368 | /// | ||
| 369 | /// The lease store is the arbiter of who holds what: this function only | ||
| 370 | /// authorizes the caller, resolves the issue, and translates outcomes | ||
| 371 | /// into the exit-code contract (0 ok, 1 error, 4 conflict). Like the | ||
| 372 | /// release path, the SQLite calls are synchronous I/O on the async | ||
| 373 | /// runtime — accepted at this server's scale, and each one is a | ||
| 374 | /// single-row transaction rather than a durable multi-megabyte write. | ||
| 375 | fn handle_lease_command( | ||
| 376 | &mut self, | ||
| 377 | channel: ChannelId, | ||
| 378 | session: &mut Session, | ||
| 379 | cmd: LeaseCmd, | ||
| 380 | resolved_path: &Path, | ||
| 381 | principal: &str, | ||
| 382 | regime: &Regime, | ||
| 383 | ) -> Result<(), russh::Error> { | ||
| 384 | // Unknown repo and unauthorized repo get the SAME reply, so the error | ||
| 385 | // cannot be used to probe which private repos exist — as in | ||
| 386 | // handle_release_command and the HTTP layer. | ||
| 387 | const NOT_FOUND: &str = "error: repository not found\n"; | ||
| 388 | |||
| 389 | let entry = match crate::repos::entry_for_path(&self.config.repos_dir, resolved_path) { | ||
| 390 | Some(entry) => entry, | ||
| 391 | None => { | ||
| 392 | warn!("Rejected lease command: unknown repo {:?}", resolved_path); | ||
| 393 | return reply_and_close(session, channel, NOT_FOUND, 1); | ||
| 394 | } | ||
| 395 | }; | ||
| 396 | |||
| 397 | // Reading who holds what needs read; claiming or yielding work is a | ||
| 398 | // routine collaboration act, so it needs write — not the RW+ that | ||
| 399 | // releases demand, because a lease rewrites no history. | ||
| 400 | let needed = match &cmd { | ||
| 401 | LeaseCmd::List { .. } => Access::Read, | ||
| 402 | LeaseCmd::Acquire { .. } | LeaseCmd::Renew { .. } | LeaseCmd::Release { .. } => { | ||
| 403 | Access::Write | ||
| 404 | } | ||
| 405 | }; | ||
| 406 | let authorized = match regime { | ||
| 407 | Regime::Closed => false, | ||
| 408 | Regime::Ungoverned => match needed { | ||
| 409 | Access::Read => entry.policy.allows_read(principal), | ||
| 410 | _ => entry.policy.allows_write(principal), | ||
| 411 | }, | ||
| 412 | Regime::Governed { | ||
| 413 | governance, name, .. | ||
| 414 | } => match governance::repo_key(&self.config.repos_dir, resolved_path) { | ||
| 415 | Some(key) => { | ||
| 416 | let creator = governance::creator_of(resolved_path); | ||
| 417 | let subject = Subject::with_creator(name, creator.as_deref()); | ||
| 418 | governance.conf.allows_repo(&key, &subject, needed) | ||
| 419 | } | ||
| 420 | None => false, | ||
| 421 | }, | ||
| 422 | }; | ||
| 423 | if !authorized { | ||
| 424 | warn!( | ||
| 425 | "Rejected lease command: principal {} not authorized on {:?}", | ||
| 426 | log_principal(regime, principal), | ||
| 427 | resolved_path | ||
| 428 | ); | ||
| 429 | return reply_and_close(session, channel, NOT_FOUND, 1); | ||
| 430 | } | ||
| 431 | |||
| 432 | // Who the lease is recorded as belonging to. Under governance that is | ||
| 433 | // the person's name, so a lease survives them rotating a key; without | ||
| 434 | // it, the fingerprint principal is all there is. | ||
| 435 | let holder = match regime { | ||
| 436 | Regime::Governed { name, .. } => name.clone(), | ||
| 437 | _ => principal.to_string(), | ||
| 438 | }; | ||
| 439 | |||
| 440 | // The repo key leases are stored under. `entry_for_path` already | ||
| 441 | // proved this path is a repo under repos_dir; the relative form keeps | ||
| 442 | // rows portable if repos_dir moves. | ||
| 443 | let repo_key = resolved_path | ||
| 444 | .strip_prefix(&self.config.repos_dir) | ||
| 445 | .unwrap_or(resolved_path) | ||
| 446 | .to_string_lossy() | ||
| 447 | .to_string(); | ||
| 448 | |||
| 449 | // Acquiring points at a specific issue, so the issue has to exist and | ||
| 450 | // be open: leasing work nobody can do is a bug we should refuse, not | ||
| 451 | // record. Renew/release/list act on lease rows alone — an issue closed | ||
| 452 | // mid-tenure must still be releasable. | ||
| 453 | let issue_id = match &cmd { | ||
| 454 | LeaseCmd::Acquire { issue, .. } => { | ||
| 455 | let repo = match git2::Repository::open(resolved_path) { | ||
| 456 | Ok(r) => r, | ||
| 457 | Err(e) => { | ||
| 458 | error!("Failed to open {:?} for a lease: {}", resolved_path, e); | ||
| 459 | return reply_and_close(session, channel, NOT_FOUND, 1); | ||
| 460 | } | ||
| 461 | }; | ||
| 462 | let (ref_name, full_id) = | ||
| 463 | match git_collab::state::resolve_issue_ref(&repo, issue) { | ||
| 464 | Ok(pair) => pair, | ||
| 465 | Err(_) => { | ||
| 466 | return reply_and_close( | ||
| 467 | session, | ||
| 468 | channel, | ||
| 469 | "error: issue not found\n", | ||
| 470 | 1, | ||
| 471 | ); | ||
| 472 | } | ||
| 473 | }; | ||
| 474 | match git_collab::state::IssueState::from_ref(&repo, &ref_name, &full_id) { | ||
| 475 | Ok(is) if is.status == git_collab::state::IssueStatus::Open => full_id, | ||
| 476 | Ok(_) => { | ||
| 477 | return reply_and_close(session, channel, "error: issue is closed\n", 1); | ||
| 478 | } | ||
| 479 | Err(e) => { | ||
| 480 | error!("Failed to read issue {} in {:?}: {}", full_id, resolved_path, e); | ||
| 481 | return reply_and_close(session, channel, "error: issue not found\n", 1); | ||
| 482 | } | ||
| 483 | } | ||
| 484 | } | ||
| 485 | LeaseCmd::Renew { issue, .. } | LeaseCmd::Release { issue, .. } => issue.clone(), | ||
| 486 | LeaseCmd::List { .. } => String::new(), | ||
| 487 | }; | ||
| 488 | |||
| 489 | let mut conn = match crate::leases::open(&self.config.collab_db) { | ||
| 490 | Ok(c) => c, | ||
| 491 | Err(e) => { | ||
| 492 | error!("Failed to open the lease database: {}", e); | ||
| 493 | return reply_and_close(session, channel, "error: lease store unavailable\n", 1); | ||
| 494 | } | ||
| 495 | }; | ||
| 496 | let now = unix_now() as i64; | ||
| 497 | |||
| 498 | let (reply, code) = match lease_outcome(&mut conn, &cmd, &repo_key, &issue_id, &holder, now) | ||
| 499 | { | ||
| 500 | Ok(pair) => pair, | ||
| 501 | Err(e) => { | ||
| 502 | error!("Lease operation failed on {}: {}", repo_key, e); | ||
| 503 | return reply_and_close(session, channel, "error: lease store failed\n", 1); | ||
| 504 | } | ||
| 505 | }; | ||
| 506 | reply_and_close(session, channel, &reply, code) | ||
| 507 | } | ||
| 508 | } | ||
| 509 | |||
| 510 | /// Render an RFC3339 instant, or `null`, for the JSON replies. | ||
| 511 | fn rfc3339(secs: Option<i64>) -> serde_json::Value { | ||
| 512 | match secs.and_then(|s| chrono::DateTime::from_timestamp(s, 0)) { | ||
| 513 | Some(dt) => { | ||
| 514 | serde_json::Value::String(dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) | ||
| 515 | } | ||
| 516 | None => serde_json::Value::Null, | ||
| 517 | } | ||
| 518 | } | ||
| 519 | |||
| 520 | /// Run the lease operation and render its reply. Split out of the handler so | ||
| 521 | /// the store outcomes map to JSON and exit codes in one readable place. | ||
| 522 | fn lease_outcome( | ||
| 523 | conn: &mut rusqlite::Connection, | ||
| 524 | cmd: &LeaseCmd, | ||
| 525 | repo_key: &str, | ||
| 526 | issue_id: &str, | ||
| 527 | holder: &str, | ||
| 528 | now: i64, | ||
| 529 | ) -> rusqlite::Result<(String, u32)> { | ||
| 530 | use crate::leases; | ||
| 531 | let pair = match cmd { | ||
| 532 | LeaseCmd::Acquire { ttl_secs, .. } => { | ||
| 533 | match leases::acquire(conn, repo_key, issue_id, holder, *ttl_secs, now)? { | ||
| 534 | leases::Acquire::Acquired { token, expires_at } => ( | ||
| 535 | serde_json::json!({ | ||
| 536 | "status": "acquired", | ||
| 537 | "repo": repo_key, | ||
| 538 | "issue": issue_id, | ||
| 539 | "holder": holder, | ||
| 540 | "token": token, | ||
| 541 | "expires_at": rfc3339(expires_at), | ||
| 542 | }), | ||
| 543 | 0, | ||
| 544 | ), | ||
| 545 | leases::Acquire::Held { holder, expires_at } => ( | ||
| 546 | serde_json::json!({ | ||
| 547 | "status": "held", | ||
| 548 | "repo": repo_key, | ||
| 549 | "issue": issue_id, | ||
| 550 | "holder": holder, | ||
| 551 | "expires_at": rfc3339(expires_at), | ||
| 552 | }), | ||
| 553 | 4, | ||
| 554 | ), | ||
| 555 | } | ||
| 556 | } | ||
| 557 | LeaseCmd::Renew { ttl_secs, .. } => { | ||
| 558 | match leases::renew(conn, repo_key, issue_id, holder, Some(*ttl_secs), now)? { | ||
| 559 | leases::Renew::Renewed { token, expires_at } => ( | ||
| 560 | serde_json::json!({ | ||
| 561 | "status": "renewed", | ||
| 562 | "repo": repo_key, | ||
| 563 | "issue": issue_id, | ||
| 564 | "holder": holder, | ||
| 565 | "token": token, | ||
| 566 | "expires_at": rfc3339(expires_at), | ||
| 567 | }), | ||
| 568 | 0, | ||
| 569 | ), | ||
| 570 | leases::Renew::NotHolder { holder } => ( | ||
| 571 | serde_json::json!({ | ||
| 572 | "status": "not-holder", | ||
| 573 | "repo": repo_key, | ||
| 574 | "issue": issue_id, | ||
| 575 | "holder": holder, | ||
| 576 | }), | ||
| 577 | 4, | ||
| 578 | ), | ||
| 579 | } | ||
| 580 | } | ||
| 581 | LeaseCmd::Release { .. } => match leases::release(conn, repo_key, issue_id, holder, now)? { | ||
| 582 | leases::Release::Released => ( | ||
| 583 | serde_json::json!({ | ||
| 584 | "status": "released", | ||
| 585 | "repo": repo_key, | ||
| 586 | "issue": issue_id, | ||
| 587 | }), | ||
| 588 | 0, | ||
| 589 | ), | ||
| 590 | leases::Release::NotHolder { holder } => ( | ||
| 591 | serde_json::json!({ | ||
| 592 | "status": "not-holder", | ||
| 593 | "repo": repo_key, | ||
| 594 | "issue": issue_id, | ||
| 595 | "holder": holder, | ||
| 596 | }), | ||
| 597 | 4, | ||
| 598 | ), | ||
| 599 | }, | ||
| 600 | LeaseCmd::List { .. } => { | ||
| 601 | let live = leases::list(conn, repo_key, now)?; | ||
| 602 | let rows: Vec<serde_json::Value> = live | ||
| 603 | .into_iter() | ||
| 604 | .map(|l| { | ||
| 605 | serde_json::json!({ | ||
| 606 | "issue": l.issue_id, | ||
| 607 | "holder": l.holder, | ||
| 608 | "token": l.token, | ||
| 609 | "acquired_at": rfc3339(Some(l.acquired_at)), | ||
| 610 | "expires_at": rfc3339(l.expires_at), | ||
| 611 | }) | ||
| 612 | }) | ||
| 613 | .collect(); | ||
| 614 | (serde_json::json!({ "leases": rows }), 0) | ||
| 615 | } | ||
| 616 | }; | ||
| 617 | let (value, code) = pair; | ||
| 618 | Ok((format!("{}\n", value), code)) | ||
| 364 | } | 619 | } |
| 365 | 620 | ||
| 366 | /// The principal string recorded in repo policies, e.g. | 621 | /// The principal string recorded in repo policies, e.g. |
| @@ -428,6 +683,7 @@ impl GitCmd { | |||
| 428 | pub enum ExecCommand { | 683 | pub enum ExecCommand { |
| 429 | Git { cmd: GitCmd, repo: String }, | 684 | Git { cmd: GitCmd, repo: String }, |
| 430 | Release(ReleaseCmd), | 685 | Release(ReleaseCmd), |
| 686 | Lease(LeaseCmd), | ||
| 431 | } | 687 | } |
| 432 | 688 | ||
| 433 | impl ExecCommand { | 689 | impl ExecCommand { |
| @@ -436,10 +692,55 @@ impl ExecCommand { | |||
| 436 | match self { | 692 | match self { |
| 437 | ExecCommand::Git { repo, .. } => repo, | 693 | ExecCommand::Git { repo, .. } => repo, |
| 438 | ExecCommand::Release(rel) => rel.repo(), | 694 | ExecCommand::Release(rel) => rel.repo(), |
| 695 | ExecCommand::Lease(lease) => lease.repo(), | ||
| 696 | } | ||
| 697 | } | ||
| 698 | } | ||
| 699 | |||
| 700 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 701 | pub enum LeaseCmd { | ||
| 702 | Acquire { | ||
| 703 | repo: String, | ||
| 704 | issue: String, | ||
| 705 | ttl_secs: Option<i64>, | ||
| 706 | }, | ||
| 707 | Renew { | ||
| 708 | repo: String, | ||
| 709 | issue: String, | ||
| 710 | ttl_secs: i64, | ||
| 711 | }, | ||
| 712 | Release { | ||
| 713 | repo: String, | ||
| 714 | issue: String, | ||
| 715 | }, | ||
| 716 | List { | ||
| 717 | repo: String, | ||
| 718 | }, | ||
| 719 | } | ||
| 720 | |||
| 721 | impl LeaseCmd { | ||
| 722 | pub fn repo(&self) -> &str { | ||
| 723 | match self { | ||
| 724 | LeaseCmd::Acquire { repo, .. } | ||
| 725 | | LeaseCmd::Renew { repo, .. } | ||
| 726 | | LeaseCmd::Release { repo, .. } | ||
| 727 | | LeaseCmd::List { repo } => repo, | ||
| 439 | } | 728 | } |
| 440 | } | 729 | } |
| 441 | } | 730 | } |
| 442 | 731 | ||
| 732 | /// Parse a `--ttl <n>` pair: strictly positive seconds. | ||
| 733 | fn parse_ttl(flag: &str, value: &str) -> Option<i64> { | ||
| 734 | if flag != "--ttl" { | ||
| 735 | return None; | ||
| 736 | } | ||
| 737 | let n: i64 = value.parse().ok()?; | ||
| 738 | if n <= 0 { | ||
| 739 | return None; | ||
| 740 | } | ||
| 741 | Some(n) | ||
| 742 | } | ||
| 743 | |||
| 443 | #[derive(Debug, Clone, PartialEq, Eq)] | 744 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 444 | pub enum ReleaseCmd { | 745 | pub enum ReleaseCmd { |
| 445 | Upload { | 746 | Upload { |
| @@ -523,15 +824,21 @@ pub fn parse_exec_command(data: &str) -> Option<ExecCommand> { | |||
| 523 | 824 | ||
| 524 | let tokens = shell_tokens(data)?; | 825 | let tokens = shell_tokens(data)?; |
| 525 | let mut it = tokens.into_iter(); | 826 | let mut it = tokens.into_iter(); |
| 526 | if it.next()? != "collab-release" { | 827 | let command = it.next()?; |
| 527 | return None; | ||
| 528 | } | ||
| 529 | let verb = it.next()?; | 828 | let verb = it.next()?; |
| 530 | let rest: Vec<String> = it.collect(); | 829 | let rest: Vec<String> = it.collect(); |
| 531 | if verb.is_empty() || rest.iter().any(|a| a.is_empty()) { | 830 | if verb.is_empty() || rest.iter().any(|a| a.is_empty()) { |
| 532 | return None; | 831 | return None; |
| 533 | } | 832 | } |
| 534 | match (verb.as_str(), rest.as_slice()) { | 833 | match command.as_str() { |
| 834 | "collab-release" => parse_release_command(&verb, &rest), | ||
| 835 | "collab-lease" => parse_lease_command(&verb, &rest), | ||
| 836 | _ => None, | ||
| 837 | } | ||
| 838 | } | ||
| 839 | |||
| 840 | fn parse_release_command(verb: &str, rest: &[String]) -> Option<ExecCommand> { | ||
| 841 | match (verb, rest) { | ||
| 535 | ("upload", [repo, version, filename]) => Some(ExecCommand::Release(ReleaseCmd::Upload { | 842 | ("upload", [repo, version, filename]) => Some(ExecCommand::Release(ReleaseCmd::Upload { |
| 536 | repo: repo.clone(), | 843 | repo: repo.clone(), |
| 537 | version: version.clone(), | 844 | version: version.clone(), |
| @@ -563,6 +870,34 @@ pub fn parse_exec_command(data: &str) -> Option<ExecCommand> { | |||
| 563 | } | 870 | } |
| 564 | } | 871 | } |
| 565 | 872 | ||
| 873 | fn parse_lease_command(verb: &str, rest: &[String]) -> Option<ExecCommand> { | ||
| 874 | let cmd = match (verb, rest) { | ||
| 875 | ("acquire", [repo, issue]) => LeaseCmd::Acquire { | ||
| 876 | repo: repo.clone(), | ||
| 877 | issue: issue.clone(), | ||
| 878 | ttl_secs: None, | ||
| 879 | }, | ||
| 880 | ("acquire", [repo, issue, flag, value]) => LeaseCmd::Acquire { | ||
| 881 | repo: repo.clone(), | ||
| 882 | issue: issue.clone(), | ||
| 883 | ttl_secs: Some(parse_ttl(flag, value)?), | ||
| 884 | }, | ||
| 885 | // Renewing an open-ended lease is meaningless; renew REQUIRES a ttl. | ||
| 886 | ("renew", [repo, issue, flag, value]) => LeaseCmd::Renew { | ||
| 887 | repo: repo.clone(), | ||
| 888 | issue: issue.clone(), | ||
| 889 | ttl_secs: parse_ttl(flag, value)?, | ||
| 890 | }, | ||
| 891 | ("release", [repo, issue]) => LeaseCmd::Release { | ||
| 892 | repo: repo.clone(), | ||
| 893 | issue: issue.clone(), | ||
| 894 | }, | ||
| 895 | ("list", [repo]) => LeaseCmd::List { repo: repo.clone() }, | ||
| 896 | _ => return None, | ||
| 897 | }; | ||
| 898 | Some(ExecCommand::Lease(cmd)) | ||
| 899 | } | ||
| 900 | |||
| 566 | /// Resolve a requested repo path to a safe absolute path under repos_dir. | 901 | /// Resolve a requested repo path to a safe absolute path under repos_dir. |
| 567 | /// Returns None if the path escapes repos_dir (e.g. via `..` or symlinks). | 902 | /// Returns None if the path escapes repos_dir (e.g. via `..` or symlinks). |
| 568 | pub fn resolve_repo_path(repos_dir: &Path, requested: &str) -> Option<PathBuf> { | 903 | pub fn resolve_repo_path(repos_dir: &Path, requested: &str) -> Option<PathBuf> { |
| @@ -828,6 +1163,16 @@ impl Handler for SshHandler { | |||
| 828 | ®ime, | 1163 | ®ime, |
| 829 | ); | 1164 | ); |
| 830 | } | 1165 | } |
| 1166 | ExecCommand::Lease(lease) => { | ||
| 1167 | return self.handle_lease_command( | ||
| 1168 | channel, | ||
| 1169 | session, | ||
| 1170 | lease, | ||
| 1171 | &resolved_path, | ||
| 1172 | &principal, | ||
| 1173 | ®ime, | ||
| 1174 | ); | ||
| 1175 | } | ||
| 831 | }; | 1176 | }; |
| 832 | 1177 | ||
| 833 | // The name rules are written against, and the repository they name. | 1178 | // The name rules are written against, and the repository they name. |
| @@ -1389,12 +1734,103 @@ mod tests { | |||
| 1389 | fn parse_release_rejects_empty_args() { | 1734 | fn parse_release_rejects_empty_args() { |
| 1390 | assert_eq!(parse_exec_command("collab-release list ''"), None); | 1735 | assert_eq!(parse_exec_command("collab-release list ''"), None); |
| 1391 | assert_eq!(parse_exec_command("collab-release upload '' 'v' 'f'"), None); | 1736 | assert_eq!(parse_exec_command("collab-release upload '' 'v' 'f'"), None); |
| 1392 | assert_eq!(parse_exec_command("collab-release upload 'r' '' 'f'"), None); | ||
| 1393 | assert_eq!(parse_exec_command(""), None); | 1737 | assert_eq!(parse_exec_command(""), None); |
| 1394 | assert_eq!(parse_exec_command(" "), None); | 1738 | assert_eq!(parse_exec_command(" "), None); |
| 1395 | } | 1739 | } |
| 1396 | 1740 | ||
| 1397 | #[test] | 1741 | #[test] |
| 1742 | fn parse_lease_acquire() { | ||
| 1743 | assert_eq!( | ||
| 1744 | parse_exec_command("collab-lease acquire 'r.git' 'a1b2c3d4'"), | ||
| 1745 | Some(ExecCommand::Lease(LeaseCmd::Acquire { | ||
| 1746 | repo: "r.git".into(), | ||
| 1747 | issue: "a1b2c3d4".into(), | ||
| 1748 | ttl_secs: None, | ||
| 1749 | })) | ||
| 1750 | ); | ||
| 1751 | } | ||
| 1752 | |||
| 1753 | #[test] | ||
| 1754 | fn parse_lease_acquire_with_ttl() { | ||
| 1755 | assert_eq!( | ||
| 1756 | parse_exec_command("collab-lease acquire 'r.git' 'a1b2c3d4' --ttl 300"), | ||
| 1757 | Some(ExecCommand::Lease(LeaseCmd::Acquire { | ||
| 1758 | repo: "r.git".into(), | ||
| 1759 | issue: "a1b2c3d4".into(), | ||
| 1760 | ttl_secs: Some(300), | ||
| 1761 | })) | ||
| 1762 | ); | ||
| 1763 | } | ||
| 1764 | |||
| 1765 | #[test] | ||
| 1766 | fn parse_lease_renew_requires_ttl() { | ||
| 1767 | assert_eq!( | ||
| 1768 | parse_exec_command("collab-lease renew 'r.git' 'a1b2c3d4' --ttl 300"), | ||
| 1769 | Some(ExecCommand::Lease(LeaseCmd::Renew { | ||
| 1770 | repo: "r.git".into(), | ||
| 1771 | issue: "a1b2c3d4".into(), | ||
| 1772 | ttl_secs: 300, | ||
| 1773 | })) | ||
| 1774 | ); | ||
| 1775 | assert_eq!( | ||
| 1776 | parse_exec_command("collab-lease renew 'r.git' 'a1b2c3d4'"), | ||
| 1777 | None | ||
| 1778 | ); | ||
| 1779 | } | ||
| 1780 | |||
| 1781 | #[test] | ||
| 1782 | fn parse_lease_release_and_list() { | ||
| 1783 | assert_eq!( | ||
| 1784 | parse_exec_command("collab-lease release 'r.git' 'a1b2c3d4'"), | ||
| 1785 | Some(ExecCommand::Lease(LeaseCmd::Release { | ||
| 1786 | repo: "r.git".into(), | ||
| 1787 | issue: "a1b2c3d4".into(), | ||
| 1788 | })) | ||
| 1789 | ); | ||
| 1790 | assert_eq!( | ||
| 1791 | parse_exec_command("collab-lease list 'r.git'"), | ||
| 1792 | Some(ExecCommand::Lease(LeaseCmd::List { | ||
| 1793 | repo: "r.git".into() | ||
| 1794 | })) | ||
| 1795 | ); | ||
| 1796 | assert_eq!( | ||
| 1797 | parse_exec_command("collab-lease list 'r.git'").unwrap().repo(), | ||
| 1798 | "r.git" | ||
| 1799 | ); | ||
| 1800 | } | ||
| 1801 | |||
| 1802 | #[test] | ||
| 1803 | fn parse_lease_rejects_malformed() { | ||
| 1804 | assert_eq!(parse_exec_command("collab-lease"), None); | ||
| 1805 | assert_eq!(parse_exec_command("collab-lease frobnicate 'r'"), None); | ||
| 1806 | assert_eq!(parse_exec_command("collab-lease acquire 'r'"), None); | ||
| 1807 | assert_eq!(parse_exec_command("collab-lease acquire '' 'i'"), None); | ||
| 1808 | assert_eq!(parse_exec_command("collab-lease acquire 'r' ''"), None); | ||
| 1809 | assert_eq!( | ||
| 1810 | parse_exec_command("collab-lease acquire 'r' 'i' --ttl abc"), | ||
| 1811 | None | ||
| 1812 | ); | ||
| 1813 | assert_eq!( | ||
| 1814 | parse_exec_command("collab-lease acquire 'r' 'i' --ttl 0"), | ||
| 1815 | None | ||
| 1816 | ); | ||
| 1817 | assert_eq!( | ||
| 1818 | parse_exec_command("collab-lease acquire 'r' 'i' --ttl -5"), | ||
| 1819 | None | ||
| 1820 | ); | ||
| 1821 | assert_eq!( | ||
| 1822 | parse_exec_command("collab-lease acquire 'r' 'i' --frob 3"), | ||
| 1823 | None | ||
| 1824 | ); | ||
| 1825 | assert_eq!( | ||
| 1826 | parse_exec_command("collab-lease acquire 'r' 'i' --ttl 3 extra"), | ||
| 1827 | None | ||
| 1828 | ); | ||
| 1829 | assert_eq!(parse_exec_command("collab-lease list 'r' extra"), None); | ||
| 1830 | assert_eq!(parse_exec_command("collab-lease acquire 'unclosed"), None); | ||
| 1831 | } | ||
| 1832 | |||
| 1833 | #[test] | ||
| 1398 | fn parse_delete_force_is_filename_not_flag() { | 1834 | fn parse_delete_force_is_filename_not_flag() { |
| 1399 | // `--force` in the filename slot for `delete` is just a filename, | 1835 | // `--force` in the filename slot for `delete` is just a filename, |
| 1400 | // not a flag (delete has no force flag); it is rejected downstream | 1836 | // not a flag (delete has no force flag); it is rejected downstream |
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -1191,6 +1191,56 @@ impl ServerHarness { | |||
| 1191 | key_path | 1191 | key_path |
| 1192 | } | 1192 | } |
| 1193 | 1193 | ||
| 1194 | /// Generate a second client keypair and authorize it *alongside* the | ||
| 1195 | /// first, for tests that need two distinct principals over SSH (a lease | ||
| 1196 | /// conflict needs a rival). Returns the key path. | ||
| 1197 | /// | ||
| 1198 | /// `authorized_keys` is read per authentication attempt, so appending | ||
| 1199 | /// needs no server restart. Not thread-safe, for the same reason | ||
| 1200 | /// `ssh_client_key` isn't: it writes shared state under the harness root. | ||
| 1201 | pub fn second_authorized_key(&self) -> PathBuf { | ||
| 1202 | // Ensure the first key exists and owns the file before appending, or | ||
| 1203 | // ssh_client_key would later overwrite this one away. | ||
| 1204 | let _ = self.ssh_client_key(); | ||
| 1205 | let key_path = self.root.path().join("id_ed25519_second"); | ||
| 1206 | if !key_path.exists() { | ||
| 1207 | let output = Command::new("ssh-keygen") | ||
| 1208 | .args([ | ||
| 1209 | "-t", | ||
| 1210 | "ed25519", | ||
| 1211 | "-N", | ||
| 1212 | "", | ||
| 1213 | "-q", | ||
| 1214 | "-C", | ||
| 1215 | "second@test", | ||
| 1216 | "-f", | ||
| 1217 | key_path.to_str().unwrap(), | ||
| 1218 | ]) | ||
| 1219 | .output() | ||
| 1220 | .expect("failed to run ssh-keygen"); | ||
| 1221 | assert!( | ||
| 1222 | output.status.success(), | ||
| 1223 | "ssh-keygen failed: {}", | ||
| 1224 | String::from_utf8_lossy(&output.stderr) | ||
| 1225 | ); | ||
| 1226 | let pubkey = std::fs::read_to_string(key_path.with_extension("pub")).unwrap(); | ||
| 1227 | let authorized = self.root.path().join("authorized_keys"); | ||
| 1228 | let mut content = std::fs::read_to_string(&authorized).unwrap_or_default(); | ||
| 1229 | if !content.ends_with('\n') && !content.is_empty() { | ||
| 1230 | content.push('\n'); | ||
| 1231 | } | ||
| 1232 | content.push_str(&pubkey); | ||
| 1233 | std::fs::write(&authorized, content).unwrap(); | ||
| 1234 | } | ||
| 1235 | key_path | ||
| 1236 | } | ||
| 1237 | |||
| 1238 | /// The work repo as a `git2::Repository`, for the event-writing helpers | ||
| 1239 | /// (`open_issue` and friends) that take one. | ||
| 1240 | pub fn work_repo_git2(&self) -> Repository { | ||
| 1241 | Repository::open(self.work_repo.dir.path()).unwrap() | ||
| 1242 | } | ||
| 1243 | |||
| 1194 | /// Generate a *named* client keypair under the harness root, or return the | 1244 | /// Generate a *named* client keypair under the harness root, or return the |
| 1195 | /// one already generated for that name. Unlike `ssh_client_key`, this does | 1245 | /// one already generated for that name. Unlike `ssh_client_key`, this does |
| 1196 | /// not touch `authorized_keys`: these keys are enrolled through | 1246 | /// not touch `authorized_keys`: these keys are enrolled through |
tests/lease_server_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,307 @@ | |||
| 1 | mod common; | ||
| 2 | |||
| 3 | use std::process::Output; | ||
| 4 | |||
| 5 | use common::ServerHarness; | ||
| 6 | |||
| 7 | fn stdout_json(output: &Output) -> serde_json::Value { | ||
| 8 | let text = String::from_utf8_lossy(&output.stdout); | ||
| 9 | serde_json::from_str(text.trim()).unwrap_or_else(|e| { | ||
| 10 | panic!( | ||
| 11 | "expected JSON on stdout, got {:?} (stderr: {})\nparse error: {e}", | ||
| 12 | text, | ||
| 13 | String::from_utf8_lossy(&output.stderr) | ||
| 14 | ) | ||
| 15 | }) | ||
| 16 | } | ||
| 17 | |||
| 18 | fn code(output: &Output) -> i32 { | ||
| 19 | output.status.code().unwrap_or(-1) | ||
| 20 | } | ||
| 21 | |||
| 22 | /// A harness with one open issue pushed to the server, returning its full id. | ||
| 23 | /// Leases are keyed on the issue id, and `acquire` resolves it against the | ||
| 24 | /// server's copy of `refs/collab/*` — so the refs have to be there first. | ||
| 25 | fn harness_with_issue(name: &str) -> (ServerHarness, String) { | ||
| 26 | let harness = ServerHarness::new(name); | ||
| 27 | harness.push_head(); | ||
| 28 | let (_ref_name, id) = common::open_issue( | ||
| 29 | &harness.work_repo_git2(), | ||
| 30 | &common::alice(), | ||
| 31 | "parser chokes on empty input", | ||
| 32 | ); | ||
| 33 | harness.push_collab_refs(); | ||
| 34 | (harness, id) | ||
| 35 | } | ||
| 36 | |||
| 37 | #[test] | ||
| 38 | fn acquire_open_issue_succeeds() { | ||
| 39 | let (harness, id) = harness_with_issue("lease-acquire"); | ||
| 40 | |||
| 41 | let out = harness.ssh_exec(&format!( | ||
| 42 | "collab-lease acquire 'lease-acquire.git' '{}'", | ||
| 43 | id | ||
| 44 | )); | ||
| 45 | assert_eq!(code(&out), 0, "stderr: {}", String::from_utf8_lossy(&out.stderr)); | ||
| 46 | let json = stdout_json(&out); | ||
| 47 | assert_eq!(json["status"], "acquired"); | ||
| 48 | assert_eq!(json["issue"], id); | ||
| 49 | assert_eq!(json["token"], 1); | ||
| 50 | // No --ttl: an open-ended lease, i.e. a human assignment. | ||
| 51 | assert_eq!(json["expires_at"], serde_json::Value::Null); | ||
| 52 | } | ||
| 53 | |||
| 54 | #[test] | ||
| 55 | fn acquire_with_ttl_reports_expiry() { | ||
| 56 | let (harness, id) = harness_with_issue("lease-ttl"); | ||
| 57 | |||
| 58 | let out = harness.ssh_exec(&format!( | ||
| 59 | "collab-lease acquire 'lease-ttl.git' '{}' --ttl 300", | ||
| 60 | id | ||
| 61 | )); | ||
| 62 | assert_eq!(code(&out), 0); | ||
| 63 | let json = stdout_json(&out); | ||
| 64 | let expires = json["expires_at"].as_str().expect("expires_at set"); | ||
| 65 | assert!( | ||
| 66 | expires.ends_with('Z') && expires.contains('T'), | ||
| 67 | "expected an RFC3339 instant, got {expires}" | ||
| 68 | ); | ||
| 69 | } | ||
| 70 | |||
| 71 | #[test] | ||
| 72 | fn acquire_by_issue_id_prefix_reports_full_id() { | ||
| 73 | let (harness, id) = harness_with_issue("lease-prefix"); | ||
| 74 | |||
| 75 | let out = harness.ssh_exec(&format!( | ||
| 76 | "collab-lease acquire 'lease-prefix.git' '{}'", | ||
| 77 | &id[..8] | ||
| 78 | )); | ||
| 79 | assert_eq!(code(&out), 0); | ||
| 80 | // The reply carries the full id, so a client that claimed by prefix can | ||
| 81 | // renew and release without re-resolving. | ||
| 82 | assert_eq!(stdout_json(&out)["issue"], id); | ||
| 83 | } | ||
| 84 | |||
| 85 | #[test] | ||
| 86 | fn acquire_conflict_reports_holder_with_exit_4() { | ||
| 87 | let (harness, id) = harness_with_issue("lease-conflict"); | ||
| 88 | let first = harness.ssh_client_key(); | ||
| 89 | let second = harness.second_authorized_key(); | ||
| 90 | |||
| 91 | let mine = harness.ssh_exec_as( | ||
| 92 | &first, | ||
| 93 | &format!("collab-lease acquire 'lease-conflict.git' '{}' --ttl 300", id), | ||
| 94 | b"", | ||
| 95 | ); | ||
| 96 | assert_eq!(code(&mine), 0); | ||
| 97 | let my_holder = stdout_json(&mine)["holder"].as_str().unwrap().to_string(); | ||
| 98 | |||
| 99 | let theirs = harness.ssh_exec_as( | ||
| 100 | &second, | ||
| 101 | &format!("collab-lease acquire 'lease-conflict.git' '{}' --ttl 300", id), | ||
| 102 | b"", | ||
| 103 | ); | ||
| 104 | assert_eq!( | ||
| 105 | code(&theirs), | ||
| 106 | 4, | ||
| 107 | "a lost race must exit 4, not 1: stdout {} stderr {}", | ||
| 108 | String::from_utf8_lossy(&theirs.stdout), | ||
| 109 | String::from_utf8_lossy(&theirs.stderr) | ||
| 110 | ); | ||
| 111 | let json = stdout_json(&theirs); | ||
| 112 | assert_eq!(json["status"], "held"); | ||
| 113 | assert_eq!(json["holder"], my_holder); | ||
| 114 | } | ||
| 115 | |||
| 116 | #[test] | ||
| 117 | fn reacquire_by_same_key_is_idempotent() { | ||
| 118 | let (harness, id) = harness_with_issue("lease-idem"); | ||
| 119 | let cmd = format!("collab-lease acquire 'lease-idem.git' '{}' --ttl 300", id); | ||
| 120 | |||
| 121 | let first = harness.ssh_exec(&cmd); | ||
| 122 | let second = harness.ssh_exec(&cmd); | ||
| 123 | assert_eq!(code(&first), 0); | ||
| 124 | assert_eq!(code(&second), 0, "re-acquire by the holder must succeed"); | ||
| 125 | assert_eq!(stdout_json(&first)["token"], stdout_json(&second)["token"]); | ||
| 126 | } | ||
| 127 | |||
| 128 | #[test] | ||
| 129 | fn renew_extends_and_wrong_key_exits_4() { | ||
| 130 | let (harness, id) = harness_with_issue("lease-renew"); | ||
| 131 | let first = harness.ssh_client_key(); | ||
| 132 | let second = harness.second_authorized_key(); | ||
| 133 | |||
| 134 | harness.ssh_exec_as( | ||
| 135 | &first, | ||
| 136 | &format!("collab-lease acquire 'lease-renew.git' '{}' --ttl 60", id), | ||
| 137 | b"", | ||
| 138 | ); | ||
| 139 | |||
| 140 | let mine = harness.ssh_exec_as( | ||
| 141 | &first, | ||
| 142 | &format!("collab-lease renew 'lease-renew.git' '{}' --ttl 600", id), | ||
| 143 | b"", | ||
| 144 | ); | ||
| 145 | assert_eq!(code(&mine), 0); | ||
| 146 | assert_eq!(stdout_json(&mine)["status"], "renewed"); | ||
| 147 | |||
| 148 | let theirs = harness.ssh_exec_as( | ||
| 149 | &second, | ||
| 150 | &format!("collab-lease renew 'lease-renew.git' '{}' --ttl 600", id), | ||
| 151 | b"", | ||
| 152 | ); | ||
| 153 | assert_eq!(code(&theirs), 4); | ||
| 154 | assert_eq!(stdout_json(&theirs)["status"], "not-holder"); | ||
| 155 | } | ||
| 156 | |||
| 157 | #[test] | ||
| 158 | fn release_frees_the_issue_and_bumps_the_next_tenure() { | ||
| 159 | let (harness, id) = harness_with_issue("lease-release"); | ||
| 160 | let first = harness.ssh_client_key(); | ||
| 161 | let second = harness.second_authorized_key(); | ||
| 162 | |||
| 163 | harness.ssh_exec_as( | ||
| 164 | &first, | ||
| 165 | &format!("collab-lease acquire 'lease-release.git' '{}' --ttl 300", id), | ||
| 166 | b"", | ||
| 167 | ); | ||
| 168 | |||
| 169 | // Someone else's live lease is not theirs to drop. | ||
| 170 | let refused = harness.ssh_exec_as( | ||
| 171 | &second, | ||
| 172 | &format!("collab-lease release 'lease-release.git' '{}'", id), | ||
| 173 | b"", | ||
| 174 | ); | ||
| 175 | assert_eq!(code(&refused), 4); | ||
| 176 | |||
| 177 | let released = harness.ssh_exec_as( | ||
| 178 | &first, | ||
| 179 | &format!("collab-lease release 'lease-release.git' '{}'", id), | ||
| 180 | b"", | ||
| 181 | ); | ||
| 182 | assert_eq!(code(&released), 0); | ||
| 183 | assert_eq!(stdout_json(&released)["status"], "released"); | ||
| 184 | |||
| 185 | let next = harness.ssh_exec_as( | ||
| 186 | &second, | ||
| 187 | &format!("collab-lease acquire 'lease-release.git' '{}' --ttl 300", id), | ||
| 188 | b"", | ||
| 189 | ); | ||
| 190 | assert_eq!(code(&next), 0); | ||
| 191 | // A new tenure, so the fencing token moves on. | ||
| 192 | assert_eq!(stdout_json(&next)["token"], 2); | ||
| 193 | } | ||
| 194 | |||
| 195 | #[test] | ||
| 196 | fn release_without_a_lease_succeeds() { | ||
| 197 | let (harness, id) = harness_with_issue("lease-release-noop"); | ||
| 198 | |||
| 199 | // Idempotent: a client retrying after a dropped connection must not fail. | ||
| 200 | let out = harness.ssh_exec(&format!( | ||
| 201 | "collab-lease release 'lease-release-noop.git' '{}'", | ||
| 202 | id | ||
| 203 | )); | ||
| 204 | assert_eq!(code(&out), 0); | ||
| 205 | assert_eq!(stdout_json(&out)["status"], "released"); | ||
| 206 | } | ||
| 207 | |||
| 208 | #[test] | ||
| 209 | fn list_shows_live_leases() { | ||
| 210 | let (harness, id) = harness_with_issue("lease-list"); | ||
| 211 | |||
| 212 | let empty = harness.ssh_exec("collab-lease list 'lease-list.git'"); | ||
| 213 | assert_eq!(code(&empty), 0); | ||
| 214 | assert_eq!(stdout_json(&empty)["leases"].as_array().unwrap().len(), 0); | ||
| 215 | |||
| 216 | harness.ssh_exec(&format!( | ||
| 217 | "collab-lease acquire 'lease-list.git' '{}' --ttl 300", | ||
| 218 | id | ||
| 219 | )); | ||
| 220 | |||
| 221 | let out = harness.ssh_exec("collab-lease list 'lease-list.git'"); | ||
| 222 | let rows = stdout_json(&out)["leases"].as_array().unwrap().clone(); | ||
| 223 | assert_eq!(rows.len(), 1); | ||
| 224 | assert_eq!(rows[0]["issue"], id); | ||
| 225 | assert_eq!(rows[0]["token"], 1); | ||
| 226 | } | ||
| 227 | |||
| 228 | #[test] | ||
| 229 | fn acquire_unknown_issue_fails() { | ||
| 230 | let (harness, _id) = harness_with_issue("lease-unknown"); | ||
| 231 | |||
| 232 | let out = harness.ssh_exec("collab-lease acquire 'lease-unknown.git' 'deadbeef'"); | ||
| 233 | assert_eq!(code(&out), 1); | ||
| 234 | assert!( | ||
| 235 | String::from_utf8_lossy(&out.stdout).contains("issue not found"), | ||
| 236 | "stdout: {}", | ||
| 237 | String::from_utf8_lossy(&out.stdout) | ||
| 238 | ); | ||
| 239 | } | ||
| 240 | |||
| 241 | #[test] | ||
| 242 | fn acquire_closed_issue_is_refused() { | ||
| 243 | let harness = ServerHarness::new("lease-closed"); | ||
| 244 | harness.push_head(); | ||
| 245 | let repo = harness.work_repo_git2(); | ||
| 246 | let (ref_name, id) = common::open_issue(&repo, &common::alice(), "already handled"); | ||
| 247 | common::close_issue(&repo, &ref_name, &common::alice()); | ||
| 248 | harness.push_collab_refs(); | ||
| 249 | |||
| 250 | let out = harness.ssh_exec(&format!("collab-lease acquire 'lease-closed.git' '{}'", id)); | ||
| 251 | assert_eq!(code(&out), 1); | ||
| 252 | assert!( | ||
| 253 | String::from_utf8_lossy(&out.stdout).contains("closed"), | ||
| 254 | "stdout: {}", | ||
| 255 | String::from_utf8_lossy(&out.stdout) | ||
| 256 | ); | ||
| 257 | } | ||
| 258 | |||
| 259 | #[test] | ||
| 260 | fn unknown_repo_and_unauthorized_repo_answer_identically() { | ||
| 261 | let (harness, id) = harness_with_issue("lease-policy"); | ||
| 262 | |||
| 263 | let unknown = harness.ssh_exec(&format!( | ||
| 264 | "collab-lease acquire 'no-such-repo.git' '{}'", | ||
| 265 | id | ||
| 266 | )); | ||
| 267 | |||
| 268 | // Readable but not writable: acquiring is a write. | ||
| 269 | harness.write_repo_server_policy( | ||
| 270 | "visibility = \"public\"\n[access]\nread = [\"*\"]\nwrite = []\n", | ||
| 271 | ); | ||
| 272 | let unauthorized = harness.ssh_exec(&format!( | ||
| 273 | "collab-lease acquire 'lease-policy.git' '{}'", | ||
| 274 | id | ||
| 275 | )); | ||
| 276 | |||
| 277 | assert_eq!(code(&unknown), 1); | ||
| 278 | assert_eq!(code(&unauthorized), 1); | ||
| 279 | // The same reply either way, so the error cannot be used to probe which | ||
| 280 | // private repos exist. | ||
| 281 | assert_eq!( | ||
| 282 | String::from_utf8_lossy(&unknown.stdout), | ||
| 283 | String::from_utf8_lossy(&unauthorized.stdout), | ||
| 284 | ); | ||
| 285 | assert!( | ||
| 286 | String::from_utf8_lossy(&unauthorized.stdout).contains("repository not found"), | ||
| 287 | "stdout: {}", | ||
| 288 | String::from_utf8_lossy(&unauthorized.stdout) | ||
| 289 | ); | ||
| 290 | } | ||
| 291 | |||
| 292 | #[test] | ||
| 293 | fn read_only_principal_may_list_but_not_acquire() { | ||
| 294 | let (harness, id) = harness_with_issue("lease-readonly"); | ||
| 295 | harness.write_repo_server_policy( | ||
| 296 | "visibility = \"public\"\n[access]\nread = [\"*\"]\nwrite = []\n", | ||
| 297 | ); | ||
| 298 | |||
| 299 | let list = harness.ssh_exec("collab-lease list 'lease-readonly.git'"); | ||
| 300 | assert_eq!(code(&list), 0, "listing needs only read"); | ||
| 301 | |||
| 302 | let acquire = harness.ssh_exec(&format!( | ||
| 303 | "collab-lease acquire 'lease-readonly.git' '{}'", | ||
| 304 | id | ||
| 305 | )); | ||
| 306 | assert_eq!(code(&acquire), 1, "acquiring needs write"); | ||
| 307 | } | ||