Skip to main content

max / makenotwork

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