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