1use std::path::PathBuf;
2use std::thread;
3use std::time::Duration;
4
5use rns_core::display::prettyb256rep;
6use rns_core::msgpack::{self, Value};
7use rns_core::types::IdentityHash;
8use rns_crypto::identity::Identity;
9use rns_net::link_manager::ResourceStrategy;
10use rns_net::{
11 AnnouncedIdentity, Callbacks, DestHash, Destination, PacketHash, RequestResponse, RnsNode,
12};
13
14use crate::acl::{Access, Operation};
15use crate::config::ServerConfig;
16use crate::logging;
17use crate::pages;
18use crate::protocol;
19use crate::util::{default_reticulum_dir, default_rngit_dir, hex, load_or_create_identity};
20use crate::{git, Error, Result};
21
22pub fn main<I>(args: I) -> Result<()>
23where
24 I: IntoIterator<Item = String>,
25{
26 let options = ServerOptions::parse(args)?;
27 git::check_git_available()?;
28
29 let rngit_dir = options.config_dir.unwrap_or_else(default_rngit_dir);
30 let rns_dir = options.rns_config_dir.or_else(default_reticulum_dir);
31 let config = ServerConfig::load_or_create_for_run(rngit_dir, rns_dir)?;
32 logging::init_file_logger(&config.dir.join("server_log"), config.log_level)?;
33
34 let identity = load_or_create_identity(&config.identity_path)?;
35 if options.print_identity {
36 let client = load_or_create_identity(&config.client_identity_path)?;
37 print_identity(&identity, &client, options.base256, config.serve_nomadnet);
38 return Ok(());
39 }
40
41 run_server(config, identity)
42}
43
44pub fn run_server(config: ServerConfig, identity: Identity) -> Result<()> {
45 let node = RnsNode::from_config(
46 config.reticulum_dir.as_deref(),
47 Box::<ServerCallbacks>::default(),
48 )?;
49
50 let announce_interval = Duration::from_secs(config.announce_interval_secs);
51 let destinations = register_server_destinations(&node, config.clone(), &identity)?;
52
53 loop {
54 thread::sleep(announce_interval);
55 let _ = node.announce(&destinations.repositories, &identity, None);
56 if let Some(page_destination) = destinations.nomadnet.as_ref() {
57 let _ = node.announce(
58 page_destination,
59 &identity,
60 Some(config.node_name.as_bytes()),
61 );
62 }
63 }
64}
65
66#[derive(Debug)]
67pub struct ServerDestinations {
68 pub repositories: Destination,
69 pub nomadnet: Option<Destination>,
70}
71
72pub fn register_server_destinations(
73 node: &RnsNode,
74 config: ServerConfig,
75 identity: &Identity,
76) -> Result<ServerDestinations> {
77 let repositories = register_repository_destination(node, config.clone(), identity)?;
78 let nomadnet = if config.serve_nomadnet {
79 Some(pages::register_nomadnet_destination(
80 node, &config, identity,
81 )?)
82 } else {
83 None
84 };
85 Ok(ServerDestinations {
86 repositories,
87 nomadnet,
88 })
89}
90
91pub fn register_repository_destination(
92 node: &RnsNode,
93 config: ServerConfig,
94 identity: &Identity,
95) -> Result<Destination> {
96 crate::util::ensure_dir(&config.repositories_dir)?;
97 let access = Access::new_with_aliases(
98 &config.allow_read,
99 &config.allow_write,
100 &config.allow_create,
101 &config.allow_stats,
102 &config.allow_release,
103 &config.allow_interact,
104 &config.allow_admin,
105 config.repositories_dir.clone(),
106 config.identity_aliases.clone(),
107 )?
108 .with_propose(&config.allow_propose)?
109 .with_blocked_identities(config.blocked_identities.clone());
110 let destination = Destination::single_in(
111 protocol::APP_NAME,
112 &[protocol::ASPECT_REPOSITORIES],
113 IdentityHash(*identity.hash()),
114 );
115 let public_key = identity
116 .get_public_key()
117 .ok_or_else(|| Error::msg("repository identity has no public key"))?;
118 let private_key = identity
119 .get_private_key()
120 .ok_or_else(|| Error::msg("repository identity has no private key"))?;
121 let sig_prv: [u8; 32] = private_key[32..64].try_into().unwrap();
122 let sig_pub: [u8; 32] = public_key[32..64].try_into().unwrap();
123
124 node.register_link_destination(
125 destination.hash.0,
126 sig_prv,
127 sig_pub,
128 ResourceStrategy::AcceptAll as u8,
129 )
130 .map_err(|_| Error::msg("failed to register link destination"))?;
131 register_handlers(node, config, access)?;
132 node.announce(&destination, identity, None)
133 .map_err(|_| Error::msg("failed to announce rngit destination"))?;
134 Ok(destination)
135}
136
137fn register_handlers(node: &RnsNode, config: ServerConfig, access: Access) -> Result<()> {
138 let list_config = config.clone();
139 let list_access = access.clone();
140 node.register_request_handler(
141 protocol::PATH_LIST,
142 None,
143 move |_link, _path, data, remote| {
144 Some(
145 handle_list(&list_config, &list_access, data, remote)
146 .unwrap_or_else(error_response),
147 )
148 },
149 )
150 .map_err(|_| Error::msg("failed to register list handler"))?;
151
152 let fetch_config = config.clone();
153 let fetch_access = access.clone();
154 node.register_request_handler_response(
155 protocol::PATH_FETCH,
156 None,
157 move |_link, _path, data, remote| {
158 Some(
159 handle_fetch(&fetch_config, &fetch_access, data, remote)
160 .unwrap_or_else(|err| RequestResponse::Bytes(error_response(err))),
161 )
162 },
163 )
164 .map_err(|_| Error::msg("failed to register fetch handler"))?;
165
166 let push_config = config.clone();
167 let push_access = access.clone();
168 node.register_request_handler(
169 protocol::PATH_PUSH,
170 None,
171 move |_link, _path, data, remote| {
172 Some(
173 handle_push(&push_config, &push_access, data, remote)
174 .unwrap_or_else(error_response),
175 )
176 },
177 )
178 .map_err(|_| Error::msg("failed to register push handler"))?;
179
180 let create_config = config.clone();
181 let create_access = access.clone();
182 node.register_request_handler(
183 protocol::PATH_CREATE,
184 None,
185 move |_link, _path, data, remote| {
186 Some(
187 handle_create(&create_config, &create_access, data, remote)
188 .unwrap_or_else(error_response),
189 )
190 },
191 )
192 .map_err(|_| Error::msg("failed to register create handler"))?;
193
194 let fork_config = config.clone();
195 let fork_access = access.clone();
196 node.register_request_handler(
197 protocol::PATH_FORK,
198 None,
199 move |_link, _path, data, remote| {
200 Some(
201 handle_fork(&fork_config, &fork_access, data, remote)
202 .unwrap_or_else(error_response),
203 )
204 },
205 )
206 .map_err(|_| Error::msg("failed to register fork handler"))?;
207
208 let sync_config = config.clone();
209 let sync_access = access.clone();
210 node.register_request_handler(
211 protocol::PATH_SYNC,
212 None,
213 move |_link, _path, data, remote| {
214 Some(
215 handle_sync(&sync_config, &sync_access, data, remote)
216 .unwrap_or_else(error_response),
217 )
218 },
219 )
220 .map_err(|_| Error::msg("failed to register sync handler"))?;
221
222 let mirror_config = config.clone();
223 let mirror_access = access.clone();
224 node.register_request_handler(
225 protocol::PATH_MIRROR,
226 None,
227 move |_link, _path, data, remote| {
228 Some(
229 handle_mirror(&mirror_config, &mirror_access, data, remote)
230 .unwrap_or_else(error_response),
231 )
232 },
233 )
234 .map_err(|_| Error::msg("failed to register mirror handler"))?;
235
236 let release_config = config.clone();
237 let release_access = access.clone();
238 node.register_request_handler_response(
239 protocol::PATH_RELEASE,
240 None,
241 move |_link, _path, data, remote| {
242 Some(
243 handle_release_response(&release_config, &release_access, data, remote)
244 .unwrap_or_else(|err| RequestResponse::Bytes(error_response(err))),
245 )
246 },
247 )
248 .map_err(|_| Error::msg("failed to register release handler"))?;
249
250 let work_config = config.clone();
251 let work_access = access.clone();
252 node.register_request_handler(
253 protocol::PATH_WORK,
254 None,
255 move |_link, _path, data, remote| {
256 Some(
257 handle_work(&work_config, &work_access, data, remote)
258 .unwrap_or_else(error_response),
259 )
260 },
261 )
262 .map_err(|_| Error::msg("failed to register work handler"))?;
263
264 let perms_config = config.clone();
265 let perms_access = access.clone();
266 node.register_request_handler(
267 protocol::PATH_PERMS,
268 None,
269 move |_link, _path, data, remote| {
270 Some(
271 handle_perms(&perms_config, &perms_access, data, remote)
272 .unwrap_or_else(error_response),
273 )
274 },
275 )
276 .map_err(|_| Error::msg("failed to register permissions handler"))?;
277
278 node.register_request_handler(
279 protocol::PATH_DELETE,
280 None,
281 move |_link, _path, data, remote| {
282 Some(handle_delete(&config, &access, data, remote).unwrap_or_else(error_response))
283 },
284 )
285 .map_err(|_| Error::msg("failed to register delete handler"))?;
286
287 Ok(())
288}
289
290pub fn handle_list(
291 config: &ServerConfig,
292 access: &Access,
293 data: &[u8],
294 remote: Option<&([u8; 16], [u8; 64])>,
295) -> Result<Vec<u8>> {
296 let repo = protocol::repository_from_request(data)?;
297 let remote_hash = remote.map(|(hash, _)| hash);
298 if !access.allows(Operation::Read, &repo, remote_hash)? {
299 return Ok(protocol::status_bytes(
300 protocol::RES_DISALLOWED,
301 b"read denied",
302 ));
303 }
304 let path = git::repository_path(&config.repositories_dir, &repo)?;
305 match git::list_refs_text(&path) {
306 Ok(refs) => Ok(protocol::status_bytes(protocol::RES_OK, refs)),
307 Err(err) if err.to_string() == "repository not found" => Ok(protocol::status_bytes(
308 protocol::RES_NOT_FOUND,
309 b"repository not found",
310 )),
311 Err(err) => Ok(remote_error_response("list", &repo, err)),
312 }
313}
314
315pub fn handle_fetch(
316 config: &ServerConfig,
317 access: &Access,
318 data: &[u8],
319 remote: Option<&([u8; 16], [u8; 64])>,
320) -> Result<RequestResponse> {
321 let (repo, have, requested_refs) = protocol::parse_fetch_request(data)?;
322 let requested_refnames = requested_refs
323 .iter()
324 .map(|(_, refname)| refname.clone())
325 .collect::<Vec<_>>();
326 let requested_shas = requested_refs
327 .iter()
328 .map(|(sha, _)| sha.clone())
329 .collect::<Vec<_>>();
330 let remote_hash = remote.map(|(hash, _)| hash);
331 if !access.allows(Operation::Read, &repo, remote_hash)? {
332 return Ok(RequestResponse::Bytes(protocol::status_bytes(
333 protocol::RES_DISALLOWED,
334 b"read denied",
335 )));
336 }
337 if let Err(err) = git::validate_shas(&have) {
338 return Ok(RequestResponse::Bytes(protocol::status_bytes(
339 protocol::RES_INVALID_REQ,
340 err.to_string(),
341 )));
342 }
343 if let Err(err) = git::validate_shas(&requested_shas) {
344 return Ok(RequestResponse::Bytes(protocol::status_bytes(
345 protocol::RES_INVALID_REQ,
346 err.to_string(),
347 )));
348 }
349 for refname in &requested_refnames {
350 if let Err(err) = git::validate_namespaced_ref(refname) {
351 return Ok(RequestResponse::Bytes(protocol::status_bytes(
352 protocol::RES_INVALID_REQ,
353 err.to_string(),
354 )));
355 }
356 }
357 let path = git::repository_path(&config.repositories_dir, &repo)?;
358 match git::create_bundle_for_refs(&path, &have, &requested_refnames) {
359 Ok(bundle) if bundle.is_empty() => {
360 crate::stats::record_fetch(config, &repo, remote_hash);
361 Ok(RequestResponse::Bytes(protocol::status_bytes(
362 protocol::RES_OK,
363 Vec::new(),
364 )))
365 }
366 Ok(bundle) => {
367 crate::stats::record_fetch(config, &repo, remote_hash);
368 Ok(RequestResponse::Resource {
369 data: bundle,
370 metadata: Some(protocol::metadata_status(protocol::RES_OK)),
371 auto_compress: true,
372 })
373 }
374 Err(err) if err.to_string() == "repository not found" => Ok(RequestResponse::Bytes(
375 protocol::status_bytes(protocol::RES_NOT_FOUND, b"repository not found"),
376 )),
377 Err(err) => Ok(RequestResponse::Bytes(remote_error_response(
378 "fetch", &repo, err,
379 ))),
380 }
381}
382
383pub fn handle_push(
384 config: &ServerConfig,
385 access: &Access,
386 data: &[u8],
387 remote: Option<&([u8; 16], [u8; 64])>,
388) -> Result<Vec<u8>> {
389 let (repo, bundle, updates) = protocol::parse_push_request(data)?;
390 let remote_hash = remote.map(|(hash, _)| hash);
391 let path = git::repository_path(&config.repositories_dir, &repo)?;
392 if let Err(err) = git::validate_ref_updates(&updates) {
393 return Ok(protocol::status_bytes(
394 protocol::RES_INVALID_REQ,
395 err.to_string(),
396 ));
397 }
398 let op = if git::is_bare_repository(&path) {
399 Operation::Write
400 } else {
401 Operation::Create
402 };
403 if !access.allows(op, &repo, remote_hash)? {
404 let message = match op {
405 Operation::Create => b"create denied".as_slice(),
406 Operation::Write => b"write denied".as_slice(),
407 Operation::Read => b"read denied".as_slice(),
408 Operation::Stats => b"stats denied".as_slice(),
409 Operation::Release => b"release denied".as_slice(),
410 Operation::Interact => b"interact denied".as_slice(),
411 Operation::Propose => b"propose denied".as_slice(),
412 Operation::Admin => b"admin denied".as_slice(),
413 };
414 return Ok(protocol::status_bytes(protocol::RES_DISALLOWED, message));
415 }
416 match git::apply_push(&path, &bundle, &updates) {
417 Ok(()) => {
418 crate::stats::record_push(config, &repo, remote_hash);
419 Ok(protocol::status_bytes(protocol::RES_OK, b"ok"))
420 }
421 Err(err) => Ok(remote_error_response("push", &repo, err)),
422 }
423}
424
425pub fn handle_create(
426 config: &ServerConfig,
427 access: &Access,
428 data: &[u8],
429 remote: Option<&([u8; 16], [u8; 64])>,
430) -> Result<Vec<u8>> {
431 let Some((remote_hash, _)) = remote else {
432 return Ok(protocol::status_bytes(
433 protocol::RES_DISALLOWED,
434 b"not identified",
435 ));
436 };
437 let repo = protocol::repository_from_request(data)?;
438 let Some((group, name)) = repo.split_once('/') else {
439 return Ok(protocol::status_bytes(
440 protocol::RES_INVALID_REQ,
441 b"invalid request",
442 ));
443 };
444 if group.is_empty() || name.is_empty() || name.contains('/') {
445 return Ok(protocol::status_bytes(
446 protocol::RES_INVALID_REQ,
447 b"invalid request",
448 ));
449 }
450
451 let group_path = config.repositories_dir.join(group);
452 if !group_path.is_dir() {
453 return Ok(protocol::status_bytes(
454 protocol::RES_NOT_FOUND,
455 b"not found",
456 ));
457 }
458
459 let read_access = access.allows(Operation::Read, &repo, Some(remote_hash))?;
460 let create_access = access.allows(Operation::Create, &repo, Some(remote_hash))?;
461 if !create_access {
462 let (code, message) = if read_access {
463 (protocol::RES_DISALLOWED, b"not allowed".as_slice())
464 } else {
465 (protocol::RES_NOT_FOUND, b"not found".as_slice())
466 };
467 return Ok(protocol::status_bytes(code, message));
468 }
469
470 let repository_path = git::repository_path(&config.repositories_dir, &repo)?;
471 if repository_path.exists() {
472 let (code, message) = if read_access {
473 (
474 protocol::RES_DISALLOWED,
475 b"repository already exists".as_slice(),
476 )
477 } else {
478 (protocol::RES_NOT_FOUND, b"not found".as_slice())
479 };
480 return Ok(protocol::status_bytes(code, message));
481 }
482
483 if let Err(err) = git::ensure_bare_repository(&repository_path) {
484 let _ = std::fs::remove_dir_all(&repository_path);
485 return Ok(remote_error_response("create", &repo, err));
486 }
487
488 let allowed_path = config.repositories_dir.join(format!("{repo}.allowed"));
489 let permissions = format!("adm:{}\n", hex(remote_hash));
490 if let Err(err) = std::fs::write(&allowed_path, permissions) {
491 let _ = std::fs::remove_dir_all(&repository_path);
492 return Ok(remote_error_response("create", &repo, err));
493 }
494
495 Ok(protocol::status_bytes(protocol::RES_OK, b"ok"))
496}
497
498pub fn handle_fork(
499 config: &ServerConfig,
500 access: &Access,
501 data: &[u8],
502 remote: Option<&([u8; 16], [u8; 64])>,
503) -> Result<Vec<u8>> {
504 handle_remote_clone(config, access, data, remote, "fork")
505}
506
507pub fn handle_mirror(
508 config: &ServerConfig,
509 access: &Access,
510 data: &[u8],
511 remote: Option<&([u8; 16], [u8; 64])>,
512) -> Result<Vec<u8>> {
513 handle_remote_clone(config, access, data, remote, "mirror")
514}
515
516fn handle_remote_clone(
517 config: &ServerConfig,
518 access: &Access,
519 data: &[u8],
520 remote: Option<&([u8; 16], [u8; 64])>,
521 repository_type: &str,
522) -> Result<Vec<u8>> {
523 let Some((remote_hash, _)) = remote else {
524 return Ok(protocol::status_bytes(
525 protocol::RES_DISALLOWED,
526 b"not identified",
527 ));
528 };
529 let (repo, source) = match protocol::parse_remote_clone_request(data) {
530 Ok(request) => request,
531 Err(err) => {
532 return Ok(protocol::status_bytes(
533 protocol::RES_INVALID_REQ,
534 err.to_string(),
535 ));
536 }
537 };
538 let Some((group, name)) = repo.split_once('/') else {
539 return Ok(protocol::status_bytes(
540 protocol::RES_INVALID_REQ,
541 b"invalid request",
542 ));
543 };
544 if group.is_empty() || name.is_empty() || name.contains('/') {
545 return Ok(protocol::status_bytes(
546 protocol::RES_INVALID_REQ,
547 b"invalid request",
548 ));
549 }
550
551 let group_path = config.repositories_dir.join(group);
552 if !group_path.is_dir() {
553 return Ok(protocol::status_bytes(
554 protocol::RES_NOT_FOUND,
555 b"not found",
556 ));
557 }
558
559 let read_access = access.allows(Operation::Read, &repo, Some(remote_hash))?;
560 let create_access = access.allows(Operation::Create, &repo, Some(remote_hash))?;
561 if !create_access {
562 let (code, message) = if read_access {
563 (protocol::RES_DISALLOWED, b"not allowed".as_slice())
564 } else {
565 (protocol::RES_NOT_FOUND, b"not found".as_slice())
566 };
567 return Ok(protocol::status_bytes(code, message));
568 }
569
570 let repository_path = git::repository_path(&config.repositories_dir, &repo)?;
571 if repository_path.exists() {
572 let (code, message) = if read_access {
573 (
574 protocol::RES_DISALLOWED,
575 b"repository already exists".as_slice(),
576 )
577 } else {
578 (protocol::RES_NOT_FOUND, b"not found".as_slice())
579 };
580 return Ok(protocol::status_bytes(code, message));
581 }
582
583 if let Err(err) = git::clone_remote_bare(&source, &repository_path, repository_type) {
584 let _ = std::fs::remove_dir_all(&repository_path);
585 return Ok(remote_error_response(repository_type, &repo, err));
586 }
587
588 let allowed_path = config.repositories_dir.join(format!("{repo}.allowed"));
589 let permissions = format!("adm:{}\n", hex(remote_hash));
590 if let Err(err) = std::fs::write(&allowed_path, permissions) {
591 let _ = std::fs::remove_dir_all(&repository_path);
592 return Ok(remote_error_response(repository_type, &repo, err));
593 }
594
595 Ok(protocol::status_bytes(protocol::RES_OK, b"ok"))
596}
597
598pub fn handle_sync(
599 config: &ServerConfig,
600 access: &Access,
601 data: &[u8],
602 remote: Option<&([u8; 16], [u8; 64])>,
603) -> Result<Vec<u8>> {
604 let Some((remote_hash, _)) = remote else {
605 return Ok(protocol::status_bytes(
606 protocol::RES_DISALLOWED,
607 b"not identified",
608 ));
609 };
610 let repo = protocol::repository_from_request(data)?;
611 let remote_hash = Some(remote_hash);
612 let read_access = access.allows(Operation::Read, &repo, remote_hash)?;
613 let write_access = access.allows(Operation::Write, &repo, remote_hash)?;
614 if !read_access {
615 return Ok(protocol::status_bytes(
616 protocol::RES_NOT_FOUND,
617 b"not found",
618 ));
619 }
620 if !write_access {
621 return Ok(protocol::status_bytes(
622 protocol::RES_DISALLOWED,
623 b"not allowed",
624 ));
625 }
626
627 let repository_path = git::repository_path(&config.repositories_dir, &repo)?;
628 if !git::is_bare_repository(&repository_path) {
629 return Ok(protocol::status_bytes(
630 protocol::RES_NOT_FOUND,
631 b"not found",
632 ));
633 }
634 let repository_type = git::repository_config(&repository_path, "repository.rngit.type")?;
635 if !matches!(repository_type.as_deref(), Some("fork" | "mirror")) {
636 return Ok(protocol::status_bytes(
637 protocol::RES_INVALID_REQ,
638 b"repository is neither fork nor mirror",
639 ));
640 }
641 if let Err(_err) = git::sync_upstream(&repository_path) {
642 let message = match repository_type.as_deref() {
643 Some("mirror") => b"mirror sync failed".as_slice(),
644 Some("fork") => b"fork sync failed".as_slice(),
645 _ => b"sync failed".as_slice(),
646 };
647 return Ok(protocol::status_bytes(protocol::RES_REMOTE_FAIL, message));
648 }
649
650 if let Some(kind) = repository_type.as_deref() {
651 log::info!("rngit {kind} {repo} synced successfully");
652 }
653
654 Ok(protocol::status_bytes(protocol::RES_OK, b"ok"))
655}
656
657pub fn handle_delete(
658 config: &ServerConfig,
659 access: &Access,
660 data: &[u8],
661 remote: Option<&([u8; 16], [u8; 64])>,
662) -> Result<Vec<u8>> {
663 let repo = protocol::repository_from_request(data)?;
664 let remote_hash = remote.map(|(hash, _)| hash);
665 if !access.allows(Operation::Write, &repo, remote_hash)? {
666 return Ok(protocol::status_bytes(
667 protocol::RES_DISALLOWED,
668 b"write denied",
669 ));
670 }
671 let path = git::repository_path(&config.repositories_dir, &repo)?;
672 if !path.exists() {
673 return Ok(protocol::status_bytes(
674 protocol::RES_NOT_FOUND,
675 b"repository not found",
676 ));
677 }
678 if let Err(err) = std::fs::remove_dir_all(path) {
679 return Ok(remote_error_response("delete", &repo, err));
680 }
681 Ok(protocol::status_bytes(protocol::RES_OK, b"deleted"))
682}
683
684pub fn handle_release(
685 config: &ServerConfig,
686 access: &Access,
687 data: &[u8],
688 remote: Option<&([u8; 16], [u8; 64])>,
689) -> Result<Vec<u8>> {
690 match handle_release_response(config, access, data, remote)? {
691 RequestResponse::Bytes(data) => Ok(data),
692 RequestResponse::Resource { data, .. } => {
693 Ok(protocol::status_bytes(protocol::RES_OK, data))
694 }
695 }
696}
697
698pub fn handle_release_response(
699 config: &ServerConfig,
700 access: &Access,
701 data: &[u8],
702 remote: Option<&([u8; 16], [u8; 64])>,
703) -> Result<RequestResponse> {
704 let Some((remote_hash, _)) = remote else {
705 return Ok(RequestResponse::Bytes(protocol::status_bytes(
706 protocol::RES_DISALLOWED,
707 b"not identified",
708 )));
709 };
710 let request = crate::release::parse_request(data)?;
711 let repo = request.repository.as_str();
712 if !access.allows(Operation::Read, repo, Some(remote_hash))? {
713 return Ok(RequestResponse::Bytes(protocol::status_bytes(
714 protocol::RES_NOT_FOUND,
715 b"not found",
716 )));
717 }
718 let release_access = access.allows(Operation::Release, repo, Some(remote_hash))?;
719 let permitted = match request.operation.as_str() {
720 "list" | "view" | "fetch" => true,
721 "create" | "delete" | "latest" => release_access,
722 _ => false,
723 };
724 if !permitted {
725 return Ok(RequestResponse::Bytes(protocol::status_bytes(
726 protocol::RES_DISALLOWED,
727 b"not allowed",
728 )));
729 }
730
731 let repository_path = git::repository_path(&config.repositories_dir, repo)?;
732 if !git::is_bare_repository(&repository_path) {
733 return Ok(RequestResponse::Bytes(protocol::status_bytes(
734 protocol::RES_NOT_FOUND,
735 b"repository not found",
736 )));
737 }
738 let releases_path = crate::release::release_sidecar_path(&repository_path);
739 let response = match request.operation.as_str() {
740 "list" => RequestResponse::Bytes(crate::release::list_response(&releases_path)?),
741 "view" => {
742 let Some(tag) = request.tag.as_deref() else {
743 return Ok(RequestResponse::Bytes(protocol::status_bytes(
744 protocol::RES_INVALID_REQ,
745 b"no tag specified",
746 )));
747 };
748 RequestResponse::Bytes(crate::release::view_response(&releases_path, tag)?)
749 }
750 "fetch" => crate::release::fetch_response(&releases_path, &request)?,
751 "create" => match request.step.as_deref() {
752 Some("init") => RequestResponse::Bytes(crate::release::create_init(
753 &releases_path,
754 &repository_path,
755 &request,
756 remote_hash,
757 )?),
758 Some("artifact") => {
759 RequestResponse::Bytes(crate::release::create_artifact(&releases_path, &request)?)
760 }
761 Some("finalize") => {
762 RequestResponse::Bytes(crate::release::create_finalize(&releases_path, &request)?)
763 }
764 _ => RequestResponse::Bytes(protocol::status_bytes(
765 protocol::RES_INVALID_REQ,
766 b"invalid request",
767 )),
768 },
769 "delete" => {
770 RequestResponse::Bytes(crate::release::delete_release(&releases_path, &request)?)
771 }
772 "latest" => RequestResponse::Bytes(crate::release::set_latest_release(
773 &releases_path,
774 &request,
775 )?),
776 _ => RequestResponse::Bytes(protocol::status_bytes(
777 protocol::RES_INVALID_REQ,
778 b"invalid request",
779 )),
780 };
781 Ok(response)
782}
783
784pub fn handle_perms(
785 config: &ServerConfig,
786 access: &Access,
787 data: &[u8],
788 remote: Option<&([u8; 16], [u8; 64])>,
789) -> Result<Vec<u8>> {
790 let Some((remote_hash, _)) = remote else {
791 return Ok(protocol::status_bytes(
792 protocol::RES_DISALLOWED,
793 b"not identified",
794 ));
795 };
796 let value =
797 msgpack::unpack_exact(data).map_err(|err| Error::msg(format!("invalid msgpack: {err}")))?;
798 let map = value
799 .as_map()
800 .ok_or_else(|| Error::msg("request must be a msgpack map"))?;
801 let operation = map_get_str(map, "operation").unwrap_or("");
802 match operation {
803 "gperms" => {
804 let group = map_get_index_str(map, protocol::IDX_GROUP).unwrap_or("");
805 handle_group_permissions(config, access, group, map, remote_hash)
806 }
807 "rperms" => {
808 let repo = map_get_index_str(map, protocol::IDX_REPOSITORY).unwrap_or("");
809 handle_repository_permissions(config, access, repo, map, remote_hash)
810 }
811 _ => Ok(protocol::status_bytes(
812 protocol::RES_INVALID_REQ,
813 b"invalid request",
814 )),
815 }
816}
817
818fn handle_group_permissions(
819 config: &ServerConfig,
820 access: &Access,
821 group: &str,
822 map: &[(Value, Value)],
823 remote_hash: &[u8; 16],
824) -> Result<Vec<u8>> {
825 if !valid_group_name(group) {
826 return Ok(protocol::status_bytes(
827 protocol::RES_INVALID_REQ,
828 b"invalid group",
829 ));
830 }
831 let group_dir = config.repositories_dir.join(group);
832 if !group_dir.is_dir() {
833 return Ok(protocol::status_bytes(
834 protocol::RES_NOT_FOUND,
835 b"not found",
836 ));
837 }
838 let synthetic_repo = format!("{group}/__group__");
839 let read_access = access.allows(Operation::Read, &synthetic_repo, Some(remote_hash))?;
840 let admin_access = access.allows(Operation::Admin, &synthetic_repo, Some(remote_hash))?;
841 if !read_access {
842 return Ok(protocol::status_bytes(
843 protocol::RES_NOT_FOUND,
844 b"not found",
845 ));
846 }
847 if !admin_access {
848 return Ok(protocol::status_bytes(
849 protocol::RES_DISALLOWED,
850 b"not allowed",
851 ));
852 }
853 let allowed_path = config.repositories_dir.join(format!("{group}.allowed"));
854 handle_permissions_step(config, map, &allowed_path)
855}
856
857fn handle_repository_permissions(
858 config: &ServerConfig,
859 access: &Access,
860 repo: &str,
861 map: &[(Value, Value)],
862 remote_hash: &[u8; 16],
863) -> Result<Vec<u8>> {
864 if let Err(err) = crate::util::validate_repo_name(repo) {
865 return Ok(protocol::status_bytes(
866 protocol::RES_INVALID_REQ,
867 err.to_string(),
868 ));
869 }
870 let read_access = access.allows(Operation::Read, repo, Some(remote_hash))?;
871 let admin_access = access.allows(Operation::Admin, repo, Some(remote_hash))?;
872 let repo_path = git::repository_path(&config.repositories_dir, repo)?;
873 if !git::is_bare_repository(&repo_path) {
874 return Ok(protocol::status_bytes(
875 protocol::RES_NOT_FOUND,
876 b"not found",
877 ));
878 }
879 if !read_access {
880 return Ok(protocol::status_bytes(
881 protocol::RES_NOT_FOUND,
882 b"not found",
883 ));
884 }
885 if !admin_access {
886 return Ok(protocol::status_bytes(
887 protocol::RES_DISALLOWED,
888 b"not allowed",
889 ));
890 }
891 let allowed_path = config.repositories_dir.join(format!("{repo}.allowed"));
892 handle_permissions_step(config, map, &allowed_path)
893}
894
895fn handle_permissions_step(
896 config: &ServerConfig,
897 map: &[(Value, Value)],
898 allowed_path: &std::path::Path,
899) -> Result<Vec<u8>> {
900 match map_get_str(map, "step") {
901 Some("get") => {
902 let content = match std::fs::read_to_string(allowed_path) {
903 Ok(content) => content,
904 Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
905 Err(err) => return Err(err.into()),
906 };
907 Ok(protocol::status_bytes(
908 protocol::RES_OK,
909 msgpack::pack(&Value::Map(vec![(
910 Value::Str("content".into()),
911 Value::Str(content),
912 )])),
913 ))
914 }
915 Some("set") => {
916 let content = map_get_str(map, "content").unwrap_or("");
917 if let Err(err) =
918 crate::acl::validate_allowed_input_with_aliases(content, &config.identity_aliases)
919 {
920 return Ok(protocol::status_bytes(
921 protocol::RES_INVALID_REQ,
922 format!("invalid permissions: {err}"),
923 ));
924 }
925 write_permissions_file(allowed_path, content)?;
926 Ok(protocol::status_bytes(protocol::RES_OK, b""))
927 }
928 Some(_) => Ok(protocol::status_bytes(
929 protocol::RES_INVALID_REQ,
930 b"invalid step",
931 )),
932 None => Ok(protocol::status_bytes(
933 protocol::RES_INVALID_REQ,
934 b"invalid request",
935 )),
936 }
937}
938
939fn write_permissions_file(path: &std::path::Path, content: &str) -> Result<()> {
940 if let Some(parent) = path.parent() {
941 std::fs::create_dir_all(parent)?;
942 }
943 let tmp = path.with_extension("allowed.tmp");
944 std::fs::write(&tmp, content)?;
945 std::fs::rename(tmp, path)?;
946 Ok(())
947}
948
949fn valid_group_name(group: &str) -> bool {
950 !group.is_empty() && !group.contains('/') && crate::util::validate_repo_name(group).is_ok()
951}
952
953fn map_get_str<'a>(map: &'a [(Value, Value)], key: &str) -> Option<&'a str> {
954 map.iter().find_map(|(candidate, value)| match candidate {
955 Value::Str(candidate) if candidate == key => value.as_str(),
956 _ => None,
957 })
958}
959
960fn map_get_index_str<'a>(map: &'a [(Value, Value)], key: u64) -> Option<&'a str> {
961 map.iter().find_map(|(candidate, value)| match candidate {
962 Value::UInt(candidate) if *candidate == key => value.as_str(),
963 _ => None,
964 })
965}
966
967pub fn handle_work(
968 config: &ServerConfig,
969 access: &Access,
970 data: &[u8],
971 remote: Option<&([u8; 16], [u8; 64])>,
972) -> Result<Vec<u8>> {
973 let Some((remote_hash, remote_pubkey)) = remote else {
974 return Ok(protocol::status_bytes(
975 protocol::RES_DISALLOWED,
976 b"not identified",
977 ));
978 };
979 let request = crate::work::parse_request(data)?;
980 let repo = request.repository.as_str();
981 if !access.allows(Operation::Read, repo, Some(remote_hash))? {
982 return Ok(protocol::status_bytes(
983 protocol::RES_NOT_FOUND,
984 b"not found",
985 ));
986 }
987 let repository_path = git::repository_path(&config.repositories_dir, repo)?;
988 if !git::is_bare_repository(&repository_path) {
989 return Ok(protocol::status_bytes(
990 protocol::RES_NOT_FOUND,
991 b"repository not found",
992 ));
993 }
994 let work_path = crate::work::work_sidecar_path(&repository_path);
995 let mut interact_access = access.allows(Operation::Interact, repo, Some(remote_hash))?;
996 let propose_access = access.allows(Operation::Propose, repo, Some(remote_hash))?;
997 if request.operation == "comment" {
998 if let Some(doc_id) = request.doc_id {
999 interact_access |= crate::work::document_permission_allows(
1000 &work_path,
1001 doc_id,
1002 Operation::Interact,
1003 Some(remote_hash),
1004 )?;
1005 }
1006 }
1007 let mut write_access = access.allows(Operation::Write, repo, Some(remote_hash))?;
1008 if request.operation == "edit" {
1009 if let Some(doc_id) = request.doc_id {
1010 interact_access |= crate::work::document_permission_allows(
1011 &work_path,
1012 doc_id,
1013 Operation::Interact,
1014 Some(remote_hash),
1015 )?;
1016 write_access |= crate::work::document_permission_allows(
1017 &work_path,
1018 doc_id,
1019 Operation::Write,
1020 Some(remote_hash),
1021 )?;
1022 }
1023 }
1024 let manage_access = interact_access && write_access;
1025 let permitted = match request.operation.as_str() {
1026 "list" | "view" => true,
1027 "comment" => interact_access,
1028 "propose" => propose_access,
1029 "create" | "edit" | "delete" | "complete" | "activate" => manage_access,
1030 "perms" => true,
1031 _ => false,
1032 };
1033 if !permitted {
1034 return Ok(protocol::status_bytes(
1035 protocol::RES_DISALLOWED,
1036 b"not allowed",
1037 ));
1038 }
1039
1040 match request.operation.as_str() {
1041 "list" => {
1042 let scope = request
1043 .scope
1044 .as_deref()
1045 .map(crate::work::WorkListScope::parse)
1046 .unwrap_or(Some(crate::work::WorkListScope::Active))
1047 .ok_or_else(|| Error::msg("invalid scope"))?;
1048 crate::work::list_response(&work_path, scope)
1049 }
1050 "view" => {
1051 let doc_id = request
1052 .doc_id
1053 .ok_or_else(|| Error::msg("no document ID specified"))?;
1054 match work_scope_for_document(&work_path, request.scope.as_deref(), doc_id)? {
1055 Some(scope) => crate::work::view_response(&work_path, scope, doc_id),
1056 None => Ok(protocol::status_bytes(
1057 protocol::RES_NOT_FOUND,
1058 b"not found",
1059 )),
1060 }
1061 }
1062 "create" => {
1063 let content = request.content.unwrap_or_default();
1064 let signature = request.signature;
1065 let identity =
1066 match validate_work_signature(&content, signature.as_deref(), remote_pubkey) {
1067 Ok(identity) => identity,
1068 Err(err) => return Ok(work_error_response(err)),
1069 };
1070 work_status_result(
1071 crate::work::create_document(
1072 &work_path,
1073 crate::work::WorkInput {
1074 title: request.title.unwrap_or_default(),
1075 content,
1076 format: request.format.unwrap_or_else(|| "markdown".into()),
1077 signature,
1078 identity,
1079 author: *remote_hash,
1080 },
1081 )
1082 .map(crate::work::created_response),
1083 )
1084 }
1085 "propose" => {
1086 let content = request.content.unwrap_or_default();
1087 let signature = request.signature;
1088 let identity =
1089 match validate_work_signature(&content, signature.as_deref(), remote_pubkey) {
1090 Ok(Some(identity)) => Some(identity),
1091 Ok(None) => {
1092 return Ok(work_error_response(Error::msg("no signature provided")))
1093 }
1094 Err(err) => return Ok(work_error_response(err)),
1095 };
1096 work_status_result(
1097 crate::work::propose_document(
1098 &work_path,
1099 crate::work::WorkInput {
1100 title: request.title.unwrap_or_default(),
1101 content,
1102 format: request.format.unwrap_or_else(|| "markdown".into()),
1103 signature,
1104 identity,
1105 author: *remote_hash,
1106 },
1107 )
1108 .map(crate::work::created_response),
1109 )
1110 }
1111 "edit" => {
1112 let doc_id = request
1113 .doc_id
1114 .ok_or_else(|| Error::msg("no document ID specified"))?;
1115 let signature = request.signature;
1116 let identity = if let Some(content) = request.content.as_deref() {
1117 match validate_work_signature(content, signature.as_deref(), remote_pubkey) {
1118 Ok(identity) => identity,
1119 Err(err) => return Ok(work_error_response(err)),
1120 }
1121 } else {
1122 None
1123 };
1124 match work_scope_for_document(&work_path, request.scope.as_deref(), doc_id)? {
1125 Some(scope) => work_status_result(
1126 crate::work::edit_document(
1127 &work_path,
1128 scope,
1129 doc_id,
1130 remote_hash,
1131 crate::work::WorkEdit {
1132 title: request.title,
1133 content: request.content,
1134 signature,
1135 identity,
1136 },
1137 )
1138 .map(|_| protocol::status_bytes(protocol::RES_OK, b"")),
1139 ),
1140 None => Ok(protocol::status_bytes(
1141 protocol::RES_NOT_FOUND,
1142 b"not found",
1143 )),
1144 }
1145 }
1146 "delete" => {
1147 let doc_id = request
1148 .doc_id
1149 .ok_or_else(|| Error::msg("no document ID specified"))?;
1150 let scope = work_scope(request.scope.as_deref())?;
1151 work_status_result(
1152 crate::work::delete_document(&work_path, scope, doc_id, remote_hash)
1153 .map(|_| protocol::status_bytes(protocol::RES_OK, b"")),
1154 )
1155 }
1156 "comment" => {
1157 let doc_id = request
1158 .doc_id
1159 .ok_or_else(|| Error::msg("no document ID specified"))?;
1160 let scope = work_scope(request.scope.as_deref())?;
1161 work_status_result(
1162 crate::work::add_comment(
1163 &work_path,
1164 scope,
1165 doc_id,
1166 crate::work::WorkCommentInput {
1167 content: request.content.unwrap_or_default(),
1168 format: request.format.unwrap_or_else(|| "markdown".into()),
1169 signature: request.signature,
1170 author: *remote_hash,
1171 },
1172 )
1173 .map(crate::work::comment_response),
1174 )
1175 }
1176 "complete" => {
1177 let doc_id = request
1178 .doc_id
1179 .ok_or_else(|| Error::msg("no document ID specified"))?;
1180 work_status_result(
1181 crate::work::complete_document(&work_path, doc_id, remote_hash).map(|_| {
1182 crate::work::transition_response(doc_id, crate::work::WorkScope::Completed)
1183 }),
1184 )
1185 }
1186 "activate" => {
1187 let doc_id = request
1188 .doc_id
1189 .ok_or_else(|| Error::msg("no document ID specified"))?;
1190 work_status_result(
1191 crate::work::activate_document(&work_path, doc_id, remote_hash).map(|_| {
1192 crate::work::transition_response(doc_id, crate::work::WorkScope::Active)
1193 }),
1194 )
1195 }
1196 "perms" => {
1197 let doc_id = request
1198 .doc_id
1199 .ok_or_else(|| Error::msg("no document ID specified"))?;
1200 let allowed =
1201 match work_permissions_allowed(&work_path, doc_id, remote_hash, manage_access) {
1202 Ok(allowed) => allowed,
1203 Err(err) => return Ok(work_error_response(err)),
1204 };
1205 if !allowed {
1206 return Ok(protocol::status_bytes(
1207 protocol::RES_DISALLOWED,
1208 b"not allowed",
1209 ));
1210 }
1211 match request.step.as_deref() {
1212 Some("get") => work_status_result(
1213 crate::work::get_document_permissions(&work_path, doc_id)
1214 .map(crate::work::permissions_response),
1215 ),
1216 Some("set") => work_status_result(
1217 crate::work::set_document_permissions(
1218 &work_path,
1219 doc_id,
1220 request.content.as_deref().unwrap_or(""),
1221 )
1222 .map(|_| protocol::status_bytes(protocol::RES_OK, b"")),
1223 ),
1224 Some(_) => Ok(protocol::status_bytes(
1225 protocol::RES_INVALID_REQ,
1226 b"invalid step",
1227 )),
1228 None => Ok(protocol::status_bytes(
1229 protocol::RES_INVALID_REQ,
1230 b"invalid request",
1231 )),
1232 }
1233 }
1234 _ => Ok(protocol::status_bytes(
1235 protocol::RES_INVALID_REQ,
1236 b"invalid request",
1237 )),
1238 }
1239}
1240
1241fn work_permissions_allowed(
1242 work_path: &std::path::Path,
1243 doc_id: u64,
1244 remote_hash: &[u8; 16],
1245 manage_access: bool,
1246) -> Result<bool> {
1247 let is_author = crate::work::document_author(work_path, doc_id)? == *remote_hash;
1248 let doc_admin = crate::work::document_permission_allows(
1249 work_path,
1250 doc_id,
1251 Operation::Admin,
1252 Some(remote_hash),
1253 )?;
1254 Ok((is_author && manage_access) || doc_admin)
1255}
1256
1257fn validate_work_signature(
1258 content: &str,
1259 signature: Option<&[u8]>,
1260 remote_pubkey: &[u8; 64],
1261) -> Result<Option<Vec<u8>>> {
1262 let Some(signature) = signature else {
1263 return Ok(None);
1264 };
1265 if signature.len() != 64 {
1266 return Err(Error::msg("invalid signature"));
1267 }
1268 let signature: &[u8; 64] = signature.try_into().unwrap();
1269 let identity = Identity::from_public_key(remote_pubkey);
1270 if !identity.verify(signature, content.trim().as_bytes()) {
1271 return Err(Error::msg("invalid signature"));
1272 }
1273 Ok(Some(remote_pubkey.to_vec()))
1274}
1275
1276fn work_scope(scope: Option<&str>) -> Result<crate::work::WorkScope> {
1277 scope
1278 .map(crate::work::WorkScope::parse)
1279 .unwrap_or(Some(crate::work::WorkScope::Active))
1280 .ok_or_else(|| Error::msg("invalid scope"))
1281}
1282
1283fn work_scope_for_document(
1284 work_path: &std::path::Path,
1285 scope: Option<&str>,
1286 doc_id: u64,
1287) -> Result<Option<crate::work::WorkScope>> {
1288 match scope.unwrap_or("active") {
1289 "all" => crate::work::find_document_scope(work_path, doc_id),
1290 value => crate::work::WorkScope::parse(value)
1291 .map(Some)
1292 .ok_or_else(|| Error::msg("invalid scope")),
1293 }
1294}
1295
1296fn work_status_result(result: Result<Vec<u8>>) -> Result<Vec<u8>> {
1297 match result {
1298 Ok(response) => Ok(response),
1299 Err(err) => Ok(work_error_response(err)),
1300 }
1301}
1302
1303fn work_error_response(err: Error) -> Vec<u8> {
1304 let message = err.to_string();
1305 let code = if message.starts_with("invalid permission")
1306 || message.starts_with("invalid hex")
1307 || message.starts_with("expected 32 hex characters")
1308 {
1309 protocol::RES_INVALID_REQ
1310 } else {
1311 match message.as_str() {
1312 "document not found" => protocol::RES_NOT_FOUND,
1313 "no access, not author" => {
1314 return protocol::status_bytes(protocol::RES_DISALLOWED, b"not allowed");
1315 }
1316 "title is required"
1317 | "content is required"
1318 | "no signature provided"
1319 | "invalid signature"
1320 | "content limit exceeded"
1321 | "no changes specified" => protocol::RES_INVALID_REQ,
1322 _ => protocol::RES_REMOTE_FAIL,
1323 }
1324 };
1325 protocol::status_bytes(code, message)
1326}
1327
1328fn error_response(err: Error) -> Vec<u8> {
1329 protocol::status_bytes(protocol::RES_INVALID_REQ, err.to_string())
1330}
1331
1332fn remote_error_response(context: &str, repository: &str, err: impl std::fmt::Display) -> Vec<u8> {
1333 log::error!("rngit {context} failed for {repository}: {err}");
1334 protocol::status_bytes(protocol::RES_REMOTE_FAIL, b"Remote error")
1335}
1336
1337fn repository_destination(identity: &Identity) -> Destination {
1338 Destination::single_in(
1339 protocol::APP_NAME,
1340 &[protocol::ASPECT_REPOSITORIES],
1341 IdentityHash(*identity.hash()),
1342 )
1343}
1344
1345fn identity_report(
1346 identity: &Identity,
1347 client: &Identity,
1348 base256: bool,
1349 serve_nomadnet: bool,
1350) -> String {
1351 let destination = repository_destination(identity);
1352 let mut out = String::new();
1353 out.push_str(&format!("client_identity = {}\n", hex(client.hash())));
1354 if base256 {
1355 out.push_str(&format!(
1356 "client_identity_b256 = {}\n",
1357 prettyb256rep(client.hash())
1358 ));
1359 }
1360 out.push_str(&format!("repository_identity = {}\n", hex(identity.hash())));
1361 if base256 {
1362 out.push_str(&format!(
1363 "repository_identity_b256 = {}",
1364 prettyb256rep(identity.hash())
1365 ));
1366 out.push('\n');
1367 }
1368 out.push_str(&format!("destination = {}\n", hex(&destination.hash.0)));
1369 if base256 {
1370 out.push_str(&format!(
1371 "destination_b256 = {}\n",
1372 prettyb256rep(&destination.hash.0)
1373 ));
1374 }
1375 if serve_nomadnet {
1376 let page_destination = pages::destination_for_identity(identity);
1377 out.push_str(&format!(
1378 "nomadnet_destination = {}\n",
1379 hex(&page_destination.hash.0)
1380 ));
1381 if base256 {
1382 out.push_str(&format!(
1383 "nomadnet_destination_b256 = {}\n",
1384 prettyb256rep(&page_destination.hash.0)
1385 ));
1386 }
1387 }
1388 out
1389}
1390
1391fn print_identity(identity: &Identity, client: &Identity, base256: bool, serve_nomadnet: bool) {
1392 print!(
1393 "{}",
1394 identity_report(identity, client, base256, serve_nomadnet)
1395 );
1396}
1397
1398#[derive(Default)]
1399struct ServerCallbacks;
1400
1401impl Callbacks for ServerCallbacks {
1402 fn on_announce(&mut self, _announced: AnnouncedIdentity) {}
1403
1404 fn on_path_updated(&mut self, _dest_hash: DestHash, _hops: u8) {}
1405
1406 fn on_local_delivery(&mut self, _dest_hash: DestHash, _raw: Vec<u8>, _packet_hash: PacketHash) {
1407 }
1408}
1409
1410#[derive(Debug, Default)]
1411struct ServerOptions {
1412 config_dir: Option<PathBuf>,
1413 rns_config_dir: Option<PathBuf>,
1414 print_identity: bool,
1415 base256: bool,
1416}
1417
1418impl ServerOptions {
1419 fn parse<I>(args: I) -> Result<Self>
1420 where
1421 I: IntoIterator<Item = String>,
1422 {
1423 let mut options = ServerOptions::default();
1424 let mut args = args.into_iter();
1425 while let Some(arg) = args.next() {
1426 match arg.as_str() {
1427 "-c" | "--config" => {
1428 options.config_dir = Some(PathBuf::from(
1429 args.next()
1430 .ok_or_else(|| Error::msg("missing config path"))?,
1431 ));
1432 }
1433 "--rnsconfig" => {
1434 options.rns_config_dir = Some(PathBuf::from(
1435 args.next()
1436 .ok_or_else(|| Error::msg("missing RNS config path"))?,
1437 ));
1438 }
1439 "--print-identity" => options.print_identity = true,
1440 "-Z" | "--base256" => options.base256 = true,
1441 "--service" | "--interactive" => {
1442 return Err(Error::msg(format!(
1443 "{arg} is not supported by this rngit binary\n{}",
1444 usage()
1445 )))
1446 }
1447 "-h" | "--help" => return Err(Error::msg(usage())),
1448 other => {
1449 return Err(Error::msg(format!(
1450 "unknown argument: {other}\n{}",
1451 usage()
1452 )))
1453 }
1454 }
1455 }
1456 Ok(options)
1457 }
1458}
1459
1460fn usage() -> &'static str {
1461 "usage: rngit [--config DIR] [--rnsconfig DIR] [--print-identity] [-Z|--base256]"
1462}
1463
1464#[cfg(test)]
1465mod tests {
1466 use super::*;
1467 use rns_core::msgpack::{self, Value};
1468 use rns_crypto::OsRng;
1469
1470 const REMOTE: [u8; 16] = [0x44; 16];
1471 const OTHER_REMOTE: [u8; 16] = [0x55; 16];
1472 const REMOTE_SIG: [u8; 64] = [0x66; 64];
1473
1474 fn cfg(root: &std::path::Path) -> ServerConfig {
1475 ServerConfig {
1476 dir: root.to_path_buf(),
1477 reticulum_dir: None,
1478 repositories_dir: root.join("repositories"),
1479 identity_path: root.join("repositories_identity"),
1480 client_identity_path: root.join("client_identity"),
1481 node_name: "Anonymous Git Node".into(),
1482 announce_interval_secs: 300,
1483 serve_nomadnet: false,
1484 templates_dir: root.join("templates"),
1485 unicode_icons: false,
1486 record_stats: false,
1487 stats_ignore_identities: Vec::new(),
1488 stats_push_ignore_identities: Vec::new(),
1489 blocked_identities: Vec::new(),
1490 identity_aliases: std::collections::BTreeMap::new(),
1491 allow_read: vec!["all".into()],
1492 allow_write: vec!["all".into()],
1493 allow_create: vec!["all".into()],
1494 allow_stats: vec!["none".into()],
1495 allow_release: vec!["none".into()],
1496 allow_interact: vec!["none".into()],
1497 allow_propose: vec!["none".into()],
1498 allow_admin: vec!["none".into()],
1499 log_level: logging::DEFAULT_LOG_LEVEL,
1500 }
1501 }
1502
1503 #[test]
1504 fn parses_base256_print_identity_options() {
1505 let opts = ServerOptions::parse(vec![
1506 "--print-identity".to_string(),
1507 "--base256".to_string(),
1508 ])
1509 .unwrap();
1510 assert!(opts.print_identity);
1511 assert!(opts.base256);
1512
1513 let short = ServerOptions::parse(vec!["-Z".to_string()]).unwrap();
1514 assert!(short.base256);
1515 }
1516
1517 #[test]
1518 fn rejects_unsupported_service_mode_flags() {
1519 let service = ServerOptions::parse(vec!["--service".to_string()]).unwrap_err();
1520 assert!(service.to_string().contains("not supported"));
1521
1522 let interactive = ServerOptions::parse(vec!["--interactive".to_string()]).unwrap_err();
1523 assert!(interactive.to_string().contains("not supported"));
1524 }
1525
1526 #[test]
1527 fn repository_destination_uses_git_repositories_name() {
1528 let identity = Identity::new(&mut OsRng);
1529 let destination = repository_destination(&identity);
1530 let expected =
1531 Destination::single_in("git", &["repositories"], IdentityHash(*identity.hash()));
1532 assert_eq!(destination.hash, expected.hash);
1533 }
1534
1535 #[test]
1536 fn identity_report_includes_nomadnet_destination_only_when_enabled() {
1537 let identity = Identity::new(&mut OsRng);
1538 let client = Identity::new(&mut OsRng);
1539
1540 let without_pages = identity_report(&identity, &client, false, false);
1541 assert!(!without_pages.contains("nomadnet_destination"));
1542
1543 let with_pages = identity_report(&identity, &client, true, true);
1544 let nomadnet = pages::destination_for_identity(&identity);
1545 assert!(with_pages.contains(&format!("nomadnet_destination = {}", hex(&nomadnet.hash.0))));
1546 assert!(with_pages.contains("nomadnet_destination_b256 = "));
1547 }
1548
1549 #[test]
1550 fn list_missing_repo_returns_not_found_status() {
1551 let tmp = tempfile::tempdir().unwrap();
1552 let config = cfg(tmp.path());
1553 let access = Access::new(
1554 &config.allow_read,
1555 &config.allow_write,
1556 &config.allow_create,
1557 &config.allow_stats,
1558 &config.allow_release,
1559 &config.allow_interact,
1560 &config.allow_admin,
1561 config.repositories_dir.clone(),
1562 )
1563 .unwrap();
1564 let req = protocol::repository_request("group/repo");
1565 let resp = handle_list(&config, &access, &req, None).unwrap();
1566 assert_eq!(resp[0], protocol::RES_NOT_FOUND);
1567 }
1568
1569 #[test]
1570 fn push_is_blocked_by_acl() {
1571 let tmp = tempfile::tempdir().unwrap();
1572 let mut config = cfg(tmp.path());
1573 config.allow_write = vec!["none".into()];
1574 config.allow_create = vec!["none".into()];
1575 let access = Access::new(
1576 &config.allow_read,
1577 &config.allow_write,
1578 &config.allow_create,
1579 &config.allow_stats,
1580 &config.allow_release,
1581 &config.allow_interact,
1582 &config.allow_admin,
1583 config.repositories_dir.clone(),
1584 )
1585 .unwrap();
1586 let req = protocol::push_request("repo", Vec::new(), Vec::new());
1587 let resp = handle_push(&config, &access, &req, None).unwrap();
1588 assert_eq!(resp[0], protocol::RES_DISALLOWED);
1589 }
1590
1591 #[test]
1592 fn push_can_create_missing_repo_with_global_create() {
1593 let tmp = tempfile::tempdir().unwrap();
1594 let mut config = cfg(tmp.path());
1595 config.allow_write = vec!["none".into()];
1596 config.allow_create = vec!["all".into()];
1597 let access = Access::new(
1598 &config.allow_read,
1599 &config.allow_write,
1600 &config.allow_create,
1601 &config.allow_stats,
1602 &config.allow_release,
1603 &config.allow_interact,
1604 &config.allow_admin,
1605 config.repositories_dir.clone(),
1606 )
1607 .unwrap();
1608 let req = protocol::push_request("repo", Vec::new(), Vec::new());
1609 let resp = handle_push(&config, &access, &req, None).unwrap();
1610 assert_eq!(resp[0], protocol::RES_OK);
1611 assert!(git::is_bare_repository(
1612 &config.repositories_dir.join("repo")
1613 ));
1614 }
1615
1616 #[test]
1617 fn global_write_alone_cannot_create_missing_repo() {
1618 let tmp = tempfile::tempdir().unwrap();
1619 let mut config = cfg(tmp.path());
1620 config.allow_write = vec!["all".into()];
1621 config.allow_create = vec!["none".into()];
1622 let access = Access::new(
1623 &config.allow_read,
1624 &config.allow_write,
1625 &config.allow_create,
1626 &config.allow_stats,
1627 &config.allow_release,
1628 &config.allow_interact,
1629 &config.allow_admin,
1630 config.repositories_dir.clone(),
1631 )
1632 .unwrap();
1633 let req = protocol::push_request("repo", Vec::new(), Vec::new());
1634 let resp = handle_push(&config, &access, &req, None).unwrap();
1635 assert_eq!(resp[0], protocol::RES_DISALLOWED);
1636 assert!(!config.repositories_dir.join("repo").exists());
1637 }
1638
1639 #[test]
1640 fn existing_repo_push_still_requires_write() {
1641 let tmp = tempfile::tempdir().unwrap();
1642 let mut config = cfg(tmp.path());
1643 config.allow_write = vec!["none".into()];
1644 config.allow_create = vec!["all".into()];
1645 git::ensure_bare_repository(&config.repositories_dir.join("repo")).unwrap();
1646 let access = Access::new(
1647 &config.allow_read,
1648 &config.allow_write,
1649 &config.allow_create,
1650 &config.allow_stats,
1651 &config.allow_release,
1652 &config.allow_interact,
1653 &config.allow_admin,
1654 config.repositories_dir.clone(),
1655 )
1656 .unwrap();
1657 let req = protocol::push_request("repo", Vec::new(), Vec::new());
1658 let resp = handle_push(&config, &access, &req, None).unwrap();
1659 assert_eq!(resp[0], protocol::RES_DISALLOWED);
1660 }
1661
1662 #[test]
1663 fn repo_allowed_file_can_grant_create_for_missing_bare_repo() {
1664 let tmp = tempfile::tempdir().unwrap();
1665 let mut config = cfg(tmp.path());
1666 config.allow_write = vec!["none".into()];
1667 config.allow_create = vec!["none".into()];
1668 let repo = config.repositories_dir.join("group/repo");
1669 std::fs::create_dir_all(&repo).unwrap();
1670 std::fs::write(repo.join(".allowed"), "create = all\n").unwrap();
1671 let access = Access::new(
1672 &config.allow_read,
1673 &config.allow_write,
1674 &config.allow_create,
1675 &config.allow_stats,
1676 &config.allow_release,
1677 &config.allow_interact,
1678 &config.allow_admin,
1679 config.repositories_dir.clone(),
1680 )
1681 .unwrap();
1682 let req = protocol::push_request("group/repo", Vec::new(), Vec::new());
1683 let resp = handle_push(&config, &access, &req, None).unwrap();
1684 assert_eq!(resp[0], protocol::RES_OK);
1685 assert!(git::is_bare_repository(&repo));
1686 }
1687
1688 #[test]
1689 fn repo_sidecar_allowed_file_can_grant_create_for_missing_bare_repo() {
1690 let tmp = tempfile::tempdir().unwrap();
1691 let mut config = cfg(tmp.path());
1692 config.allow_write = vec!["none".into()];
1693 config.allow_create = vec!["none".into()];
1694 let repo = config.repositories_dir.join("group/repo");
1695 std::fs::create_dir_all(&repo).unwrap();
1696 std::fs::write(
1697 config.repositories_dir.join("group/repo.allowed"),
1698 "create = all\n",
1699 )
1700 .unwrap();
1701 let access = Access::new(
1702 &config.allow_read,
1703 &config.allow_write,
1704 &config.allow_create,
1705 &config.allow_stats,
1706 &config.allow_release,
1707 &config.allow_interact,
1708 &config.allow_admin,
1709 config.repositories_dir.clone(),
1710 )
1711 .unwrap();
1712 let req = protocol::push_request("group/repo", Vec::new(), Vec::new());
1713 let resp = handle_push(&config, &access, &req, None).unwrap();
1714 assert_eq!(resp[0], protocol::RES_OK);
1715 assert!(git::is_bare_repository(&repo));
1716 }
1717
1718 #[test]
1719 fn group_allowed_file_can_grant_create_for_missing_repo() {
1720 let tmp = tempfile::tempdir().unwrap();
1721 let mut config = cfg(tmp.path());
1722 config.allow_write = vec!["none".into()];
1723 config.allow_create = vec!["none".into()];
1724 let group = config.repositories_dir.join("group");
1725 std::fs::create_dir_all(&group).unwrap();
1726 std::fs::write(group.join("group.allowed"), "create = all\n").unwrap();
1727 let access = Access::new(
1728 &config.allow_read,
1729 &config.allow_write,
1730 &config.allow_create,
1731 &config.allow_stats,
1732 &config.allow_release,
1733 &config.allow_interact,
1734 &config.allow_admin,
1735 config.repositories_dir.clone(),
1736 )
1737 .unwrap();
1738 let req = protocol::push_request("group/repo", Vec::new(), Vec::new());
1739 let resp = handle_push(&config, &access, &req, None).unwrap();
1740 assert_eq!(resp[0], protocol::RES_OK);
1741 assert!(git::is_bare_repository(
1742 &config.repositories_dir.join("group/repo")
1743 ));
1744 }
1745
1746 #[test]
1747 fn group_sidecar_allowed_file_can_grant_create_for_missing_repo() {
1748 let tmp = tempfile::tempdir().unwrap();
1749 let mut config = cfg(tmp.path());
1750 config.allow_write = vec!["none".into()];
1751 config.allow_create = vec!["none".into()];
1752 let group = config.repositories_dir.join("group");
1753 std::fs::create_dir_all(&group).unwrap();
1754 std::fs::write(
1755 config.repositories_dir.join("group.allowed"),
1756 "create = all\n",
1757 )
1758 .unwrap();
1759 let access = Access::new(
1760 &config.allow_read,
1761 &config.allow_write,
1762 &config.allow_create,
1763 &config.allow_stats,
1764 &config.allow_release,
1765 &config.allow_interact,
1766 &config.allow_admin,
1767 config.repositories_dir.clone(),
1768 )
1769 .unwrap();
1770 let req = protocol::push_request("group/repo", Vec::new(), Vec::new());
1771 let resp = handle_push(&config, &access, &req, None).unwrap();
1772 assert_eq!(resp[0], protocol::RES_OK);
1773 assert!(git::is_bare_repository(
1774 &config.repositories_dir.join("group/repo")
1775 ));
1776 }
1777
1778 #[test]
1779 fn create_handler_initializes_bare_repo_and_grants_creator_admin() {
1780 let tmp = tempfile::tempdir().unwrap();
1781 let mut config = cfg(tmp.path());
1782 config.allow_create = vec!["all".into()];
1783 let group = config.repositories_dir.join("group");
1784 std::fs::create_dir_all(&group).unwrap();
1785 let access = make_access(&config);
1786 let remote = ([0x11; 16], [0u8; 64]);
1787 let req = protocol::repository_request("group/created");
1788
1789 let resp = handle_create(&config, &access, &req, Some(&remote)).unwrap();
1790
1791 assert_eq!(resp[0], protocol::RES_OK);
1792 assert!(git::is_bare_repository(
1793 &config.repositories_dir.join("group/created")
1794 ));
1795 assert_eq!(
1796 std::fs::read_to_string(config.repositories_dir.join("group/created.allowed")).unwrap(),
1797 "adm:11111111111111111111111111111111\n"
1798 );
1799 assert!(access
1800 .allows(Operation::Write, "group/created", Some(&remote.0))
1801 .unwrap());
1802 }
1803
1804 #[test]
1805 fn create_handler_requires_identified_peer_and_existing_group() {
1806 let tmp = tempfile::tempdir().unwrap();
1807 let mut config = cfg(tmp.path());
1808 config.allow_create = vec!["all".into()];
1809 let access = make_access(&config);
1810 let req = protocol::repository_request("group/created");
1811
1812 let anonymous = handle_create(&config, &access, &req, None).unwrap();
1813 assert_eq!(anonymous[0], protocol::RES_DISALLOWED);
1814
1815 let remote = ([0x11; 16], [0u8; 64]);
1816 let missing_group = handle_create(&config, &access, &req, Some(&remote)).unwrap();
1817 assert_eq!(missing_group[0], protocol::RES_NOT_FOUND);
1818 }
1819
1820 #[test]
1821 fn create_handler_rejects_existing_or_invalid_repositories() {
1822 let tmp = tempfile::tempdir().unwrap();
1823 let mut config = cfg(tmp.path());
1824 config.allow_create = vec!["all".into()];
1825 std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
1826 git::ensure_bare_repository(&config.repositories_dir.join("group/existing")).unwrap();
1827 let access = make_access(&config);
1828 let remote = ([0x11; 16], [0u8; 64]);
1829
1830 let existing = handle_create(
1831 &config,
1832 &access,
1833 &protocol::repository_request("group/existing"),
1834 Some(&remote),
1835 )
1836 .unwrap();
1837 assert_eq!(existing[0], protocol::RES_DISALLOWED);
1838 assert_eq!(&existing[1..], b"repository already exists");
1839
1840 let nested = handle_create(
1841 &config,
1842 &access,
1843 &protocol::repository_request("group/nested/repo"),
1844 Some(&remote),
1845 )
1846 .unwrap();
1847 assert_eq!(nested[0], protocol::RES_INVALID_REQ);
1848 }
1849
1850 #[test]
1851 fn fork_handler_fetches_source_repo_and_records_metadata() {
1852 let tmp = tempfile::tempdir().unwrap();
1853 let source = create_source_repo(tmp.path());
1854 let mut config = cfg(tmp.path());
1855 config.allow_create = vec!["all".into()];
1856 std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
1857 let access = make_access(&config);
1858 let remote = ([0x22; 16], [0u8; 64]);
1859 let req =
1860 protocol::remote_clone_request("group/forked", source.to_str().expect("utf-8 path"));
1861
1862 let resp = handle_fork(&config, &access, &req, Some(&remote)).unwrap();
1863
1864 let target = config.repositories_dir.join("group/forked");
1865 assert_eq!(resp[0], protocol::RES_OK);
1866 assert!(git::is_bare_repository(&target));
1867 assert!(!git::list_refs(&target).unwrap().is_empty());
1868 assert_eq!(git_config(&target, "repository.rngit.type"), "fork");
1869 assert_eq!(
1870 git_config(&target, "repository.rngit.upstream.source"),
1871 source.to_string_lossy()
1872 );
1873 assert!(git_config(&target, "repository.rngit.upstream.sync")
1874 .parse::<u64>()
1875 .is_ok());
1876 assert_eq!(
1877 std::fs::read_to_string(config.repositories_dir.join("group/forked.allowed")).unwrap(),
1878 "adm:22222222222222222222222222222222\n"
1879 );
1880 }
1881
1882 #[test]
1883 fn mirror_handler_records_mirror_type() {
1884 let tmp = tempfile::tempdir().unwrap();
1885 let source = create_source_repo_on_branch(tmp.path(), "main");
1886 let mut config = cfg(tmp.path());
1887 config.allow_create = vec!["all".into()];
1888 std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
1889 let access = make_access(&config);
1890 let remote = ([0x22; 16], [0u8; 64]);
1891 let req =
1892 protocol::remote_clone_request("group/mirrored", source.to_str().expect("utf-8 path"));
1893
1894 let resp = handle_mirror(&config, &access, &req, Some(&remote)).unwrap();
1895
1896 let target = config.repositories_dir.join("group/mirrored");
1897 assert_eq!(resp[0], protocol::RES_OK);
1898 assert_eq!(git_config(&target, "repository.rngit.type"), "mirror");
1899 assert_eq!(git_symbolic_ref(&target, "HEAD"), "refs/heads/main");
1900 assert!(git_config(&target, "repository.rngit.upstream.sync")
1901 .parse::<u64>()
1902 .is_ok());
1903 }
1904
1905 #[test]
1906 fn remote_clone_rejects_missing_source_and_existing_target() {
1907 let tmp = tempfile::tempdir().unwrap();
1908 let mut config = cfg(tmp.path());
1909 config.allow_create = vec!["all".into()];
1910 std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
1911 git::ensure_bare_repository(&config.repositories_dir.join("group/existing")).unwrap();
1912 let access = make_access(&config);
1913 let remote = ([0x22; 16], [0u8; 64]);
1914
1915 let missing_source = handle_fork(
1916 &config,
1917 &access,
1918 &protocol::repository_request("group/new"),
1919 Some(&remote),
1920 )
1921 .unwrap();
1922 assert_eq!(missing_source[0], protocol::RES_INVALID_REQ);
1923
1924 let existing = handle_fork(
1925 &config,
1926 &access,
1927 &protocol::remote_clone_request("group/existing", "unused"),
1928 Some(&remote),
1929 )
1930 .unwrap();
1931 assert_eq!(existing[0], protocol::RES_DISALLOWED);
1932 assert_eq!(&existing[1..], b"repository already exists");
1933 }
1934
1935 #[test]
1936 fn sync_handler_fetches_upstream_updates_for_fork() {
1937 let tmp = tempfile::tempdir().unwrap();
1938 let source = create_source_repo(tmp.path());
1939 let mut config = cfg(tmp.path());
1940 config.allow_create = vec!["all".into()];
1941 std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
1942 let access = make_access(&config);
1943 let remote = ([0x22; 16], [0u8; 64]);
1944 let fork_req =
1945 protocol::remote_clone_request("group/forked", source.to_str().expect("utf-8 path"));
1946 assert_eq!(
1947 handle_fork(&config, &access, &fork_req, Some(&remote)).unwrap()[0],
1948 protocol::RES_OK
1949 );
1950
1951 let updated_sha = commit_source_file(&source, "second.txt", "second\n");
1952
1953 let resp = handle_sync(
1954 &config,
1955 &access,
1956 &protocol::repository_request("group/forked"),
1957 Some(&remote),
1958 )
1959 .unwrap();
1960
1961 assert_eq!(resp[0], protocol::RES_OK);
1962 assert_eq!(
1963 git_rev_parse(&config.repositories_dir.join("group/forked"), "HEAD"),
1964 updated_sha
1965 );
1966 assert!(git_config(
1967 &config.repositories_dir.join("group/forked"),
1968 "repository.rngit.upstream.sync"
1969 )
1970 .parse::<u64>()
1971 .is_ok());
1972 }
1973
1974 #[test]
1975 fn sync_handler_rejects_non_clone_or_missing_write_access() {
1976 let tmp = tempfile::tempdir().unwrap();
1977 let mut config = cfg(tmp.path());
1978 config.allow_write = vec!["none".into()];
1979 let repo = config.repositories_dir.join("group/plain");
1980 git::ensure_bare_repository(&repo).unwrap();
1981 let access = make_access(&config);
1982 let remote = ([0x22; 16], [0u8; 64]);
1983
1984 let no_write = handle_sync(
1985 &config,
1986 &access,
1987 &protocol::repository_request("group/plain"),
1988 Some(&remote),
1989 )
1990 .unwrap();
1991 assert_eq!(no_write[0], protocol::RES_DISALLOWED);
1992
1993 config.allow_write = vec!["all".into()];
1994 let access = make_access(&config);
1995 let plain = handle_sync(
1996 &config,
1997 &access,
1998 &protocol::repository_request("group/plain"),
1999 Some(&remote),
2000 )
2001 .unwrap();
2002 assert_eq!(plain[0], protocol::RES_INVALID_REQ);
2003 assert_eq!(&plain[1..], b"repository is neither fork nor mirror");
2004 }
2005
2006 #[test]
2007 fn push_rejects_invalid_repository_name_before_create_acl() {
2008 let tmp = tempfile::tempdir().unwrap();
2009 let mut config = cfg(tmp.path());
2010 config.allow_write = vec!["none".into()];
2011 config.allow_create = vec!["all".into()];
2012 let access = Access::new(
2013 &config.allow_read,
2014 &config.allow_write,
2015 &config.allow_create,
2016 &config.allow_stats,
2017 &config.allow_release,
2018 &config.allow_interact,
2019 &config.allow_admin,
2020 config.repositories_dir.clone(),
2021 )
2022 .unwrap();
2023 let req = protocol::push_request("../repo", Vec::new(), Vec::new());
2024 assert!(handle_push(&config, &access, &req, None).is_err());
2025 }
2026
2027 #[test]
2028 fn push_rejects_invalid_ref_before_creating_missing_repo() {
2029 let tmp = tempfile::tempdir().unwrap();
2030 let config = cfg(tmp.path());
2031 let access = make_access(&config);
2032 let req = protocol::push_request(
2033 "group/repo",
2034 Vec::new(),
2035 vec![protocol::RefUpdate {
2036 refname: "-refs/heads/main".into(),
2037 old: None,
2038 new: Some("0123456789abcdef0123456789abcdef01234567".into()),
2039 force: true,
2040 }],
2041 );
2042
2043 let resp = handle_push(&config, &access, &req, None).unwrap();
2044
2045 assert_eq!(resp[0], protocol::RES_INVALID_REQ);
2046 assert!(!config.repositories_dir.join("group/repo").exists());
2047 }
2048
2049 #[test]
2050 fn push_rejects_invalid_sha_before_creating_missing_repo() {
2051 let tmp = tempfile::tempdir().unwrap();
2052 let config = cfg(tmp.path());
2053 let access = make_access(&config);
2054 let req = protocol::push_request(
2055 "group/repo",
2056 Vec::new(),
2057 vec![protocol::RefUpdate {
2058 refname: "refs/heads/main".into(),
2059 old: None,
2060 new: Some("--not-a-sha".into()),
2061 force: true,
2062 }],
2063 );
2064
2065 let resp = handle_push(&config, &access, &req, None).unwrap();
2066
2067 assert_eq!(resp[0], protocol::RES_INVALID_REQ);
2068 assert!(!config.repositories_dir.join("group/repo").exists());
2069 }
2070
2071 #[test]
2072 fn fetch_existing_repo_can_return_ok_status_or_resource() {
2073 let tmp = tempfile::tempdir().unwrap();
2074 let config = cfg(tmp.path());
2075 let access = Access::new(
2076 &config.allow_read,
2077 &config.allow_write,
2078 &config.allow_create,
2079 &config.allow_stats,
2080 &config.allow_release,
2081 &config.allow_interact,
2082 &config.allow_admin,
2083 config.repositories_dir.clone(),
2084 )
2085 .unwrap();
2086 let repo = config.repositories_dir.join("repo");
2087 git::ensure_bare_repository(&repo).unwrap();
2088 let req = protocol::fetch_request("repo", &[]);
2089 match handle_fetch(&config, &access, &req, None).unwrap() {
2090 RequestResponse::Bytes(bytes) => assert_eq!(bytes[0], protocol::RES_OK),
2091 RequestResponse::Resource { metadata, .. } => assert!(metadata.is_some()),
2092 }
2093 }
2094
2095 #[test]
2096 fn fetch_rejects_invalid_have_sha() {
2097 let tmp = tempfile::tempdir().unwrap();
2098 let config = cfg(tmp.path());
2099 let access = make_access(&config);
2100 let repo = config.repositories_dir.join("repo");
2101 git::ensure_bare_repository(&repo).unwrap();
2102 let req = protocol::fetch_request("repo", &["--upload-pack=/tmp/x".into()]);
2103
2104 let response = handle_fetch(&config, &access, &req, None).unwrap();
2105
2106 let RequestResponse::Bytes(bytes) = response else {
2107 panic!("invalid fetch request should return status bytes");
2108 };
2109 assert_eq!(bytes[0], protocol::RES_INVALID_REQ);
2110 }
2111
2112 #[test]
2113 fn git_failures_return_generic_client_errors() {
2114 let tmp = tempfile::tempdir().unwrap();
2115 let config = cfg(tmp.path());
2116 let access = make_access(&config);
2117 let repo = config.repositories_dir.join("broken");
2118 create_corrupt_bare_repo(&repo);
2119
2120 let list = handle_list(
2121 &config,
2122 &access,
2123 &protocol::repository_request("broken"),
2124 None,
2125 )
2126 .unwrap();
2127 assert_generic_remote_error(&list);
2128
2129 let fetch = handle_fetch(
2130 &config,
2131 &access,
2132 &protocol::fetch_request("broken", &[]),
2133 None,
2134 )
2135 .unwrap();
2136 let RequestResponse::Bytes(fetch) = fetch else {
2137 panic!("corrupt repository fetch should return status bytes");
2138 };
2139 assert_generic_remote_error(&fetch);
2140
2141 git::ensure_bare_repository(&config.repositories_dir.join("push-target")).unwrap();
2142 let push = handle_push(
2143 &config,
2144 &access,
2145 &protocol::push_request("push-target", b"not a git bundle".to_vec(), Vec::new()),
2146 None,
2147 )
2148 .unwrap();
2149 assert_generic_remote_error(&push);
2150 }
2151
2152 #[test]
2153 fn work_protocol_enforces_read_interact_and_write_access() {
2154 let tmp = tempfile::tempdir().unwrap();
2155 let mut config = cfg(tmp.path());
2156 git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2157
2158 config.allow_read = vec!["none".into()];
2159 config.allow_write = vec!["all".into()];
2160 config.allow_interact = vec!["all".into()];
2161 let access = make_access(&config);
2162 let list = handle_work(
2163 &config,
2164 &access,
2165 &work_request(&[
2166 ("repository", strv("group/repo")),
2167 ("operation", strv("list")),
2168 ]),
2169 Some(&(REMOTE, REMOTE_SIG)),
2170 )
2171 .unwrap();
2172 assert_eq!(list[0], protocol::RES_NOT_FOUND);
2173
2174 config.allow_read = vec!["all".into()];
2175 config.allow_write = vec!["all".into()];
2176 config.allow_interact = vec!["none".into()];
2177 let access = make_access(&config);
2178 let create = handle_work(
2179 &config,
2180 &access,
2181 &work_request(&[
2182 ("repository", strv("group/repo")),
2183 ("operation", strv("create")),
2184 ("title", strv("Task")),
2185 ("content", strv("Body")),
2186 ]),
2187 Some(&(REMOTE, REMOTE_SIG)),
2188 )
2189 .unwrap();
2190 assert_eq!(create[0], protocol::RES_DISALLOWED);
2191
2192 config.allow_interact = vec!["all".into()];
2193 config.allow_write = vec!["none".into()];
2194 let access = make_access(&config);
2195 let create = handle_work(
2196 &config,
2197 &access,
2198 &work_request(&[
2199 ("repository", strv("group/repo")),
2200 ("operation", strv("create")),
2201 ("title", strv("Task")),
2202 ("content", strv("Body")),
2203 ]),
2204 Some(&(REMOTE, REMOTE_SIG)),
2205 )
2206 .unwrap();
2207 assert_eq!(create[0], protocol::RES_DISALLOWED);
2208
2209 config.allow_write = vec!["all".into()];
2210 let access = make_access(&config);
2211 let create = handle_work(
2212 &config,
2213 &access,
2214 &work_request(&[
2215 ("repository", strv("group/repo")),
2216 ("operation", strv("create")),
2217 ("title", strv("Task")),
2218 ("content", strv("Body")),
2219 ]),
2220 Some(&(REMOTE, REMOTE_SIG)),
2221 )
2222 .unwrap();
2223 assert_eq!(create[0], protocol::RES_OK);
2224
2225 config.allow_write = vec!["none".into()];
2226 let access = make_access(&config);
2227 let comment = handle_work(
2228 &config,
2229 &access,
2230 &work_request(&[
2231 ("repository", strv("group/repo")),
2232 ("operation", strv("comment")),
2233 ("doc_id", uintv(1)),
2234 ("content", strv("Comment")),
2235 ]),
2236 Some(&(REMOTE, REMOTE_SIG)),
2237 )
2238 .unwrap();
2239 assert_eq!(comment[0], protocol::RES_OK);
2240 }
2241
2242 #[test]
2243 fn work_protocol_lifecycle_round_trips_documents_and_comments() {
2244 let tmp = tempfile::tempdir().unwrap();
2245 let mut config = cfg(tmp.path());
2246 config.allow_interact = vec!["all".into()];
2247 git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2248 let access = make_access(&config);
2249
2250 let create = handle_work(
2251 &config,
2252 &access,
2253 &work_request(&[
2254 ("repository", strv("group/repo")),
2255 ("operation", strv("create")),
2256 ("title", strv("Initial title")),
2257 ("content", strv("Initial body")),
2258 ("format", strv("micron")),
2259 ]),
2260 Some(&(REMOTE, REMOTE_SIG)),
2261 )
2262 .unwrap();
2263 let created = body_value(&create);
2264 assert_eq!(created.map_get("id").and_then(Value::as_integer), Some(1));
2265 assert_eq!(
2266 created.map_get("scope").and_then(Value::as_str),
2267 Some("active")
2268 );
2269
2270 let comment = handle_work(
2271 &config,
2272 &access,
2273 &work_request(&[
2274 ("repository", strv("group/repo")),
2275 ("operation", strv("comment")),
2276 ("doc_id", uintv(1)),
2277 ("content", strv("Progress update")),
2278 ("format", strv("markdown")),
2279 ]),
2280 Some(&(REMOTE, REMOTE_SIG)),
2281 )
2282 .unwrap();
2283 assert_eq!(
2284 body_value(&comment)
2285 .map_get("id")
2286 .and_then(Value::as_integer),
2287 Some(1)
2288 );
2289
2290 let view = handle_work(
2291 &config,
2292 &access,
2293 &work_request(&[
2294 ("repository", strv("group/repo")),
2295 ("operation", strv("view")),
2296 ("doc_id", uintv(1)),
2297 ]),
2298 Some(&(REMOTE, REMOTE_SIG)),
2299 )
2300 .unwrap();
2301 let view = body_value(&view);
2302 assert_eq!(
2303 view.map_get("content").and_then(Value::as_str),
2304 Some("Initial body")
2305 );
2306 let comments = view
2307 .map_get("comments")
2308 .and_then(Value::as_array)
2309 .expect("comments array");
2310 assert_eq!(comments.len(), 1);
2311 assert_eq!(
2312 comments[0].map_get("content").and_then(Value::as_str),
2313 Some("Progress update")
2314 );
2315
2316 let edit = handle_work(
2317 &config,
2318 &access,
2319 &work_request(&[
2320 ("repository", strv("group/repo")),
2321 ("operation", strv("edit")),
2322 ("doc_id", uintv(1)),
2323 ("title", strv("Edited title")),
2324 ("content", strv("Edited body")),
2325 ]),
2326 Some(&(REMOTE, REMOTE_SIG)),
2327 )
2328 .unwrap();
2329 assert_eq!(edit, vec![protocol::RES_OK]);
2330
2331 let complete = handle_work(
2332 &config,
2333 &access,
2334 &work_request(&[
2335 ("repository", strv("group/repo")),
2336 ("operation", strv("complete")),
2337 ("doc_id", uintv(1)),
2338 ]),
2339 Some(&(REMOTE, REMOTE_SIG)),
2340 )
2341 .unwrap();
2342 assert_eq!(
2343 body_value(&complete)
2344 .map_get("scope")
2345 .and_then(Value::as_str),
2346 Some("completed")
2347 );
2348
2349 let all = handle_work(
2350 &config,
2351 &access,
2352 &work_request(&[
2353 ("repository", strv("group/repo")),
2354 ("operation", strv("list")),
2355 ("scope", strv("all")),
2356 ]),
2357 Some(&(REMOTE, REMOTE_SIG)),
2358 )
2359 .unwrap();
2360 let all = body_value(&all);
2361 assert_eq!(
2362 all.map_get("active")
2363 .and_then(Value::as_array)
2364 .unwrap()
2365 .len(),
2366 0
2367 );
2368 assert_eq!(
2369 all.map_get("completed")
2370 .and_then(Value::as_array)
2371 .unwrap()
2372 .len(),
2373 1
2374 );
2375
2376 let activate = handle_work(
2377 &config,
2378 &access,
2379 &work_request(&[
2380 ("repository", strv("group/repo")),
2381 ("operation", strv("activate")),
2382 ("doc_id", uintv(1)),
2383 ]),
2384 Some(&(REMOTE, REMOTE_SIG)),
2385 )
2386 .unwrap();
2387 assert_eq!(
2388 body_value(&activate)
2389 .map_get("scope")
2390 .and_then(Value::as_str),
2391 Some("active")
2392 );
2393
2394 let delete = handle_work(
2395 &config,
2396 &access,
2397 &work_request(&[
2398 ("repository", strv("group/repo")),
2399 ("operation", strv("delete")),
2400 ("doc_id", uintv(1)),
2401 ]),
2402 Some(&(REMOTE, REMOTE_SIG)),
2403 )
2404 .unwrap();
2405 assert_eq!(delete, vec![protocol::RES_OK]);
2406 }
2407
2408 #[test]
2409 fn work_view_and_edit_scope_all_return_not_found_for_missing_document() {
2410 let tmp = tempfile::tempdir().unwrap();
2411 let mut config = cfg(tmp.path());
2412 config.allow_write = vec!["all".into()];
2413 config.allow_interact = vec!["all".into()];
2414 git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2415 let access = make_access(&config);
2416
2417 let view = handle_work(
2418 &config,
2419 &access,
2420 &work_request(&[
2421 ("repository", strv("group/repo")),
2422 ("operation", strv("view")),
2423 ("scope", strv("all")),
2424 ("doc_id", uintv(99)),
2425 ]),
2426 Some(&(REMOTE, REMOTE_SIG)),
2427 )
2428 .unwrap();
2429 assert_eq!(view[0], protocol::RES_NOT_FOUND);
2430
2431 let edit = handle_work(
2432 &config,
2433 &access,
2434 &work_request(&[
2435 ("repository", strv("group/repo")),
2436 ("operation", strv("edit")),
2437 ("scope", strv("all")),
2438 ("doc_id", uintv(99)),
2439 ("title", strv("Missing")),
2440 ]),
2441 Some(&(REMOTE, REMOTE_SIG)),
2442 )
2443 .unwrap();
2444 assert_eq!(edit[0], protocol::RES_NOT_FOUND);
2445 }
2446
2447 #[test]
2448 fn work_protocol_proposes_signed_documents_in_proposed_scope() {
2449 let tmp = tempfile::tempdir().unwrap();
2450 let mut config = cfg(tmp.path());
2451 config.allow_write = vec!["none".into()];
2452 config.allow_interact = vec!["none".into()];
2453 config.allow_propose = vec!["all".into()];
2454 git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2455 let access = make_access(&config);
2456 let identity = Identity::new(&mut OsRng);
2457 let remote_hash = *identity.hash();
2458 let remote_pubkey: [u8; 64] = identity.get_public_key().unwrap().try_into().unwrap();
2459 let signature = identity.sign(b"Proposal body").unwrap().to_vec();
2460
2461 let proposed = handle_work(
2462 &config,
2463 &access,
2464 &work_request(&[
2465 ("repository", strv("group/repo")),
2466 ("operation", strv("propose")),
2467 ("title", strv("Proposal")),
2468 ("content", strv("Proposal body")),
2469 ("signature", binv(&signature)),
2470 ]),
2471 Some(&(remote_hash, remote_pubkey)),
2472 )
2473 .unwrap();
2474 let proposed = body_value(&proposed);
2475 assert_eq!(proposed.map_get("id").and_then(Value::as_integer), Some(1));
2476 assert_eq!(
2477 proposed.map_get("scope").and_then(Value::as_str),
2478 Some("proposed")
2479 );
2480
2481 let list = handle_work(
2482 &config,
2483 &access,
2484 &work_request(&[
2485 ("repository", strv("group/repo")),
2486 ("operation", strv("list")),
2487 ("scope", strv("all")),
2488 ]),
2489 Some(&(remote_hash, remote_pubkey)),
2490 )
2491 .unwrap();
2492 let list = body_value(&list);
2493 assert_eq!(
2494 list.map_get("proposed")
2495 .and_then(Value::as_array)
2496 .unwrap()
2497 .len(),
2498 1
2499 );
2500
2501 let edit = handle_work(
2502 &config,
2503 &access,
2504 &work_request(&[
2505 ("repository", strv("group/repo")),
2506 ("operation", strv("edit")),
2507 ("scope", strv("proposed")),
2508 ("doc_id", uintv(1)),
2509 ("title", strv("Updated proposal")),
2510 ]),
2511 Some(&(remote_hash, remote_pubkey)),
2512 )
2513 .unwrap();
2514 assert_eq!(edit, vec![protocol::RES_OK]);
2515
2516 let invalid = handle_work(
2517 &config,
2518 &access,
2519 &work_request(&[
2520 ("repository", strv("group/repo")),
2521 ("operation", strv("propose")),
2522 ("title", strv("Unsigned")),
2523 ("content", strv("Unsigned body")),
2524 ]),
2525 Some(&(remote_hash, remote_pubkey)),
2526 )
2527 .unwrap();
2528 assert_eq!(invalid[0], protocol::RES_INVALID_REQ);
2529 }
2530
2531 #[test]
2532 fn work_protocol_validates_and_exposes_document_signatures() {
2533 let tmp = tempfile::tempdir().unwrap();
2534 let mut config = cfg(tmp.path());
2535 config.allow_interact = vec!["all".into()];
2536 git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2537 let access = make_access(&config);
2538 let identity = Identity::new(&mut OsRng);
2539 let remote_hash = *identity.hash();
2540 let remote_pubkey: [u8; 64] = identity.get_public_key().unwrap().try_into().unwrap();
2541 let signature = identity.sign(b"Signed body").unwrap().to_vec();
2542
2543 let create = handle_work(
2544 &config,
2545 &access,
2546 &work_request(&[
2547 ("repository", strv("group/repo")),
2548 ("operation", strv("create")),
2549 ("title", strv("Signed task")),
2550 ("content", strv("Signed body")),
2551 ("signature", binv(&signature)),
2552 ]),
2553 Some(&(remote_hash, remote_pubkey)),
2554 )
2555 .unwrap();
2556 assert_eq!(create[0], protocol::RES_OK);
2557
2558 let view = handle_work(
2559 &config,
2560 &access,
2561 &work_request(&[
2562 ("repository", strv("group/repo")),
2563 ("operation", strv("view")),
2564 ("doc_id", uintv(1)),
2565 ]),
2566 Some(&(remote_hash, remote_pubkey)),
2567 )
2568 .unwrap();
2569 let view = body_value(&view);
2570 let meta = view.map_get("meta").unwrap();
2571 assert_eq!(
2572 meta.map_get("signature").and_then(Value::as_bin),
2573 Some(signature.as_slice())
2574 );
2575 assert_eq!(
2576 meta.map_get("identity").and_then(Value::as_bin),
2577 Some(remote_pubkey.as_slice())
2578 );
2579
2580 let invalid = handle_work(
2581 &config,
2582 &access,
2583 &work_request(&[
2584 ("repository", strv("group/repo")),
2585 ("operation", strv("create")),
2586 ("title", strv("Invalid task")),
2587 ("content", strv("Different body")),
2588 ("signature", binv(&signature)),
2589 ]),
2590 Some(&(remote_hash, remote_pubkey)),
2591 )
2592 .unwrap();
2593 assert_eq!(invalid[0], protocol::RES_INVALID_REQ);
2594 }
2595
2596 #[test]
2597 fn work_protocol_rejects_non_author_management_operations() {
2598 let tmp = tempfile::tempdir().unwrap();
2599 let mut config = cfg(tmp.path());
2600 config.allow_interact = vec!["all".into()];
2601 git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2602 let access = make_access(&config);
2603
2604 let create = handle_work(
2605 &config,
2606 &access,
2607 &work_request(&[
2608 ("repository", strv("group/repo")),
2609 ("operation", strv("create")),
2610 ("title", strv("Task")),
2611 ("content", strv("Body")),
2612 ]),
2613 Some(&(REMOTE, REMOTE_SIG)),
2614 )
2615 .unwrap();
2616 assert_eq!(create[0], protocol::RES_OK);
2617
2618 for operation in ["edit", "complete", "delete"] {
2619 let denied = handle_work(
2620 &config,
2621 &access,
2622 &work_request(&[
2623 ("repository", strv("group/repo")),
2624 ("operation", strv(operation)),
2625 ("doc_id", uintv(1)),
2626 ("content", strv("Other edit")),
2627 ]),
2628 Some(&(OTHER_REMOTE, REMOTE_SIG)),
2629 )
2630 .unwrap();
2631 assert_eq!(denied[0], protocol::RES_DISALLOWED);
2632 }
2633 }
2634
2635 #[test]
2636 fn work_document_local_interact_allows_comments_without_repo_interact() {
2637 let tmp = tempfile::tempdir().unwrap();
2638 let mut config = cfg(tmp.path());
2639 config.allow_write = vec!["none".into()];
2640 config.allow_interact = vec!["none".into()];
2641 let repo_path = config.repositories_dir.join("group/repo");
2642 git::ensure_bare_repository(&repo_path).unwrap();
2643 let work_path = crate::work::work_sidecar_path(&repo_path);
2644 crate::work::create_document(
2645 &work_path,
2646 crate::work::WorkInput {
2647 title: "Task".into(),
2648 content: "Body".into(),
2649 format: "markdown".into(),
2650 signature: None,
2651 identity: None,
2652 author: REMOTE,
2653 },
2654 )
2655 .unwrap();
2656 std::fs::write(work_path.join("1.allowed"), "interact = all\n").unwrap();
2657 let access = make_access(&config);
2658
2659 let comment = handle_work(
2660 &config,
2661 &access,
2662 &work_request(&[
2663 ("repository", strv("group/repo")),
2664 ("operation", strv("comment")),
2665 ("doc_id", uintv(1)),
2666 ("content", strv("Comment from doc ACL")),
2667 ]),
2668 Some(&(OTHER_REMOTE, REMOTE_SIG)),
2669 )
2670 .unwrap();
2671 assert_eq!(comment[0], protocol::RES_OK);
2672
2673 let edit = handle_work(
2674 &config,
2675 &access,
2676 &work_request(&[
2677 ("repository", strv("group/repo")),
2678 ("operation", strv("edit")),
2679 ("doc_id", uintv(1)),
2680 ("content", strv("Not allowed")),
2681 ]),
2682 Some(&(OTHER_REMOTE, REMOTE_SIG)),
2683 )
2684 .unwrap();
2685 assert_eq!(edit[0], protocol::RES_DISALLOWED);
2686 }
2687
2688 #[test]
2689 fn work_permissions_get_set_and_validation_are_author_gated() {
2690 let tmp = tempfile::tempdir().unwrap();
2691 let mut config = cfg(tmp.path());
2692 config.allow_interact = vec!["all".into()];
2693 let repo_path = config.repositories_dir.join("group/repo");
2694 git::ensure_bare_repository(&repo_path).unwrap();
2695 let work_path = crate::work::work_sidecar_path(&repo_path);
2696 crate::work::create_document(
2697 &work_path,
2698 crate::work::WorkInput {
2699 title: "Task".into(),
2700 content: "Body".into(),
2701 format: "markdown".into(),
2702 signature: None,
2703 identity: None,
2704 author: REMOTE,
2705 },
2706 )
2707 .unwrap();
2708 let access = make_access(&config);
2709
2710 let get = handle_work(
2711 &config,
2712 &access,
2713 &work_request(&[
2714 ("repository", strv("group/repo")),
2715 ("operation", strv("perms")),
2716 ("step", strv("get")),
2717 ("doc_id", uintv(1)),
2718 ]),
2719 Some(&(REMOTE, REMOTE_SIG)),
2720 )
2721 .unwrap();
2722 assert_eq!(
2723 body_value(&get).map_get("content").and_then(Value::as_str),
2724 Some("")
2725 );
2726
2727 let content = format!(
2728 "interact = {}\nadmin = {}\n",
2729 crate::util::hex(&OTHER_REMOTE),
2730 crate::util::hex(&OTHER_REMOTE)
2731 );
2732 let set = handle_work(
2733 &config,
2734 &access,
2735 &work_request(&[
2736 ("repository", strv("group/repo")),
2737 ("operation", strv("perms")),
2738 ("step", strv("set")),
2739 ("doc_id", uintv(1)),
2740 ("content", strv(&content)),
2741 ]),
2742 Some(&(REMOTE, REMOTE_SIG)),
2743 )
2744 .unwrap();
2745 assert_eq!(set, vec![protocol::RES_OK]);
2746 assert_eq!(
2747 std::fs::read_to_string(work_path.join("1.allowed")).unwrap(),
2748 content
2749 );
2750
2751 let denied = handle_work(
2752 &config,
2753 &access,
2754 &work_request(&[
2755 ("repository", strv("group/repo")),
2756 ("operation", strv("perms")),
2757 ("step", strv("get")),
2758 ("doc_id", uintv(1)),
2759 ]),
2760 Some(&([0x33; 16], REMOTE_SIG)),
2761 )
2762 .unwrap();
2763 assert_eq!(denied[0], protocol::RES_DISALLOWED);
2764
2765 let invalid = handle_work(
2766 &config,
2767 &access,
2768 &work_request(&[
2769 ("repository", strv("group/repo")),
2770 ("operation", strv("perms")),
2771 ("step", strv("set")),
2772 ("doc_id", uintv(1)),
2773 ("content", strv("interact = not-a-hex-identity\n")),
2774 ]),
2775 Some(&(REMOTE, REMOTE_SIG)),
2776 )
2777 .unwrap();
2778 assert_eq!(invalid[0], protocol::RES_INVALID_REQ);
2779 assert_eq!(
2780 std::fs::read_to_string(work_path.join("1.allowed")).unwrap(),
2781 content
2782 );
2783
2784 let missing = handle_work(
2785 &config,
2786 &access,
2787 &work_request(&[
2788 ("repository", strv("group/repo")),
2789 ("operation", strv("perms")),
2790 ("step", strv("get")),
2791 ("doc_id", uintv(99)),
2792 ]),
2793 Some(&(REMOTE, REMOTE_SIG)),
2794 )
2795 .unwrap();
2796 assert_eq!(missing[0], protocol::RES_NOT_FOUND);
2797 }
2798
2799 #[test]
2800 fn work_document_admin_can_get_and_set_permissions() {
2801 let tmp = tempfile::tempdir().unwrap();
2802 let mut config = cfg(tmp.path());
2803 config.allow_write = vec!["none".into()];
2804 config.allow_interact = vec!["none".into()];
2805 let repo_path = config.repositories_dir.join("group/repo");
2806 git::ensure_bare_repository(&repo_path).unwrap();
2807 let work_path = crate::work::work_sidecar_path(&repo_path);
2808 crate::work::create_document(
2809 &work_path,
2810 crate::work::WorkInput {
2811 title: "Task".into(),
2812 content: "Body".into(),
2813 format: "markdown".into(),
2814 signature: None,
2815 identity: None,
2816 author: REMOTE,
2817 },
2818 )
2819 .unwrap();
2820 std::fs::write(
2821 work_path.join("1.allowed"),
2822 format!("admin = {}\n", crate::util::hex(&OTHER_REMOTE)),
2823 )
2824 .unwrap();
2825 let access = make_access(&config);
2826
2827 let get = handle_work(
2828 &config,
2829 &access,
2830 &work_request(&[
2831 ("repository", strv("group/repo")),
2832 ("operation", strv("perms")),
2833 ("step", strv("get")),
2834 ("doc_id", uintv(1)),
2835 ]),
2836 Some(&(OTHER_REMOTE, REMOTE_SIG)),
2837 )
2838 .unwrap();
2839 assert_eq!(get[0], protocol::RES_OK);
2840
2841 let set = handle_work(
2842 &config,
2843 &access,
2844 &work_request(&[
2845 ("repository", strv("group/repo")),
2846 ("operation", strv("perms")),
2847 ("step", strv("set")),
2848 ("doc_id", uintv(1)),
2849 ("content", strv("interact = all\n")),
2850 ]),
2851 Some(&(OTHER_REMOTE, REMOTE_SIG)),
2852 )
2853 .unwrap();
2854 assert_eq!(set, vec![protocol::RES_OK]);
2855 assert_eq!(
2856 std::fs::read_to_string(work_path.join("1.allowed")).unwrap(),
2857 "interact = all\n"
2858 );
2859 }
2860
2861 #[test]
2862 fn repository_permissions_get_set_and_validate_allowed_file() {
2863 let tmp = tempfile::tempdir().unwrap();
2864 let mut config = cfg(tmp.path());
2865 config.allow_admin = vec![crate::util::hex(&REMOTE)];
2866 let repo_path = config.repositories_dir.join("group/repo");
2867 git::ensure_bare_repository(&repo_path).unwrap();
2868 let access = make_access(&config);
2869
2870 let get = handle_perms(
2871 &config,
2872 &access,
2873 &work_request(&[
2874 ("repository", strv("group/repo")),
2875 ("operation", strv("rperms")),
2876 ("step", strv("get")),
2877 ]),
2878 Some(&(REMOTE, REMOTE_SIG)),
2879 )
2880 .unwrap();
2881 assert_eq!(
2882 body_value(&get).map_get("content").and_then(Value::as_str),
2883 Some("")
2884 );
2885
2886 let set = handle_perms(
2887 &config,
2888 &access,
2889 &work_request(&[
2890 ("repository", strv("group/repo")),
2891 ("operation", strv("rperms")),
2892 ("step", strv("set")),
2893 (
2894 "content",
2895 strv(
2896 "write = all
2897",
2898 ),
2899 ),
2900 ]),
2901 Some(&(REMOTE, REMOTE_SIG)),
2902 )
2903 .unwrap();
2904 assert_eq!(set, vec![protocol::RES_OK]);
2905 assert_eq!(
2906 std::fs::read_to_string(config.repositories_dir.join("group/repo.allowed")).unwrap(),
2907 "write = all
2908"
2909 );
2910
2911 let invalid = handle_perms(
2912 &config,
2913 &access,
2914 &work_request(&[
2915 ("repository", strv("group/repo")),
2916 ("operation", strv("rperms")),
2917 ("step", strv("set")),
2918 (
2919 "content",
2920 strv(
2921 "write = not-a-hex-identity
2922",
2923 ),
2924 ),
2925 ]),
2926 Some(&(REMOTE, REMOTE_SIG)),
2927 )
2928 .unwrap();
2929 assert_eq!(invalid[0], protocol::RES_INVALID_REQ);
2930 assert_eq!(
2931 std::fs::read_to_string(config.repositories_dir.join("group/repo.allowed")).unwrap(),
2932 "write = all
2933"
2934 );
2935 }
2936
2937 #[test]
2938 fn group_permissions_write_group_sidecar_allowed_file() {
2939 let tmp = tempfile::tempdir().unwrap();
2940 let mut config = cfg(tmp.path());
2941 config.allow_admin = vec![crate::util::hex(&REMOTE)];
2942 std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
2943 let access = make_access(&config);
2944
2945 let request = msgpack::pack(&Value::Map(vec![
2946 (Value::UInt(protocol::IDX_GROUP), strv("group")),
2947 (Value::Str("operation".into()), strv("gperms")),
2948 (Value::Str("step".into()), strv("set")),
2949 (
2950 Value::Str("content".into()),
2951 strv(
2952 "create = all
2953",
2954 ),
2955 ),
2956 ]));
2957 let set = handle_perms(&config, &access, &request, Some(&(REMOTE, REMOTE_SIG))).unwrap();
2958 assert_eq!(set, vec![protocol::RES_OK]);
2959 assert_eq!(
2960 std::fs::read_to_string(config.repositories_dir.join("group.allowed")).unwrap(),
2961 "create = all
2962"
2963 );
2964 }
2965
2966 #[test]
2967 fn permissions_management_requires_admin_access() {
2968 let tmp = tempfile::tempdir().unwrap();
2969 let config = cfg(tmp.path());
2970 let repo_path = config.repositories_dir.join("group/repo");
2971 git::ensure_bare_repository(&repo_path).unwrap();
2972 let access = make_access(&config);
2973
2974 let denied = handle_perms(
2975 &config,
2976 &access,
2977 &work_request(&[
2978 ("repository", strv("group/repo")),
2979 ("operation", strv("rperms")),
2980 ("step", strv("get")),
2981 ]),
2982 Some(&(REMOTE, REMOTE_SIG)),
2983 )
2984 .unwrap();
2985 assert_eq!(denied[0], protocol::RES_DISALLOWED);
2986 }
2987
2988 fn make_access(config: &ServerConfig) -> Access {
2989 Access::new_with_aliases(
2990 &config.allow_read,
2991 &config.allow_write,
2992 &config.allow_create,
2993 &config.allow_stats,
2994 &config.allow_release,
2995 &config.allow_interact,
2996 &config.allow_admin,
2997 config.repositories_dir.clone(),
2998 config.identity_aliases.clone(),
2999 )
3000 .unwrap()
3001 .with_propose(&config.allow_propose)
3002 .unwrap()
3003 }
3004
3005 fn create_corrupt_bare_repo(path: &std::path::Path) {
3006 std::fs::create_dir_all(path.join("objects")).unwrap();
3007 std::fs::write(path.join("HEAD"), "ref: refs/heads/main\n").unwrap();
3008 }
3009
3010 fn create_source_repo(root: &std::path::Path) -> std::path::PathBuf {
3011 create_source_repo_on_branch(root, "master")
3012 }
3013
3014 fn create_source_repo_on_branch(root: &std::path::Path, branch: &str) -> std::path::PathBuf {
3015 let source = root.join(format!("source-{branch}"));
3016 std::fs::create_dir_all(&source).unwrap();
3017 run_git(&source, &["init", "-b", branch]);
3018 std::fs::write(source.join("README.md"), "source\n").unwrap();
3019 run_git(&source, &["add", "README.md"]);
3020 run_git(
3021 &source,
3022 &[
3023 "-c",
3024 "user.name=rngit test",
3025 "-c",
3026 "user.email=rngit@example.invalid",
3027 "commit",
3028 "-m",
3029 "initial",
3030 ],
3031 );
3032 source
3033 }
3034
3035 fn commit_source_file(repo: &std::path::Path, file: &str, content: &str) -> String {
3036 std::fs::write(repo.join(file), content).unwrap();
3037 run_git(repo, &["add", file]);
3038 run_git(
3039 repo,
3040 &[
3041 "-c",
3042 "user.name=rngit test",
3043 "-c",
3044 "user.email=rngit@example.invalid",
3045 "commit",
3046 "-m",
3047 file,
3048 ],
3049 );
3050 git_rev_parse(repo, "HEAD")
3051 }
3052
3053 fn git_symbolic_ref(repo: &std::path::Path, name: &str) -> String {
3054 let output = std::process::Command::new("git")
3055 .arg("--git-dir")
3056 .arg(repo)
3057 .arg("symbolic-ref")
3058 .arg(name)
3059 .output()
3060 .unwrap();
3061 assert!(
3062 output.status.success(),
3063 "git symbolic-ref failed: {}",
3064 String::from_utf8_lossy(&output.stderr)
3065 );
3066 String::from_utf8_lossy(&output.stdout).trim().to_string()
3067 }
3068
3069 fn git_rev_parse(repo: &std::path::Path, rev: &str) -> String {
3070 let output = if repo.join(".git").is_dir() {
3071 std::process::Command::new("git")
3072 .arg("-C")
3073 .arg(repo)
3074 .arg("rev-parse")
3075 .arg(rev)
3076 .output()
3077 .unwrap()
3078 } else {
3079 std::process::Command::new("git")
3080 .arg("--git-dir")
3081 .arg(repo)
3082 .arg("rev-parse")
3083 .arg(rev)
3084 .output()
3085 .unwrap()
3086 };
3087 assert!(
3088 output.status.success(),
3089 "git rev-parse failed: {}",
3090 String::from_utf8_lossy(&output.stderr)
3091 );
3092 String::from_utf8_lossy(&output.stdout).trim().to_string()
3093 }
3094
3095 fn git_config(repo: &std::path::Path, key: &str) -> String {
3096 let output = std::process::Command::new("git")
3097 .arg("--git-dir")
3098 .arg(repo)
3099 .arg("config")
3100 .arg("--get")
3101 .arg(key)
3102 .output()
3103 .unwrap();
3104 assert!(
3105 output.status.success(),
3106 "git config failed: {}",
3107 String::from_utf8_lossy(&output.stderr)
3108 );
3109 String::from_utf8_lossy(&output.stdout).trim().to_string()
3110 }
3111
3112 fn run_git(cwd: &std::path::Path, args: &[&str]) {
3113 let output = std::process::Command::new("git")
3114 .args(args)
3115 .current_dir(cwd)
3116 .output()
3117 .unwrap();
3118 assert!(
3119 output.status.success(),
3120 "git {:?} failed: {}",
3121 args,
3122 String::from_utf8_lossy(&output.stderr)
3123 );
3124 }
3125
3126 fn assert_generic_remote_error(response: &[u8]) {
3127 assert_eq!(response[0], protocol::RES_REMOTE_FAIL);
3128 assert_eq!(&response[1..], b"Remote error");
3129 }
3130
3131 fn work_request(fields: &[(&str, Value)]) -> Vec<u8> {
3132 msgpack::pack(&Value::Map(
3133 fields
3134 .iter()
3135 .map(|(key, value)| {
3136 let key = if *key == "repository" {
3137 Value::UInt(protocol::IDX_REPOSITORY)
3138 } else {
3139 Value::Str((*key).into())
3140 };
3141 (key, value.clone())
3142 })
3143 .collect(),
3144 ))
3145 }
3146
3147 fn strv(value: &str) -> Value {
3148 Value::Str(value.to_string())
3149 }
3150
3151 fn uintv(value: u64) -> Value {
3152 Value::UInt(value)
3153 }
3154
3155 fn binv(value: &[u8]) -> Value {
3156 Value::Bin(value.to_vec())
3157 }
3158
3159 fn body_value(response: &[u8]) -> Value {
3160 assert_eq!(response[0], protocol::RES_OK);
3161 msgpack::unpack_exact(&response[1..]).unwrap()
3162 }
3163}