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 =
1296 Identity::from_public_key(remote_pubkey).map_err(|_| Error::msg("invalid signature"))?;
1297 if !identity.verify(signature, content.trim().as_bytes()) {
1298 return Err(Error::msg("invalid signature"));
1299 }
1300 Ok(Some(remote_pubkey.to_vec()))
1301}
1302
1303fn work_scope(scope: Option<&str>) -> Result<crate::work::WorkScope> {
1304 scope
1305 .map(crate::work::WorkScope::parse)
1306 .unwrap_or(Some(crate::work::WorkScope::Active))
1307 .ok_or_else(|| Error::msg("invalid scope"))
1308}
1309
1310fn work_scope_for_document(
1311 work_path: &std::path::Path,
1312 scope: Option<&str>,
1313 doc_id: u64,
1314) -> Result<Option<crate::work::WorkScope>> {
1315 match scope.unwrap_or("active") {
1316 "all" => crate::work::find_document_scope(work_path, doc_id),
1317 value => crate::work::WorkScope::parse(value)
1318 .map(Some)
1319 .ok_or_else(|| Error::msg("invalid scope")),
1320 }
1321}
1322
1323fn work_status_result(result: Result<Vec<u8>>) -> Result<Vec<u8>> {
1324 match result {
1325 Ok(response) => Ok(response),
1326 Err(err) => Ok(work_error_response(err)),
1327 }
1328}
1329
1330fn work_error_response(err: Error) -> Vec<u8> {
1331 let message = err.to_string();
1332 let code = if message.starts_with("invalid permission")
1333 || message.starts_with("invalid hex")
1334 || message.starts_with("expected 32 hex characters")
1335 {
1336 protocol::RES_INVALID_REQ
1337 } else {
1338 match message.as_str() {
1339 "document not found" => protocol::RES_NOT_FOUND,
1340 "no access, not author" => {
1341 return protocol::status_bytes(protocol::RES_DISALLOWED, b"not allowed");
1342 }
1343 "title is required"
1344 | "content is required"
1345 | "no signature provided"
1346 | "invalid signature"
1347 | "content limit exceeded"
1348 | "no changes specified" => protocol::RES_INVALID_REQ,
1349 _ => protocol::RES_REMOTE_FAIL,
1350 }
1351 };
1352 protocol::status_bytes(code, message)
1353}
1354
1355fn error_response(err: Error) -> Vec<u8> {
1356 protocol::status_bytes(protocol::RES_INVALID_REQ, err.to_string())
1357}
1358
1359fn remote_error_response(context: &str, repository: &str, err: impl std::fmt::Display) -> Vec<u8> {
1360 log::error!("rngit {context} failed for {repository}: {err}");
1361 protocol::status_bytes(protocol::RES_REMOTE_FAIL, b"Remote error")
1362}
1363
1364fn repository_destination(identity: &Identity) -> Destination {
1365 Destination::single_in(
1366 protocol::APP_NAME,
1367 &[protocol::ASPECT_REPOSITORIES],
1368 IdentityHash(*identity.hash()),
1369 )
1370}
1371
1372fn identity_report(
1373 identity: &Identity,
1374 client: &Identity,
1375 base256: bool,
1376 serve_nomadnet: bool,
1377) -> String {
1378 let destination = repository_destination(identity);
1379 let mut out = String::new();
1380 out.push_str(&format!("client_identity = {}\n", hex(client.hash())));
1381 if base256 {
1382 out.push_str(&format!(
1383 "client_identity_b256 = {}\n",
1384 prettyb256rep(client.hash())
1385 ));
1386 }
1387 out.push_str(&format!("repository_identity = {}\n", hex(identity.hash())));
1388 if base256 {
1389 out.push_str(&format!(
1390 "repository_identity_b256 = {}",
1391 prettyb256rep(identity.hash())
1392 ));
1393 out.push('\n');
1394 }
1395 out.push_str(&format!("destination = {}\n", hex(&destination.hash.0)));
1396 if base256 {
1397 out.push_str(&format!(
1398 "destination_b256 = {}\n",
1399 prettyb256rep(&destination.hash.0)
1400 ));
1401 }
1402 if serve_nomadnet {
1403 let page_destination = pages::destination_for_identity(identity);
1404 out.push_str(&format!(
1405 "nomadnet_destination = {}\n",
1406 hex(&page_destination.hash.0)
1407 ));
1408 if base256 {
1409 out.push_str(&format!(
1410 "nomadnet_destination_b256 = {}\n",
1411 prettyb256rep(&page_destination.hash.0)
1412 ));
1413 }
1414 }
1415 out
1416}
1417
1418fn print_identity(identity: &Identity, client: &Identity, base256: bool, serve_nomadnet: bool) {
1419 print!(
1420 "{}",
1421 identity_report(identity, client, base256, serve_nomadnet)
1422 );
1423}
1424
1425#[derive(Default)]
1426struct ServerCallbacks;
1427
1428impl Callbacks for ServerCallbacks {
1429 fn on_announce(&mut self, _announced: AnnouncedIdentity) {}
1430
1431 fn on_path_updated(&mut self, _dest_hash: DestHash, _hops: u8) {}
1432
1433 fn on_local_delivery(&mut self, _dest_hash: DestHash, _raw: Vec<u8>, _packet_hash: PacketHash) {
1434 }
1435}
1436
1437#[derive(Debug, Default)]
1438struct ServerOptions {
1439 config_dir: Option<PathBuf>,
1440 rns_config_dir: Option<PathBuf>,
1441 print_identity: bool,
1442 base256: bool,
1443}
1444
1445impl ServerOptions {
1446 fn parse<I>(args: I) -> Result<Self>
1447 where
1448 I: IntoIterator<Item = String>,
1449 {
1450 let mut options = ServerOptions::default();
1451 let mut args = args.into_iter();
1452 while let Some(arg) = args.next() {
1453 match arg.as_str() {
1454 "-c" | "--config" => {
1455 options.config_dir = Some(PathBuf::from(
1456 args.next()
1457 .ok_or_else(|| Error::msg("missing config path"))?,
1458 ));
1459 }
1460 "--rnsconfig" => {
1461 options.rns_config_dir = Some(PathBuf::from(
1462 args.next()
1463 .ok_or_else(|| Error::msg("missing RNS config path"))?,
1464 ));
1465 }
1466 "--print-identity" => options.print_identity = true,
1467 "-Z" | "--base256" => options.base256 = true,
1468 "--service" | "--interactive" => {
1469 return Err(Error::msg(format!(
1470 "{arg} is not supported by this rngit binary\n{}",
1471 usage()
1472 )))
1473 }
1474 "-h" | "--help" => return Err(Error::msg(usage())),
1475 other => {
1476 return Err(Error::msg(format!(
1477 "unknown argument: {other}\n{}",
1478 usage()
1479 )))
1480 }
1481 }
1482 }
1483 Ok(options)
1484 }
1485}
1486
1487fn usage() -> &'static str {
1488 "usage: rngit [--config DIR] [--rnsconfig DIR] [--print-identity] [-Z|--base256]"
1489}
1490
1491#[cfg(test)]
1492mod tests {
1493 use super::*;
1494 use rns_core::msgpack::{self, Value};
1495 use rns_crypto::OsRng;
1496
1497 const REMOTE: [u8; 16] = [0x44; 16];
1498 const OTHER_REMOTE: [u8; 16] = [0x55; 16];
1499 const REMOTE_SIG: [u8; 64] = [0x66; 64];
1500
1501 fn cfg(root: &std::path::Path) -> ServerConfig {
1502 ServerConfig {
1503 dir: root.to_path_buf(),
1504 reticulum_dir: None,
1505 repositories_dir: root.join("repositories"),
1506 identity_path: root.join("repositories_identity"),
1507 client_identity_path: root.join("client_identity"),
1508 node_name: "Anonymous Git Node".into(),
1509 announce_interval_secs: 300,
1510 serve_nomadnet: false,
1511 templates_dir: root.join("templates"),
1512 unicode_icons: false,
1513 media_conversion: true,
1514 record_stats: false,
1515 stats_ignore_identities: Vec::new(),
1516 stats_push_ignore_identities: Vec::new(),
1517 blocked_identities: Vec::new(),
1518 identity_aliases: std::collections::BTreeMap::new(),
1519 allow_read: vec!["all".into()],
1520 allow_write: vec!["all".into()],
1521 allow_create: vec!["all".into()],
1522 allow_stats: vec!["none".into()],
1523 allow_release: vec!["none".into()],
1524 allow_interact: vec!["none".into()],
1525 allow_propose: vec!["none".into()],
1526 allow_admin: vec!["none".into()],
1527 log_level: logging::DEFAULT_LOG_LEVEL,
1528 }
1529 }
1530
1531 #[test]
1532 fn parses_base256_print_identity_options() {
1533 let opts = ServerOptions::parse(vec![
1534 "--print-identity".to_string(),
1535 "--base256".to_string(),
1536 ])
1537 .unwrap();
1538 assert!(opts.print_identity);
1539 assert!(opts.base256);
1540
1541 let short = ServerOptions::parse(vec!["-Z".to_string()]).unwrap();
1542 assert!(short.base256);
1543 }
1544
1545 #[test]
1546 fn rejects_unsupported_service_mode_flags() {
1547 let service = ServerOptions::parse(vec!["--service".to_string()]).unwrap_err();
1548 assert!(service.to_string().contains("not supported"));
1549
1550 let interactive = ServerOptions::parse(vec!["--interactive".to_string()]).unwrap_err();
1551 assert!(interactive.to_string().contains("not supported"));
1552 }
1553
1554 #[test]
1555 fn repository_destination_uses_git_repositories_name() {
1556 let identity = Identity::new(&mut OsRng);
1557 let destination = repository_destination(&identity);
1558 let expected =
1559 Destination::single_in("git", &["repositories"], IdentityHash(*identity.hash()));
1560 assert_eq!(destination.hash, expected.hash);
1561 }
1562
1563 #[test]
1564 fn identity_report_includes_nomadnet_destination_only_when_enabled() {
1565 let identity = Identity::new(&mut OsRng);
1566 let client = Identity::new(&mut OsRng);
1567
1568 let without_pages = identity_report(&identity, &client, false, false);
1569 assert!(!without_pages.contains("nomadnet_destination"));
1570
1571 let with_pages = identity_report(&identity, &client, true, true);
1572 let nomadnet = pages::destination_for_identity(&identity);
1573 assert!(with_pages.contains(&format!("nomadnet_destination = {}", hex(&nomadnet.hash.0))));
1574 assert!(with_pages.contains("nomadnet_destination_b256 = "));
1575 }
1576
1577 #[test]
1578 fn list_missing_repo_returns_not_found_status() {
1579 let tmp = tempfile::tempdir().unwrap();
1580 let config = cfg(tmp.path());
1581 let access = Access::new(
1582 &config.allow_read,
1583 &config.allow_write,
1584 &config.allow_create,
1585 &config.allow_stats,
1586 &config.allow_release,
1587 &config.allow_interact,
1588 &config.allow_admin,
1589 config.repositories_dir.clone(),
1590 )
1591 .unwrap();
1592 let req = protocol::repository_request("group/repo");
1593 let resp = handle_list(&config, &access, &req, None).unwrap();
1594 assert_eq!(resp[0], protocol::RES_NOT_FOUND);
1595 }
1596
1597 #[test]
1598 fn push_is_blocked_by_acl() {
1599 let tmp = tempfile::tempdir().unwrap();
1600 let mut config = cfg(tmp.path());
1601 config.allow_write = vec!["none".into()];
1602 config.allow_create = vec!["none".into()];
1603 let access = Access::new(
1604 &config.allow_read,
1605 &config.allow_write,
1606 &config.allow_create,
1607 &config.allow_stats,
1608 &config.allow_release,
1609 &config.allow_interact,
1610 &config.allow_admin,
1611 config.repositories_dir.clone(),
1612 )
1613 .unwrap();
1614 let req = protocol::push_request("repo", Vec::new(), Vec::new());
1615 let resp = handle_push(&config, &access, &req, None).unwrap();
1616 assert_eq!(resp[0], protocol::RES_DISALLOWED);
1617 }
1618
1619 #[test]
1620 fn push_can_create_missing_repo_with_global_create() {
1621 let tmp = tempfile::tempdir().unwrap();
1622 let mut config = cfg(tmp.path());
1623 config.allow_write = vec!["none".into()];
1624 config.allow_create = vec!["all".into()];
1625 let access = Access::new(
1626 &config.allow_read,
1627 &config.allow_write,
1628 &config.allow_create,
1629 &config.allow_stats,
1630 &config.allow_release,
1631 &config.allow_interact,
1632 &config.allow_admin,
1633 config.repositories_dir.clone(),
1634 )
1635 .unwrap();
1636 let req = protocol::push_request("repo", Vec::new(), Vec::new());
1637 let resp = handle_push(&config, &access, &req, None).unwrap();
1638 assert_eq!(resp[0], protocol::RES_OK);
1639 assert!(git::is_bare_repository(
1640 &config.repositories_dir.join("repo")
1641 ));
1642 }
1643
1644 #[test]
1645 fn global_write_alone_cannot_create_missing_repo() {
1646 let tmp = tempfile::tempdir().unwrap();
1647 let mut config = cfg(tmp.path());
1648 config.allow_write = vec!["all".into()];
1649 config.allow_create = vec!["none".into()];
1650 let access = Access::new(
1651 &config.allow_read,
1652 &config.allow_write,
1653 &config.allow_create,
1654 &config.allow_stats,
1655 &config.allow_release,
1656 &config.allow_interact,
1657 &config.allow_admin,
1658 config.repositories_dir.clone(),
1659 )
1660 .unwrap();
1661 let req = protocol::push_request("repo", Vec::new(), Vec::new());
1662 let resp = handle_push(&config, &access, &req, None).unwrap();
1663 assert_eq!(resp[0], protocol::RES_DISALLOWED);
1664 assert!(!config.repositories_dir.join("repo").exists());
1665 }
1666
1667 #[test]
1668 fn existing_repo_push_still_requires_write() {
1669 let tmp = tempfile::tempdir().unwrap();
1670 let mut config = cfg(tmp.path());
1671 config.allow_write = vec!["none".into()];
1672 config.allow_create = vec!["all".into()];
1673 git::ensure_bare_repository(&config.repositories_dir.join("repo")).unwrap();
1674 let access = Access::new(
1675 &config.allow_read,
1676 &config.allow_write,
1677 &config.allow_create,
1678 &config.allow_stats,
1679 &config.allow_release,
1680 &config.allow_interact,
1681 &config.allow_admin,
1682 config.repositories_dir.clone(),
1683 )
1684 .unwrap();
1685 let req = protocol::push_request("repo", Vec::new(), Vec::new());
1686 let resp = handle_push(&config, &access, &req, None).unwrap();
1687 assert_eq!(resp[0], protocol::RES_DISALLOWED);
1688 }
1689
1690 #[test]
1691 fn repo_allowed_file_can_grant_create_for_missing_bare_repo() {
1692 let tmp = tempfile::tempdir().unwrap();
1693 let mut config = cfg(tmp.path());
1694 config.allow_write = vec!["none".into()];
1695 config.allow_create = vec!["none".into()];
1696 let repo = config.repositories_dir.join("group/repo");
1697 std::fs::create_dir_all(&repo).unwrap();
1698 std::fs::write(repo.join(".allowed"), "create = all\n").unwrap();
1699 let access = Access::new(
1700 &config.allow_read,
1701 &config.allow_write,
1702 &config.allow_create,
1703 &config.allow_stats,
1704 &config.allow_release,
1705 &config.allow_interact,
1706 &config.allow_admin,
1707 config.repositories_dir.clone(),
1708 )
1709 .unwrap();
1710 let req = protocol::push_request("group/repo", Vec::new(), Vec::new());
1711 let resp = handle_push(&config, &access, &req, None).unwrap();
1712 assert_eq!(resp[0], protocol::RES_OK);
1713 assert!(git::is_bare_repository(&repo));
1714 }
1715
1716 #[test]
1717 fn repo_sidecar_allowed_file_can_grant_create_for_missing_bare_repo() {
1718 let tmp = tempfile::tempdir().unwrap();
1719 let mut config = cfg(tmp.path());
1720 config.allow_write = vec!["none".into()];
1721 config.allow_create = vec!["none".into()];
1722 let repo = config.repositories_dir.join("group/repo");
1723 std::fs::create_dir_all(&repo).unwrap();
1724 std::fs::write(
1725 config.repositories_dir.join("group/repo.allowed"),
1726 "create = all\n",
1727 )
1728 .unwrap();
1729 let access = Access::new(
1730 &config.allow_read,
1731 &config.allow_write,
1732 &config.allow_create,
1733 &config.allow_stats,
1734 &config.allow_release,
1735 &config.allow_interact,
1736 &config.allow_admin,
1737 config.repositories_dir.clone(),
1738 )
1739 .unwrap();
1740 let req = protocol::push_request("group/repo", Vec::new(), Vec::new());
1741 let resp = handle_push(&config, &access, &req, None).unwrap();
1742 assert_eq!(resp[0], protocol::RES_OK);
1743 assert!(git::is_bare_repository(&repo));
1744 }
1745
1746 #[test]
1747 fn group_allowed_file_can_grant_create_for_missing_repo() {
1748 let tmp = tempfile::tempdir().unwrap();
1749 let mut config = cfg(tmp.path());
1750 config.allow_write = vec!["none".into()];
1751 config.allow_create = vec!["none".into()];
1752 let group = config.repositories_dir.join("group");
1753 std::fs::create_dir_all(&group).unwrap();
1754 std::fs::write(group.join("group.allowed"), "create = all\n").unwrap();
1755 let access = Access::new(
1756 &config.allow_read,
1757 &config.allow_write,
1758 &config.allow_create,
1759 &config.allow_stats,
1760 &config.allow_release,
1761 &config.allow_interact,
1762 &config.allow_admin,
1763 config.repositories_dir.clone(),
1764 )
1765 .unwrap();
1766 let req = protocol::push_request("group/repo", Vec::new(), Vec::new());
1767 let resp = handle_push(&config, &access, &req, None).unwrap();
1768 assert_eq!(resp[0], protocol::RES_OK);
1769 assert!(git::is_bare_repository(
1770 &config.repositories_dir.join("group/repo")
1771 ));
1772 }
1773
1774 #[test]
1775 fn group_sidecar_allowed_file_can_grant_create_for_missing_repo() {
1776 let tmp = tempfile::tempdir().unwrap();
1777 let mut config = cfg(tmp.path());
1778 config.allow_write = vec!["none".into()];
1779 config.allow_create = vec!["none".into()];
1780 let group = config.repositories_dir.join("group");
1781 std::fs::create_dir_all(&group).unwrap();
1782 std::fs::write(
1783 config.repositories_dir.join("group.allowed"),
1784 "create = all\n",
1785 )
1786 .unwrap();
1787 let access = Access::new(
1788 &config.allow_read,
1789 &config.allow_write,
1790 &config.allow_create,
1791 &config.allow_stats,
1792 &config.allow_release,
1793 &config.allow_interact,
1794 &config.allow_admin,
1795 config.repositories_dir.clone(),
1796 )
1797 .unwrap();
1798 let req = protocol::push_request("group/repo", Vec::new(), Vec::new());
1799 let resp = handle_push(&config, &access, &req, None).unwrap();
1800 assert_eq!(resp[0], protocol::RES_OK);
1801 assert!(git::is_bare_repository(
1802 &config.repositories_dir.join("group/repo")
1803 ));
1804 }
1805
1806 #[test]
1807 fn create_handler_initializes_bare_repo_and_grants_creator_admin() {
1808 let tmp = tempfile::tempdir().unwrap();
1809 let mut config = cfg(tmp.path());
1810 config.allow_create = vec!["all".into()];
1811 let group = config.repositories_dir.join("group");
1812 std::fs::create_dir_all(&group).unwrap();
1813 let access = make_access(&config);
1814 let remote = ([0x11; 16], [0u8; 64]);
1815 let req = protocol::repository_request("group/created");
1816
1817 let resp = handle_create(&config, &access, &req, Some(&remote)).unwrap();
1818
1819 assert_eq!(resp[0], protocol::RES_OK);
1820 assert!(git::is_bare_repository(
1821 &config.repositories_dir.join("group/created")
1822 ));
1823 assert_eq!(
1824 std::fs::read_to_string(config.repositories_dir.join("group/created.allowed")).unwrap(),
1825 "adm:11111111111111111111111111111111\n"
1826 );
1827 assert!(access
1828 .allows(Operation::Write, "group/created", Some(&remote.0))
1829 .unwrap());
1830 }
1831
1832 #[test]
1833 fn create_handler_requires_identified_peer_and_existing_group() {
1834 let tmp = tempfile::tempdir().unwrap();
1835 let mut config = cfg(tmp.path());
1836 config.allow_create = vec!["all".into()];
1837 let access = make_access(&config);
1838 let req = protocol::repository_request("group/created");
1839
1840 let anonymous = handle_create(&config, &access, &req, None).unwrap();
1841 assert_eq!(anonymous[0], protocol::RES_DISALLOWED);
1842
1843 let remote = ([0x11; 16], [0u8; 64]);
1844 let missing_group = handle_create(&config, &access, &req, Some(&remote)).unwrap();
1845 assert_eq!(missing_group[0], protocol::RES_NOT_FOUND);
1846 }
1847
1848 #[test]
1849 fn create_handler_rejects_existing_or_invalid_repositories() {
1850 let tmp = tempfile::tempdir().unwrap();
1851 let mut config = cfg(tmp.path());
1852 config.allow_create = vec!["all".into()];
1853 std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
1854 git::ensure_bare_repository(&config.repositories_dir.join("group/existing")).unwrap();
1855 let access = make_access(&config);
1856 let remote = ([0x11; 16], [0u8; 64]);
1857
1858 let existing = handle_create(
1859 &config,
1860 &access,
1861 &protocol::repository_request("group/existing"),
1862 Some(&remote),
1863 )
1864 .unwrap();
1865 assert_eq!(existing[0], protocol::RES_DISALLOWED);
1866 assert_eq!(&existing[1..], b"repository already exists");
1867
1868 let nested = handle_create(
1869 &config,
1870 &access,
1871 &protocol::repository_request("group/nested/repo"),
1872 Some(&remote),
1873 )
1874 .unwrap();
1875 assert_eq!(nested[0], protocol::RES_INVALID_REQ);
1876 }
1877
1878 #[test]
1879 fn fork_handler_fetches_source_repo_and_records_metadata() {
1880 let tmp = tempfile::tempdir().unwrap();
1881 create_source_repo(tmp.path());
1882 let mut config = cfg(tmp.path());
1883 config.allow_create = vec!["all".into()];
1884 std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
1885 let access = make_access(&config);
1886 let remote = ([0x22; 16], [0u8; 64]);
1887 let daemon = git::test_support::GitDaemon::serve(tmp.path());
1888 let source_url = daemon.url("source-master");
1889 let req = protocol::remote_clone_request("group/forked", &source_url);
1890
1891 let resp = handle_fork(&config, &access, &req, Some(&remote)).unwrap();
1892
1893 let target = config.repositories_dir.join("group/forked");
1894 assert_eq!(resp[0], protocol::RES_OK);
1895 assert!(git::is_bare_repository(&target));
1896 assert!(!git::list_refs(&target).unwrap().is_empty());
1897 assert_eq!(git_config(&target, "repository.rngit.type"), "fork");
1898 assert_eq!(
1899 git_config(&target, "repository.rngit.upstream.source"),
1900 source_url
1901 );
1902 assert!(git_config(&target, "repository.rngit.upstream.sync")
1903 .parse::<u64>()
1904 .is_ok());
1905 assert_eq!(
1906 std::fs::read_to_string(config.repositories_dir.join("group/forked.allowed")).unwrap(),
1907 "adm:22222222222222222222222222222222\n"
1908 );
1909 }
1910
1911 #[test]
1912 fn mirror_handler_records_mirror_type() {
1913 let tmp = tempfile::tempdir().unwrap();
1914 create_source_repo_on_branch(tmp.path(), "main");
1915 let mut config = cfg(tmp.path());
1916 config.allow_create = vec!["all".into()];
1917 std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
1918 let access = make_access(&config);
1919 let remote = ([0x22; 16], [0u8; 64]);
1920 let daemon = git::test_support::GitDaemon::serve(tmp.path());
1921 let req = protocol::remote_clone_request("group/mirrored", &daemon.url("source-main"));
1922
1923 let resp = handle_mirror(&config, &access, &req, Some(&remote)).unwrap();
1924
1925 let target = config.repositories_dir.join("group/mirrored");
1926 assert_eq!(resp[0], protocol::RES_OK);
1927 assert_eq!(git_config(&target, "repository.rngit.type"), "mirror");
1928 assert_eq!(git_symbolic_ref(&target, "HEAD"), "refs/heads/main");
1929 assert!(git_config(&target, "repository.rngit.upstream.sync")
1930 .parse::<u64>()
1931 .is_ok());
1932 }
1933
1934 #[test]
1935 fn remote_clone_rejects_missing_source_and_existing_target() {
1936 let tmp = tempfile::tempdir().unwrap();
1937 let mut config = cfg(tmp.path());
1938 config.allow_create = vec!["all".into()];
1939 std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
1940 git::ensure_bare_repository(&config.repositories_dir.join("group/existing")).unwrap();
1941 let access = make_access(&config);
1942 let remote = ([0x22; 16], [0u8; 64]);
1943
1944 let missing_source = handle_fork(
1945 &config,
1946 &access,
1947 &protocol::repository_request("group/new"),
1948 Some(&remote),
1949 )
1950 .unwrap();
1951 assert_eq!(missing_source[0], protocol::RES_INVALID_REQ);
1952
1953 let local_source = create_source_repo(tmp.path());
1954 for source in [
1955 local_source.to_str().expect("utf-8 path").to_string(),
1956 format!("file://{}", local_source.display()),
1957 "--option=value".to_string(),
1958 ] {
1959 let rejected = handle_fork(
1960 &config,
1961 &access,
1962 &protocol::remote_clone_request("group/new", &source),
1963 Some(&remote),
1964 )
1965 .unwrap();
1966 assert_eq!(rejected[0], protocol::RES_INVALID_REQ, "{source:?}");
1967 assert!(!config.repositories_dir.join("group/new").exists());
1968 }
1969
1970 let existing = handle_fork(
1971 &config,
1972 &access,
1973 &protocol::remote_clone_request("group/existing", "https://example.invalid/unused.git"),
1974 Some(&remote),
1975 )
1976 .unwrap();
1977 assert_eq!(existing[0], protocol::RES_DISALLOWED);
1978 assert_eq!(&existing[1..], b"repository already exists");
1979 }
1980
1981 #[test]
1982 fn sync_handler_fetches_upstream_updates_for_fork() {
1983 let tmp = tempfile::tempdir().unwrap();
1984 let source = create_source_repo(tmp.path());
1985 let mut config = cfg(tmp.path());
1986 config.allow_create = vec!["all".into()];
1987 std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
1988 let access = make_access(&config);
1989 let remote = ([0x22; 16], [0u8; 64]);
1990 let daemon = git::test_support::GitDaemon::serve(tmp.path());
1991 let fork_req = protocol::remote_clone_request("group/forked", &daemon.url("source-master"));
1992 assert_eq!(
1993 handle_fork(&config, &access, &fork_req, Some(&remote)).unwrap()[0],
1994 protocol::RES_OK
1995 );
1996
1997 let updated_sha = commit_source_file(&source, "second.txt", "second\n");
1998
1999 let resp = handle_sync(
2000 &config,
2001 &access,
2002 &protocol::repository_request("group/forked"),
2003 Some(&remote),
2004 )
2005 .unwrap();
2006
2007 assert_eq!(resp[0], protocol::RES_OK);
2008 assert_eq!(
2009 git_rev_parse(&config.repositories_dir.join("group/forked"), "HEAD"),
2010 updated_sha
2011 );
2012 assert!(git_config(
2013 &config.repositories_dir.join("group/forked"),
2014 "repository.rngit.upstream.sync"
2015 )
2016 .parse::<u64>()
2017 .is_ok());
2018 }
2019
2020 #[test]
2021 fn sync_handler_rejects_non_clone_or_missing_write_access() {
2022 let tmp = tempfile::tempdir().unwrap();
2023 let mut config = cfg(tmp.path());
2024 config.allow_write = vec!["none".into()];
2025 let repo = config.repositories_dir.join("group/plain");
2026 git::ensure_bare_repository(&repo).unwrap();
2027 let access = make_access(&config);
2028 let remote = ([0x22; 16], [0u8; 64]);
2029
2030 let no_write = handle_sync(
2031 &config,
2032 &access,
2033 &protocol::repository_request("group/plain"),
2034 Some(&remote),
2035 )
2036 .unwrap();
2037 assert_eq!(no_write[0], protocol::RES_DISALLOWED);
2038
2039 config.allow_write = vec!["all".into()];
2040 let access = make_access(&config);
2041 let plain = handle_sync(
2042 &config,
2043 &access,
2044 &protocol::repository_request("group/plain"),
2045 Some(&remote),
2046 )
2047 .unwrap();
2048 assert_eq!(plain[0], protocol::RES_INVALID_REQ);
2049 assert_eq!(&plain[1..], b"repository is neither fork nor mirror");
2050 }
2051
2052 #[test]
2053 fn push_rejects_invalid_repository_name_before_create_acl() {
2054 let tmp = tempfile::tempdir().unwrap();
2055 let mut config = cfg(tmp.path());
2056 config.allow_write = vec!["none".into()];
2057 config.allow_create = vec!["all".into()];
2058 let access = Access::new(
2059 &config.allow_read,
2060 &config.allow_write,
2061 &config.allow_create,
2062 &config.allow_stats,
2063 &config.allow_release,
2064 &config.allow_interact,
2065 &config.allow_admin,
2066 config.repositories_dir.clone(),
2067 )
2068 .unwrap();
2069 let req = protocol::push_request("../repo", Vec::new(), Vec::new());
2070 assert!(handle_push(&config, &access, &req, None).is_err());
2071 }
2072
2073 #[test]
2074 fn push_rejects_invalid_ref_before_creating_missing_repo() {
2075 let tmp = tempfile::tempdir().unwrap();
2076 let config = cfg(tmp.path());
2077 let access = make_access(&config);
2078 let req = protocol::push_request(
2079 "group/repo",
2080 Vec::new(),
2081 vec![protocol::RefUpdate {
2082 refname: "-refs/heads/main".into(),
2083 old: None,
2084 new: Some("0123456789abcdef0123456789abcdef01234567".into()),
2085 force: true,
2086 }],
2087 );
2088
2089 let resp = handle_push(&config, &access, &req, None).unwrap();
2090
2091 assert_eq!(resp[0], protocol::RES_INVALID_REQ);
2092 assert!(!config.repositories_dir.join("group/repo").exists());
2093 }
2094
2095 #[test]
2096 fn push_rejects_invalid_sha_before_creating_missing_repo() {
2097 let tmp = tempfile::tempdir().unwrap();
2098 let config = cfg(tmp.path());
2099 let access = make_access(&config);
2100 let req = protocol::push_request(
2101 "group/repo",
2102 Vec::new(),
2103 vec![protocol::RefUpdate {
2104 refname: "refs/heads/main".into(),
2105 old: None,
2106 new: Some("--not-a-sha".into()),
2107 force: true,
2108 }],
2109 );
2110
2111 let resp = handle_push(&config, &access, &req, None).unwrap();
2112
2113 assert_eq!(resp[0], protocol::RES_INVALID_REQ);
2114 assert!(!config.repositories_dir.join("group/repo").exists());
2115 }
2116
2117 #[test]
2118 fn fetch_existing_repo_can_return_ok_status_or_resource() {
2119 let tmp = tempfile::tempdir().unwrap();
2120 let config = cfg(tmp.path());
2121 let access = Access::new(
2122 &config.allow_read,
2123 &config.allow_write,
2124 &config.allow_create,
2125 &config.allow_stats,
2126 &config.allow_release,
2127 &config.allow_interact,
2128 &config.allow_admin,
2129 config.repositories_dir.clone(),
2130 )
2131 .unwrap();
2132 let repo = config.repositories_dir.join("repo");
2133 git::ensure_bare_repository(&repo).unwrap();
2134 let req = protocol::fetch_request("repo", &[]);
2135 match handle_fetch(&config, &access, &req, None).unwrap() {
2136 RequestResponse::File { .. } => panic!("fetch should return a value"),
2137 RequestResponse::Bytes(bytes) => assert_eq!(bytes[0], protocol::RES_OK),
2138 RequestResponse::Resource { metadata, .. } => assert!(metadata.is_some()),
2139 }
2140 }
2141
2142 #[test]
2143 fn fetch_rejects_invalid_have_sha() {
2144 let tmp = tempfile::tempdir().unwrap();
2145 let config = cfg(tmp.path());
2146 let access = make_access(&config);
2147 let repo = config.repositories_dir.join("repo");
2148 git::ensure_bare_repository(&repo).unwrap();
2149 let req = protocol::fetch_request("repo", &["--upload-pack=/tmp/x".into()]);
2150
2151 let response = handle_fetch(&config, &access, &req, None).unwrap();
2152
2153 let RequestResponse::Bytes(bytes) = response else {
2154 panic!("invalid fetch request should return status bytes");
2155 };
2156 assert_eq!(bytes[0], protocol::RES_INVALID_REQ);
2157 }
2158
2159 #[test]
2160 fn git_failures_return_generic_client_errors() {
2161 let tmp = tempfile::tempdir().unwrap();
2162 let config = cfg(tmp.path());
2163 let access = make_access(&config);
2164 let repo = config.repositories_dir.join("broken");
2165 create_corrupt_bare_repo(&repo);
2166
2167 let list = handle_list(
2168 &config,
2169 &access,
2170 &protocol::repository_request("broken"),
2171 None,
2172 )
2173 .unwrap();
2174 assert_generic_remote_error(&list);
2175
2176 let fetch = handle_fetch(
2177 &config,
2178 &access,
2179 &protocol::fetch_request("broken", &[]),
2180 None,
2181 )
2182 .unwrap();
2183 let RequestResponse::Bytes(fetch) = fetch else {
2184 panic!("corrupt repository fetch should return status bytes");
2185 };
2186 assert_generic_remote_error(&fetch);
2187
2188 git::ensure_bare_repository(&config.repositories_dir.join("push-target")).unwrap();
2189 let push = handle_push(
2190 &config,
2191 &access,
2192 &protocol::push_request("push-target", b"not a git bundle".to_vec(), Vec::new()),
2193 None,
2194 )
2195 .unwrap();
2196 assert_generic_remote_error(&push);
2197 }
2198
2199 #[test]
2200 fn work_protocol_enforces_read_interact_and_write_access() {
2201 let tmp = tempfile::tempdir().unwrap();
2202 let mut config = cfg(tmp.path());
2203 git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2204
2205 config.allow_read = vec!["none".into()];
2206 config.allow_write = vec!["all".into()];
2207 config.allow_interact = vec!["all".into()];
2208 let access = make_access(&config);
2209 let list = handle_work(
2210 &config,
2211 &access,
2212 &work_request(&[
2213 ("repository", strv("group/repo")),
2214 ("operation", strv("list")),
2215 ]),
2216 Some(&(REMOTE, REMOTE_SIG)),
2217 )
2218 .unwrap();
2219 assert_eq!(list[0], protocol::RES_NOT_FOUND);
2220
2221 config.allow_read = vec!["all".into()];
2222 config.allow_write = vec!["all".into()];
2223 config.allow_interact = vec!["none".into()];
2224 let access = make_access(&config);
2225 let create = handle_work(
2226 &config,
2227 &access,
2228 &work_request(&[
2229 ("repository", strv("group/repo")),
2230 ("operation", strv("create")),
2231 ("title", strv("Task")),
2232 ("content", strv("Body")),
2233 ]),
2234 Some(&(REMOTE, REMOTE_SIG)),
2235 )
2236 .unwrap();
2237 assert_eq!(create[0], protocol::RES_DISALLOWED);
2238
2239 config.allow_interact = vec!["all".into()];
2240 config.allow_write = vec!["none".into()];
2241 let access = make_access(&config);
2242 let create = handle_work(
2243 &config,
2244 &access,
2245 &work_request(&[
2246 ("repository", strv("group/repo")),
2247 ("operation", strv("create")),
2248 ("title", strv("Task")),
2249 ("content", strv("Body")),
2250 ]),
2251 Some(&(REMOTE, REMOTE_SIG)),
2252 )
2253 .unwrap();
2254 assert_eq!(create[0], protocol::RES_DISALLOWED);
2255
2256 config.allow_write = vec!["all".into()];
2257 let access = make_access(&config);
2258 let create = handle_work(
2259 &config,
2260 &access,
2261 &work_request(&[
2262 ("repository", strv("group/repo")),
2263 ("operation", strv("create")),
2264 ("title", strv("Task")),
2265 ("content", strv("Body")),
2266 ]),
2267 Some(&(REMOTE, REMOTE_SIG)),
2268 )
2269 .unwrap();
2270 assert_eq!(create[0], protocol::RES_OK);
2271
2272 config.allow_write = vec!["none".into()];
2273 let access = make_access(&config);
2274 let comment = handle_work(
2275 &config,
2276 &access,
2277 &work_request(&[
2278 ("repository", strv("group/repo")),
2279 ("operation", strv("comment")),
2280 ("doc_id", uintv(1)),
2281 ("content", strv("Comment")),
2282 ]),
2283 Some(&(REMOTE, REMOTE_SIG)),
2284 )
2285 .unwrap();
2286 assert_eq!(comment[0], protocol::RES_OK);
2287 }
2288
2289 #[test]
2290 fn work_protocol_lifecycle_round_trips_documents_and_comments() {
2291 let tmp = tempfile::tempdir().unwrap();
2292 let mut config = cfg(tmp.path());
2293 config.allow_interact = vec!["all".into()];
2294 git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2295 let access = make_access(&config);
2296
2297 let create = handle_work(
2298 &config,
2299 &access,
2300 &work_request(&[
2301 ("repository", strv("group/repo")),
2302 ("operation", strv("create")),
2303 ("title", strv("Initial title")),
2304 ("content", strv("Initial body")),
2305 ("format", strv("micron")),
2306 ]),
2307 Some(&(REMOTE, REMOTE_SIG)),
2308 )
2309 .unwrap();
2310 let created = body_value(&create);
2311 assert_eq!(created.map_get("id").and_then(Value::as_integer), Some(1));
2312 assert_eq!(
2313 created.map_get("scope").and_then(Value::as_str),
2314 Some("active")
2315 );
2316
2317 let comment = handle_work(
2318 &config,
2319 &access,
2320 &work_request(&[
2321 ("repository", strv("group/repo")),
2322 ("operation", strv("comment")),
2323 ("doc_id", uintv(1)),
2324 ("content", strv("Progress update")),
2325 ("format", strv("markdown")),
2326 ]),
2327 Some(&(REMOTE, REMOTE_SIG)),
2328 )
2329 .unwrap();
2330 assert_eq!(
2331 body_value(&comment)
2332 .map_get("id")
2333 .and_then(Value::as_integer),
2334 Some(1)
2335 );
2336
2337 let view = handle_work(
2338 &config,
2339 &access,
2340 &work_request(&[
2341 ("repository", strv("group/repo")),
2342 ("operation", strv("view")),
2343 ("doc_id", uintv(1)),
2344 ]),
2345 Some(&(REMOTE, REMOTE_SIG)),
2346 )
2347 .unwrap();
2348 let view = body_value(&view);
2349 assert_eq!(
2350 view.map_get("content").and_then(Value::as_str),
2351 Some("Initial body")
2352 );
2353 let comments = view
2354 .map_get("comments")
2355 .and_then(Value::as_array)
2356 .expect("comments array");
2357 assert_eq!(comments.len(), 1);
2358 assert_eq!(
2359 comments[0].map_get("content").and_then(Value::as_str),
2360 Some("Progress update")
2361 );
2362
2363 let edit = handle_work(
2364 &config,
2365 &access,
2366 &work_request(&[
2367 ("repository", strv("group/repo")),
2368 ("operation", strv("edit")),
2369 ("doc_id", uintv(1)),
2370 ("title", strv("Edited title")),
2371 ("content", strv("Edited body")),
2372 ]),
2373 Some(&(REMOTE, REMOTE_SIG)),
2374 )
2375 .unwrap();
2376 assert_eq!(edit, vec![protocol::RES_OK]);
2377
2378 let complete = handle_work(
2379 &config,
2380 &access,
2381 &work_request(&[
2382 ("repository", strv("group/repo")),
2383 ("operation", strv("complete")),
2384 ("doc_id", uintv(1)),
2385 ]),
2386 Some(&(REMOTE, REMOTE_SIG)),
2387 )
2388 .unwrap();
2389 assert_eq!(
2390 body_value(&complete)
2391 .map_get("scope")
2392 .and_then(Value::as_str),
2393 Some("completed")
2394 );
2395
2396 let all = handle_work(
2397 &config,
2398 &access,
2399 &work_request(&[
2400 ("repository", strv("group/repo")),
2401 ("operation", strv("list")),
2402 ("scope", strv("all")),
2403 ]),
2404 Some(&(REMOTE, REMOTE_SIG)),
2405 )
2406 .unwrap();
2407 let all = body_value(&all);
2408 assert_eq!(
2409 all.map_get("active")
2410 .and_then(Value::as_array)
2411 .unwrap()
2412 .len(),
2413 0
2414 );
2415 assert_eq!(
2416 all.map_get("completed")
2417 .and_then(Value::as_array)
2418 .unwrap()
2419 .len(),
2420 1
2421 );
2422
2423 let activate = handle_work(
2424 &config,
2425 &access,
2426 &work_request(&[
2427 ("repository", strv("group/repo")),
2428 ("operation", strv("activate")),
2429 ("doc_id", uintv(1)),
2430 ]),
2431 Some(&(REMOTE, REMOTE_SIG)),
2432 )
2433 .unwrap();
2434 assert_eq!(
2435 body_value(&activate)
2436 .map_get("scope")
2437 .and_then(Value::as_str),
2438 Some("active")
2439 );
2440
2441 let delete = handle_work(
2442 &config,
2443 &access,
2444 &work_request(&[
2445 ("repository", strv("group/repo")),
2446 ("operation", strv("delete")),
2447 ("doc_id", uintv(1)),
2448 ]),
2449 Some(&(REMOTE, REMOTE_SIG)),
2450 )
2451 .unwrap();
2452 assert_eq!(delete, vec![protocol::RES_OK]);
2453 }
2454
2455 #[test]
2456 fn work_view_and_edit_scope_all_return_not_found_for_missing_document() {
2457 let tmp = tempfile::tempdir().unwrap();
2458 let mut config = cfg(tmp.path());
2459 config.allow_write = vec!["all".into()];
2460 config.allow_interact = vec!["all".into()];
2461 git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2462 let access = make_access(&config);
2463
2464 let view = handle_work(
2465 &config,
2466 &access,
2467 &work_request(&[
2468 ("repository", strv("group/repo")),
2469 ("operation", strv("view")),
2470 ("scope", strv("all")),
2471 ("doc_id", uintv(99)),
2472 ]),
2473 Some(&(REMOTE, REMOTE_SIG)),
2474 )
2475 .unwrap();
2476 assert_eq!(view[0], protocol::RES_NOT_FOUND);
2477
2478 let edit = handle_work(
2479 &config,
2480 &access,
2481 &work_request(&[
2482 ("repository", strv("group/repo")),
2483 ("operation", strv("edit")),
2484 ("scope", strv("all")),
2485 ("doc_id", uintv(99)),
2486 ("title", strv("Missing")),
2487 ]),
2488 Some(&(REMOTE, REMOTE_SIG)),
2489 )
2490 .unwrap();
2491 assert_eq!(edit[0], protocol::RES_NOT_FOUND);
2492 }
2493
2494 #[test]
2495 fn work_protocol_proposes_signed_documents_in_proposed_scope() {
2496 let tmp = tempfile::tempdir().unwrap();
2497 let mut config = cfg(tmp.path());
2498 config.allow_write = vec!["none".into()];
2499 config.allow_interact = vec!["none".into()];
2500 config.allow_propose = vec!["all".into()];
2501 git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2502 let access = make_access(&config);
2503 let identity = Identity::new(&mut OsRng);
2504 let remote_hash = *identity.hash();
2505 let remote_pubkey: [u8; 64] = identity.get_public_key().unwrap();
2506 let signature = identity.sign(b"Proposal body").unwrap().to_vec();
2507
2508 let proposed = handle_work(
2509 &config,
2510 &access,
2511 &work_request(&[
2512 ("repository", strv("group/repo")),
2513 ("operation", strv("propose")),
2514 ("title", strv("Proposal")),
2515 ("content", strv("Proposal body")),
2516 ("signature", binv(&signature)),
2517 ]),
2518 Some(&(remote_hash, remote_pubkey)),
2519 )
2520 .unwrap();
2521 let proposed = body_value(&proposed);
2522 assert_eq!(proposed.map_get("id").and_then(Value::as_integer), Some(1));
2523 assert_eq!(
2524 proposed.map_get("scope").and_then(Value::as_str),
2525 Some("proposed")
2526 );
2527
2528 let list = handle_work(
2529 &config,
2530 &access,
2531 &work_request(&[
2532 ("repository", strv("group/repo")),
2533 ("operation", strv("list")),
2534 ("scope", strv("all")),
2535 ]),
2536 Some(&(remote_hash, remote_pubkey)),
2537 )
2538 .unwrap();
2539 let list = body_value(&list);
2540 assert_eq!(
2541 list.map_get("proposed")
2542 .and_then(Value::as_array)
2543 .unwrap()
2544 .len(),
2545 1
2546 );
2547
2548 let edit = handle_work(
2549 &config,
2550 &access,
2551 &work_request(&[
2552 ("repository", strv("group/repo")),
2553 ("operation", strv("edit")),
2554 ("scope", strv("proposed")),
2555 ("doc_id", uintv(1)),
2556 ("title", strv("Updated proposal")),
2557 ]),
2558 Some(&(remote_hash, remote_pubkey)),
2559 )
2560 .unwrap();
2561 assert_eq!(edit, vec![protocol::RES_OK]);
2562
2563 let invalid = handle_work(
2564 &config,
2565 &access,
2566 &work_request(&[
2567 ("repository", strv("group/repo")),
2568 ("operation", strv("propose")),
2569 ("title", strv("Unsigned")),
2570 ("content", strv("Unsigned body")),
2571 ]),
2572 Some(&(remote_hash, remote_pubkey)),
2573 )
2574 .unwrap();
2575 assert_eq!(invalid[0], protocol::RES_INVALID_REQ);
2576 }
2577
2578 #[test]
2579 fn work_protocol_validates_and_exposes_document_signatures() {
2580 let tmp = tempfile::tempdir().unwrap();
2581 let mut config = cfg(tmp.path());
2582 config.allow_interact = vec!["all".into()];
2583 git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2584 let access = make_access(&config);
2585 let identity = Identity::new(&mut OsRng);
2586 let remote_hash = *identity.hash();
2587 let remote_pubkey: [u8; 64] = identity.get_public_key().unwrap();
2588 let signature = identity.sign(b"Signed body").unwrap().to_vec();
2589
2590 let create = handle_work(
2591 &config,
2592 &access,
2593 &work_request(&[
2594 ("repository", strv("group/repo")),
2595 ("operation", strv("create")),
2596 ("title", strv("Signed task")),
2597 ("content", strv("Signed body")),
2598 ("signature", binv(&signature)),
2599 ]),
2600 Some(&(remote_hash, remote_pubkey)),
2601 )
2602 .unwrap();
2603 assert_eq!(create[0], protocol::RES_OK);
2604
2605 let view = handle_work(
2606 &config,
2607 &access,
2608 &work_request(&[
2609 ("repository", strv("group/repo")),
2610 ("operation", strv("view")),
2611 ("doc_id", uintv(1)),
2612 ]),
2613 Some(&(remote_hash, remote_pubkey)),
2614 )
2615 .unwrap();
2616 let view = body_value(&view);
2617 let meta = view.map_get("meta").unwrap();
2618 assert_eq!(
2619 meta.map_get("signature").and_then(Value::as_bin),
2620 Some(signature.as_slice())
2621 );
2622 assert_eq!(
2623 meta.map_get("identity").and_then(Value::as_bin),
2624 Some(remote_pubkey.as_slice())
2625 );
2626
2627 let invalid = handle_work(
2628 &config,
2629 &access,
2630 &work_request(&[
2631 ("repository", strv("group/repo")),
2632 ("operation", strv("create")),
2633 ("title", strv("Invalid task")),
2634 ("content", strv("Different body")),
2635 ("signature", binv(&signature)),
2636 ]),
2637 Some(&(remote_hash, remote_pubkey)),
2638 )
2639 .unwrap();
2640 assert_eq!(invalid[0], protocol::RES_INVALID_REQ);
2641 }
2642
2643 #[test]
2644 fn work_admins_can_transition_another_authors_document() {
2645 for document_admin in [false, true] {
2646 let tmp = tempfile::tempdir().unwrap();
2647 let mut config = cfg(tmp.path());
2648 config.allow_interact = vec!["all".into()];
2649 if !document_admin {
2650 config.allow_admin = vec![crate::util::hex(&OTHER_REMOTE)];
2651 }
2652 let repo_path = config.repositories_dir.join("group/repo");
2653 git::ensure_bare_repository(&repo_path).unwrap();
2654 let work_path = crate::work::work_sidecar_path(&repo_path);
2655 crate::work::create_document(
2656 &work_path,
2657 crate::work::WorkInput {
2658 title: "Task".into(),
2659 content: "Body".into(),
2660 format: "plain".into(),
2661 signature: None,
2662 identity: None,
2663 author: REMOTE,
2664 },
2665 )
2666 .unwrap();
2667 if document_admin {
2668 crate::work::set_document_permissions(
2669 &work_path,
2670 1,
2671 &format!("admin = {}\n", crate::util::hex(&OTHER_REMOTE)),
2672 )
2673 .unwrap();
2674 }
2675 let access = make_access(&config);
2676 for operation in ["complete", "activate"] {
2677 let response = handle_work(
2678 &config,
2679 &access,
2680 &work_request(&[
2681 ("repository", strv("group/repo")),
2682 ("operation", strv(operation)),
2683 ("doc_id", uintv(1)),
2684 ]),
2685 Some(&(OTHER_REMOTE, REMOTE_SIG)),
2686 )
2687 .unwrap();
2688 assert_eq!(response[0], protocol::RES_OK, "{operation}: {response:?}");
2689 }
2690 assert_eq!(crate::work::document_author(&work_path, 1).unwrap(), REMOTE);
2691 crate::work::set_document_permissions(&work_path, 1, "admin = none\n").unwrap();
2692 let denied = handle_work(
2693 &config,
2694 &access,
2695 &work_request(&[
2696 ("repository", strv("group/repo")),
2697 ("operation", strv("complete")),
2698 ("doc_id", uintv(1)),
2699 ]),
2700 Some(&(OTHER_REMOTE, REMOTE_SIG)),
2701 )
2702 .unwrap();
2703 assert_eq!(denied[0], protocol::RES_DISALLOWED);
2704 let response = handle_work(
2705 &config,
2706 &access,
2707 &work_request(&[
2708 ("repository", strv("group/repo")),
2709 ("operation", strv("edit")),
2710 ("doc_id", uintv(1)),
2711 ("content", strv("Other edit")),
2712 ]),
2713 Some(&(OTHER_REMOTE, REMOTE_SIG)),
2714 )
2715 .unwrap();
2716 assert_eq!(response[0], protocol::RES_DISALLOWED);
2717 }
2718 }
2719
2720 #[test]
2721 fn work_protocol_rejects_non_author_management_operations() {
2722 let tmp = tempfile::tempdir().unwrap();
2723 let mut config = cfg(tmp.path());
2724 config.allow_interact = vec!["all".into()];
2725 git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2726 let access = make_access(&config);
2727
2728 let create = handle_work(
2729 &config,
2730 &access,
2731 &work_request(&[
2732 ("repository", strv("group/repo")),
2733 ("operation", strv("create")),
2734 ("title", strv("Task")),
2735 ("content", strv("Body")),
2736 ]),
2737 Some(&(REMOTE, REMOTE_SIG)),
2738 )
2739 .unwrap();
2740 assert_eq!(create[0], protocol::RES_OK);
2741
2742 for operation in ["edit", "complete", "delete"] {
2743 let denied = handle_work(
2744 &config,
2745 &access,
2746 &work_request(&[
2747 ("repository", strv("group/repo")),
2748 ("operation", strv(operation)),
2749 ("doc_id", uintv(1)),
2750 ("content", strv("Other edit")),
2751 ]),
2752 Some(&(OTHER_REMOTE, REMOTE_SIG)),
2753 )
2754 .unwrap();
2755 assert_eq!(denied[0], protocol::RES_DISALLOWED);
2756 }
2757 }
2758
2759 #[test]
2760 fn work_document_local_interact_allows_comments_without_repo_interact() {
2761 let tmp = tempfile::tempdir().unwrap();
2762 let mut config = cfg(tmp.path());
2763 config.allow_write = vec!["none".into()];
2764 config.allow_interact = vec!["none".into()];
2765 let repo_path = config.repositories_dir.join("group/repo");
2766 git::ensure_bare_repository(&repo_path).unwrap();
2767 let work_path = crate::work::work_sidecar_path(&repo_path);
2768 crate::work::create_document(
2769 &work_path,
2770 crate::work::WorkInput {
2771 title: "Task".into(),
2772 content: "Body".into(),
2773 format: "markdown".into(),
2774 signature: None,
2775 identity: None,
2776 author: REMOTE,
2777 },
2778 )
2779 .unwrap();
2780 std::fs::write(work_path.join("1.allowed"), "interact = all\n").unwrap();
2781 let access = make_access(&config);
2782
2783 let comment = handle_work(
2784 &config,
2785 &access,
2786 &work_request(&[
2787 ("repository", strv("group/repo")),
2788 ("operation", strv("comment")),
2789 ("doc_id", uintv(1)),
2790 ("content", strv("Comment from doc ACL")),
2791 ]),
2792 Some(&(OTHER_REMOTE, REMOTE_SIG)),
2793 )
2794 .unwrap();
2795 assert_eq!(comment[0], protocol::RES_OK);
2796
2797 let edit = handle_work(
2798 &config,
2799 &access,
2800 &work_request(&[
2801 ("repository", strv("group/repo")),
2802 ("operation", strv("edit")),
2803 ("doc_id", uintv(1)),
2804 ("content", strv("Not allowed")),
2805 ]),
2806 Some(&(OTHER_REMOTE, REMOTE_SIG)),
2807 )
2808 .unwrap();
2809 assert_eq!(edit[0], protocol::RES_DISALLOWED);
2810 }
2811
2812 #[test]
2813 fn work_permissions_get_set_and_validation_are_author_gated() {
2814 let tmp = tempfile::tempdir().unwrap();
2815 let mut config = cfg(tmp.path());
2816 config.allow_interact = vec!["all".into()];
2817 let repo_path = config.repositories_dir.join("group/repo");
2818 git::ensure_bare_repository(&repo_path).unwrap();
2819 let work_path = crate::work::work_sidecar_path(&repo_path);
2820 crate::work::create_document(
2821 &work_path,
2822 crate::work::WorkInput {
2823 title: "Task".into(),
2824 content: "Body".into(),
2825 format: "markdown".into(),
2826 signature: None,
2827 identity: None,
2828 author: REMOTE,
2829 },
2830 )
2831 .unwrap();
2832 let access = make_access(&config);
2833
2834 let get = handle_work(
2835 &config,
2836 &access,
2837 &work_request(&[
2838 ("repository", strv("group/repo")),
2839 ("operation", strv("perms")),
2840 ("step", strv("get")),
2841 ("doc_id", uintv(1)),
2842 ]),
2843 Some(&(REMOTE, REMOTE_SIG)),
2844 )
2845 .unwrap();
2846 assert_eq!(
2847 body_value(&get).map_get("content").and_then(Value::as_str),
2848 Some("")
2849 );
2850
2851 let content = format!(
2852 "interact = {}\nadmin = {}\n",
2853 crate::util::hex(&OTHER_REMOTE),
2854 crate::util::hex(&OTHER_REMOTE)
2855 );
2856 let set = handle_work(
2857 &config,
2858 &access,
2859 &work_request(&[
2860 ("repository", strv("group/repo")),
2861 ("operation", strv("perms")),
2862 ("step", strv("set")),
2863 ("doc_id", uintv(1)),
2864 ("content", strv(&content)),
2865 ]),
2866 Some(&(REMOTE, REMOTE_SIG)),
2867 )
2868 .unwrap();
2869 assert_eq!(set, vec![protocol::RES_OK]);
2870 assert_eq!(
2871 std::fs::read_to_string(work_path.join("1.allowed")).unwrap(),
2872 content
2873 );
2874
2875 let denied = handle_work(
2876 &config,
2877 &access,
2878 &work_request(&[
2879 ("repository", strv("group/repo")),
2880 ("operation", strv("perms")),
2881 ("step", strv("get")),
2882 ("doc_id", uintv(1)),
2883 ]),
2884 Some(&([0x33; 16], REMOTE_SIG)),
2885 )
2886 .unwrap();
2887 assert_eq!(denied[0], protocol::RES_DISALLOWED);
2888
2889 let invalid = handle_work(
2890 &config,
2891 &access,
2892 &work_request(&[
2893 ("repository", strv("group/repo")),
2894 ("operation", strv("perms")),
2895 ("step", strv("set")),
2896 ("doc_id", uintv(1)),
2897 ("content", strv("interact = not-a-hex-identity\n")),
2898 ]),
2899 Some(&(REMOTE, REMOTE_SIG)),
2900 )
2901 .unwrap();
2902 assert_eq!(invalid[0], protocol::RES_INVALID_REQ);
2903 assert_eq!(
2904 std::fs::read_to_string(work_path.join("1.allowed")).unwrap(),
2905 content
2906 );
2907
2908 let missing = handle_work(
2909 &config,
2910 &access,
2911 &work_request(&[
2912 ("repository", strv("group/repo")),
2913 ("operation", strv("perms")),
2914 ("step", strv("get")),
2915 ("doc_id", uintv(99)),
2916 ]),
2917 Some(&(REMOTE, REMOTE_SIG)),
2918 )
2919 .unwrap();
2920 assert_eq!(missing[0], protocol::RES_NOT_FOUND);
2921 }
2922
2923 #[test]
2924 fn work_document_admin_can_get_and_set_permissions() {
2925 let tmp = tempfile::tempdir().unwrap();
2926 let mut config = cfg(tmp.path());
2927 config.allow_write = vec!["none".into()];
2928 config.allow_interact = vec!["none".into()];
2929 let repo_path = config.repositories_dir.join("group/repo");
2930 git::ensure_bare_repository(&repo_path).unwrap();
2931 let work_path = crate::work::work_sidecar_path(&repo_path);
2932 crate::work::create_document(
2933 &work_path,
2934 crate::work::WorkInput {
2935 title: "Task".into(),
2936 content: "Body".into(),
2937 format: "markdown".into(),
2938 signature: None,
2939 identity: None,
2940 author: REMOTE,
2941 },
2942 )
2943 .unwrap();
2944 std::fs::write(
2945 work_path.join("1.allowed"),
2946 format!("admin = {}\n", crate::util::hex(&OTHER_REMOTE)),
2947 )
2948 .unwrap();
2949 let access = make_access(&config);
2950
2951 let get = handle_work(
2952 &config,
2953 &access,
2954 &work_request(&[
2955 ("repository", strv("group/repo")),
2956 ("operation", strv("perms")),
2957 ("step", strv("get")),
2958 ("doc_id", uintv(1)),
2959 ]),
2960 Some(&(OTHER_REMOTE, REMOTE_SIG)),
2961 )
2962 .unwrap();
2963 assert_eq!(get[0], protocol::RES_OK);
2964
2965 let set = handle_work(
2966 &config,
2967 &access,
2968 &work_request(&[
2969 ("repository", strv("group/repo")),
2970 ("operation", strv("perms")),
2971 ("step", strv("set")),
2972 ("doc_id", uintv(1)),
2973 ("content", strv("interact = all\n")),
2974 ]),
2975 Some(&(OTHER_REMOTE, REMOTE_SIG)),
2976 )
2977 .unwrap();
2978 assert_eq!(set, vec![protocol::RES_OK]);
2979 assert_eq!(
2980 std::fs::read_to_string(work_path.join("1.allowed")).unwrap(),
2981 "interact = all\n"
2982 );
2983 }
2984
2985 #[test]
2986 #[cfg(unix)]
2987 fn permissions_updates_preserve_executable_resolvers() {
2988 use std::os::unix::fs::PermissionsExt;
2989 let tmp = tempfile::tempdir().unwrap();
2990 let config = cfg(tmp.path());
2991 std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
2992 for relative in ["group.allowed", "group/repo.allowed"] {
2993 let path = config.repositories_dir.join(relative);
2994 let original = "#!/bin/sh\necho 'read = all'\n";
2995 std::fs::write(&path, original).unwrap();
2996 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
2997 let response = handle_permissions_step(
2998 &config,
2999 &[
3000 (strv("step"), strv("set")),
3001 (strv("content"), strv("read = none\n")),
3002 ],
3003 &path,
3004 )
3005 .unwrap();
3006 assert_eq!(response[0], protocol::RES_DISALLOWED);
3007 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
3008 assert_eq!(
3009 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
3010 0o755
3011 );
3012 }
3013 }
3014
3015 #[test]
3016 fn permissions_updates_are_visible_without_rebuilding_access() {
3017 let tmp = tempfile::tempdir().unwrap();
3018 let mut config = cfg(tmp.path());
3019 config.allow_write = vec!["none".into()];
3020 std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
3021 let access = make_access(&config);
3022 for relative in ["group.allowed", "group/repo.allowed"] {
3023 let path = config.repositories_dir.join(relative);
3024 for (content, allowed) in [("write = all\n", true), ("write = none\n", false)] {
3025 let response = handle_permissions_step(
3026 &config,
3027 &[
3028 (strv("step"), strv("set")),
3029 (strv("content"), strv(content)),
3030 ],
3031 &path,
3032 )
3033 .unwrap();
3034 assert_eq!(response, vec![protocol::RES_OK]);
3035 assert_eq!(
3036 access
3037 .allows(Operation::Write, "group/repo", Some(&REMOTE))
3038 .unwrap(),
3039 allowed
3040 );
3041 }
3042 }
3043 }
3044
3045 #[test]
3046 fn repository_permissions_get_set_and_validate_allowed_file() {
3047 let tmp = tempfile::tempdir().unwrap();
3048 let mut config = cfg(tmp.path());
3049 config.allow_admin = vec![crate::util::hex(&REMOTE)];
3050 let repo_path = config.repositories_dir.join("group/repo");
3051 git::ensure_bare_repository(&repo_path).unwrap();
3052 let access = make_access(&config);
3053
3054 let get = handle_perms(
3055 &config,
3056 &access,
3057 &work_request(&[
3058 ("repository", strv("group/repo")),
3059 ("operation", strv("rperms")),
3060 ("step", strv("get")),
3061 ]),
3062 Some(&(REMOTE, REMOTE_SIG)),
3063 )
3064 .unwrap();
3065 assert_eq!(
3066 body_value(&get).map_get("content").and_then(Value::as_str),
3067 Some("")
3068 );
3069
3070 let set = handle_perms(
3071 &config,
3072 &access,
3073 &work_request(&[
3074 ("repository", strv("group/repo")),
3075 ("operation", strv("rperms")),
3076 ("step", strv("set")),
3077 (
3078 "content",
3079 strv(
3080 "write = all
3081",
3082 ),
3083 ),
3084 ]),
3085 Some(&(REMOTE, REMOTE_SIG)),
3086 )
3087 .unwrap();
3088 assert_eq!(set, vec![protocol::RES_OK]);
3089 assert_eq!(
3090 std::fs::read_to_string(config.repositories_dir.join("group/repo.allowed")).unwrap(),
3091 "write = all
3092"
3093 );
3094
3095 let invalid = handle_perms(
3096 &config,
3097 &access,
3098 &work_request(&[
3099 ("repository", strv("group/repo")),
3100 ("operation", strv("rperms")),
3101 ("step", strv("set")),
3102 (
3103 "content",
3104 strv(
3105 "write = not-a-hex-identity
3106",
3107 ),
3108 ),
3109 ]),
3110 Some(&(REMOTE, REMOTE_SIG)),
3111 )
3112 .unwrap();
3113 assert_eq!(invalid[0], protocol::RES_INVALID_REQ);
3114 assert_eq!(
3115 std::fs::read_to_string(config.repositories_dir.join("group/repo.allowed")).unwrap(),
3116 "write = all
3117"
3118 );
3119 }
3120
3121 #[test]
3122 fn group_permissions_write_group_sidecar_allowed_file() {
3123 let tmp = tempfile::tempdir().unwrap();
3124 let mut config = cfg(tmp.path());
3125 config.allow_admin = vec![crate::util::hex(&REMOTE)];
3126 std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
3127 let access = make_access(&config);
3128
3129 let request = msgpack::pack(&Value::Map(vec![
3130 (Value::UInt(protocol::IDX_GROUP), strv("group")),
3131 (Value::Str("operation".into()), strv("gperms")),
3132 (Value::Str("step".into()), strv("set")),
3133 (
3134 Value::Str("content".into()),
3135 strv(
3136 "create = all
3137",
3138 ),
3139 ),
3140 ]));
3141 let set = handle_perms(&config, &access, &request, Some(&(REMOTE, REMOTE_SIG))).unwrap();
3142 assert_eq!(set, vec![protocol::RES_OK]);
3143 assert_eq!(
3144 std::fs::read_to_string(config.repositories_dir.join("group.allowed")).unwrap(),
3145 "create = all
3146"
3147 );
3148 }
3149
3150 #[test]
3151 fn permissions_management_requires_admin_access() {
3152 let tmp = tempfile::tempdir().unwrap();
3153 let config = cfg(tmp.path());
3154 let repo_path = config.repositories_dir.join("group/repo");
3155 git::ensure_bare_repository(&repo_path).unwrap();
3156 let access = make_access(&config);
3157
3158 let denied = handle_perms(
3159 &config,
3160 &access,
3161 &work_request(&[
3162 ("repository", strv("group/repo")),
3163 ("operation", strv("rperms")),
3164 ("step", strv("get")),
3165 ]),
3166 Some(&(REMOTE, REMOTE_SIG)),
3167 )
3168 .unwrap();
3169 assert_eq!(denied[0], protocol::RES_DISALLOWED);
3170 }
3171
3172 fn make_access(config: &ServerConfig) -> Access {
3173 Access::new_with_aliases(
3174 &config.allow_read,
3175 &config.allow_write,
3176 &config.allow_create,
3177 &config.allow_stats,
3178 &config.allow_release,
3179 &config.allow_interact,
3180 &config.allow_admin,
3181 config.repositories_dir.clone(),
3182 config.identity_aliases.clone(),
3183 )
3184 .unwrap()
3185 .with_propose(&config.allow_propose)
3186 .unwrap()
3187 }
3188
3189 fn create_corrupt_bare_repo(path: &std::path::Path) {
3190 std::fs::create_dir_all(path.join("objects")).unwrap();
3191 std::fs::write(path.join("HEAD"), "ref: refs/heads/main\n").unwrap();
3192 }
3193
3194 fn create_source_repo(root: &std::path::Path) -> std::path::PathBuf {
3195 create_source_repo_on_branch(root, "master")
3196 }
3197
3198 fn create_source_repo_on_branch(root: &std::path::Path, branch: &str) -> std::path::PathBuf {
3199 let source = root.join(format!("source-{branch}"));
3200 std::fs::create_dir_all(&source).unwrap();
3201 run_git(&source, &["init", "-b", branch]);
3202 std::fs::write(source.join("README.md"), "source\n").unwrap();
3203 run_git(&source, &["add", "README.md"]);
3204 run_git(
3205 &source,
3206 &[
3207 "-c",
3208 "user.name=rngit test",
3209 "-c",
3210 "user.email=rngit@example.invalid",
3211 "commit",
3212 "-m",
3213 "initial",
3214 ],
3215 );
3216 source
3217 }
3218
3219 fn commit_source_file(repo: &std::path::Path, file: &str, content: &str) -> String {
3220 std::fs::write(repo.join(file), content).unwrap();
3221 run_git(repo, &["add", file]);
3222 run_git(
3223 repo,
3224 &[
3225 "-c",
3226 "user.name=rngit test",
3227 "-c",
3228 "user.email=rngit@example.invalid",
3229 "commit",
3230 "-m",
3231 file,
3232 ],
3233 );
3234 git_rev_parse(repo, "HEAD")
3235 }
3236
3237 fn git_symbolic_ref(repo: &std::path::Path, name: &str) -> String {
3238 let output = std::process::Command::new("git")
3239 .arg("--git-dir")
3240 .arg(repo)
3241 .arg("symbolic-ref")
3242 .arg(name)
3243 .output()
3244 .unwrap();
3245 assert!(
3246 output.status.success(),
3247 "git symbolic-ref failed: {}",
3248 String::from_utf8_lossy(&output.stderr)
3249 );
3250 String::from_utf8_lossy(&output.stdout).trim().to_string()
3251 }
3252
3253 fn git_rev_parse(repo: &std::path::Path, rev: &str) -> String {
3254 let output = if repo.join(".git").is_dir() {
3255 std::process::Command::new("git")
3256 .arg("-C")
3257 .arg(repo)
3258 .arg("rev-parse")
3259 .arg(rev)
3260 .output()
3261 .unwrap()
3262 } else {
3263 std::process::Command::new("git")
3264 .arg("--git-dir")
3265 .arg(repo)
3266 .arg("rev-parse")
3267 .arg(rev)
3268 .output()
3269 .unwrap()
3270 };
3271 assert!(
3272 output.status.success(),
3273 "git rev-parse failed: {}",
3274 String::from_utf8_lossy(&output.stderr)
3275 );
3276 String::from_utf8_lossy(&output.stdout).trim().to_string()
3277 }
3278
3279 fn git_config(repo: &std::path::Path, key: &str) -> String {
3280 let output = std::process::Command::new("git")
3281 .arg("--git-dir")
3282 .arg(repo)
3283 .arg("config")
3284 .arg("--get")
3285 .arg(key)
3286 .output()
3287 .unwrap();
3288 assert!(
3289 output.status.success(),
3290 "git config failed: {}",
3291 String::from_utf8_lossy(&output.stderr)
3292 );
3293 String::from_utf8_lossy(&output.stdout).trim().to_string()
3294 }
3295
3296 fn run_git(cwd: &std::path::Path, args: &[&str]) {
3297 let output = std::process::Command::new("git")
3298 .args(args)
3299 .current_dir(cwd)
3300 .output()
3301 .unwrap();
3302 assert!(
3303 output.status.success(),
3304 "git {:?} failed: {}",
3305 args,
3306 String::from_utf8_lossy(&output.stderr)
3307 );
3308 }
3309
3310 fn assert_generic_remote_error(response: &[u8]) {
3311 assert_eq!(response[0], protocol::RES_REMOTE_FAIL);
3312 assert_eq!(&response[1..], b"Remote error");
3313 }
3314
3315 fn work_request(fields: &[(&str, Value)]) -> Vec<u8> {
3316 msgpack::pack(&Value::Map(
3317 fields
3318 .iter()
3319 .map(|(key, value)| {
3320 let key = if *key == "repository" {
3321 Value::UInt(protocol::IDX_REPOSITORY)
3322 } else {
3323 Value::Str((*key).into())
3324 };
3325 (key, value.clone())
3326 })
3327 .collect(),
3328 ))
3329 }
3330
3331 fn strv(value: &str) -> Value {
3332 Value::Str(value.to_string())
3333 }
3334
3335 fn uintv(value: u64) -> Value {
3336 Value::UInt(value)
3337 }
3338
3339 fn binv(value: &[u8]) -> Value {
3340 Value::Bin(value.to_vec())
3341 }
3342
3343 fn body_value(response: &[u8]) -> Value {
3344 assert_eq!(response[0], protocol::RES_OK);
3345 msgpack::unpack_exact(&response[1..]).unwrap()
3346 }
3347}