Skip to main content

max / makenotwork

29.1 KB · 876 lines History Blame Raw
1 //! SSH-based git operations and management commands.
2 //!
3 //! Called from the `mnw-admin git-auth` command, which is invoked by sshd's
4 //! `command=` prefix in authorized_keys. Handles git push/pull access control
5 //! and interactive management commands (repo list, key management, etc.).
6
7 use sqlx::PgPool;
8 use std::fmt::Write as _;
9
10 use crate::db::{self, UserId, Username};
11 use crate::validation::validate_git_repo_name;
12
13 // ── Constants ──
14
15 pub const MNW_ADMIN_PATH: &str = "/opt/mnw/current/mnw-admin";
16
17 /// The git user's home directory (`GIT_HOME`, default `/opt/git`). Configurable
18 /// because the home was relocated to `/var/lib/mnw/git` in the 2026-06 soak
19 /// cleanup: `/opt/git` was deleted, and hardcoding it forced a load-bearing
20 /// symlink so `rebuild-keys` would keep writing to a live path. Matches the
21 /// `GIT_HOME` used by `deploy/setup-git-ssh.sh`.
22 fn git_home() -> std::path::PathBuf {
23 std::path::PathBuf::from(std::env::var("GIT_HOME").unwrap_or_else(|_| "/opt/git".to_string()))
24 }
25
26 /// Path to the git user's `authorized_keys`, managed by `mnw-admin rebuild-keys`
27 /// and consulted by sshd's `command=` routing. Derived from [`git_home`] so a
28 /// relocated home needs only `GIT_HOME` set, not a symlink.
29 pub fn authorized_keys_path() -> std::path::PathBuf {
30 git_home().join(".ssh").join("authorized_keys")
31 }
32
33 // ── Git operations ──
34
35 #[derive(Debug)]
36 enum GitOperation {
37 UploadPack,
38 ReceivePack,
39 Archive,
40 }
41
42 impl GitOperation {
43 fn command(&self) -> &'static str {
44 match self {
45 Self::UploadPack => "git-upload-pack",
46 Self::ReceivePack => "git-receive-pack",
47 Self::Archive => "git-upload-archive",
48 }
49 }
50 }
51
52 /// Authenticate and dispatch an SSH git-auth invocation.
53 ///
54 /// Reads `SSH_ORIGINAL_COMMAND` to determine whether this is a git operation
55 /// (git-upload-pack, git-receive-pack) or a management command (repo list, etc.).
56 pub async fn dispatch(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> {
57 let original_cmd = std::env::var("SSH_ORIGINAL_COMMAND")
58 .map_err(|_| anyhow::anyhow!("SSH_ORIGINAL_COMMAND not set"))?;
59
60 // Look up the SSH key → user
61 let key_id: db::SshKeyId = key_id_str
62 .parse()
63 .map_err(|_| anyhow::anyhow!("invalid key ID"))?;
64
65 let (_, user_id, ssh_username) = db::ssh_keys::get_key_with_user(pool, key_id)
66 .await?
67 .ok_or_else(|| anyhow::anyhow!("SSH key not found"))?;
68
69 // Verify user is not suspended or deactivated
70 let user = db::users::get_user_by_id(pool, user_id)
71 .await?
72 .ok_or_else(|| anyhow::anyhow!("user not found for SSH key"))?;
73 if user.is_suspended() {
74 anyhow::bail!("account is suspended");
75 }
76 if user.is_deactivated() {
77 anyhow::bail!("account is deactivated");
78 }
79
80 // Dispatch: git operations start with "git-", everything else is a management command
81 if original_cmd.starts_with("git-") {
82 exec_git_operation(pool, user_id, &original_cmd).await
83 } else {
84 exec_management_command(pool, user_id, &ssh_username, &original_cmd).await
85 }
86 }
87
88 async fn exec_git_operation(
89 pool: &PgPool,
90 user_id: UserId,
91 original_cmd: &str,
92 ) -> anyhow::Result<()> {
93 let (operation, repo_path) = parse_ssh_command(original_cmd)?;
94 let (owner, repo_name) = parse_repo_path(&repo_path)?;
95
96 // Validate the SSH-supplied owner and repo name before any DB lookup or
97 // shell reconstruction. `parse_repo_path` is a path-shape check, not a
98 // syntax check, without this, a malformed name could reach the DB layer
99 // or end up embedded in the `git-shell -c` argument below.
100 let owner_username =
101 Username::new(owner).map_err(|_| anyhow::anyhow!("repository not found"))?;
102 validate_git_repo_name(repo_name).map_err(|_| anyhow::anyhow!("repository not found"))?;
103
104 let owner_user = db::users::get_user_by_username(pool, &owner_username)
105 .await?
106 .ok_or_else(|| anyhow::anyhow!("repository not found"))?;
107
108 let repo =
109 match db::git_repos::get_repo_by_user_and_name(pool, owner_user.id, repo_name).await? {
110 Some(repo) => repo,
111 None => {
112 // Auto-create on push if the authenticated user owns the namespace.
113 // Only register in the DB, the caller creates the bare repo on disk.
114 if !matches!(operation, GitOperation::ReceivePack) || user_id != owner_user.id {
115 anyhow::bail!("repository not found");
116 }
117
118 tracing::info!(owner = %owner, repo = %repo_name, "registering new repository");
119 db::git_repos::create_repo(pool, owner_user.id, repo_name).await?
120 }
121 };
122
123 // Permission check, owner always has full access, collaborators checked via DB
124 let is_owner = user_id == owner_user.id;
125 match operation {
126 GitOperation::ReceivePack => {
127 if !is_owner {
128 let can_push = db::repo_collaborators::can_user_push(pool, repo.id, user_id)
129 .await
130 .unwrap_or_else(|e| {
131 // Fail closed, but don't do it silently: a DB error here
132 // denies a legitimate push with no trace (audit Run 17
133 // Observability).
134 tracing::warn!(repo_id = %repo.id, user_id = %user_id, error = ?e, "can_user_push check failed; denying push");
135 false
136 });
137 if !can_push {
138 anyhow::bail!(
139 "permission denied: you do not have push access to {owner}/{repo_name}"
140 );
141 }
142 }
143
144 // Per-account disk quota. The push consumes the *namespace owner's*
145 // storage, so the quota is checked against `owner_username`. Enforced
146 // via the shared `git::enforce_disk_quota` so the SSH and smart-HTTP
147 // push paths share one guard (fuzz 2026-07-06 M1).
148 let owner_dir = git_repos_root().join(owner_username.as_ref());
149 crate::git::enforce_disk_quota(owner_dir).await?;
150 }
151 GitOperation::UploadPack | GitOperation::Archive => {
152 if repo.visibility == db::Visibility::Private && !is_owner {
153 let is_collab = db::repo_collaborators::is_collaborator(pool, repo.id, user_id)
154 .await
155 .unwrap_or_else(|e| {
156 // Fail closed (treat as not-a-collaborator) but log: a DB
157 // error here hides a private repo from a legitimate
158 // collaborator with no trace (audit Run 17 Observability).
159 tracing::warn!(repo_id = %repo.id, user_id = %user_id, error = ?e, "is_collaborator check failed; denying read");
160 false
161 });
162 if !is_collab {
163 anyhow::bail!("repository not found");
164 }
165 }
166 }
167 }
168
169 // Authorized, exec git-shell with a sanitized command reconstructed
170 // from validated components (prevents argument injection via the original
171 // command). Use `owner_username` (the `Username`-validated value), not the
172 // raw `owner` &str: `Username::new` preserves the string but constrains the
173 // charset, so the value flowing into the `git-shell -c` argument stays
174 // load-bearing on the validated type even if `parse_repo_path` ever loosens.
175 let sanitized_cmd = format!(
176 "{} '/{}/{}.git'",
177 operation.command(),
178 owner_username.as_ref(),
179 repo_name
180 );
181 run_git_shell(&sanitized_cmd).await
182 }
183
184 /// The configured git repository root (`GIT_REPOS_PATH`, default `/opt/git`).
185 fn git_repos_root() -> std::path::PathBuf {
186 std::path::PathBuf::from(
187 std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string()),
188 )
189 }
190
191 fn parse_ssh_command(cmd: &str) -> anyhow::Result<(GitOperation, String)> {
192 let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
193 if parts.len() != 2 {
194 anyhow::bail!("invalid git command");
195 }
196
197 let operation = match parts[0] {
198 "git-upload-pack" => GitOperation::UploadPack,
199 "git-receive-pack" => GitOperation::ReceivePack,
200 "git-upload-archive" => GitOperation::Archive,
201 _ => anyhow::bail!("unsupported git command: {}", parts[0]),
202 };
203
204 let repo_path = parts[1].trim_matches('\'').trim_matches('"');
205 Ok((operation, repo_path.to_string()))
206 }
207
208 fn parse_repo_path(path: &str) -> anyhow::Result<(&str, &str)> {
209 let path = path.trim_start_matches('/');
210 let (owner, rest) = path
211 .split_once('/')
212 .ok_or_else(|| anyhow::anyhow!("invalid repository path: missing owner or repo"))?;
213
214 if owner.contains("..") || rest.contains("..") {
215 anyhow::bail!("invalid repository path: path traversal not allowed");
216 }
217
218 // Reject lone-dot segments, `parse_repo_path` is the gate before the
219 // `format!("{op} '/{owner}/{repo_name}.git'")` that flows into `git-shell`.
220 // `validate_git_repo_name` below would also catch most of these, but the
221 // belt-and-braces rejection here keeps the dispatch path itself strict.
222 if owner == "." || rest.split('/').any(|seg| seg == "." || seg == "..") {
223 anyhow::bail!("invalid repository path: lone-dot segment not allowed");
224 }
225
226 let repo_name = rest.strip_suffix(".git").unwrap_or(rest);
227
228 if owner.is_empty() || repo_name.is_empty() {
229 anyhow::bail!("invalid repository path: empty owner or repo name");
230 }
231
232 Ok((owner, repo_name))
233 }
234
235 /// Run git-shell as a child with inherited stdio (the ssh channel's fds) under a
236 /// runaway-backstop timeout, then exit this process with the child's status.
237 ///
238 /// Replaces the previous `exec()`-into-git-shell: `exec` left no opportunity to
239 /// bound a stalled transfer, so a client that stopped reading could pin the
240 /// process indefinitely. Spawning lets us `timeout` the wait and kill a stuck
241 /// operation (`GIT_SSH_OP_TIMEOUT_SECS`). The process still terminates here on
242 /// every path, so it behaves like the old `exec` to the git client (its exit
243 /// code propagates); it returns `Err` only if git-shell cannot be spawned.
244 async fn run_git_shell(original_cmd: &str) -> anyhow::Result<()> {
245 use tokio::process::Command;
246
247 let mut child = Command::new("git-shell")
248 .args(["-c", original_cmd])
249 .spawn()
250 .map_err(|e| anyhow::anyhow!("failed to spawn git-shell: {e}"))?;
251
252 let timeout = std::time::Duration::from_secs(crate::constants::GIT_SSH_OP_TIMEOUT_SECS);
253 match tokio::time::timeout(timeout, child.wait()).await {
254 Ok(Ok(status)) => std::process::exit(status.code().unwrap_or(0)),
255 Ok(Err(e)) => anyhow::bail!("git-shell wait failed: {e}"),
256 Err(_elapsed) => {
257 let _ = child.start_kill();
258 let _ = child.wait().await;
259 eprintln!(
260 "git operation timed out after {}s",
261 crate::constants::GIT_SSH_OP_TIMEOUT_SECS
262 );
263 std::process::exit(124); // matches coreutils `timeout` exit code
264 }
265 }
266 }
267
268 /// Install a post-receive hook in a bare git repository.
269 pub fn install_hook_for_repo(repo_dir: &std::path::Path, hook_content: &str) -> anyhow::Result<()> {
270 let hooks_dir = repo_dir.join("hooks");
271 std::fs::create_dir_all(&hooks_dir)?;
272 let hook_path = hooks_dir.join("post-receive");
273 std::fs::write(&hook_path, hook_content)?;
274
275 #[cfg(unix)]
276 {
277 use std::os::unix::fs::PermissionsExt;
278 std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
279 }
280
281 Ok(())
282 }
283
284 // ── SSH management commands ──
285
286 #[derive(Debug, PartialEq)]
287 enum ManagementCommand {
288 RepoList,
289 RepoInfo {
290 name: String,
291 },
292 RepoDelete {
293 name: String,
294 },
295 RepoSetVisibility {
296 name: String,
297 visibility: db::Visibility,
298 },
299 RepoSetDescription {
300 name: String,
301 description: String,
302 },
303 KeyList,
304 KeyRemove {
305 fingerprint: String,
306 },
307 }
308
309 /// Split a command string on whitespace, respecting double-quoted segments.
310 fn shell_tokenize(input: &str) -> Vec<String> {
311 let mut tokens = Vec::new();
312 let mut current = String::new();
313 let mut in_quotes = false;
314
315 for ch in input.chars() {
316 if in_quotes {
317 if ch == '"' {
318 in_quotes = false;
319 } else {
320 current.push(ch);
321 }
322 } else if ch == '"' {
323 in_quotes = true;
324 } else if ch.is_ascii_whitespace() {
325 if !current.is_empty() {
326 tokens.push(std::mem::take(&mut current));
327 }
328 } else {
329 current.push(ch);
330 }
331 }
332
333 if !current.is_empty() {
334 tokens.push(current);
335 }
336
337 tokens
338 }
339
340 fn parse_management_command(tokens: &[String]) -> anyhow::Result<ManagementCommand> {
341 let strs: Vec<&str> = tokens.iter().map(std::string::String::as_str).collect();
342
343 match strs.as_slice() {
344 ["repo", "list"] => Ok(ManagementCommand::RepoList),
345 ["repo", "info", name] => Ok(ManagementCommand::RepoInfo {
346 name: name.to_string(),
347 }),
348 ["repo", "delete", name, "--confirm"] => Ok(ManagementCommand::RepoDelete {
349 name: name.to_string(),
350 }),
351 ["repo", "delete", _, ..] => anyhow::bail!("repo delete requires --confirm flag"),
352 ["repo", "set-visibility", name, vis] => {
353 let visibility: db::Visibility = vis
354 .parse()
355 .map_err(|_| anyhow::anyhow!("visibility must be public, private, or unlisted"))?;
356 Ok(ManagementCommand::RepoSetVisibility {
357 name: name.to_string(),
358 visibility,
359 })
360 }
361 ["repo", "set-description", name, desc] => Ok(ManagementCommand::RepoSetDescription {
362 name: name.to_string(),
363 description: desc.to_string(),
364 }),
365 ["key", "list"] => Ok(ManagementCommand::KeyList),
366 ["key", "rm", fingerprint] => Ok(ManagementCommand::KeyRemove {
367 fingerprint: fingerprint.to_string(),
368 }),
369 _ => anyhow::bail!(
370 "unknown command; available: repo list|info|delete|set-visibility|set-description, key list|rm"
371 ),
372 }
373 }
374
375 async fn exec_management_command(
376 pool: &PgPool,
377 user_id: UserId,
378 username: &str,
379 original_cmd: &str,
380 ) -> anyhow::Result<()> {
381 let tokens = shell_tokenize(original_cmd);
382 let cmd = parse_management_command(&tokens)?;
383
384 match cmd {
385 ManagementCommand::RepoList => cmd_ssh_repo_list(pool, user_id).await,
386 ManagementCommand::RepoInfo { name } => cmd_ssh_repo_info(pool, user_id, &name).await,
387 ManagementCommand::RepoDelete { name } => {
388 cmd_ssh_repo_delete(pool, user_id, username, &name).await
389 }
390 ManagementCommand::RepoSetVisibility { name, visibility } => {
391 cmd_ssh_repo_set_visibility(pool, user_id, &name, visibility).await
392 }
393 ManagementCommand::RepoSetDescription { name, description } => {
394 cmd_ssh_repo_set_description(pool, user_id, &name, &description).await
395 }
396 ManagementCommand::KeyList => cmd_ssh_key_list(pool, user_id).await,
397 ManagementCommand::KeyRemove { fingerprint } => {
398 cmd_ssh_key_remove(pool, user_id, &fingerprint).await
399 }
400 }
401 }
402
403 /// Render a value for a fixed-width table column: "-" if empty, ellipsized if
404 /// wider than `max_width` (chars), otherwise the value unchanged.
405 fn display_with_ellipsis(value: &str, max_width: usize) -> String {
406 if value.is_empty() {
407 "-".to_string()
408 } else if value.chars().count() > max_width {
409 let truncated: String = value.chars().take(max_width.saturating_sub(3)).collect();
410 format!("{truncated}...")
411 } else {
412 value.to_string()
413 }
414 }
415
416 async fn cmd_ssh_repo_list(pool: &PgPool, user_id: UserId) -> anyhow::Result<()> {
417 let repos = db::git_repos::get_repos_by_user(pool, user_id).await?;
418
419 if repos.is_empty() {
420 println!("No repositories.");
421 return Ok(());
422 }
423
424 println!("{:<30} {:<10} Description", "Name", "Visibility");
425 println!("{}", "-".repeat(70));
426
427 for repo in &repos {
428 let desc = display_with_ellipsis(&repo.description, 28);
429 println!("{:<30} {:<10} {}", repo.name, repo.visibility, desc);
430 }
431
432 println!("\n{} repo(s).", repos.len());
433 Ok(())
434 }
435
436 async fn cmd_ssh_repo_info(pool: &PgPool, user_id: UserId, name: &str) -> anyhow::Result<()> {
437 // Validate the name up front (defense-in-depth: the DB lookup 404s on a bogus
438 // name and the delete path canonicalize-guards the FS path, but reject
439 // traversal/control characters before building any path) (ultra-fuzz Sec M2).
440 validate_git_repo_name(name)?;
441
442 let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
443 .await?
444 .ok_or_else(|| anyhow::anyhow!("repository '{name}' not found"))?;
445
446 let (open_issues, closed_issues) = db::issues::get_issue_counts(pool, repo.id).await?;
447
448 println!("Name: {}", repo.name);
449 println!("Visibility: {}", repo.visibility);
450 println!(
451 "Description: {}",
452 if repo.description.is_empty() {
453 "-"
454 } else {
455 &repo.description
456 }
457 );
458 println!(
459 "Created: {}",
460 repo.created_at.format("%Y-%m-%d %H:%M UTC")
461 );
462 println!("Issues: {open_issues} open, {closed_issues} closed");
463
464 Ok(())
465 }
466
467 async fn cmd_ssh_repo_delete(
468 pool: &PgPool,
469 user_id: UserId,
470 username: &str,
471 name: &str,
472 ) -> anyhow::Result<()> {
473 // Validate the name up front (defense-in-depth: the DB lookup 404s on a bogus
474 // name and the delete path canonicalize-guards the FS path, but reject
475 // traversal/control characters before building any path) (ultra-fuzz Sec M2).
476 validate_git_repo_name(name)?;
477
478 let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
479 .await?
480 .ok_or_else(|| anyhow::anyhow!("repository '{name}' not found"))?;
481
482 db::git_repos::delete_repo(pool, repo.id).await?;
483
484 let git_root = std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string());
485 let git_root_path = std::path::Path::new(&git_root);
486 let repo_dir = git_root_path.join(username).join(format!("{name}.git"));
487
488 if repo_dir.exists() {
489 let canonical = repo_dir.canonicalize()?;
490 let canonical_root = git_root_path.canonicalize()?;
491 if !canonical.starts_with(&canonical_root) {
492 anyhow::bail!("repo path escapes git root");
493 }
494 std::fs::remove_dir_all(&canonical)?;
495 }
496
497 println!("Deleted repository '{name}'.");
498 Ok(())
499 }
500
501 async fn cmd_ssh_repo_set_visibility(
502 pool: &PgPool,
503 user_id: UserId,
504 name: &str,
505 visibility: db::Visibility,
506 ) -> anyhow::Result<()> {
507 // Validate the name up front (defense-in-depth: the DB lookup 404s on a bogus
508 // name and the delete path canonicalize-guards the FS path, but reject
509 // traversal/control characters before building any path) (ultra-fuzz Sec M2).
510 validate_git_repo_name(name)?;
511
512 let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
513 .await?
514 .ok_or_else(|| anyhow::anyhow!("repository '{name}' not found"))?;
515
516 db::git_repos::update_visibility(pool, repo.id, visibility).await?;
517
518 println!("Set visibility of '{name}' to '{visibility}'.");
519 Ok(())
520 }
521
522 async fn cmd_ssh_repo_set_description(
523 pool: &PgPool,
524 user_id: UserId,
525 name: &str,
526 description: &str,
527 ) -> anyhow::Result<()> {
528 // Validate the name up front (defense-in-depth: the DB lookup 404s on a bogus
529 // name and the delete path canonicalize-guards the FS path, but reject
530 // traversal/control characters before building any path) (ultra-fuzz Sec M2).
531 validate_git_repo_name(name)?;
532
533 let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
534 .await?
535 .ok_or_else(|| anyhow::anyhow!("repository '{name}' not found"))?;
536
537 db::git_repos::update_repo_settings(pool, repo.id, description, repo.visibility).await?;
538
539 println!("Updated description of '{name}'.");
540 Ok(())
541 }
542
543 async fn cmd_ssh_key_list(pool: &PgPool, user_id: UserId) -> anyhow::Result<()> {
544 let keys = db::ssh_keys::list_keys_by_user(pool, user_id).await?;
545
546 if keys.is_empty() {
547 println!("No SSH keys.");
548 return Ok(());
549 }
550
551 println!("{:<50} {:<20} Added", "Fingerprint", "Label");
552 println!("{}", "-".repeat(80));
553
554 for key in &keys {
555 let label = display_with_ellipsis(&key.label, 20);
556 println!(
557 "{:<50} {:<20} {}",
558 key.fingerprint,
559 label,
560 key.created_at.format("%Y-%m-%d"),
561 );
562 }
563
564 println!("\n{} key(s).", keys.len());
565 Ok(())
566 }
567
568 async fn cmd_ssh_key_remove(
569 pool: &PgPool,
570 user_id: UserId,
571 fingerprint: &str,
572 ) -> anyhow::Result<()> {
573 let deleted = db::ssh_keys::delete_key_by_fingerprint(pool, user_id, fingerprint).await?;
574
575 if !deleted {
576 anyhow::bail!("SSH key with fingerprint '{fingerprint}' not found");
577 }
578
579 write_authorized_keys(pool, true).await?;
580
581 println!("Removed SSH key '{fingerprint}'.");
582 Ok(())
583 }
584
585 /// Write the authorized_keys file from all DB keys. Optionally set git:git ownership.
586 pub async fn write_authorized_keys(pool: &PgPool, set_ownership: bool) -> anyhow::Result<()> {
587 let keys = db::ssh_keys::get_all_keys_with_username(pool).await?;
588
589 let mut content = String::new();
590 content.push_str("# Managed by mnw-admin rebuild-keys. Do not edit manually.\n");
591
592 for key in &keys {
593 writeln!(
594 content,
595 "command=\"{} git-auth {}\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty {}",
596 MNW_ADMIN_PATH, key.id, key.public_key,
597 )
598 .unwrap();
599 }
600
601 let keys_path = authorized_keys_path();
602 let tmp_path = keys_path.with_extension("tmp");
603 std::fs::write(&tmp_path, &content)?;
604 std::fs::rename(&tmp_path, &keys_path)?;
605
606 #[cfg(unix)]
607 {
608 use std::os::unix::fs::PermissionsExt;
609 std::fs::set_permissions(&keys_path, std::fs::Permissions::from_mode(0o600))?;
610
611 if set_ownership {
612 let status = std::process::Command::new("chown")
613 .arg("git:git")
614 .arg(&keys_path)
615 .status()?;
616 if !status.success() {
617 anyhow::bail!("chown git:git failed on {}", keys_path.display());
618 }
619 }
620 }
621
622 Ok(())
623 }
624
625 #[cfg(test)]
626 mod tests {
627 use super::*;
628
629 // ── shell_tokenize ──
630
631 #[test]
632 fn tokenize_simple() {
633 assert_eq!(shell_tokenize("repo list"), vec!["repo", "list"]);
634 }
635
636 #[test]
637 fn tokenize_extra_whitespace() {
638 assert_eq!(
639 shell_tokenize(" repo info myrepo "),
640 vec!["repo", "info", "myrepo"],
641 );
642 }
643
644 #[test]
645 fn tokenize_quoted_string() {
646 assert_eq!(
647 shell_tokenize(r#"repo set-description myrepo "A cool project""#),
648 vec!["repo", "set-description", "myrepo", "A cool project"],
649 );
650 }
651
652 #[test]
653 fn tokenize_empty_quotes() {
654 assert_eq!(
655 shell_tokenize(r#"repo set-description myrepo """#),
656 vec!["repo", "set-description", "myrepo"],
657 );
658 }
659
660 #[test]
661 fn tokenize_unterminated_quote() {
662 assert_eq!(
663 shell_tokenize(r#"repo set-description myrepo "unterminated"#),
664 vec!["repo", "set-description", "myrepo", "unterminated"],
665 );
666 }
667
668 #[test]
669 fn tokenize_empty_input() {
670 assert!(shell_tokenize("").is_empty());
671 assert!(shell_tokenize(" ").is_empty());
672 }
673
674 // ── parse_ssh_command ──
675
676 #[test]
677 fn parse_upload_pack() {
678 let (op, path) = parse_ssh_command("git-upload-pack '/user/repo.git'").unwrap();
679 assert!(matches!(op, GitOperation::UploadPack));
680 assert_eq!(path, "/user/repo.git");
681 }
682
683 #[test]
684 fn parse_receive_pack() {
685 let (op, path) = parse_ssh_command("git-receive-pack '/user/repo.git'").unwrap();
686 assert!(matches!(op, GitOperation::ReceivePack));
687 assert_eq!(path, "/user/repo.git");
688 }
689
690 #[test]
691 fn parse_upload_archive() {
692 let (op, path) = parse_ssh_command("git-upload-archive '/user/repo.git'").unwrap();
693 assert!(matches!(op, GitOperation::Archive));
694 assert_eq!(path, "/user/repo.git");
695 }
696
697 #[test]
698 fn parse_ssh_command_double_quotes() {
699 let (_, path) = parse_ssh_command(r#"git-upload-pack "/user/repo.git""#).unwrap();
700 assert_eq!(path, "/user/repo.git");
701 }
702
703 #[test]
704 fn parse_ssh_command_unsupported() {
705 assert!(parse_ssh_command("git-foo '/user/repo.git'").is_err());
706 }
707
708 #[test]
709 fn parse_ssh_command_no_space() {
710 assert!(parse_ssh_command("git-upload-pack").is_err());
711 }
712
713 // ── parse_repo_path ──
714
715 #[test]
716 fn parse_valid_repo_path() {
717 let (owner, name) = parse_repo_path("/alice/myrepo.git").unwrap();
718 assert_eq!(owner, "alice");
719 assert_eq!(name, "myrepo");
720 }
721
722 #[test]
723 fn parse_repo_path_no_git_suffix() {
724 let (owner, name) = parse_repo_path("/bob/project").unwrap();
725 assert_eq!(owner, "bob");
726 assert_eq!(name, "project");
727 }
728
729 #[test]
730 fn parse_repo_path_no_leading_slash() {
731 let (owner, name) = parse_repo_path("carol/stuff.git").unwrap();
732 assert_eq!(owner, "carol");
733 assert_eq!(name, "stuff");
734 }
735
736 #[test]
737 fn parse_repo_path_traversal_rejected() {
738 assert!(parse_repo_path("../evil/repo").is_err());
739 assert!(parse_repo_path("user/../repo").is_err());
740 }
741
742 #[test]
743 fn parse_repo_path_missing_repo() {
744 assert!(parse_repo_path("/onlyowner").is_err());
745 }
746
747 #[test]
748 fn parse_repo_path_empty_owner() {
749 assert!(parse_repo_path("//repo").is_err());
750 }
751
752 #[test]
753 fn parse_repo_path_bare_git_suffix_only() {
754 assert!(parse_repo_path("/owner/.git").is_err());
755 }
756
757 // ── parse_management_command ──
758
759 #[test]
760 fn parse_repo_list() {
761 let tokens: Vec<String> = vec!["repo".into(), "list".into()];
762 assert_eq!(
763 parse_management_command(&tokens).unwrap(),
764 ManagementCommand::RepoList
765 );
766 }
767
768 #[test]
769 fn parse_repo_info() {
770 let tokens: Vec<String> = vec!["repo".into(), "info".into(), "docengine".into()];
771 assert_eq!(
772 parse_management_command(&tokens).unwrap(),
773 ManagementCommand::RepoInfo {
774 name: "docengine".into()
775 },
776 );
777 }
778
779 #[test]
780 fn parse_repo_delete_with_confirm() {
781 let tokens: Vec<String> = vec![
782 "repo".into(),
783 "delete".into(),
784 "old".into(),
785 "--confirm".into(),
786 ];
787 assert_eq!(
788 parse_management_command(&tokens).unwrap(),
789 ManagementCommand::RepoDelete { name: "old".into() },
790 );
791 }
792
793 #[test]
794 fn parse_repo_delete_without_confirm_fails() {
795 let tokens: Vec<String> = vec!["repo".into(), "delete".into(), "old".into()];
796 assert!(parse_management_command(&tokens).is_err());
797 }
798
799 #[test]
800 fn parse_repo_set_visibility() {
801 let tokens: Vec<String> = vec![
802 "repo".into(),
803 "set-visibility".into(),
804 "myrepo".into(),
805 "private".into(),
806 ];
807 assert_eq!(
808 parse_management_command(&tokens).unwrap(),
809 ManagementCommand::RepoSetVisibility {
810 name: "myrepo".into(),
811 visibility: db::Visibility::Private
812 },
813 );
814 }
815
816 #[test]
817 fn parse_repo_set_visibility_invalid() {
818 let tokens: Vec<String> = vec![
819 "repo".into(),
820 "set-visibility".into(),
821 "myrepo".into(),
822 "secret".into(),
823 ];
824 assert!(parse_management_command(&tokens).is_err());
825 }
826
827 #[test]
828 fn parse_repo_set_description() {
829 let tokens: Vec<String> = vec![
830 "repo".into(),
831 "set-description".into(),
832 "myrepo".into(),
833 "A new description".into(),
834 ];
835 assert_eq!(
836 parse_management_command(&tokens).unwrap(),
837 ManagementCommand::RepoSetDescription {
838 name: "myrepo".into(),
839 description: "A new description".into()
840 },
841 );
842 }
843
844 #[test]
845 fn parse_key_list() {
846 let tokens: Vec<String> = vec!["key".into(), "list".into()];
847 assert_eq!(
848 parse_management_command(&tokens).unwrap(),
849 ManagementCommand::KeyList
850 );
851 }
852
853 #[test]
854 fn parse_key_rm() {
855 let tokens: Vec<String> = vec!["key".into(), "rm".into(), "SHA256:abc123".into()];
856 assert_eq!(
857 parse_management_command(&tokens).unwrap(),
858 ManagementCommand::KeyRemove {
859 fingerprint: "SHA256:abc123".into()
860 },
861 );
862 }
863
864 #[test]
865 fn parse_invalid_command() {
866 let tokens: Vec<String> = vec!["frobnicate".into()];
867 assert!(parse_management_command(&tokens).is_err());
868 }
869
870 #[test]
871 fn parse_empty_tokens() {
872 let tokens: Vec<String> = vec![];
873 assert!(parse_management_command(&tokens).is_err());
874 }
875 }
876