1use std::path::PathBuf;
2use std::thread;
3use std::time::Duration;
4
5use rns_core::display::prettyb256rep;
6use rns_core::types::IdentityHash;
7use rns_crypto::identity::Identity;
8use rns_net::link_manager::ResourceStrategy;
9use rns_net::{
10 AnnouncedIdentity, Callbacks, DestHash, Destination, PacketHash, RequestResponse, RnsNode,
11};
12
13use crate::acl::{Access, Operation};
14use crate::config::ServerConfig;
15use crate::logging;
16use crate::pages;
17use crate::protocol;
18use crate::util::{default_reticulum_dir, default_rngit_dir, hex, load_or_create_identity};
19use crate::{git, Error, Result};
20
21pub fn main<I>(args: I) -> Result<()>
22where
23 I: IntoIterator<Item = String>,
24{
25 let options = ServerOptions::parse(args)?;
26 git::check_git_available()?;
27
28 let rngit_dir = options.config_dir.unwrap_or_else(default_rngit_dir);
29 let rns_dir = options.rns_config_dir.or_else(default_reticulum_dir);
30 let (config, created) = ServerConfig::load_or_create(rngit_dir, rns_dir)?;
31 logging::init_file_logger(&config.dir.join("server_log"), config.log_level)?;
32 if created {
33 return Err(Error::msg(format!(
34 "created default config at {}; edit it and run rngit again",
35 config.dir.join("server_config").display()
36 )));
37 }
38
39 let identity = load_or_create_identity(&config.identity_path)?;
40 if options.print_identity {
41 let client = load_or_create_identity(&config.client_identity_path)?;
42 print_identity(&identity, &client, options.base256, config.serve_nomadnet);
43 return Ok(());
44 }
45
46 run_server(config, identity)
47}
48
49pub fn run_server(config: ServerConfig, identity: Identity) -> Result<()> {
50 let node = RnsNode::from_config(
51 config.reticulum_dir.as_deref(),
52 Box::<ServerCallbacks>::default(),
53 )?;
54
55 let announce_interval = Duration::from_secs(config.announce_interval_secs);
56 let destinations = register_server_destinations(&node, config.clone(), &identity)?;
57
58 loop {
59 thread::sleep(announce_interval);
60 let _ = node.announce(&destinations.repositories, &identity, None);
61 if let Some(page_destination) = destinations.nomadnet.as_ref() {
62 let _ = node.announce(
63 page_destination,
64 &identity,
65 Some(config.node_name.as_bytes()),
66 );
67 }
68 }
69}
70
71#[derive(Debug)]
72pub struct ServerDestinations {
73 pub repositories: Destination,
74 pub nomadnet: Option<Destination>,
75}
76
77pub fn register_server_destinations(
78 node: &RnsNode,
79 config: ServerConfig,
80 identity: &Identity,
81) -> Result<ServerDestinations> {
82 let repositories = register_repository_destination(node, config.clone(), identity)?;
83 let nomadnet = if config.serve_nomadnet {
84 Some(pages::register_nomadnet_destination(
85 node, &config, identity,
86 )?)
87 } else {
88 None
89 };
90 Ok(ServerDestinations {
91 repositories,
92 nomadnet,
93 })
94}
95
96pub fn register_repository_destination(
97 node: &RnsNode,
98 config: ServerConfig,
99 identity: &Identity,
100) -> Result<Destination> {
101 crate::util::ensure_dir(&config.repositories_dir)?;
102 let access = Access::new(
103 &config.allow_read,
104 &config.allow_write,
105 &config.allow_create,
106 &config.allow_stats,
107 &config.allow_release,
108 &config.allow_interact,
109 &config.allow_admin,
110 config.repositories_dir.clone(),
111 )?;
112 let destination = Destination::single_in(
113 protocol::APP_NAME,
114 &[protocol::ASPECT_REPOSITORIES],
115 IdentityHash(*identity.hash()),
116 );
117 let public_key = identity
118 .get_public_key()
119 .ok_or_else(|| Error::msg("repository identity has no public key"))?;
120 let private_key = identity
121 .get_private_key()
122 .ok_or_else(|| Error::msg("repository identity has no private key"))?;
123 let sig_prv: [u8; 32] = private_key[32..64].try_into().unwrap();
124 let sig_pub: [u8; 32] = public_key[32..64].try_into().unwrap();
125
126 node.register_link_destination(
127 destination.hash.0,
128 sig_prv,
129 sig_pub,
130 ResourceStrategy::AcceptAll as u8,
131 )
132 .map_err(|_| Error::msg("failed to register link destination"))?;
133 register_handlers(node, config, access)?;
134 node.announce(&destination, identity, None)
135 .map_err(|_| Error::msg("failed to announce rngit destination"))?;
136 Ok(destination)
137}
138
139fn register_handlers(node: &RnsNode, config: ServerConfig, access: Access) -> Result<()> {
140 let list_config = config.clone();
141 let list_access = access.clone();
142 node.register_request_handler(
143 protocol::PATH_LIST,
144 None,
145 move |_link, _path, data, remote| {
146 Some(
147 handle_list(&list_config, &list_access, data, remote)
148 .unwrap_or_else(error_response),
149 )
150 },
151 )
152 .map_err(|_| Error::msg("failed to register list handler"))?;
153
154 let fetch_config = config.clone();
155 let fetch_access = access.clone();
156 node.register_request_handler_response(
157 protocol::PATH_FETCH,
158 None,
159 move |_link, _path, data, remote| {
160 Some(
161 handle_fetch(&fetch_config, &fetch_access, data, remote)
162 .unwrap_or_else(|err| RequestResponse::Bytes(error_response(err))),
163 )
164 },
165 )
166 .map_err(|_| Error::msg("failed to register fetch handler"))?;
167
168 let push_config = config.clone();
169 let push_access = access.clone();
170 node.register_request_handler(
171 protocol::PATH_PUSH,
172 None,
173 move |_link, _path, data, remote| {
174 Some(
175 handle_push(&push_config, &push_access, data, remote)
176 .unwrap_or_else(error_response),
177 )
178 },
179 )
180 .map_err(|_| Error::msg("failed to register push handler"))?;
181
182 let release_config = config.clone();
183 let release_access = access.clone();
184 node.register_request_handler(
185 protocol::PATH_RELEASE,
186 None,
187 move |_link, _path, data, remote| {
188 Some(
189 handle_release(&release_config, &release_access, data, remote)
190 .unwrap_or_else(error_response),
191 )
192 },
193 )
194 .map_err(|_| Error::msg("failed to register release handler"))?;
195
196 let work_config = config.clone();
197 let work_access = access.clone();
198 node.register_request_handler(
199 protocol::PATH_WORK,
200 None,
201 move |_link, _path, data, remote| {
202 Some(
203 handle_work(&work_config, &work_access, data, remote)
204 .unwrap_or_else(error_response),
205 )
206 },
207 )
208 .map_err(|_| Error::msg("failed to register work handler"))?;
209
210 node.register_request_handler(
211 protocol::PATH_DELETE,
212 None,
213 move |_link, _path, data, remote| {
214 Some(handle_delete(&config, &access, data, remote).unwrap_or_else(error_response))
215 },
216 )
217 .map_err(|_| Error::msg("failed to register delete handler"))?;
218
219 Ok(())
220}
221
222pub fn handle_list(
223 config: &ServerConfig,
224 access: &Access,
225 data: &[u8],
226 remote: Option<&([u8; 16], [u8; 64])>,
227) -> Result<Vec<u8>> {
228 let repo = protocol::repository_from_request(data)?;
229 let remote_hash = remote.map(|(hash, _)| hash);
230 if !access.allows(Operation::Read, &repo, remote_hash)? {
231 return Ok(protocol::status_bytes(
232 protocol::RES_DISALLOWED,
233 b"read denied",
234 ));
235 }
236 let path = git::repository_path(&config.repositories_dir, &repo)?;
237 match git::list_refs_text(&path) {
238 Ok(refs) => Ok(protocol::status_bytes(protocol::RES_OK, refs)),
239 Err(err) if err.to_string() == "repository not found" => Ok(protocol::status_bytes(
240 protocol::RES_NOT_FOUND,
241 b"repository not found",
242 )),
243 Err(err) => Ok(remote_error_response("list", &repo, err)),
244 }
245}
246
247pub fn handle_fetch(
248 config: &ServerConfig,
249 access: &Access,
250 data: &[u8],
251 remote: Option<&([u8; 16], [u8; 64])>,
252) -> Result<RequestResponse> {
253 let (repo, have) = protocol::parse_fetch_request(data)?;
254 let remote_hash = remote.map(|(hash, _)| hash);
255 if !access.allows(Operation::Read, &repo, remote_hash)? {
256 return Ok(RequestResponse::Bytes(protocol::status_bytes(
257 protocol::RES_DISALLOWED,
258 b"read denied",
259 )));
260 }
261 if let Err(err) = git::validate_shas(&have) {
262 return Ok(RequestResponse::Bytes(protocol::status_bytes(
263 protocol::RES_INVALID_REQ,
264 err.to_string(),
265 )));
266 }
267 let path = git::repository_path(&config.repositories_dir, &repo)?;
268 match git::create_bundle(&path, &have) {
269 Ok(bundle) if bundle.is_empty() => {
270 crate::stats::record_fetch(config, &repo, remote_hash);
271 Ok(RequestResponse::Bytes(protocol::status_bytes(
272 protocol::RES_OK,
273 Vec::new(),
274 )))
275 }
276 Ok(bundle) => {
277 crate::stats::record_fetch(config, &repo, remote_hash);
278 Ok(RequestResponse::Resource {
279 data: bundle,
280 metadata: Some(protocol::metadata_status(protocol::RES_OK)),
281 auto_compress: true,
282 })
283 }
284 Err(err) if err.to_string() == "repository not found" => Ok(RequestResponse::Bytes(
285 protocol::status_bytes(protocol::RES_NOT_FOUND, b"repository not found"),
286 )),
287 Err(err) => Ok(RequestResponse::Bytes(remote_error_response(
288 "fetch", &repo, err,
289 ))),
290 }
291}
292
293pub fn handle_push(
294 config: &ServerConfig,
295 access: &Access,
296 data: &[u8],
297 remote: Option<&([u8; 16], [u8; 64])>,
298) -> Result<Vec<u8>> {
299 let (repo, bundle, updates) = protocol::parse_push_request(data)?;
300 let remote_hash = remote.map(|(hash, _)| hash);
301 let path = git::repository_path(&config.repositories_dir, &repo)?;
302 if let Err(err) = git::validate_ref_updates(&updates) {
303 return Ok(protocol::status_bytes(
304 protocol::RES_INVALID_REQ,
305 err.to_string(),
306 ));
307 }
308 let op = if git::is_bare_repository(&path) {
309 Operation::Write
310 } else {
311 Operation::Create
312 };
313 if !access.allows(op, &repo, remote_hash)? {
314 let message = match op {
315 Operation::Create => b"create denied".as_slice(),
316 Operation::Write => b"write denied".as_slice(),
317 Operation::Read => b"read denied".as_slice(),
318 Operation::Stats => b"stats denied".as_slice(),
319 Operation::Release => b"release denied".as_slice(),
320 Operation::Interact => b"interact denied".as_slice(),
321 Operation::Admin => b"admin denied".as_slice(),
322 };
323 return Ok(protocol::status_bytes(protocol::RES_DISALLOWED, message));
324 }
325 match git::apply_push(&path, &bundle, &updates) {
326 Ok(()) => {
327 crate::stats::record_push(config, &repo, remote_hash);
328 Ok(protocol::status_bytes(protocol::RES_OK, b"ok"))
329 }
330 Err(err) => Ok(remote_error_response("push", &repo, err)),
331 }
332}
333
334pub fn handle_delete(
335 config: &ServerConfig,
336 access: &Access,
337 data: &[u8],
338 remote: Option<&([u8; 16], [u8; 64])>,
339) -> Result<Vec<u8>> {
340 let repo = protocol::repository_from_request(data)?;
341 let remote_hash = remote.map(|(hash, _)| hash);
342 if !access.allows(Operation::Write, &repo, remote_hash)? {
343 return Ok(protocol::status_bytes(
344 protocol::RES_DISALLOWED,
345 b"write denied",
346 ));
347 }
348 let path = git::repository_path(&config.repositories_dir, &repo)?;
349 if !path.exists() {
350 return Ok(protocol::status_bytes(
351 protocol::RES_NOT_FOUND,
352 b"repository not found",
353 ));
354 }
355 if let Err(err) = std::fs::remove_dir_all(path) {
356 return Ok(remote_error_response("delete", &repo, err));
357 }
358 Ok(protocol::status_bytes(protocol::RES_OK, b"deleted"))
359}
360
361pub fn handle_release(
362 config: &ServerConfig,
363 access: &Access,
364 data: &[u8],
365 remote: Option<&([u8; 16], [u8; 64])>,
366) -> Result<Vec<u8>> {
367 let Some((remote_hash, _)) = remote else {
368 return Ok(protocol::status_bytes(
369 protocol::RES_DISALLOWED,
370 b"not identified",
371 ));
372 };
373 let request = crate::release::parse_request(data)?;
374 let repo = request.repository.as_str();
375 if !access.allows(Operation::Read, repo, Some(remote_hash))? {
376 return Ok(protocol::status_bytes(
377 protocol::RES_NOT_FOUND,
378 b"not found",
379 ));
380 }
381 let release_access = access.allows(Operation::Release, repo, Some(remote_hash))?;
382 let permitted = match request.operation.as_str() {
383 "list" | "view" => true,
384 "create" | "delete" => release_access,
385 _ => false,
386 };
387 if !permitted {
388 return Ok(protocol::status_bytes(
389 protocol::RES_DISALLOWED,
390 b"not allowed",
391 ));
392 }
393
394 let repository_path = git::repository_path(&config.repositories_dir, repo)?;
395 if !git::is_bare_repository(&repository_path) {
396 return Ok(protocol::status_bytes(
397 protocol::RES_NOT_FOUND,
398 b"repository not found",
399 ));
400 }
401 let releases_path = crate::release::release_sidecar_path(&repository_path);
402 match request.operation.as_str() {
403 "list" => crate::release::list_response(&releases_path),
404 "view" => {
405 let Some(tag) = request.tag.as_deref() else {
406 return Ok(protocol::status_bytes(
407 protocol::RES_INVALID_REQ,
408 b"no tag specified",
409 ));
410 };
411 crate::release::view_response(&releases_path, tag)
412 }
413 "create" => match request.step.as_deref() {
414 Some("init") => {
415 crate::release::create_init(&releases_path, &repository_path, &request, remote_hash)
416 }
417 Some("artifact") => crate::release::create_artifact(&releases_path, &request),
418 Some("finalize") => crate::release::create_finalize(&releases_path, &request),
419 _ => Ok(protocol::status_bytes(
420 protocol::RES_INVALID_REQ,
421 b"invalid request",
422 )),
423 },
424 "delete" => crate::release::delete_release(&releases_path, &request),
425 _ => Ok(protocol::status_bytes(
426 protocol::RES_INVALID_REQ,
427 b"invalid request",
428 )),
429 }
430}
431
432pub fn handle_work(
433 config: &ServerConfig,
434 access: &Access,
435 data: &[u8],
436 remote: Option<&([u8; 16], [u8; 64])>,
437) -> Result<Vec<u8>> {
438 let Some((remote_hash, _)) = remote else {
439 return Ok(protocol::status_bytes(
440 protocol::RES_DISALLOWED,
441 b"not identified",
442 ));
443 };
444 let request = crate::work::parse_request(data)?;
445 let repo = request.repository.as_str();
446 if !access.allows(Operation::Read, repo, Some(remote_hash))? {
447 return Ok(protocol::status_bytes(
448 protocol::RES_NOT_FOUND,
449 b"not found",
450 ));
451 }
452 let repository_path = git::repository_path(&config.repositories_dir, repo)?;
453 if !git::is_bare_repository(&repository_path) {
454 return Ok(protocol::status_bytes(
455 protocol::RES_NOT_FOUND,
456 b"repository not found",
457 ));
458 }
459 let work_path = crate::work::work_sidecar_path(&repository_path);
460 let mut interact_access = access.allows(Operation::Interact, repo, Some(remote_hash))?;
461 if request.operation == "comment" {
462 if let Some(doc_id) = request.doc_id {
463 interact_access |= crate::work::document_permission_allows(
464 &work_path,
465 doc_id,
466 Operation::Interact,
467 Some(remote_hash),
468 )?;
469 }
470 }
471 let write_access = access.allows(Operation::Write, repo, Some(remote_hash))?;
472 let manage_access = interact_access && write_access;
473 let permitted = match request.operation.as_str() {
474 "list" | "view" => true,
475 "comment" => interact_access,
476 "create" | "edit" | "delete" | "complete" | "activate" => manage_access,
477 "perms" => true,
478 _ => false,
479 };
480 if !permitted {
481 return Ok(protocol::status_bytes(
482 protocol::RES_DISALLOWED,
483 b"not allowed",
484 ));
485 }
486
487 match request.operation.as_str() {
488 "list" => {
489 let scope = request
490 .scope
491 .as_deref()
492 .map(crate::work::WorkListScope::parse)
493 .unwrap_or(Some(crate::work::WorkListScope::Active))
494 .ok_or_else(|| Error::msg("invalid scope"))?;
495 crate::work::list_response(&work_path, scope)
496 }
497 "view" => {
498 let doc_id = request
499 .doc_id
500 .ok_or_else(|| Error::msg("no document ID specified"))?;
501 let scope = work_scope(request.scope.as_deref())?;
502 crate::work::view_response(&work_path, scope, doc_id)
503 }
504 "create" => work_status_result(
505 crate::work::create_document(
506 &work_path,
507 crate::work::WorkInput {
508 title: request.title.unwrap_or_default(),
509 content: request.content.unwrap_or_default(),
510 format: request.format.unwrap_or_else(|| "markdown".into()),
511 signature: request.signature,
512 author: *remote_hash,
513 },
514 )
515 .map(crate::work::created_response),
516 ),
517 "edit" => {
518 let doc_id = request
519 .doc_id
520 .ok_or_else(|| Error::msg("no document ID specified"))?;
521 let scope = work_scope(request.scope.as_deref())?;
522 work_status_result(
523 crate::work::edit_document(
524 &work_path,
525 scope,
526 doc_id,
527 remote_hash,
528 crate::work::WorkEdit {
529 title: request.title,
530 content: request.content,
531 signature: request.signature,
532 },
533 )
534 .map(|_| protocol::status_bytes(protocol::RES_OK, b"")),
535 )
536 }
537 "delete" => {
538 let doc_id = request
539 .doc_id
540 .ok_or_else(|| Error::msg("no document ID specified"))?;
541 let scope = work_scope(request.scope.as_deref())?;
542 work_status_result(
543 crate::work::delete_document(&work_path, scope, doc_id, remote_hash)
544 .map(|_| protocol::status_bytes(protocol::RES_OK, b"")),
545 )
546 }
547 "comment" => {
548 let doc_id = request
549 .doc_id
550 .ok_or_else(|| Error::msg("no document ID specified"))?;
551 let scope = work_scope(request.scope.as_deref())?;
552 work_status_result(
553 crate::work::add_comment(
554 &work_path,
555 scope,
556 doc_id,
557 crate::work::WorkCommentInput {
558 content: request.content.unwrap_or_default(),
559 format: request.format.unwrap_or_else(|| "markdown".into()),
560 signature: request.signature,
561 author: *remote_hash,
562 },
563 )
564 .map(crate::work::comment_response),
565 )
566 }
567 "complete" => {
568 let doc_id = request
569 .doc_id
570 .ok_or_else(|| Error::msg("no document ID specified"))?;
571 work_status_result(
572 crate::work::complete_document(&work_path, doc_id, remote_hash).map(|_| {
573 crate::work::transition_response(doc_id, crate::work::WorkScope::Completed)
574 }),
575 )
576 }
577 "activate" => {
578 let doc_id = request
579 .doc_id
580 .ok_or_else(|| Error::msg("no document ID specified"))?;
581 work_status_result(
582 crate::work::activate_document(&work_path, doc_id, remote_hash).map(|_| {
583 crate::work::transition_response(doc_id, crate::work::WorkScope::Active)
584 }),
585 )
586 }
587 "perms" => {
588 let doc_id = request
589 .doc_id
590 .ok_or_else(|| Error::msg("no document ID specified"))?;
591 let allowed =
592 match work_permissions_allowed(&work_path, doc_id, remote_hash, manage_access) {
593 Ok(allowed) => allowed,
594 Err(err) => return Ok(work_error_response(err)),
595 };
596 if !allowed {
597 return Ok(protocol::status_bytes(
598 protocol::RES_DISALLOWED,
599 b"not allowed",
600 ));
601 }
602 match request.step.as_deref() {
603 Some("get") => work_status_result(
604 crate::work::get_document_permissions(&work_path, doc_id)
605 .map(crate::work::permissions_response),
606 ),
607 Some("set") => work_status_result(
608 crate::work::set_document_permissions(
609 &work_path,
610 doc_id,
611 request.content.as_deref().unwrap_or(""),
612 )
613 .map(|_| protocol::status_bytes(protocol::RES_OK, b"")),
614 ),
615 Some(_) => Ok(protocol::status_bytes(
616 protocol::RES_INVALID_REQ,
617 b"invalid step",
618 )),
619 None => Ok(protocol::status_bytes(
620 protocol::RES_INVALID_REQ,
621 b"invalid request",
622 )),
623 }
624 }
625 _ => Ok(protocol::status_bytes(
626 protocol::RES_INVALID_REQ,
627 b"invalid request",
628 )),
629 }
630}
631
632fn work_permissions_allowed(
633 work_path: &std::path::Path,
634 doc_id: u64,
635 remote_hash: &[u8; 16],
636 manage_access: bool,
637) -> Result<bool> {
638 let is_author = crate::work::document_author(work_path, doc_id)? == *remote_hash;
639 let doc_admin = crate::work::document_permission_allows(
640 work_path,
641 doc_id,
642 Operation::Admin,
643 Some(remote_hash),
644 )?;
645 Ok((is_author && manage_access) || doc_admin)
646}
647
648fn work_scope(scope: Option<&str>) -> Result<crate::work::WorkScope> {
649 scope
650 .map(crate::work::WorkScope::parse)
651 .unwrap_or(Some(crate::work::WorkScope::Active))
652 .ok_or_else(|| Error::msg("invalid scope"))
653}
654
655fn work_status_result(result: Result<Vec<u8>>) -> Result<Vec<u8>> {
656 match result {
657 Ok(response) => Ok(response),
658 Err(err) => Ok(work_error_response(err)),
659 }
660}
661
662fn work_error_response(err: Error) -> Vec<u8> {
663 let message = err.to_string();
664 let code = if message.starts_with("invalid permission")
665 || message.starts_with("invalid hex")
666 || message.starts_with("expected 32 hex characters")
667 {
668 protocol::RES_INVALID_REQ
669 } else {
670 match message.as_str() {
671 "document not found" => protocol::RES_NOT_FOUND,
672 "no access, not author" => protocol::RES_DISALLOWED,
673 "title is required"
674 | "content is required"
675 | "invalid signature"
676 | "content limit exceeded"
677 | "no changes specified" => protocol::RES_INVALID_REQ,
678 _ => protocol::RES_REMOTE_FAIL,
679 }
680 };
681 protocol::status_bytes(code, message)
682}
683
684fn error_response(err: Error) -> Vec<u8> {
685 protocol::status_bytes(protocol::RES_INVALID_REQ, err.to_string())
686}
687
688fn remote_error_response(context: &str, repository: &str, err: impl std::fmt::Display) -> Vec<u8> {
689 log::error!("rngit {context} failed for {repository}: {err}");
690 protocol::status_bytes(protocol::RES_REMOTE_FAIL, b"Remote error")
691}
692
693fn repository_destination(identity: &Identity) -> Destination {
694 Destination::single_in(
695 protocol::APP_NAME,
696 &[protocol::ASPECT_REPOSITORIES],
697 IdentityHash(*identity.hash()),
698 )
699}
700
701fn identity_report(
702 identity: &Identity,
703 client: &Identity,
704 base256: bool,
705 serve_nomadnet: bool,
706) -> String {
707 let destination = repository_destination(identity);
708 let mut out = String::new();
709 out.push_str(&format!("client_identity = {}\n", hex(client.hash())));
710 if base256 {
711 out.push_str(&format!(
712 "client_identity_b256 = {}\n",
713 prettyb256rep(client.hash())
714 ));
715 }
716 out.push_str(&format!("repository_identity = {}\n", hex(identity.hash())));
717 if base256 {
718 out.push_str(&format!(
719 "repository_identity_b256 = {}",
720 prettyb256rep(identity.hash())
721 ));
722 out.push('\n');
723 }
724 out.push_str(&format!("destination = {}\n", hex(&destination.hash.0)));
725 if base256 {
726 out.push_str(&format!(
727 "destination_b256 = {}\n",
728 prettyb256rep(&destination.hash.0)
729 ));
730 }
731 if serve_nomadnet {
732 let page_destination = pages::destination_for_identity(identity);
733 out.push_str(&format!(
734 "nomadnet_destination = {}\n",
735 hex(&page_destination.hash.0)
736 ));
737 if base256 {
738 out.push_str(&format!(
739 "nomadnet_destination_b256 = {}\n",
740 prettyb256rep(&page_destination.hash.0)
741 ));
742 }
743 }
744 out
745}
746
747fn print_identity(identity: &Identity, client: &Identity, base256: bool, serve_nomadnet: bool) {
748 print!(
749 "{}",
750 identity_report(identity, client, base256, serve_nomadnet)
751 );
752}
753
754#[derive(Default)]
755struct ServerCallbacks;
756
757impl Callbacks for ServerCallbacks {
758 fn on_announce(&mut self, _announced: AnnouncedIdentity) {}
759
760 fn on_path_updated(&mut self, _dest_hash: DestHash, _hops: u8) {}
761
762 fn on_local_delivery(&mut self, _dest_hash: DestHash, _raw: Vec<u8>, _packet_hash: PacketHash) {
763 }
764}
765
766#[derive(Debug, Default)]
767struct ServerOptions {
768 config_dir: Option<PathBuf>,
769 rns_config_dir: Option<PathBuf>,
770 print_identity: bool,
771 base256: bool,
772}
773
774impl ServerOptions {
775 fn parse<I>(args: I) -> Result<Self>
776 where
777 I: IntoIterator<Item = String>,
778 {
779 let mut options = ServerOptions::default();
780 let mut args = args.into_iter();
781 while let Some(arg) = args.next() {
782 match arg.as_str() {
783 "-c" | "--config" => {
784 options.config_dir = Some(PathBuf::from(
785 args.next()
786 .ok_or_else(|| Error::msg("missing config path"))?,
787 ));
788 }
789 "--rnsconfig" => {
790 options.rns_config_dir = Some(PathBuf::from(
791 args.next()
792 .ok_or_else(|| Error::msg("missing RNS config path"))?,
793 ));
794 }
795 "--print-identity" => options.print_identity = true,
796 "-Z" | "--base256" => options.base256 = true,
797 "--service" | "--interactive" => {
798 return Err(Error::msg(format!(
799 "{arg} is not supported by this rngit binary\n{}",
800 usage()
801 )))
802 }
803 "-h" | "--help" => return Err(Error::msg(usage())),
804 other => {
805 return Err(Error::msg(format!(
806 "unknown argument: {other}\n{}",
807 usage()
808 )))
809 }
810 }
811 }
812 Ok(options)
813 }
814}
815
816fn usage() -> &'static str {
817 "usage: rngit [--config DIR] [--rnsconfig DIR] [--print-identity] [-Z|--base256]"
818}
819
820#[cfg(test)]
821mod tests {
822 use super::*;
823 use rns_core::msgpack::{self, Value};
824 use rns_crypto::OsRng;
825
826 const REMOTE: [u8; 16] = [0x44; 16];
827 const OTHER_REMOTE: [u8; 16] = [0x55; 16];
828 const REMOTE_SIG: [u8; 64] = [0x66; 64];
829
830 fn cfg(root: &std::path::Path) -> ServerConfig {
831 ServerConfig {
832 dir: root.to_path_buf(),
833 reticulum_dir: None,
834 repositories_dir: root.join("repositories"),
835 identity_path: root.join("repositories_identity"),
836 client_identity_path: root.join("client_identity"),
837 node_name: "Anonymous Git Node".into(),
838 announce_interval_secs: 300,
839 serve_nomadnet: false,
840 templates_dir: root.join("templates"),
841 unicode_icons: false,
842 record_stats: false,
843 stats_ignore_identities: Vec::new(),
844 allow_read: vec!["all".into()],
845 allow_write: vec!["all".into()],
846 allow_create: vec!["all".into()],
847 allow_stats: vec!["none".into()],
848 allow_release: vec!["none".into()],
849 allow_interact: vec!["none".into()],
850 allow_admin: vec!["none".into()],
851 log_level: logging::DEFAULT_LOG_LEVEL,
852 }
853 }
854
855 #[test]
856 fn parses_base256_print_identity_options() {
857 let opts = ServerOptions::parse(vec![
858 "--print-identity".to_string(),
859 "--base256".to_string(),
860 ])
861 .unwrap();
862 assert!(opts.print_identity);
863 assert!(opts.base256);
864
865 let short = ServerOptions::parse(vec!["-Z".to_string()]).unwrap();
866 assert!(short.base256);
867 }
868
869 #[test]
870 fn rejects_unsupported_service_mode_flags() {
871 let service = ServerOptions::parse(vec!["--service".to_string()]).unwrap_err();
872 assert!(service.to_string().contains("not supported"));
873
874 let interactive = ServerOptions::parse(vec!["--interactive".to_string()]).unwrap_err();
875 assert!(interactive.to_string().contains("not supported"));
876 }
877
878 #[test]
879 fn repository_destination_uses_git_repositories_name() {
880 let identity = Identity::new(&mut OsRng);
881 let destination = repository_destination(&identity);
882 let expected =
883 Destination::single_in("git", &["repositories"], IdentityHash(*identity.hash()));
884 assert_eq!(destination.hash, expected.hash);
885 }
886
887 #[test]
888 fn identity_report_includes_nomadnet_destination_only_when_enabled() {
889 let identity = Identity::new(&mut OsRng);
890 let client = Identity::new(&mut OsRng);
891
892 let without_pages = identity_report(&identity, &client, false, false);
893 assert!(!without_pages.contains("nomadnet_destination"));
894
895 let with_pages = identity_report(&identity, &client, true, true);
896 let nomadnet = pages::destination_for_identity(&identity);
897 assert!(with_pages.contains(&format!("nomadnet_destination = {}", hex(&nomadnet.hash.0))));
898 assert!(with_pages.contains("nomadnet_destination_b256 = "));
899 }
900
901 #[test]
902 fn list_missing_repo_returns_not_found_status() {
903 let tmp = tempfile::tempdir().unwrap();
904 let config = cfg(tmp.path());
905 let access = Access::new(
906 &config.allow_read,
907 &config.allow_write,
908 &config.allow_create,
909 &config.allow_stats,
910 &config.allow_release,
911 &config.allow_interact,
912 &config.allow_admin,
913 config.repositories_dir.clone(),
914 )
915 .unwrap();
916 let req = protocol::repository_request("group/repo");
917 let resp = handle_list(&config, &access, &req, None).unwrap();
918 assert_eq!(resp[0], protocol::RES_NOT_FOUND);
919 }
920
921 #[test]
922 fn push_is_blocked_by_acl() {
923 let tmp = tempfile::tempdir().unwrap();
924 let mut config = cfg(tmp.path());
925 config.allow_write = vec!["none".into()];
926 config.allow_create = vec!["none".into()];
927 let access = Access::new(
928 &config.allow_read,
929 &config.allow_write,
930 &config.allow_create,
931 &config.allow_stats,
932 &config.allow_release,
933 &config.allow_interact,
934 &config.allow_admin,
935 config.repositories_dir.clone(),
936 )
937 .unwrap();
938 let req = protocol::push_request("repo", Vec::new(), Vec::new());
939 let resp = handle_push(&config, &access, &req, None).unwrap();
940 assert_eq!(resp[0], protocol::RES_DISALLOWED);
941 }
942
943 #[test]
944 fn push_can_create_missing_repo_with_global_create() {
945 let tmp = tempfile::tempdir().unwrap();
946 let mut config = cfg(tmp.path());
947 config.allow_write = vec!["none".into()];
948 config.allow_create = vec!["all".into()];
949 let access = Access::new(
950 &config.allow_read,
951 &config.allow_write,
952 &config.allow_create,
953 &config.allow_stats,
954 &config.allow_release,
955 &config.allow_interact,
956 &config.allow_admin,
957 config.repositories_dir.clone(),
958 )
959 .unwrap();
960 let req = protocol::push_request("repo", Vec::new(), Vec::new());
961 let resp = handle_push(&config, &access, &req, None).unwrap();
962 assert_eq!(resp[0], protocol::RES_OK);
963 assert!(git::is_bare_repository(
964 &config.repositories_dir.join("repo")
965 ));
966 }
967
968 #[test]
969 fn global_write_alone_cannot_create_missing_repo() {
970 let tmp = tempfile::tempdir().unwrap();
971 let mut config = cfg(tmp.path());
972 config.allow_write = vec!["all".into()];
973 config.allow_create = vec!["none".into()];
974 let access = Access::new(
975 &config.allow_read,
976 &config.allow_write,
977 &config.allow_create,
978 &config.allow_stats,
979 &config.allow_release,
980 &config.allow_interact,
981 &config.allow_admin,
982 config.repositories_dir.clone(),
983 )
984 .unwrap();
985 let req = protocol::push_request("repo", Vec::new(), Vec::new());
986 let resp = handle_push(&config, &access, &req, None).unwrap();
987 assert_eq!(resp[0], protocol::RES_DISALLOWED);
988 assert!(!config.repositories_dir.join("repo").exists());
989 }
990
991 #[test]
992 fn existing_repo_push_still_requires_write() {
993 let tmp = tempfile::tempdir().unwrap();
994 let mut config = cfg(tmp.path());
995 config.allow_write = vec!["none".into()];
996 config.allow_create = vec!["all".into()];
997 git::ensure_bare_repository(&config.repositories_dir.join("repo")).unwrap();
998 let access = Access::new(
999 &config.allow_read,
1000 &config.allow_write,
1001 &config.allow_create,
1002 &config.allow_stats,
1003 &config.allow_release,
1004 &config.allow_interact,
1005 &config.allow_admin,
1006 config.repositories_dir.clone(),
1007 )
1008 .unwrap();
1009 let req = protocol::push_request("repo", Vec::new(), Vec::new());
1010 let resp = handle_push(&config, &access, &req, None).unwrap();
1011 assert_eq!(resp[0], protocol::RES_DISALLOWED);
1012 }
1013
1014 #[test]
1015 fn repo_allowed_file_can_grant_create_for_missing_bare_repo() {
1016 let tmp = tempfile::tempdir().unwrap();
1017 let mut config = cfg(tmp.path());
1018 config.allow_write = vec!["none".into()];
1019 config.allow_create = vec!["none".into()];
1020 let repo = config.repositories_dir.join("group/repo");
1021 std::fs::create_dir_all(&repo).unwrap();
1022 std::fs::write(repo.join(".allowed"), "create = all\n").unwrap();
1023 let access = Access::new(
1024 &config.allow_read,
1025 &config.allow_write,
1026 &config.allow_create,
1027 &config.allow_stats,
1028 &config.allow_release,
1029 &config.allow_interact,
1030 &config.allow_admin,
1031 config.repositories_dir.clone(),
1032 )
1033 .unwrap();
1034 let req = protocol::push_request("group/repo", Vec::new(), Vec::new());
1035 let resp = handle_push(&config, &access, &req, None).unwrap();
1036 assert_eq!(resp[0], protocol::RES_OK);
1037 assert!(git::is_bare_repository(&repo));
1038 }
1039
1040 #[test]
1041 fn group_allowed_file_can_grant_create_for_missing_repo() {
1042 let tmp = tempfile::tempdir().unwrap();
1043 let mut config = cfg(tmp.path());
1044 config.allow_write = vec!["none".into()];
1045 config.allow_create = vec!["none".into()];
1046 let group = config.repositories_dir.join("group");
1047 std::fs::create_dir_all(&group).unwrap();
1048 std::fs::write(group.join("group.allowed"), "create = all\n").unwrap();
1049 let access = Access::new(
1050 &config.allow_read,
1051 &config.allow_write,
1052 &config.allow_create,
1053 &config.allow_stats,
1054 &config.allow_release,
1055 &config.allow_interact,
1056 &config.allow_admin,
1057 config.repositories_dir.clone(),
1058 )
1059 .unwrap();
1060 let req = protocol::push_request("group/repo", Vec::new(), Vec::new());
1061 let resp = handle_push(&config, &access, &req, None).unwrap();
1062 assert_eq!(resp[0], protocol::RES_OK);
1063 assert!(git::is_bare_repository(
1064 &config.repositories_dir.join("group/repo")
1065 ));
1066 }
1067
1068 #[test]
1069 fn push_rejects_invalid_repository_name_before_create_acl() {
1070 let tmp = tempfile::tempdir().unwrap();
1071 let mut config = cfg(tmp.path());
1072 config.allow_write = vec!["none".into()];
1073 config.allow_create = vec!["all".into()];
1074 let access = Access::new(
1075 &config.allow_read,
1076 &config.allow_write,
1077 &config.allow_create,
1078 &config.allow_stats,
1079 &config.allow_release,
1080 &config.allow_interact,
1081 &config.allow_admin,
1082 config.repositories_dir.clone(),
1083 )
1084 .unwrap();
1085 let req = protocol::push_request("../repo", Vec::new(), Vec::new());
1086 assert!(handle_push(&config, &access, &req, None).is_err());
1087 }
1088
1089 #[test]
1090 fn push_rejects_invalid_ref_before_creating_missing_repo() {
1091 let tmp = tempfile::tempdir().unwrap();
1092 let config = cfg(tmp.path());
1093 let access = make_access(&config);
1094 let req = protocol::push_request(
1095 "group/repo",
1096 Vec::new(),
1097 vec![protocol::RefUpdate {
1098 refname: "-refs/heads/main".into(),
1099 old: None,
1100 new: Some("0123456789abcdef0123456789abcdef01234567".into()),
1101 force: true,
1102 }],
1103 );
1104
1105 let resp = handle_push(&config, &access, &req, None).unwrap();
1106
1107 assert_eq!(resp[0], protocol::RES_INVALID_REQ);
1108 assert!(!config.repositories_dir.join("group/repo").exists());
1109 }
1110
1111 #[test]
1112 fn push_rejects_invalid_sha_before_creating_missing_repo() {
1113 let tmp = tempfile::tempdir().unwrap();
1114 let config = cfg(tmp.path());
1115 let access = make_access(&config);
1116 let req = protocol::push_request(
1117 "group/repo",
1118 Vec::new(),
1119 vec![protocol::RefUpdate {
1120 refname: "refs/heads/main".into(),
1121 old: None,
1122 new: Some("--not-a-sha".into()),
1123 force: true,
1124 }],
1125 );
1126
1127 let resp = handle_push(&config, &access, &req, None).unwrap();
1128
1129 assert_eq!(resp[0], protocol::RES_INVALID_REQ);
1130 assert!(!config.repositories_dir.join("group/repo").exists());
1131 }
1132
1133 #[test]
1134 fn fetch_existing_repo_can_return_ok_status_or_resource() {
1135 let tmp = tempfile::tempdir().unwrap();
1136 let config = cfg(tmp.path());
1137 let access = Access::new(
1138 &config.allow_read,
1139 &config.allow_write,
1140 &config.allow_create,
1141 &config.allow_stats,
1142 &config.allow_release,
1143 &config.allow_interact,
1144 &config.allow_admin,
1145 config.repositories_dir.clone(),
1146 )
1147 .unwrap();
1148 let repo = config.repositories_dir.join("repo");
1149 git::ensure_bare_repository(&repo).unwrap();
1150 let req = protocol::fetch_request("repo", &[]);
1151 match handle_fetch(&config, &access, &req, None).unwrap() {
1152 RequestResponse::Bytes(bytes) => assert_eq!(bytes[0], protocol::RES_OK),
1153 RequestResponse::Resource { metadata, .. } => assert!(metadata.is_some()),
1154 }
1155 }
1156
1157 #[test]
1158 fn fetch_rejects_invalid_have_sha() {
1159 let tmp = tempfile::tempdir().unwrap();
1160 let config = cfg(tmp.path());
1161 let access = make_access(&config);
1162 let repo = config.repositories_dir.join("repo");
1163 git::ensure_bare_repository(&repo).unwrap();
1164 let req = protocol::fetch_request("repo", &["--upload-pack=/tmp/x".into()]);
1165
1166 let response = handle_fetch(&config, &access, &req, None).unwrap();
1167
1168 let RequestResponse::Bytes(bytes) = response else {
1169 panic!("invalid fetch request should return status bytes");
1170 };
1171 assert_eq!(bytes[0], protocol::RES_INVALID_REQ);
1172 }
1173
1174 #[test]
1175 fn git_failures_return_generic_client_errors() {
1176 let tmp = tempfile::tempdir().unwrap();
1177 let config = cfg(tmp.path());
1178 let access = make_access(&config);
1179 let repo = config.repositories_dir.join("broken");
1180 create_corrupt_bare_repo(&repo);
1181
1182 let list = handle_list(
1183 &config,
1184 &access,
1185 &protocol::repository_request("broken"),
1186 None,
1187 )
1188 .unwrap();
1189 assert_generic_remote_error(&list);
1190
1191 let fetch = handle_fetch(
1192 &config,
1193 &access,
1194 &protocol::fetch_request("broken", &[]),
1195 None,
1196 )
1197 .unwrap();
1198 let RequestResponse::Bytes(fetch) = fetch else {
1199 panic!("corrupt repository fetch should return status bytes");
1200 };
1201 assert_generic_remote_error(&fetch);
1202
1203 git::ensure_bare_repository(&config.repositories_dir.join("push-target")).unwrap();
1204 let push = handle_push(
1205 &config,
1206 &access,
1207 &protocol::push_request("push-target", b"not a git bundle".to_vec(), Vec::new()),
1208 None,
1209 )
1210 .unwrap();
1211 assert_generic_remote_error(&push);
1212 }
1213
1214 #[test]
1215 fn work_protocol_enforces_read_interact_and_write_access() {
1216 let tmp = tempfile::tempdir().unwrap();
1217 let mut config = cfg(tmp.path());
1218 git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
1219
1220 config.allow_read = vec!["none".into()];
1221 config.allow_write = vec!["all".into()];
1222 config.allow_interact = vec!["all".into()];
1223 let access = make_access(&config);
1224 let list = handle_work(
1225 &config,
1226 &access,
1227 &work_request(&[
1228 ("repository", strv("group/repo")),
1229 ("operation", strv("list")),
1230 ]),
1231 Some(&(REMOTE, REMOTE_SIG)),
1232 )
1233 .unwrap();
1234 assert_eq!(list[0], protocol::RES_NOT_FOUND);
1235
1236 config.allow_read = vec!["all".into()];
1237 config.allow_write = vec!["all".into()];
1238 config.allow_interact = vec!["none".into()];
1239 let access = make_access(&config);
1240 let create = handle_work(
1241 &config,
1242 &access,
1243 &work_request(&[
1244 ("repository", strv("group/repo")),
1245 ("operation", strv("create")),
1246 ("title", strv("Task")),
1247 ("content", strv("Body")),
1248 ]),
1249 Some(&(REMOTE, REMOTE_SIG)),
1250 )
1251 .unwrap();
1252 assert_eq!(create[0], protocol::RES_DISALLOWED);
1253
1254 config.allow_interact = vec!["all".into()];
1255 config.allow_write = vec!["none".into()];
1256 let access = make_access(&config);
1257 let create = handle_work(
1258 &config,
1259 &access,
1260 &work_request(&[
1261 ("repository", strv("group/repo")),
1262 ("operation", strv("create")),
1263 ("title", strv("Task")),
1264 ("content", strv("Body")),
1265 ]),
1266 Some(&(REMOTE, REMOTE_SIG)),
1267 )
1268 .unwrap();
1269 assert_eq!(create[0], protocol::RES_DISALLOWED);
1270
1271 config.allow_write = vec!["all".into()];
1272 let access = make_access(&config);
1273 let create = handle_work(
1274 &config,
1275 &access,
1276 &work_request(&[
1277 ("repository", strv("group/repo")),
1278 ("operation", strv("create")),
1279 ("title", strv("Task")),
1280 ("content", strv("Body")),
1281 ]),
1282 Some(&(REMOTE, REMOTE_SIG)),
1283 )
1284 .unwrap();
1285 assert_eq!(create[0], protocol::RES_OK);
1286
1287 config.allow_write = vec!["none".into()];
1288 let access = make_access(&config);
1289 let comment = handle_work(
1290 &config,
1291 &access,
1292 &work_request(&[
1293 ("repository", strv("group/repo")),
1294 ("operation", strv("comment")),
1295 ("doc_id", uintv(1)),
1296 ("content", strv("Comment")),
1297 ]),
1298 Some(&(REMOTE, REMOTE_SIG)),
1299 )
1300 .unwrap();
1301 assert_eq!(comment[0], protocol::RES_OK);
1302 }
1303
1304 #[test]
1305 fn work_protocol_lifecycle_round_trips_documents_and_comments() {
1306 let tmp = tempfile::tempdir().unwrap();
1307 let mut config = cfg(tmp.path());
1308 config.allow_interact = vec!["all".into()];
1309 git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
1310 let access = make_access(&config);
1311
1312 let create = handle_work(
1313 &config,
1314 &access,
1315 &work_request(&[
1316 ("repository", strv("group/repo")),
1317 ("operation", strv("create")),
1318 ("title", strv("Initial title")),
1319 ("content", strv("Initial body")),
1320 ("format", strv("micron")),
1321 ]),
1322 Some(&(REMOTE, REMOTE_SIG)),
1323 )
1324 .unwrap();
1325 let created = body_value(&create);
1326 assert_eq!(created.map_get("id").and_then(Value::as_integer), Some(1));
1327 assert_eq!(
1328 created.map_get("scope").and_then(Value::as_str),
1329 Some("active")
1330 );
1331
1332 let comment = handle_work(
1333 &config,
1334 &access,
1335 &work_request(&[
1336 ("repository", strv("group/repo")),
1337 ("operation", strv("comment")),
1338 ("doc_id", uintv(1)),
1339 ("content", strv("Progress update")),
1340 ("format", strv("markdown")),
1341 ]),
1342 Some(&(REMOTE, REMOTE_SIG)),
1343 )
1344 .unwrap();
1345 assert_eq!(
1346 body_value(&comment)
1347 .map_get("id")
1348 .and_then(Value::as_integer),
1349 Some(1)
1350 );
1351
1352 let view = handle_work(
1353 &config,
1354 &access,
1355 &work_request(&[
1356 ("repository", strv("group/repo")),
1357 ("operation", strv("view")),
1358 ("doc_id", uintv(1)),
1359 ]),
1360 Some(&(REMOTE, REMOTE_SIG)),
1361 )
1362 .unwrap();
1363 let view = body_value(&view);
1364 assert_eq!(
1365 view.map_get("content").and_then(Value::as_str),
1366 Some("Initial body")
1367 );
1368 let comments = view
1369 .map_get("comments")
1370 .and_then(Value::as_array)
1371 .expect("comments array");
1372 assert_eq!(comments.len(), 1);
1373 assert_eq!(
1374 comments[0].map_get("content").and_then(Value::as_str),
1375 Some("Progress update")
1376 );
1377
1378 let edit = handle_work(
1379 &config,
1380 &access,
1381 &work_request(&[
1382 ("repository", strv("group/repo")),
1383 ("operation", strv("edit")),
1384 ("doc_id", uintv(1)),
1385 ("title", strv("Edited title")),
1386 ("content", strv("Edited body")),
1387 ("signature", binv(&[0xAA; 64])),
1388 ]),
1389 Some(&(REMOTE, REMOTE_SIG)),
1390 )
1391 .unwrap();
1392 assert_eq!(edit, vec![protocol::RES_OK]);
1393
1394 let complete = handle_work(
1395 &config,
1396 &access,
1397 &work_request(&[
1398 ("repository", strv("group/repo")),
1399 ("operation", strv("complete")),
1400 ("doc_id", uintv(1)),
1401 ]),
1402 Some(&(REMOTE, REMOTE_SIG)),
1403 )
1404 .unwrap();
1405 assert_eq!(
1406 body_value(&complete)
1407 .map_get("scope")
1408 .and_then(Value::as_str),
1409 Some("completed")
1410 );
1411
1412 let all = handle_work(
1413 &config,
1414 &access,
1415 &work_request(&[
1416 ("repository", strv("group/repo")),
1417 ("operation", strv("list")),
1418 ("scope", strv("all")),
1419 ]),
1420 Some(&(REMOTE, REMOTE_SIG)),
1421 )
1422 .unwrap();
1423 let all = body_value(&all);
1424 assert_eq!(
1425 all.map_get("active")
1426 .and_then(Value::as_array)
1427 .unwrap()
1428 .len(),
1429 0
1430 );
1431 assert_eq!(
1432 all.map_get("completed")
1433 .and_then(Value::as_array)
1434 .unwrap()
1435 .len(),
1436 1
1437 );
1438
1439 let activate = handle_work(
1440 &config,
1441 &access,
1442 &work_request(&[
1443 ("repository", strv("group/repo")),
1444 ("operation", strv("activate")),
1445 ("doc_id", uintv(1)),
1446 ]),
1447 Some(&(REMOTE, REMOTE_SIG)),
1448 )
1449 .unwrap();
1450 assert_eq!(
1451 body_value(&activate)
1452 .map_get("scope")
1453 .and_then(Value::as_str),
1454 Some("active")
1455 );
1456
1457 let delete = handle_work(
1458 &config,
1459 &access,
1460 &work_request(&[
1461 ("repository", strv("group/repo")),
1462 ("operation", strv("delete")),
1463 ("doc_id", uintv(1)),
1464 ]),
1465 Some(&(REMOTE, REMOTE_SIG)),
1466 )
1467 .unwrap();
1468 assert_eq!(delete, vec![protocol::RES_OK]);
1469 }
1470
1471 #[test]
1472 fn work_protocol_rejects_non_author_management_operations() {
1473 let tmp = tempfile::tempdir().unwrap();
1474 let mut config = cfg(tmp.path());
1475 config.allow_interact = vec!["all".into()];
1476 git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
1477 let access = make_access(&config);
1478
1479 let create = handle_work(
1480 &config,
1481 &access,
1482 &work_request(&[
1483 ("repository", strv("group/repo")),
1484 ("operation", strv("create")),
1485 ("title", strv("Task")),
1486 ("content", strv("Body")),
1487 ]),
1488 Some(&(REMOTE, REMOTE_SIG)),
1489 )
1490 .unwrap();
1491 assert_eq!(create[0], protocol::RES_OK);
1492
1493 for operation in ["edit", "complete", "delete"] {
1494 let denied = handle_work(
1495 &config,
1496 &access,
1497 &work_request(&[
1498 ("repository", strv("group/repo")),
1499 ("operation", strv(operation)),
1500 ("doc_id", uintv(1)),
1501 ("content", strv("Other edit")),
1502 ]),
1503 Some(&(OTHER_REMOTE, REMOTE_SIG)),
1504 )
1505 .unwrap();
1506 assert_eq!(denied[0], protocol::RES_DISALLOWED);
1507 }
1508 }
1509
1510 #[test]
1511 fn work_document_local_interact_allows_comments_without_repo_interact() {
1512 let tmp = tempfile::tempdir().unwrap();
1513 let mut config = cfg(tmp.path());
1514 config.allow_write = vec!["none".into()];
1515 config.allow_interact = vec!["none".into()];
1516 let repo_path = config.repositories_dir.join("group/repo");
1517 git::ensure_bare_repository(&repo_path).unwrap();
1518 let work_path = crate::work::work_sidecar_path(&repo_path);
1519 crate::work::create_document(
1520 &work_path,
1521 crate::work::WorkInput {
1522 title: "Task".into(),
1523 content: "Body".into(),
1524 format: "markdown".into(),
1525 signature: None,
1526 author: REMOTE,
1527 },
1528 )
1529 .unwrap();
1530 std::fs::write(work_path.join("1.allowed"), "interact = all\n").unwrap();
1531 let access = make_access(&config);
1532
1533 let comment = handle_work(
1534 &config,
1535 &access,
1536 &work_request(&[
1537 ("repository", strv("group/repo")),
1538 ("operation", strv("comment")),
1539 ("doc_id", uintv(1)),
1540 ("content", strv("Comment from doc ACL")),
1541 ]),
1542 Some(&(OTHER_REMOTE, REMOTE_SIG)),
1543 )
1544 .unwrap();
1545 assert_eq!(comment[0], protocol::RES_OK);
1546
1547 let edit = handle_work(
1548 &config,
1549 &access,
1550 &work_request(&[
1551 ("repository", strv("group/repo")),
1552 ("operation", strv("edit")),
1553 ("doc_id", uintv(1)),
1554 ("content", strv("Not allowed")),
1555 ]),
1556 Some(&(OTHER_REMOTE, REMOTE_SIG)),
1557 )
1558 .unwrap();
1559 assert_eq!(edit[0], protocol::RES_DISALLOWED);
1560 }
1561
1562 #[test]
1563 fn work_permissions_get_set_and_validation_are_author_gated() {
1564 let tmp = tempfile::tempdir().unwrap();
1565 let mut config = cfg(tmp.path());
1566 config.allow_interact = vec!["all".into()];
1567 let repo_path = config.repositories_dir.join("group/repo");
1568 git::ensure_bare_repository(&repo_path).unwrap();
1569 let work_path = crate::work::work_sidecar_path(&repo_path);
1570 crate::work::create_document(
1571 &work_path,
1572 crate::work::WorkInput {
1573 title: "Task".into(),
1574 content: "Body".into(),
1575 format: "markdown".into(),
1576 signature: None,
1577 author: REMOTE,
1578 },
1579 )
1580 .unwrap();
1581 let access = make_access(&config);
1582
1583 let get = handle_work(
1584 &config,
1585 &access,
1586 &work_request(&[
1587 ("repository", strv("group/repo")),
1588 ("operation", strv("perms")),
1589 ("step", strv("get")),
1590 ("doc_id", uintv(1)),
1591 ]),
1592 Some(&(REMOTE, REMOTE_SIG)),
1593 )
1594 .unwrap();
1595 assert_eq!(
1596 body_value(&get).map_get("content").and_then(Value::as_str),
1597 Some("")
1598 );
1599
1600 let content = format!(
1601 "interact = {}\nadmin = {}\n",
1602 crate::util::hex(&OTHER_REMOTE),
1603 crate::util::hex(&OTHER_REMOTE)
1604 );
1605 let set = handle_work(
1606 &config,
1607 &access,
1608 &work_request(&[
1609 ("repository", strv("group/repo")),
1610 ("operation", strv("perms")),
1611 ("step", strv("set")),
1612 ("doc_id", uintv(1)),
1613 ("content", strv(&content)),
1614 ]),
1615 Some(&(REMOTE, REMOTE_SIG)),
1616 )
1617 .unwrap();
1618 assert_eq!(set, vec![protocol::RES_OK]);
1619 assert_eq!(
1620 std::fs::read_to_string(work_path.join("1.allowed")).unwrap(),
1621 content
1622 );
1623
1624 let denied = handle_work(
1625 &config,
1626 &access,
1627 &work_request(&[
1628 ("repository", strv("group/repo")),
1629 ("operation", strv("perms")),
1630 ("step", strv("get")),
1631 ("doc_id", uintv(1)),
1632 ]),
1633 Some(&([0x33; 16], REMOTE_SIG)),
1634 )
1635 .unwrap();
1636 assert_eq!(denied[0], protocol::RES_DISALLOWED);
1637
1638 let invalid = handle_work(
1639 &config,
1640 &access,
1641 &work_request(&[
1642 ("repository", strv("group/repo")),
1643 ("operation", strv("perms")),
1644 ("step", strv("set")),
1645 ("doc_id", uintv(1)),
1646 ("content", strv("interact = not-a-hex-identity\n")),
1647 ]),
1648 Some(&(REMOTE, REMOTE_SIG)),
1649 )
1650 .unwrap();
1651 assert_eq!(invalid[0], protocol::RES_INVALID_REQ);
1652 assert_eq!(
1653 std::fs::read_to_string(work_path.join("1.allowed")).unwrap(),
1654 content
1655 );
1656
1657 let missing = handle_work(
1658 &config,
1659 &access,
1660 &work_request(&[
1661 ("repository", strv("group/repo")),
1662 ("operation", strv("perms")),
1663 ("step", strv("get")),
1664 ("doc_id", uintv(99)),
1665 ]),
1666 Some(&(REMOTE, REMOTE_SIG)),
1667 )
1668 .unwrap();
1669 assert_eq!(missing[0], protocol::RES_NOT_FOUND);
1670 }
1671
1672 #[test]
1673 fn work_document_admin_can_get_and_set_permissions() {
1674 let tmp = tempfile::tempdir().unwrap();
1675 let mut config = cfg(tmp.path());
1676 config.allow_write = vec!["none".into()];
1677 config.allow_interact = vec!["none".into()];
1678 let repo_path = config.repositories_dir.join("group/repo");
1679 git::ensure_bare_repository(&repo_path).unwrap();
1680 let work_path = crate::work::work_sidecar_path(&repo_path);
1681 crate::work::create_document(
1682 &work_path,
1683 crate::work::WorkInput {
1684 title: "Task".into(),
1685 content: "Body".into(),
1686 format: "markdown".into(),
1687 signature: None,
1688 author: REMOTE,
1689 },
1690 )
1691 .unwrap();
1692 std::fs::write(
1693 work_path.join("1.allowed"),
1694 format!("admin = {}\n", crate::util::hex(&OTHER_REMOTE)),
1695 )
1696 .unwrap();
1697 let access = make_access(&config);
1698
1699 let get = handle_work(
1700 &config,
1701 &access,
1702 &work_request(&[
1703 ("repository", strv("group/repo")),
1704 ("operation", strv("perms")),
1705 ("step", strv("get")),
1706 ("doc_id", uintv(1)),
1707 ]),
1708 Some(&(OTHER_REMOTE, REMOTE_SIG)),
1709 )
1710 .unwrap();
1711 assert_eq!(get[0], protocol::RES_OK);
1712
1713 let set = handle_work(
1714 &config,
1715 &access,
1716 &work_request(&[
1717 ("repository", strv("group/repo")),
1718 ("operation", strv("perms")),
1719 ("step", strv("set")),
1720 ("doc_id", uintv(1)),
1721 ("content", strv("interact = all\n")),
1722 ]),
1723 Some(&(OTHER_REMOTE, REMOTE_SIG)),
1724 )
1725 .unwrap();
1726 assert_eq!(set, vec![protocol::RES_OK]);
1727 assert_eq!(
1728 std::fs::read_to_string(work_path.join("1.allowed")).unwrap(),
1729 "interact = all\n"
1730 );
1731 }
1732
1733 fn make_access(config: &ServerConfig) -> Access {
1734 Access::new(
1735 &config.allow_read,
1736 &config.allow_write,
1737 &config.allow_create,
1738 &config.allow_stats,
1739 &config.allow_release,
1740 &config.allow_interact,
1741 &config.allow_admin,
1742 config.repositories_dir.clone(),
1743 )
1744 .unwrap()
1745 }
1746
1747 fn create_corrupt_bare_repo(path: &std::path::Path) {
1748 std::fs::create_dir_all(path.join("objects")).unwrap();
1749 std::fs::write(path.join("HEAD"), "ref: refs/heads/main\n").unwrap();
1750 }
1751
1752 fn assert_generic_remote_error(response: &[u8]) {
1753 assert_eq!(response[0], protocol::RES_REMOTE_FAIL);
1754 assert_eq!(&response[1..], b"Remote error");
1755 }
1756
1757 fn work_request(fields: &[(&str, Value)]) -> Vec<u8> {
1758 msgpack::pack(&Value::Map(
1759 fields
1760 .iter()
1761 .map(|(key, value)| {
1762 let key = if *key == "repository" {
1763 Value::UInt(protocol::IDX_REPOSITORY)
1764 } else {
1765 Value::Str((*key).into())
1766 };
1767 (key, value.clone())
1768 })
1769 .collect(),
1770 ))
1771 }
1772
1773 fn strv(value: &str) -> Value {
1774 Value::Str(value.to_string())
1775 }
1776
1777 fn uintv(value: u64) -> Value {
1778 Value::UInt(value)
1779 }
1780
1781 fn binv(value: &[u8]) -> Value {
1782 Value::Bin(value.to_vec())
1783 }
1784
1785 fn body_value(response: &[u8]) -> Value {
1786 assert_eq!(response[0], protocol::RES_OK);
1787 msgpack::unpack_exact(&response[1..]).unwrap()
1788 }
1789}