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