Skip to main content

rns_git/
server.rs

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