Skip to main content

max / makenotwork

23.7 KB · 710 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
9 use crate::db::{self, UserId, Username};
10 use crate::validation::validate_git_repo_name;
11
12 // ── Constants ──
13
14 pub const AUTHORIZED_KEYS_PATH: &str = "/opt/git/.ssh/authorized_keys";
15 pub const MNW_ADMIN_PATH: &str = "/opt/mnw/current/mnw-admin";
16
17 // ── Git operations ──
18
19 #[derive(Debug)]
20 enum GitOperation {
21 UploadPack,
22 ReceivePack,
23 Archive,
24 }
25
26 impl GitOperation {
27 fn command(&self) -> &'static str {
28 match self {
29 Self::UploadPack => "git-upload-pack",
30 Self::ReceivePack => "git-receive-pack",
31 Self::Archive => "git-upload-archive",
32 }
33 }
34 }
35
36 /// Authenticate and dispatch an SSH git-auth invocation.
37 ///
38 /// Reads `SSH_ORIGINAL_COMMAND` to determine whether this is a git operation
39 /// (git-upload-pack, git-receive-pack) or a management command (repo list, etc.).
40 pub async fn dispatch(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> {
41 let original_cmd = std::env::var("SSH_ORIGINAL_COMMAND")
42 .map_err(|_| anyhow::anyhow!("SSH_ORIGINAL_COMMAND not set"))?;
43
44 // Look up the SSH key → user
45 let key_id: db::SshKeyId = key_id_str
46 .parse()
47 .map_err(|_| anyhow::anyhow!("invalid key ID"))?;
48
49 let (_, user_id, ssh_username) = db::ssh_keys::get_key_with_user(pool, key_id)
50 .await?
51 .ok_or_else(|| anyhow::anyhow!("SSH key not found"))?;
52
53 // Verify user is not suspended or deactivated
54 let user = db::users::get_user_by_id(pool, user_id)
55 .await?
56 .ok_or_else(|| anyhow::anyhow!("user not found for SSH key"))?;
57 if user.is_suspended() {
58 anyhow::bail!("account is suspended");
59 }
60 if user.is_deactivated() {
61 anyhow::bail!("account is deactivated");
62 }
63
64 // Dispatch: git operations start with "git-", everything else is a management command
65 if original_cmd.starts_with("git-") {
66 exec_git_operation(pool, user_id, &original_cmd).await
67 } else {
68 exec_management_command(pool, user_id, &ssh_username, &original_cmd).await
69 }
70 }
71
72 async fn exec_git_operation(
73 pool: &PgPool,
74 user_id: UserId,
75 original_cmd: &str,
76 ) -> anyhow::Result<()> {
77 let (operation, repo_path) = parse_ssh_command(original_cmd)?;
78 let (owner, repo_name) = parse_repo_path(&repo_path)?;
79
80 // Validate the SSH-supplied owner and repo name before any DB lookup or
81 // shell reconstruction. `parse_repo_path` is a path-shape check, not a
82 // syntax check — without this, a malformed name could reach the DB layer
83 // or end up embedded in the `git-shell -c` argument below.
84 let owner_username = Username::new(owner)
85 .map_err(|_| anyhow::anyhow!("repository not found"))?;
86 validate_git_repo_name(repo_name)
87 .map_err(|_| anyhow::anyhow!("repository not found"))?;
88
89 let owner_user = db::users::get_user_by_username(pool, &owner_username)
90 .await?
91 .ok_or_else(|| anyhow::anyhow!("repository not found"))?;
92
93 let repo = match db::git_repos::get_repo_by_user_and_name(pool, owner_user.id, repo_name).await? {
94 Some(repo) => repo,
95 None => {
96 // Auto-create on push if the authenticated user owns the namespace.
97 // Only register in the DB — the caller creates the bare repo on disk.
98 if !matches!(operation, GitOperation::ReceivePack) || user_id != owner_user.id {
99 anyhow::bail!("repository not found");
100 }
101
102 tracing::info!(owner = %owner, repo = %repo_name, "registering new repository");
103 db::git_repos::create_repo(pool, owner_user.id, repo_name).await?
104 }
105 };
106
107 // Permission check — owner always has full access, collaborators checked via DB
108 let is_owner = user_id == owner_user.id;
109 match operation {
110 GitOperation::ReceivePack => {
111 if !is_owner {
112 let can_push = db::repo_collaborators::can_user_push(pool, repo.id, user_id)
113 .await
114 .unwrap_or(false);
115 if !can_push {
116 anyhow::bail!("permission denied: you do not have push access to {}/{}", owner, repo_name);
117 }
118 }
119 }
120 GitOperation::UploadPack | GitOperation::Archive => {
121 if repo.visibility == db::Visibility::Private && !is_owner {
122 let is_collab = db::repo_collaborators::is_collaborator(pool, repo.id, user_id)
123 .await
124 .unwrap_or(false);
125 if !is_collab {
126 anyhow::bail!("repository not found");
127 }
128 }
129 }
130 }
131
132 // Authorized — exec git-shell with a sanitized command reconstructed
133 // from validated components (prevents argument injection via the original
134 // command). Use `owner_username` (the `Username`-validated value), not the
135 // raw `owner` &str: `Username::new` preserves the string but constrains the
136 // charset, so the value flowing into the `git-shell -c` argument stays
137 // load-bearing on the validated type even if `parse_repo_path` ever loosens.
138 let sanitized_cmd = format!("{} '/{}/{}.git'", operation.command(), owner_username.as_ref(), repo_name);
139 let err = exec_git_shell(&sanitized_cmd);
140 anyhow::bail!("failed to exec git-shell: {}", err);
141 }
142
143 fn parse_ssh_command(cmd: &str) -> anyhow::Result<(GitOperation, String)> {
144 let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
145 if parts.len() != 2 {
146 anyhow::bail!("invalid git command");
147 }
148
149 let operation = match parts[0] {
150 "git-upload-pack" => GitOperation::UploadPack,
151 "git-receive-pack" => GitOperation::ReceivePack,
152 "git-upload-archive" => GitOperation::Archive,
153 _ => anyhow::bail!("unsupported git command: {}", parts[0]),
154 };
155
156 let repo_path = parts[1].trim_matches('\'').trim_matches('"');
157 Ok((operation, repo_path.to_string()))
158 }
159
160 fn parse_repo_path(path: &str) -> anyhow::Result<(&str, &str)> {
161 let path = path.trim_start_matches('/');
162 let (owner, rest) = path
163 .split_once('/')
164 .ok_or_else(|| anyhow::anyhow!("invalid repository path: missing owner or repo"))?;
165
166 if owner.contains("..") || rest.contains("..") {
167 anyhow::bail!("invalid repository path: path traversal not allowed");
168 }
169
170 // Reject lone-dot segments — `parse_repo_path` is the gate before the
171 // `format!("{op} '/{owner}/{repo_name}.git'")` that flows into `git-shell`.
172 // `validate_git_repo_name` below would also catch most of these, but the
173 // belt-and-braces rejection here keeps the dispatch path itself strict.
174 if owner == "." || rest.split('/').any(|seg| seg == "." || seg == "..") {
175 anyhow::bail!("invalid repository path: lone-dot segment not allowed");
176 }
177
178 let repo_name = rest.strip_suffix(".git").unwrap_or(rest);
179
180 if owner.is_empty() || repo_name.is_empty() {
181 anyhow::bail!("invalid repository path: empty owner or repo name");
182 }
183
184 Ok((owner, repo_name))
185 }
186
187 /// Replace the current process with git-shell.
188 fn exec_git_shell(original_cmd: &str) -> std::io::Error {
189 use std::os::unix::process::CommandExt;
190 std::process::Command::new("git-shell")
191 .args(["-c", original_cmd])
192 .exec()
193 }
194
195 /// Install a post-receive hook in a bare git repository.
196 pub fn install_hook_for_repo(
197 repo_dir: &std::path::Path,
198 hook_content: &str,
199 ) -> anyhow::Result<()> {
200 let hooks_dir = repo_dir.join("hooks");
201 std::fs::create_dir_all(&hooks_dir)?;
202 let hook_path = hooks_dir.join("post-receive");
203 std::fs::write(&hook_path, hook_content)?;
204
205 #[cfg(unix)]
206 {
207 use std::os::unix::fs::PermissionsExt;
208 std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
209 }
210
211 Ok(())
212 }
213
214 // ── SSH management commands ──
215
216 #[derive(Debug, PartialEq)]
217 enum ManagementCommand {
218 RepoList,
219 RepoInfo { name: String },
220 RepoDelete { name: String },
221 RepoSetVisibility { name: String, visibility: db::Visibility },
222 RepoSetDescription { name: String, description: String },
223 KeyList,
224 KeyRemove { fingerprint: String },
225 }
226
227 /// Split a command string on whitespace, respecting double-quoted segments.
228 fn shell_tokenize(input: &str) -> Vec<String> {
229 let mut tokens = Vec::new();
230 let mut current = String::new();
231 let mut in_quotes = false;
232
233 for ch in input.chars() {
234 if in_quotes {
235 if ch == '"' {
236 in_quotes = false;
237 } else {
238 current.push(ch);
239 }
240 } else if ch == '"' {
241 in_quotes = true;
242 } else if ch.is_ascii_whitespace() {
243 if !current.is_empty() {
244 tokens.push(std::mem::take(&mut current));
245 }
246 } else {
247 current.push(ch);
248 }
249 }
250
251 if !current.is_empty() {
252 tokens.push(current);
253 }
254
255 tokens
256 }
257
258 fn parse_management_command(tokens: &[String]) -> anyhow::Result<ManagementCommand> {
259 let strs: Vec<&str> = tokens.iter().map(|s| s.as_str()).collect();
260
261 match strs.as_slice() {
262 ["repo", "list"] => Ok(ManagementCommand::RepoList),
263 ["repo", "info", name] => Ok(ManagementCommand::RepoInfo { name: name.to_string() }),
264 ["repo", "delete", name, "--confirm"] => Ok(ManagementCommand::RepoDelete { name: name.to_string() }),
265 ["repo", "delete", _, ..] => anyhow::bail!("repo delete requires --confirm flag"),
266 ["repo", "set-visibility", name, vis] => {
267 let visibility: db::Visibility = vis.parse()
268 .map_err(|_| anyhow::anyhow!("visibility must be public, private, or unlisted"))?;
269 Ok(ManagementCommand::RepoSetVisibility {
270 name: name.to_string(),
271 visibility,
272 })
273 }
274 ["repo", "set-description", name, desc] => Ok(ManagementCommand::RepoSetDescription {
275 name: name.to_string(),
276 description: desc.to_string(),
277 }),
278 ["key", "list"] => Ok(ManagementCommand::KeyList),
279 ["key", "rm", fingerprint] => Ok(ManagementCommand::KeyRemove { fingerprint: fingerprint.to_string() }),
280 _ => anyhow::bail!("unknown command; available: repo list|info|delete|set-visibility|set-description, key list|rm"),
281 }
282 }
283
284 async fn exec_management_command(
285 pool: &PgPool,
286 user_id: UserId,
287 username: &str,
288 original_cmd: &str,
289 ) -> anyhow::Result<()> {
290 let tokens = shell_tokenize(original_cmd);
291 let cmd = parse_management_command(&tokens)?;
292
293 match cmd {
294 ManagementCommand::RepoList => cmd_ssh_repo_list(pool, user_id).await,
295 ManagementCommand::RepoInfo { name } => cmd_ssh_repo_info(pool, user_id, &name).await,
296 ManagementCommand::RepoDelete { name } => cmd_ssh_repo_delete(pool, user_id, username, &name).await,
297 ManagementCommand::RepoSetVisibility { name, visibility } => {
298 cmd_ssh_repo_set_visibility(pool, user_id, &name, visibility).await
299 }
300 ManagementCommand::RepoSetDescription { name, description } => {
301 cmd_ssh_repo_set_description(pool, user_id, &name, &description).await
302 }
303 ManagementCommand::KeyList => cmd_ssh_key_list(pool, user_id).await,
304 ManagementCommand::KeyRemove { fingerprint } => cmd_ssh_key_remove(pool, user_id, &fingerprint).await,
305 }
306 }
307
308 /// Render a value for a fixed-width table column: "-" if empty, ellipsized if
309 /// wider than `max_width` (chars), otherwise the value unchanged.
310 fn display_with_ellipsis(value: &str, max_width: usize) -> String {
311 if value.is_empty() {
312 "-".to_string()
313 } else if value.chars().count() > max_width {
314 let truncated: String = value.chars().take(max_width.saturating_sub(3)).collect();
315 format!("{truncated}...")
316 } else {
317 value.to_string()
318 }
319 }
320
321 async fn cmd_ssh_repo_list(pool: &PgPool, user_id: UserId) -> anyhow::Result<()> {
322 let repos = db::git_repos::get_repos_by_user(pool, user_id).await?;
323
324 if repos.is_empty() {
325 println!("No repositories.");
326 return Ok(());
327 }
328
329 println!("{:<30} {:<10} Description", "Name", "Visibility");
330 println!("{}", "-".repeat(70));
331
332 for repo in &repos {
333 let desc = display_with_ellipsis(&repo.description, 28);
334 println!("{:<30} {:<10} {}", repo.name, repo.visibility, desc);
335 }
336
337 println!("\n{} repo(s).", repos.len());
338 Ok(())
339 }
340
341 async fn cmd_ssh_repo_info(pool: &PgPool, user_id: UserId, name: &str) -> anyhow::Result<()> {
342 let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
343 .await?
344 .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?;
345
346 let (open_issues, closed_issues) = db::issues::get_issue_counts(pool, repo.id).await?;
347
348 println!("Name: {}", repo.name);
349 println!("Visibility: {}", repo.visibility);
350 println!("Description: {}", if repo.description.is_empty() { "-" } else { &repo.description });
351 println!("Created: {}", repo.created_at.format("%Y-%m-%d %H:%M UTC"));
352 println!("Issues: {} open, {} closed", open_issues, closed_issues);
353
354 Ok(())
355 }
356
357 async fn cmd_ssh_repo_delete(
358 pool: &PgPool,
359 user_id: UserId,
360 username: &str,
361 name: &str,
362 ) -> anyhow::Result<()> {
363 let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
364 .await?
365 .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?;
366
367 db::git_repos::delete_repo(pool, repo.id).await?;
368
369 let git_root = std::env::var("GIT_REPOS_PATH")
370 .unwrap_or_else(|_| "/opt/git".to_string());
371 let git_root_path = std::path::Path::new(&git_root);
372 let repo_dir = git_root_path
373 .join(username)
374 .join(format!("{}.git", name));
375
376 if repo_dir.exists() {
377 let canonical = repo_dir.canonicalize()?;
378 let canonical_root = git_root_path.canonicalize()?;
379 if !canonical.starts_with(&canonical_root) {
380 anyhow::bail!("repo path escapes git root");
381 }
382 std::fs::remove_dir_all(&canonical)?;
383 }
384
385 println!("Deleted repository '{}'.", name);
386 Ok(())
387 }
388
389 async fn cmd_ssh_repo_set_visibility(
390 pool: &PgPool,
391 user_id: UserId,
392 name: &str,
393 visibility: db::Visibility,
394 ) -> anyhow::Result<()> {
395 let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
396 .await?
397 .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?;
398
399 db::git_repos::update_visibility(pool, repo.id, visibility).await?;
400
401 println!("Set visibility of '{}' to '{}'.", name, visibility);
402 Ok(())
403 }
404
405 async fn cmd_ssh_repo_set_description(
406 pool: &PgPool,
407 user_id: UserId,
408 name: &str,
409 description: &str,
410 ) -> anyhow::Result<()> {
411 let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
412 .await?
413 .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?;
414
415 db::git_repos::update_repo_settings(pool, repo.id, description, repo.visibility).await?;
416
417 println!("Updated description of '{}'.", name);
418 Ok(())
419 }
420
421 async fn cmd_ssh_key_list(pool: &PgPool, user_id: UserId) -> anyhow::Result<()> {
422 let keys = db::ssh_keys::list_keys_by_user(pool, user_id).await?;
423
424 if keys.is_empty() {
425 println!("No SSH keys.");
426 return Ok(());
427 }
428
429 println!("{:<50} {:<20} Added", "Fingerprint", "Label");
430 println!("{}", "-".repeat(80));
431
432 for key in &keys {
433 let label = display_with_ellipsis(&key.label, 20);
434 println!(
435 "{:<50} {:<20} {}",
436 key.fingerprint,
437 label,
438 key.created_at.format("%Y-%m-%d"),
439 );
440 }
441
442 println!("\n{} key(s).", keys.len());
443 Ok(())
444 }
445
446 async fn cmd_ssh_key_remove(pool: &PgPool, user_id: UserId, fingerprint: &str) -> anyhow::Result<()> {
447 let deleted = db::ssh_keys::delete_key_by_fingerprint(pool, user_id, fingerprint).await?;
448
449 if !deleted {
450 anyhow::bail!("SSH key with fingerprint '{}' not found", fingerprint);
451 }
452
453 write_authorized_keys(pool, true).await?;
454
455 println!("Removed SSH key '{}'.", fingerprint);
456 Ok(())
457 }
458
459 /// Write the authorized_keys file from all DB keys. Optionally set git:git ownership.
460 pub async fn write_authorized_keys(pool: &PgPool, set_ownership: bool) -> anyhow::Result<()> {
461 let keys = db::ssh_keys::get_all_keys_with_username(pool).await?;
462
463 let mut content = String::new();
464 content.push_str("# Managed by mnw-admin rebuild-keys. Do not edit manually.\n");
465
466 for key in &keys {
467 content.push_str(&format!(
468 "command=\"{} git-auth {}\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty {}\n",
469 MNW_ADMIN_PATH, key.id, key.public_key,
470 ));
471 }
472
473 let tmp_path = format!("{}.tmp", AUTHORIZED_KEYS_PATH);
474 std::fs::write(&tmp_path, &content)?;
475 std::fs::rename(&tmp_path, AUTHORIZED_KEYS_PATH)?;
476
477 #[cfg(unix)]
478 {
479 use std::os::unix::fs::PermissionsExt;
480 std::fs::set_permissions(AUTHORIZED_KEYS_PATH, std::fs::Permissions::from_mode(0o600))?;
481
482 if set_ownership {
483 let status = std::process::Command::new("chown")
484 .args(["git:git", AUTHORIZED_KEYS_PATH])
485 .status()?;
486 if !status.success() {
487 anyhow::bail!("chown git:git failed on {}", AUTHORIZED_KEYS_PATH);
488 }
489 }
490 }
491
492 Ok(())
493 }
494
495 #[cfg(test)]
496 mod tests {
497 use super::*;
498
499 // ── shell_tokenize ──
500
501 #[test]
502 fn tokenize_simple() {
503 assert_eq!(shell_tokenize("repo list"), vec!["repo", "list"]);
504 }
505
506 #[test]
507 fn tokenize_extra_whitespace() {
508 assert_eq!(
509 shell_tokenize(" repo info myrepo "),
510 vec!["repo", "info", "myrepo"],
511 );
512 }
513
514 #[test]
515 fn tokenize_quoted_string() {
516 assert_eq!(
517 shell_tokenize(r#"repo set-description myrepo "A cool project""#),
518 vec!["repo", "set-description", "myrepo", "A cool project"],
519 );
520 }
521
522 #[test]
523 fn tokenize_empty_quotes() {
524 assert_eq!(
525 shell_tokenize(r#"repo set-description myrepo """#),
526 vec!["repo", "set-description", "myrepo"],
527 );
528 }
529
530 #[test]
531 fn tokenize_unterminated_quote() {
532 assert_eq!(
533 shell_tokenize(r#"repo set-description myrepo "unterminated"#),
534 vec!["repo", "set-description", "myrepo", "unterminated"],
535 );
536 }
537
538 #[test]
539 fn tokenize_empty_input() {
540 assert!(shell_tokenize("").is_empty());
541 assert!(shell_tokenize(" ").is_empty());
542 }
543
544 // ── parse_ssh_command ──
545
546 #[test]
547 fn parse_upload_pack() {
548 let (op, path) = parse_ssh_command("git-upload-pack '/user/repo.git'").unwrap();
549 assert!(matches!(op, GitOperation::UploadPack));
550 assert_eq!(path, "/user/repo.git");
551 }
552
553 #[test]
554 fn parse_receive_pack() {
555 let (op, path) = parse_ssh_command("git-receive-pack '/user/repo.git'").unwrap();
556 assert!(matches!(op, GitOperation::ReceivePack));
557 assert_eq!(path, "/user/repo.git");
558 }
559
560 #[test]
561 fn parse_upload_archive() {
562 let (op, path) = parse_ssh_command("git-upload-archive '/user/repo.git'").unwrap();
563 assert!(matches!(op, GitOperation::Archive));
564 assert_eq!(path, "/user/repo.git");
565 }
566
567 #[test]
568 fn parse_ssh_command_double_quotes() {
569 let (_, path) = parse_ssh_command(r#"git-upload-pack "/user/repo.git""#).unwrap();
570 assert_eq!(path, "/user/repo.git");
571 }
572
573 #[test]
574 fn parse_ssh_command_unsupported() {
575 assert!(parse_ssh_command("git-foo '/user/repo.git'").is_err());
576 }
577
578 #[test]
579 fn parse_ssh_command_no_space() {
580 assert!(parse_ssh_command("git-upload-pack").is_err());
581 }
582
583 // ── parse_repo_path ──
584
585 #[test]
586 fn parse_valid_repo_path() {
587 let (owner, name) = parse_repo_path("/alice/myrepo.git").unwrap();
588 assert_eq!(owner, "alice");
589 assert_eq!(name, "myrepo");
590 }
591
592 #[test]
593 fn parse_repo_path_no_git_suffix() {
594 let (owner, name) = parse_repo_path("/bob/project").unwrap();
595 assert_eq!(owner, "bob");
596 assert_eq!(name, "project");
597 }
598
599 #[test]
600 fn parse_repo_path_no_leading_slash() {
601 let (owner, name) = parse_repo_path("carol/stuff.git").unwrap();
602 assert_eq!(owner, "carol");
603 assert_eq!(name, "stuff");
604 }
605
606 #[test]
607 fn parse_repo_path_traversal_rejected() {
608 assert!(parse_repo_path("../evil/repo").is_err());
609 assert!(parse_repo_path("user/../repo").is_err());
610 }
611
612 #[test]
613 fn parse_repo_path_missing_repo() {
614 assert!(parse_repo_path("/onlyowner").is_err());
615 }
616
617 #[test]
618 fn parse_repo_path_empty_owner() {
619 assert!(parse_repo_path("//repo").is_err());
620 }
621
622 #[test]
623 fn parse_repo_path_bare_git_suffix_only() {
624 assert!(parse_repo_path("/owner/.git").is_err());
625 }
626
627 // ── parse_management_command ──
628
629 #[test]
630 fn parse_repo_list() {
631 let tokens: Vec<String> = vec!["repo".into(), "list".into()];
632 assert_eq!(parse_management_command(&tokens).unwrap(), ManagementCommand::RepoList);
633 }
634
635 #[test]
636 fn parse_repo_info() {
637 let tokens: Vec<String> = vec!["repo".into(), "info".into(), "docengine".into()];
638 assert_eq!(
639 parse_management_command(&tokens).unwrap(),
640 ManagementCommand::RepoInfo { name: "docengine".into() },
641 );
642 }
643
644 #[test]
645 fn parse_repo_delete_with_confirm() {
646 let tokens: Vec<String> = vec!["repo".into(), "delete".into(), "old".into(), "--confirm".into()];
647 assert_eq!(
648 parse_management_command(&tokens).unwrap(),
649 ManagementCommand::RepoDelete { name: "old".into() },
650 );
651 }
652
653 #[test]
654 fn parse_repo_delete_without_confirm_fails() {
655 let tokens: Vec<String> = vec!["repo".into(), "delete".into(), "old".into()];
656 assert!(parse_management_command(&tokens).is_err());
657 }
658
659 #[test]
660 fn parse_repo_set_visibility() {
661 let tokens: Vec<String> = vec!["repo".into(), "set-visibility".into(), "myrepo".into(), "private".into()];
662 assert_eq!(
663 parse_management_command(&tokens).unwrap(),
664 ManagementCommand::RepoSetVisibility { name: "myrepo".into(), visibility: db::Visibility::Private },
665 );
666 }
667
668 #[test]
669 fn parse_repo_set_visibility_invalid() {
670 let tokens: Vec<String> = vec!["repo".into(), "set-visibility".into(), "myrepo".into(), "secret".into()];
671 assert!(parse_management_command(&tokens).is_err());
672 }
673
674 #[test]
675 fn parse_repo_set_description() {
676 let tokens: Vec<String> = vec!["repo".into(), "set-description".into(), "myrepo".into(), "A new description".into()];
677 assert_eq!(
678 parse_management_command(&tokens).unwrap(),
679 ManagementCommand::RepoSetDescription { name: "myrepo".into(), description: "A new description".into() },
680 );
681 }
682
683 #[test]
684 fn parse_key_list() {
685 let tokens: Vec<String> = vec!["key".into(), "list".into()];
686 assert_eq!(parse_management_command(&tokens).unwrap(), ManagementCommand::KeyList);
687 }
688
689 #[test]
690 fn parse_key_rm() {
691 let tokens: Vec<String> = vec!["key".into(), "rm".into(), "SHA256:abc123".into()];
692 assert_eq!(
693 parse_management_command(&tokens).unwrap(),
694 ManagementCommand::KeyRemove { fingerprint: "SHA256:abc123".into() },
695 );
696 }
697
698 #[test]
699 fn parse_invalid_command() {
700 let tokens: Vec<String> = vec!["frobnicate".into()];
701 assert!(parse_management_command(&tokens).is_err());
702 }
703
704 #[test]
705 fn parse_empty_tokens() {
706 let tokens: Vec<String> = vec![];
707 assert!(parse_management_command(&tokens).is_err());
708 }
709 }
710