a73x

cf0e3384

Serve issue leases over a collab-lease SSH verb

a73x   2026-09-06 07:59

Commit message
Serve issue leases over a collab-lease SSH verb

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 &regime, 1163 &regime,
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 &regime,
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 }