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::msgpack::{self, Value};
7use rns_core::types::IdentityHash;
8use rns_crypto::identity::Identity;
9use rns_net::link_manager::ResourceStrategy;
10use rns_net::{
11    AnnouncedIdentity, Callbacks, DestHash, Destination, PacketHash, RequestResponse, RnsNode,
12};
13
14use crate::acl::{Access, Operation};
15use crate::config::ServerConfig;
16use crate::logging;
17use crate::pages;
18use crate::protocol;
19use crate::util::{default_reticulum_dir, default_rngit_dir, hex, load_or_create_identity};
20use crate::{git, Error, Result};
21
22pub fn main<I>(args: I) -> Result<()>
23where
24    I: IntoIterator<Item = String>,
25{
26    let options = ServerOptions::parse(args)?;
27    git::check_git_available()?;
28
29    let rngit_dir = options.config_dir.unwrap_or_else(default_rngit_dir);
30    let rns_dir = options.rns_config_dir.or_else(default_reticulum_dir);
31    let config = ServerConfig::load_or_create_for_run(rngit_dir, rns_dir)?;
32    logging::init_file_logger(&config.dir.join("server_log"), config.log_level)?;
33
34    let identity = load_or_create_identity(&config.identity_path)?;
35    if options.print_identity {
36        let client = load_or_create_identity(&config.client_identity_path)?;
37        print_identity(&identity, &client, options.base256, config.serve_nomadnet);
38        return Ok(());
39    }
40
41    run_server(config, identity)
42}
43
44pub fn run_server(config: ServerConfig, identity: Identity) -> Result<()> {
45    let node = RnsNode::from_config(
46        config.reticulum_dir.as_deref(),
47        Box::<ServerCallbacks>::default(),
48    )?;
49
50    let announce_interval = Duration::from_secs(config.announce_interval_secs);
51    let destinations = register_server_destinations(&node, config.clone(), &identity)?;
52
53    loop {
54        thread::sleep(announce_interval);
55        let _ = node.announce(&destinations.repositories, &identity, None);
56        if let Some(page_destination) = destinations.nomadnet.as_ref() {
57            let _ = node.announce(
58                page_destination,
59                &identity,
60                Some(config.node_name.as_bytes()),
61            );
62        }
63    }
64}
65
66#[derive(Debug)]
67pub struct ServerDestinations {
68    pub repositories: Destination,
69    pub nomadnet: Option<Destination>,
70}
71
72pub fn register_server_destinations(
73    node: &RnsNode,
74    config: ServerConfig,
75    identity: &Identity,
76) -> Result<ServerDestinations> {
77    let repositories = register_repository_destination(node, config.clone(), identity)?;
78    let nomadnet = if config.serve_nomadnet {
79        Some(pages::register_nomadnet_destination(
80            node, &config, identity,
81        )?)
82    } else {
83        None
84    };
85    Ok(ServerDestinations {
86        repositories,
87        nomadnet,
88    })
89}
90
91pub fn register_repository_destination(
92    node: &RnsNode,
93    config: ServerConfig,
94    identity: &Identity,
95) -> Result<Destination> {
96    crate::util::ensure_dir(&config.repositories_dir)?;
97    let access = Access::new_with_aliases(
98        &config.allow_read,
99        &config.allow_write,
100        &config.allow_create,
101        &config.allow_stats,
102        &config.allow_release,
103        &config.allow_interact,
104        &config.allow_admin,
105        config.repositories_dir.clone(),
106        config.identity_aliases.clone(),
107    )?
108    .with_propose(&config.allow_propose)?
109    .with_blocked_identities(config.blocked_identities.clone());
110    let destination = Destination::single_in(
111        protocol::APP_NAME,
112        &[protocol::ASPECT_REPOSITORIES],
113        IdentityHash(*identity.hash()),
114    );
115    let public_key = identity
116        .get_public_key()
117        .ok_or_else(|| Error::msg("repository identity has no public key"))?;
118    let private_key = identity
119        .get_private_key()
120        .ok_or_else(|| Error::msg("repository identity has no private key"))?;
121    let sig_prv: [u8; 32] = private_key[32..64].try_into().unwrap();
122    let sig_pub: [u8; 32] = public_key[32..64].try_into().unwrap();
123
124    node.register_link_destination(
125        destination.hash.0,
126        sig_prv,
127        sig_pub,
128        ResourceStrategy::AcceptAll as u8,
129    )
130    .map_err(|_| Error::msg("failed to register link destination"))?;
131    register_handlers(node, config, access)?;
132    node.announce(&destination, identity, None)
133        .map_err(|_| Error::msg("failed to announce rngit destination"))?;
134    Ok(destination)
135}
136
137fn register_handlers(node: &RnsNode, config: ServerConfig, access: Access) -> Result<()> {
138    let list_config = config.clone();
139    let list_access = access.clone();
140    node.register_request_handler(
141        protocol::PATH_LIST,
142        None,
143        move |_link, _path, data, remote| {
144            Some(
145                handle_list(&list_config, &list_access, data, remote)
146                    .unwrap_or_else(error_response),
147            )
148        },
149    )
150    .map_err(|_| Error::msg("failed to register list handler"))?;
151
152    let fetch_config = config.clone();
153    let fetch_access = access.clone();
154    node.register_request_handler_response(
155        protocol::PATH_FETCH,
156        None,
157        move |_link, _path, data, remote| {
158            Some(
159                handle_fetch(&fetch_config, &fetch_access, data, remote)
160                    .unwrap_or_else(|err| RequestResponse::Bytes(error_response(err))),
161            )
162        },
163    )
164    .map_err(|_| Error::msg("failed to register fetch handler"))?;
165
166    let push_config = config.clone();
167    let push_access = access.clone();
168    node.register_request_handler(
169        protocol::PATH_PUSH,
170        None,
171        move |_link, _path, data, remote| {
172            Some(
173                handle_push(&push_config, &push_access, data, remote)
174                    .unwrap_or_else(error_response),
175            )
176        },
177    )
178    .map_err(|_| Error::msg("failed to register push handler"))?;
179
180    let create_config = config.clone();
181    let create_access = access.clone();
182    node.register_request_handler(
183        protocol::PATH_CREATE,
184        None,
185        move |_link, _path, data, remote| {
186            Some(
187                handle_create(&create_config, &create_access, data, remote)
188                    .unwrap_or_else(error_response),
189            )
190        },
191    )
192    .map_err(|_| Error::msg("failed to register create handler"))?;
193
194    let fork_config = config.clone();
195    let fork_access = access.clone();
196    node.register_request_handler(
197        protocol::PATH_FORK,
198        None,
199        move |_link, _path, data, remote| {
200            Some(
201                handle_fork(&fork_config, &fork_access, data, remote)
202                    .unwrap_or_else(error_response),
203            )
204        },
205    )
206    .map_err(|_| Error::msg("failed to register fork handler"))?;
207
208    let sync_config = config.clone();
209    let sync_access = access.clone();
210    node.register_request_handler(
211        protocol::PATH_SYNC,
212        None,
213        move |_link, _path, data, remote| {
214            Some(
215                handle_sync(&sync_config, &sync_access, data, remote)
216                    .unwrap_or_else(error_response),
217            )
218        },
219    )
220    .map_err(|_| Error::msg("failed to register sync handler"))?;
221
222    let mirror_config = config.clone();
223    let mirror_access = access.clone();
224    node.register_request_handler(
225        protocol::PATH_MIRROR,
226        None,
227        move |_link, _path, data, remote| {
228            Some(
229                handle_mirror(&mirror_config, &mirror_access, data, remote)
230                    .unwrap_or_else(error_response),
231            )
232        },
233    )
234    .map_err(|_| Error::msg("failed to register mirror handler"))?;
235
236    let release_config = config.clone();
237    let release_access = access.clone();
238    node.register_request_handler_response(
239        protocol::PATH_RELEASE,
240        None,
241        move |_link, _path, data, remote| {
242            Some(
243                handle_release_response(&release_config, &release_access, data, remote)
244                    .unwrap_or_else(|err| RequestResponse::Bytes(error_response(err))),
245            )
246        },
247    )
248    .map_err(|_| Error::msg("failed to register release handler"))?;
249
250    let work_config = config.clone();
251    let work_access = access.clone();
252    node.register_request_handler(
253        protocol::PATH_WORK,
254        None,
255        move |_link, _path, data, remote| {
256            Some(
257                handle_work(&work_config, &work_access, data, remote)
258                    .unwrap_or_else(error_response),
259            )
260        },
261    )
262    .map_err(|_| Error::msg("failed to register work handler"))?;
263
264    let perms_config = config.clone();
265    let perms_access = access.clone();
266    node.register_request_handler(
267        protocol::PATH_PERMS,
268        None,
269        move |_link, _path, data, remote| {
270            Some(
271                handle_perms(&perms_config, &perms_access, data, remote)
272                    .unwrap_or_else(error_response),
273            )
274        },
275    )
276    .map_err(|_| Error::msg("failed to register permissions handler"))?;
277
278    node.register_request_handler(
279        protocol::PATH_DELETE,
280        None,
281        move |_link, _path, data, remote| {
282            Some(handle_delete(&config, &access, data, remote).unwrap_or_else(error_response))
283        },
284    )
285    .map_err(|_| Error::msg("failed to register delete handler"))?;
286
287    Ok(())
288}
289
290pub fn handle_list(
291    config: &ServerConfig,
292    access: &Access,
293    data: &[u8],
294    remote: Option<&([u8; 16], [u8; 64])>,
295) -> Result<Vec<u8>> {
296    let repo = protocol::repository_from_request(data)?;
297    let remote_hash = remote.map(|(hash, _)| hash);
298    if !access.allows(Operation::Read, &repo, remote_hash)? {
299        return Ok(protocol::status_bytes(
300            protocol::RES_DISALLOWED,
301            b"read denied",
302        ));
303    }
304    let path = git::repository_path(&config.repositories_dir, &repo)?;
305    match git::list_refs_text(&path) {
306        Ok(refs) => Ok(protocol::status_bytes(protocol::RES_OK, refs)),
307        Err(err) if err.to_string() == "repository not found" => Ok(protocol::status_bytes(
308            protocol::RES_NOT_FOUND,
309            b"repository not found",
310        )),
311        Err(err) => Ok(remote_error_response("list", &repo, err)),
312    }
313}
314
315pub fn handle_fetch(
316    config: &ServerConfig,
317    access: &Access,
318    data: &[u8],
319    remote: Option<&([u8; 16], [u8; 64])>,
320) -> Result<RequestResponse> {
321    let (repo, have, 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, .. } => {
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            write_permissions_file(allowed_path, content)?;
926            Ok(protocol::status_bytes(protocol::RES_OK, b""))
927        }
928        Some(_) => Ok(protocol::status_bytes(
929            protocol::RES_INVALID_REQ,
930            b"invalid step",
931        )),
932        None => Ok(protocol::status_bytes(
933            protocol::RES_INVALID_REQ,
934            b"invalid request",
935        )),
936    }
937}
938
939fn write_permissions_file(path: &std::path::Path, content: &str) -> Result<()> {
940    if let Some(parent) = path.parent() {
941        std::fs::create_dir_all(parent)?;
942    }
943    let tmp = path.with_extension("allowed.tmp");
944    std::fs::write(&tmp, content)?;
945    std::fs::rename(tmp, path)?;
946    Ok(())
947}
948
949fn valid_group_name(group: &str) -> bool {
950    !group.is_empty() && !group.contains('/') && crate::util::validate_repo_name(group).is_ok()
951}
952
953fn map_get_str<'a>(map: &'a [(Value, Value)], key: &str) -> Option<&'a str> {
954    map.iter().find_map(|(candidate, value)| match candidate {
955        Value::Str(candidate) if candidate == key => value.as_str(),
956        _ => None,
957    })
958}
959
960fn map_get_index_str(map: &[(Value, Value)], key: u64) -> Option<&str> {
961    map.iter().find_map(|(candidate, value)| match candidate {
962        Value::UInt(candidate) if *candidate == key => value.as_str(),
963        _ => None,
964    })
965}
966
967pub fn handle_work(
968    config: &ServerConfig,
969    access: &Access,
970    data: &[u8],
971    remote: Option<&([u8; 16], [u8; 64])>,
972) -> Result<Vec<u8>> {
973    let Some((remote_hash, remote_pubkey)) = remote else {
974        return Ok(protocol::status_bytes(
975            protocol::RES_DISALLOWED,
976            b"not identified",
977        ));
978    };
979    let request = crate::work::parse_request(data)?;
980    let repo = request.repository.as_str();
981    if !access.allows(Operation::Read, repo, Some(remote_hash))? {
982        return Ok(protocol::status_bytes(
983            protocol::RES_NOT_FOUND,
984            b"not found",
985        ));
986    }
987    let repository_path = git::repository_path(&config.repositories_dir, repo)?;
988    if !git::is_bare_repository(&repository_path) {
989        return Ok(protocol::status_bytes(
990            protocol::RES_NOT_FOUND,
991            b"repository not found",
992        ));
993    }
994    let work_path = crate::work::work_sidecar_path(&repository_path);
995    let mut interact_access = access.allows(Operation::Interact, repo, Some(remote_hash))?;
996    let propose_access = access.allows(Operation::Propose, repo, Some(remote_hash))?;
997    if request.operation == "comment" {
998        if let Some(doc_id) = request.doc_id {
999            interact_access |= crate::work::document_permission_allows(
1000                &work_path,
1001                doc_id,
1002                Operation::Interact,
1003                Some(remote_hash),
1004            )?;
1005        }
1006    }
1007    let mut write_access = access.allows(Operation::Write, repo, Some(remote_hash))?;
1008    if request.operation == "edit" {
1009        if let Some(doc_id) = request.doc_id {
1010            interact_access |= crate::work::document_permission_allows(
1011                &work_path,
1012                doc_id,
1013                Operation::Interact,
1014                Some(remote_hash),
1015            )?;
1016            write_access |= crate::work::document_permission_allows(
1017                &work_path,
1018                doc_id,
1019                Operation::Write,
1020                Some(remote_hash),
1021            )?;
1022        }
1023    }
1024    let manage_access = interact_access && write_access;
1025    let permitted = match request.operation.as_str() {
1026        "list" | "view" => true,
1027        "comment" => interact_access,
1028        "propose" => propose_access,
1029        "create" | "edit" | "delete" | "complete" | "activate" => manage_access,
1030        "perms" => true,
1031        _ => false,
1032    };
1033    if !permitted {
1034        return Ok(protocol::status_bytes(
1035            protocol::RES_DISALLOWED,
1036            b"not allowed",
1037        ));
1038    }
1039
1040    match request.operation.as_str() {
1041        "list" => {
1042            let scope = request
1043                .scope
1044                .as_deref()
1045                .map(crate::work::WorkListScope::parse)
1046                .unwrap_or(Some(crate::work::WorkListScope::Active))
1047                .ok_or_else(|| Error::msg("invalid scope"))?;
1048            crate::work::list_response(&work_path, scope)
1049        }
1050        "view" => {
1051            let doc_id = request
1052                .doc_id
1053                .ok_or_else(|| Error::msg("no document ID specified"))?;
1054            match work_scope_for_document(&work_path, request.scope.as_deref(), doc_id)? {
1055                Some(scope) => crate::work::view_response(&work_path, scope, doc_id),
1056                None => Ok(protocol::status_bytes(
1057                    protocol::RES_NOT_FOUND,
1058                    b"not found",
1059                )),
1060            }
1061        }
1062        "create" => {
1063            let content = request.content.unwrap_or_default();
1064            let signature = request.signature;
1065            let identity =
1066                match validate_work_signature(&content, signature.as_deref(), remote_pubkey) {
1067                    Ok(identity) => identity,
1068                    Err(err) => return Ok(work_error_response(err)),
1069                };
1070            work_status_result(
1071                crate::work::create_document(
1072                    &work_path,
1073                    crate::work::WorkInput {
1074                        title: request.title.unwrap_or_default(),
1075                        content,
1076                        format: request.format.unwrap_or_else(|| "markdown".into()),
1077                        signature,
1078                        identity,
1079                        author: *remote_hash,
1080                    },
1081                )
1082                .map(crate::work::created_response),
1083            )
1084        }
1085        "propose" => {
1086            let content = request.content.unwrap_or_default();
1087            let signature = request.signature;
1088            let identity =
1089                match validate_work_signature(&content, signature.as_deref(), remote_pubkey) {
1090                    Ok(Some(identity)) => Some(identity),
1091                    Ok(None) => {
1092                        return Ok(work_error_response(Error::msg("no signature provided")))
1093                    }
1094                    Err(err) => return Ok(work_error_response(err)),
1095                };
1096            work_status_result(
1097                crate::work::propose_document(
1098                    &work_path,
1099                    crate::work::WorkInput {
1100                        title: request.title.unwrap_or_default(),
1101                        content,
1102                        format: request.format.unwrap_or_else(|| "markdown".into()),
1103                        signature,
1104                        identity,
1105                        author: *remote_hash,
1106                    },
1107                )
1108                .map(crate::work::created_response),
1109            )
1110        }
1111        "edit" => {
1112            let doc_id = request
1113                .doc_id
1114                .ok_or_else(|| Error::msg("no document ID specified"))?;
1115            let signature = request.signature;
1116            let identity = if let Some(content) = request.content.as_deref() {
1117                match validate_work_signature(content, signature.as_deref(), remote_pubkey) {
1118                    Ok(identity) => identity,
1119                    Err(err) => return Ok(work_error_response(err)),
1120                }
1121            } else {
1122                None
1123            };
1124            match work_scope_for_document(&work_path, request.scope.as_deref(), doc_id)? {
1125                Some(scope) => work_status_result(
1126                    crate::work::edit_document(
1127                        &work_path,
1128                        scope,
1129                        doc_id,
1130                        remote_hash,
1131                        crate::work::WorkEdit {
1132                            title: request.title,
1133                            content: request.content,
1134                            signature,
1135                            identity,
1136                        },
1137                    )
1138                    .map(|_| protocol::status_bytes(protocol::RES_OK, b"")),
1139                ),
1140                None => Ok(protocol::status_bytes(
1141                    protocol::RES_NOT_FOUND,
1142                    b"not found",
1143                )),
1144            }
1145        }
1146        "delete" => {
1147            let doc_id = request
1148                .doc_id
1149                .ok_or_else(|| Error::msg("no document ID specified"))?;
1150            let scope = work_scope(request.scope.as_deref())?;
1151            work_status_result(
1152                crate::work::delete_document(&work_path, scope, doc_id, remote_hash)
1153                    .map(|_| protocol::status_bytes(protocol::RES_OK, b"")),
1154            )
1155        }
1156        "comment" => {
1157            let doc_id = request
1158                .doc_id
1159                .ok_or_else(|| Error::msg("no document ID specified"))?;
1160            let scope = work_scope(request.scope.as_deref())?;
1161            work_status_result(
1162                crate::work::add_comment(
1163                    &work_path,
1164                    scope,
1165                    doc_id,
1166                    crate::work::WorkCommentInput {
1167                        content: request.content.unwrap_or_default(),
1168                        format: request.format.unwrap_or_else(|| "markdown".into()),
1169                        signature: request.signature,
1170                        author: *remote_hash,
1171                    },
1172                )
1173                .map(crate::work::comment_response),
1174            )
1175        }
1176        "complete" => {
1177            let doc_id = request
1178                .doc_id
1179                .ok_or_else(|| Error::msg("no document ID specified"))?;
1180            work_status_result(
1181                crate::work::complete_document(&work_path, doc_id, remote_hash).map(|_| {
1182                    crate::work::transition_response(doc_id, crate::work::WorkScope::Completed)
1183                }),
1184            )
1185        }
1186        "activate" => {
1187            let doc_id = request
1188                .doc_id
1189                .ok_or_else(|| Error::msg("no document ID specified"))?;
1190            work_status_result(
1191                crate::work::activate_document(&work_path, doc_id, remote_hash).map(|_| {
1192                    crate::work::transition_response(doc_id, crate::work::WorkScope::Active)
1193                }),
1194            )
1195        }
1196        "perms" => {
1197            let doc_id = request
1198                .doc_id
1199                .ok_or_else(|| Error::msg("no document ID specified"))?;
1200            let allowed =
1201                match work_permissions_allowed(&work_path, doc_id, remote_hash, manage_access) {
1202                    Ok(allowed) => allowed,
1203                    Err(err) => return Ok(work_error_response(err)),
1204                };
1205            if !allowed {
1206                return Ok(protocol::status_bytes(
1207                    protocol::RES_DISALLOWED,
1208                    b"not allowed",
1209                ));
1210            }
1211            match request.step.as_deref() {
1212                Some("get") => work_status_result(
1213                    crate::work::get_document_permissions(&work_path, doc_id)
1214                        .map(crate::work::permissions_response),
1215                ),
1216                Some("set") => work_status_result(
1217                    crate::work::set_document_permissions(
1218                        &work_path,
1219                        doc_id,
1220                        request.content.as_deref().unwrap_or(""),
1221                    )
1222                    .map(|_| protocol::status_bytes(protocol::RES_OK, b"")),
1223                ),
1224                Some(_) => Ok(protocol::status_bytes(
1225                    protocol::RES_INVALID_REQ,
1226                    b"invalid step",
1227                )),
1228                None => Ok(protocol::status_bytes(
1229                    protocol::RES_INVALID_REQ,
1230                    b"invalid request",
1231                )),
1232            }
1233        }
1234        _ => Ok(protocol::status_bytes(
1235            protocol::RES_INVALID_REQ,
1236            b"invalid request",
1237        )),
1238    }
1239}
1240
1241fn work_permissions_allowed(
1242    work_path: &std::path::Path,
1243    doc_id: u64,
1244    remote_hash: &[u8; 16],
1245    manage_access: bool,
1246) -> Result<bool> {
1247    let is_author = crate::work::document_author(work_path, doc_id)? == *remote_hash;
1248    let doc_admin = crate::work::document_permission_allows(
1249        work_path,
1250        doc_id,
1251        Operation::Admin,
1252        Some(remote_hash),
1253    )?;
1254    Ok((is_author && manage_access) || doc_admin)
1255}
1256
1257fn validate_work_signature(
1258    content: &str,
1259    signature: Option<&[u8]>,
1260    remote_pubkey: &[u8; 64],
1261) -> Result<Option<Vec<u8>>> {
1262    let Some(signature) = signature else {
1263        return Ok(None);
1264    };
1265    if signature.len() != 64 {
1266        return Err(Error::msg("invalid signature"));
1267    }
1268    let signature: &[u8; 64] = signature.try_into().unwrap();
1269    let identity = Identity::from_public_key(remote_pubkey);
1270    if !identity.verify(signature, content.trim().as_bytes()) {
1271        return Err(Error::msg("invalid signature"));
1272    }
1273    Ok(Some(remote_pubkey.to_vec()))
1274}
1275
1276fn work_scope(scope: Option<&str>) -> Result<crate::work::WorkScope> {
1277    scope
1278        .map(crate::work::WorkScope::parse)
1279        .unwrap_or(Some(crate::work::WorkScope::Active))
1280        .ok_or_else(|| Error::msg("invalid scope"))
1281}
1282
1283fn work_scope_for_document(
1284    work_path: &std::path::Path,
1285    scope: Option<&str>,
1286    doc_id: u64,
1287) -> Result<Option<crate::work::WorkScope>> {
1288    match scope.unwrap_or("active") {
1289        "all" => crate::work::find_document_scope(work_path, doc_id),
1290        value => crate::work::WorkScope::parse(value)
1291            .map(Some)
1292            .ok_or_else(|| Error::msg("invalid scope")),
1293    }
1294}
1295
1296fn work_status_result(result: Result<Vec<u8>>) -> Result<Vec<u8>> {
1297    match result {
1298        Ok(response) => Ok(response),
1299        Err(err) => Ok(work_error_response(err)),
1300    }
1301}
1302
1303fn work_error_response(err: Error) -> Vec<u8> {
1304    let message = err.to_string();
1305    let code = if message.starts_with("invalid permission")
1306        || message.starts_with("invalid hex")
1307        || message.starts_with("expected 32 hex characters")
1308    {
1309        protocol::RES_INVALID_REQ
1310    } else {
1311        match message.as_str() {
1312            "document not found" => protocol::RES_NOT_FOUND,
1313            "no access, not author" => {
1314                return protocol::status_bytes(protocol::RES_DISALLOWED, b"not allowed");
1315            }
1316            "title is required"
1317            | "content is required"
1318            | "no signature provided"
1319            | "invalid signature"
1320            | "content limit exceeded"
1321            | "no changes specified" => protocol::RES_INVALID_REQ,
1322            _ => protocol::RES_REMOTE_FAIL,
1323        }
1324    };
1325    protocol::status_bytes(code, message)
1326}
1327
1328fn error_response(err: Error) -> Vec<u8> {
1329    protocol::status_bytes(protocol::RES_INVALID_REQ, err.to_string())
1330}
1331
1332fn remote_error_response(context: &str, repository: &str, err: impl std::fmt::Display) -> Vec<u8> {
1333    log::error!("rngit {context} failed for {repository}: {err}");
1334    protocol::status_bytes(protocol::RES_REMOTE_FAIL, b"Remote error")
1335}
1336
1337fn repository_destination(identity: &Identity) -> Destination {
1338    Destination::single_in(
1339        protocol::APP_NAME,
1340        &[protocol::ASPECT_REPOSITORIES],
1341        IdentityHash(*identity.hash()),
1342    )
1343}
1344
1345fn identity_report(
1346    identity: &Identity,
1347    client: &Identity,
1348    base256: bool,
1349    serve_nomadnet: bool,
1350) -> String {
1351    let destination = repository_destination(identity);
1352    let mut out = String::new();
1353    out.push_str(&format!("client_identity = {}\n", hex(client.hash())));
1354    if base256 {
1355        out.push_str(&format!(
1356            "client_identity_b256 = {}\n",
1357            prettyb256rep(client.hash())
1358        ));
1359    }
1360    out.push_str(&format!("repository_identity = {}\n", hex(identity.hash())));
1361    if base256 {
1362        out.push_str(&format!(
1363            "repository_identity_b256 = {}",
1364            prettyb256rep(identity.hash())
1365        ));
1366        out.push('\n');
1367    }
1368    out.push_str(&format!("destination = {}\n", hex(&destination.hash.0)));
1369    if base256 {
1370        out.push_str(&format!(
1371            "destination_b256 = {}\n",
1372            prettyb256rep(&destination.hash.0)
1373        ));
1374    }
1375    if serve_nomadnet {
1376        let page_destination = pages::destination_for_identity(identity);
1377        out.push_str(&format!(
1378            "nomadnet_destination = {}\n",
1379            hex(&page_destination.hash.0)
1380        ));
1381        if base256 {
1382            out.push_str(&format!(
1383                "nomadnet_destination_b256 = {}\n",
1384                prettyb256rep(&page_destination.hash.0)
1385            ));
1386        }
1387    }
1388    out
1389}
1390
1391fn print_identity(identity: &Identity, client: &Identity, base256: bool, serve_nomadnet: bool) {
1392    print!(
1393        "{}",
1394        identity_report(identity, client, base256, serve_nomadnet)
1395    );
1396}
1397
1398#[derive(Default)]
1399struct ServerCallbacks;
1400
1401impl Callbacks for ServerCallbacks {
1402    fn on_announce(&mut self, _announced: AnnouncedIdentity) {}
1403
1404    fn on_path_updated(&mut self, _dest_hash: DestHash, _hops: u8) {}
1405
1406    fn on_local_delivery(&mut self, _dest_hash: DestHash, _raw: Vec<u8>, _packet_hash: PacketHash) {
1407    }
1408}
1409
1410#[derive(Debug, Default)]
1411struct ServerOptions {
1412    config_dir: Option<PathBuf>,
1413    rns_config_dir: Option<PathBuf>,
1414    print_identity: bool,
1415    base256: bool,
1416}
1417
1418impl ServerOptions {
1419    fn parse<I>(args: I) -> Result<Self>
1420    where
1421        I: IntoIterator<Item = String>,
1422    {
1423        let mut options = ServerOptions::default();
1424        let mut args = args.into_iter();
1425        while let Some(arg) = args.next() {
1426            match arg.as_str() {
1427                "-c" | "--config" => {
1428                    options.config_dir = Some(PathBuf::from(
1429                        args.next()
1430                            .ok_or_else(|| Error::msg("missing config path"))?,
1431                    ));
1432                }
1433                "--rnsconfig" => {
1434                    options.rns_config_dir = Some(PathBuf::from(
1435                        args.next()
1436                            .ok_or_else(|| Error::msg("missing RNS config path"))?,
1437                    ));
1438                }
1439                "--print-identity" => options.print_identity = true,
1440                "-Z" | "--base256" => options.base256 = true,
1441                "--service" | "--interactive" => {
1442                    return Err(Error::msg(format!(
1443                        "{arg} is not supported by this rngit binary\n{}",
1444                        usage()
1445                    )))
1446                }
1447                "-h" | "--help" => return Err(Error::msg(usage())),
1448                other => {
1449                    return Err(Error::msg(format!(
1450                        "unknown argument: {other}\n{}",
1451                        usage()
1452                    )))
1453                }
1454            }
1455        }
1456        Ok(options)
1457    }
1458}
1459
1460fn usage() -> &'static str {
1461    "usage: rngit [--config DIR] [--rnsconfig DIR] [--print-identity] [-Z|--base256]"
1462}
1463
1464#[cfg(test)]
1465mod tests {
1466    use super::*;
1467    use rns_core::msgpack::{self, Value};
1468    use rns_crypto::OsRng;
1469
1470    const REMOTE: [u8; 16] = [0x44; 16];
1471    const OTHER_REMOTE: [u8; 16] = [0x55; 16];
1472    const REMOTE_SIG: [u8; 64] = [0x66; 64];
1473
1474    fn cfg(root: &std::path::Path) -> ServerConfig {
1475        ServerConfig {
1476            dir: root.to_path_buf(),
1477            reticulum_dir: None,
1478            repositories_dir: root.join("repositories"),
1479            identity_path: root.join("repositories_identity"),
1480            client_identity_path: root.join("client_identity"),
1481            node_name: "Anonymous Git Node".into(),
1482            announce_interval_secs: 300,
1483            serve_nomadnet: false,
1484            templates_dir: root.join("templates"),
1485            unicode_icons: false,
1486            record_stats: false,
1487            stats_ignore_identities: Vec::new(),
1488            stats_push_ignore_identities: Vec::new(),
1489            blocked_identities: Vec::new(),
1490            identity_aliases: std::collections::BTreeMap::new(),
1491            allow_read: vec!["all".into()],
1492            allow_write: vec!["all".into()],
1493            allow_create: vec!["all".into()],
1494            allow_stats: vec!["none".into()],
1495            allow_release: vec!["none".into()],
1496            allow_interact: vec!["none".into()],
1497            allow_propose: vec!["none".into()],
1498            allow_admin: vec!["none".into()],
1499            log_level: logging::DEFAULT_LOG_LEVEL,
1500        }
1501    }
1502
1503    #[test]
1504    fn parses_base256_print_identity_options() {
1505        let opts = ServerOptions::parse(vec![
1506            "--print-identity".to_string(),
1507            "--base256".to_string(),
1508        ])
1509        .unwrap();
1510        assert!(opts.print_identity);
1511        assert!(opts.base256);
1512
1513        let short = ServerOptions::parse(vec!["-Z".to_string()]).unwrap();
1514        assert!(short.base256);
1515    }
1516
1517    #[test]
1518    fn rejects_unsupported_service_mode_flags() {
1519        let service = ServerOptions::parse(vec!["--service".to_string()]).unwrap_err();
1520        assert!(service.to_string().contains("not supported"));
1521
1522        let interactive = ServerOptions::parse(vec!["--interactive".to_string()]).unwrap_err();
1523        assert!(interactive.to_string().contains("not supported"));
1524    }
1525
1526    #[test]
1527    fn repository_destination_uses_git_repositories_name() {
1528        let identity = Identity::new(&mut OsRng);
1529        let destination = repository_destination(&identity);
1530        let expected =
1531            Destination::single_in("git", &["repositories"], IdentityHash(*identity.hash()));
1532        assert_eq!(destination.hash, expected.hash);
1533    }
1534
1535    #[test]
1536    fn identity_report_includes_nomadnet_destination_only_when_enabled() {
1537        let identity = Identity::new(&mut OsRng);
1538        let client = Identity::new(&mut OsRng);
1539
1540        let without_pages = identity_report(&identity, &client, false, false);
1541        assert!(!without_pages.contains("nomadnet_destination"));
1542
1543        let with_pages = identity_report(&identity, &client, true, true);
1544        let nomadnet = pages::destination_for_identity(&identity);
1545        assert!(with_pages.contains(&format!("nomadnet_destination = {}", hex(&nomadnet.hash.0))));
1546        assert!(with_pages.contains("nomadnet_destination_b256 = "));
1547    }
1548
1549    #[test]
1550    fn list_missing_repo_returns_not_found_status() {
1551        let tmp = tempfile::tempdir().unwrap();
1552        let config = cfg(tmp.path());
1553        let access = Access::new(
1554            &config.allow_read,
1555            &config.allow_write,
1556            &config.allow_create,
1557            &config.allow_stats,
1558            &config.allow_release,
1559            &config.allow_interact,
1560            &config.allow_admin,
1561            config.repositories_dir.clone(),
1562        )
1563        .unwrap();
1564        let req = protocol::repository_request("group/repo");
1565        let resp = handle_list(&config, &access, &req, None).unwrap();
1566        assert_eq!(resp[0], protocol::RES_NOT_FOUND);
1567    }
1568
1569    #[test]
1570    fn push_is_blocked_by_acl() {
1571        let tmp = tempfile::tempdir().unwrap();
1572        let mut config = cfg(tmp.path());
1573        config.allow_write = vec!["none".into()];
1574        config.allow_create = vec!["none".into()];
1575        let access = Access::new(
1576            &config.allow_read,
1577            &config.allow_write,
1578            &config.allow_create,
1579            &config.allow_stats,
1580            &config.allow_release,
1581            &config.allow_interact,
1582            &config.allow_admin,
1583            config.repositories_dir.clone(),
1584        )
1585        .unwrap();
1586        let req = protocol::push_request("repo", Vec::new(), Vec::new());
1587        let resp = handle_push(&config, &access, &req, None).unwrap();
1588        assert_eq!(resp[0], protocol::RES_DISALLOWED);
1589    }
1590
1591    #[test]
1592    fn push_can_create_missing_repo_with_global_create() {
1593        let tmp = tempfile::tempdir().unwrap();
1594        let mut config = cfg(tmp.path());
1595        config.allow_write = vec!["none".into()];
1596        config.allow_create = vec!["all".into()];
1597        let access = Access::new(
1598            &config.allow_read,
1599            &config.allow_write,
1600            &config.allow_create,
1601            &config.allow_stats,
1602            &config.allow_release,
1603            &config.allow_interact,
1604            &config.allow_admin,
1605            config.repositories_dir.clone(),
1606        )
1607        .unwrap();
1608        let req = protocol::push_request("repo", Vec::new(), Vec::new());
1609        let resp = handle_push(&config, &access, &req, None).unwrap();
1610        assert_eq!(resp[0], protocol::RES_OK);
1611        assert!(git::is_bare_repository(
1612            &config.repositories_dir.join("repo")
1613        ));
1614    }
1615
1616    #[test]
1617    fn global_write_alone_cannot_create_missing_repo() {
1618        let tmp = tempfile::tempdir().unwrap();
1619        let mut config = cfg(tmp.path());
1620        config.allow_write = vec!["all".into()];
1621        config.allow_create = vec!["none".into()];
1622        let access = Access::new(
1623            &config.allow_read,
1624            &config.allow_write,
1625            &config.allow_create,
1626            &config.allow_stats,
1627            &config.allow_release,
1628            &config.allow_interact,
1629            &config.allow_admin,
1630            config.repositories_dir.clone(),
1631        )
1632        .unwrap();
1633        let req = protocol::push_request("repo", Vec::new(), Vec::new());
1634        let resp = handle_push(&config, &access, &req, None).unwrap();
1635        assert_eq!(resp[0], protocol::RES_DISALLOWED);
1636        assert!(!config.repositories_dir.join("repo").exists());
1637    }
1638
1639    #[test]
1640    fn existing_repo_push_still_requires_write() {
1641        let tmp = tempfile::tempdir().unwrap();
1642        let mut config = cfg(tmp.path());
1643        config.allow_write = vec!["none".into()];
1644        config.allow_create = vec!["all".into()];
1645        git::ensure_bare_repository(&config.repositories_dir.join("repo")).unwrap();
1646        let access = Access::new(
1647            &config.allow_read,
1648            &config.allow_write,
1649            &config.allow_create,
1650            &config.allow_stats,
1651            &config.allow_release,
1652            &config.allow_interact,
1653            &config.allow_admin,
1654            config.repositories_dir.clone(),
1655        )
1656        .unwrap();
1657        let req = protocol::push_request("repo", Vec::new(), Vec::new());
1658        let resp = handle_push(&config, &access, &req, None).unwrap();
1659        assert_eq!(resp[0], protocol::RES_DISALLOWED);
1660    }
1661
1662    #[test]
1663    fn repo_allowed_file_can_grant_create_for_missing_bare_repo() {
1664        let tmp = tempfile::tempdir().unwrap();
1665        let mut config = cfg(tmp.path());
1666        config.allow_write = vec!["none".into()];
1667        config.allow_create = vec!["none".into()];
1668        let repo = config.repositories_dir.join("group/repo");
1669        std::fs::create_dir_all(&repo).unwrap();
1670        std::fs::write(repo.join(".allowed"), "create = all\n").unwrap();
1671        let access = Access::new(
1672            &config.allow_read,
1673            &config.allow_write,
1674            &config.allow_create,
1675            &config.allow_stats,
1676            &config.allow_release,
1677            &config.allow_interact,
1678            &config.allow_admin,
1679            config.repositories_dir.clone(),
1680        )
1681        .unwrap();
1682        let req = protocol::push_request("group/repo", Vec::new(), Vec::new());
1683        let resp = handle_push(&config, &access, &req, None).unwrap();
1684        assert_eq!(resp[0], protocol::RES_OK);
1685        assert!(git::is_bare_repository(&repo));
1686    }
1687
1688    #[test]
1689    fn repo_sidecar_allowed_file_can_grant_create_for_missing_bare_repo() {
1690        let tmp = tempfile::tempdir().unwrap();
1691        let mut config = cfg(tmp.path());
1692        config.allow_write = vec!["none".into()];
1693        config.allow_create = vec!["none".into()];
1694        let repo = config.repositories_dir.join("group/repo");
1695        std::fs::create_dir_all(&repo).unwrap();
1696        std::fs::write(
1697            config.repositories_dir.join("group/repo.allowed"),
1698            "create = all\n",
1699        )
1700        .unwrap();
1701        let access = Access::new(
1702            &config.allow_read,
1703            &config.allow_write,
1704            &config.allow_create,
1705            &config.allow_stats,
1706            &config.allow_release,
1707            &config.allow_interact,
1708            &config.allow_admin,
1709            config.repositories_dir.clone(),
1710        )
1711        .unwrap();
1712        let req = protocol::push_request("group/repo", Vec::new(), Vec::new());
1713        let resp = handle_push(&config, &access, &req, None).unwrap();
1714        assert_eq!(resp[0], protocol::RES_OK);
1715        assert!(git::is_bare_repository(&repo));
1716    }
1717
1718    #[test]
1719    fn group_allowed_file_can_grant_create_for_missing_repo() {
1720        let tmp = tempfile::tempdir().unwrap();
1721        let mut config = cfg(tmp.path());
1722        config.allow_write = vec!["none".into()];
1723        config.allow_create = vec!["none".into()];
1724        let group = config.repositories_dir.join("group");
1725        std::fs::create_dir_all(&group).unwrap();
1726        std::fs::write(group.join("group.allowed"), "create = all\n").unwrap();
1727        let access = Access::new(
1728            &config.allow_read,
1729            &config.allow_write,
1730            &config.allow_create,
1731            &config.allow_stats,
1732            &config.allow_release,
1733            &config.allow_interact,
1734            &config.allow_admin,
1735            config.repositories_dir.clone(),
1736        )
1737        .unwrap();
1738        let req = protocol::push_request("group/repo", Vec::new(), Vec::new());
1739        let resp = handle_push(&config, &access, &req, None).unwrap();
1740        assert_eq!(resp[0], protocol::RES_OK);
1741        assert!(git::is_bare_repository(
1742            &config.repositories_dir.join("group/repo")
1743        ));
1744    }
1745
1746    #[test]
1747    fn group_sidecar_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(
1755            config.repositories_dir.join("group.allowed"),
1756            "create = all\n",
1757        )
1758        .unwrap();
1759        let access = Access::new(
1760            &config.allow_read,
1761            &config.allow_write,
1762            &config.allow_create,
1763            &config.allow_stats,
1764            &config.allow_release,
1765            &config.allow_interact,
1766            &config.allow_admin,
1767            config.repositories_dir.clone(),
1768        )
1769        .unwrap();
1770        let req = protocol::push_request("group/repo", Vec::new(), Vec::new());
1771        let resp = handle_push(&config, &access, &req, None).unwrap();
1772        assert_eq!(resp[0], protocol::RES_OK);
1773        assert!(git::is_bare_repository(
1774            &config.repositories_dir.join("group/repo")
1775        ));
1776    }
1777
1778    #[test]
1779    fn create_handler_initializes_bare_repo_and_grants_creator_admin() {
1780        let tmp = tempfile::tempdir().unwrap();
1781        let mut config = cfg(tmp.path());
1782        config.allow_create = vec!["all".into()];
1783        let group = config.repositories_dir.join("group");
1784        std::fs::create_dir_all(&group).unwrap();
1785        let access = make_access(&config);
1786        let remote = ([0x11; 16], [0u8; 64]);
1787        let req = protocol::repository_request("group/created");
1788
1789        let resp = handle_create(&config, &access, &req, Some(&remote)).unwrap();
1790
1791        assert_eq!(resp[0], protocol::RES_OK);
1792        assert!(git::is_bare_repository(
1793            &config.repositories_dir.join("group/created")
1794        ));
1795        assert_eq!(
1796            std::fs::read_to_string(config.repositories_dir.join("group/created.allowed")).unwrap(),
1797            "adm:11111111111111111111111111111111\n"
1798        );
1799        assert!(access
1800            .allows(Operation::Write, "group/created", Some(&remote.0))
1801            .unwrap());
1802    }
1803
1804    #[test]
1805    fn create_handler_requires_identified_peer_and_existing_group() {
1806        let tmp = tempfile::tempdir().unwrap();
1807        let mut config = cfg(tmp.path());
1808        config.allow_create = vec!["all".into()];
1809        let access = make_access(&config);
1810        let req = protocol::repository_request("group/created");
1811
1812        let anonymous = handle_create(&config, &access, &req, None).unwrap();
1813        assert_eq!(anonymous[0], protocol::RES_DISALLOWED);
1814
1815        let remote = ([0x11; 16], [0u8; 64]);
1816        let missing_group = handle_create(&config, &access, &req, Some(&remote)).unwrap();
1817        assert_eq!(missing_group[0], protocol::RES_NOT_FOUND);
1818    }
1819
1820    #[test]
1821    fn create_handler_rejects_existing_or_invalid_repositories() {
1822        let tmp = tempfile::tempdir().unwrap();
1823        let mut config = cfg(tmp.path());
1824        config.allow_create = vec!["all".into()];
1825        std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
1826        git::ensure_bare_repository(&config.repositories_dir.join("group/existing")).unwrap();
1827        let access = make_access(&config);
1828        let remote = ([0x11; 16], [0u8; 64]);
1829
1830        let existing = handle_create(
1831            &config,
1832            &access,
1833            &protocol::repository_request("group/existing"),
1834            Some(&remote),
1835        )
1836        .unwrap();
1837        assert_eq!(existing[0], protocol::RES_DISALLOWED);
1838        assert_eq!(&existing[1..], b"repository already exists");
1839
1840        let nested = handle_create(
1841            &config,
1842            &access,
1843            &protocol::repository_request("group/nested/repo"),
1844            Some(&remote),
1845        )
1846        .unwrap();
1847        assert_eq!(nested[0], protocol::RES_INVALID_REQ);
1848    }
1849
1850    #[test]
1851    fn fork_handler_fetches_source_repo_and_records_metadata() {
1852        let tmp = tempfile::tempdir().unwrap();
1853        let source = create_source_repo(tmp.path());
1854        let mut config = cfg(tmp.path());
1855        config.allow_create = vec!["all".into()];
1856        std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
1857        let access = make_access(&config);
1858        let remote = ([0x22; 16], [0u8; 64]);
1859        let req =
1860            protocol::remote_clone_request("group/forked", source.to_str().expect("utf-8 path"));
1861
1862        let resp = handle_fork(&config, &access, &req, Some(&remote)).unwrap();
1863
1864        let target = config.repositories_dir.join("group/forked");
1865        assert_eq!(resp[0], protocol::RES_OK);
1866        assert!(git::is_bare_repository(&target));
1867        assert!(!git::list_refs(&target).unwrap().is_empty());
1868        assert_eq!(git_config(&target, "repository.rngit.type"), "fork");
1869        assert_eq!(
1870            git_config(&target, "repository.rngit.upstream.source"),
1871            source.to_string_lossy()
1872        );
1873        assert!(git_config(&target, "repository.rngit.upstream.sync")
1874            .parse::<u64>()
1875            .is_ok());
1876        assert_eq!(
1877            std::fs::read_to_string(config.repositories_dir.join("group/forked.allowed")).unwrap(),
1878            "adm:22222222222222222222222222222222\n"
1879        );
1880    }
1881
1882    #[test]
1883    fn mirror_handler_records_mirror_type() {
1884        let tmp = tempfile::tempdir().unwrap();
1885        let source = create_source_repo_on_branch(tmp.path(), "main");
1886        let mut config = cfg(tmp.path());
1887        config.allow_create = vec!["all".into()];
1888        std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
1889        let access = make_access(&config);
1890        let remote = ([0x22; 16], [0u8; 64]);
1891        let req =
1892            protocol::remote_clone_request("group/mirrored", source.to_str().expect("utf-8 path"));
1893
1894        let resp = handle_mirror(&config, &access, &req, Some(&remote)).unwrap();
1895
1896        let target = config.repositories_dir.join("group/mirrored");
1897        assert_eq!(resp[0], protocol::RES_OK);
1898        assert_eq!(git_config(&target, "repository.rngit.type"), "mirror");
1899        assert_eq!(git_symbolic_ref(&target, "HEAD"), "refs/heads/main");
1900        assert!(git_config(&target, "repository.rngit.upstream.sync")
1901            .parse::<u64>()
1902            .is_ok());
1903    }
1904
1905    #[test]
1906    fn remote_clone_rejects_missing_source_and_existing_target() {
1907        let tmp = tempfile::tempdir().unwrap();
1908        let mut config = cfg(tmp.path());
1909        config.allow_create = vec!["all".into()];
1910        std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
1911        git::ensure_bare_repository(&config.repositories_dir.join("group/existing")).unwrap();
1912        let access = make_access(&config);
1913        let remote = ([0x22; 16], [0u8; 64]);
1914
1915        let missing_source = handle_fork(
1916            &config,
1917            &access,
1918            &protocol::repository_request("group/new"),
1919            Some(&remote),
1920        )
1921        .unwrap();
1922        assert_eq!(missing_source[0], protocol::RES_INVALID_REQ);
1923
1924        let existing = handle_fork(
1925            &config,
1926            &access,
1927            &protocol::remote_clone_request("group/existing", "unused"),
1928            Some(&remote),
1929        )
1930        .unwrap();
1931        assert_eq!(existing[0], protocol::RES_DISALLOWED);
1932        assert_eq!(&existing[1..], b"repository already exists");
1933    }
1934
1935    #[test]
1936    fn sync_handler_fetches_upstream_updates_for_fork() {
1937        let tmp = tempfile::tempdir().unwrap();
1938        let source = create_source_repo(tmp.path());
1939        let mut config = cfg(tmp.path());
1940        config.allow_create = vec!["all".into()];
1941        std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
1942        let access = make_access(&config);
1943        let remote = ([0x22; 16], [0u8; 64]);
1944        let fork_req =
1945            protocol::remote_clone_request("group/forked", source.to_str().expect("utf-8 path"));
1946        assert_eq!(
1947            handle_fork(&config, &access, &fork_req, Some(&remote)).unwrap()[0],
1948            protocol::RES_OK
1949        );
1950
1951        let updated_sha = commit_source_file(&source, "second.txt", "second\n");
1952
1953        let resp = handle_sync(
1954            &config,
1955            &access,
1956            &protocol::repository_request("group/forked"),
1957            Some(&remote),
1958        )
1959        .unwrap();
1960
1961        assert_eq!(resp[0], protocol::RES_OK);
1962        assert_eq!(
1963            git_rev_parse(&config.repositories_dir.join("group/forked"), "HEAD"),
1964            updated_sha
1965        );
1966        assert!(git_config(
1967            &config.repositories_dir.join("group/forked"),
1968            "repository.rngit.upstream.sync"
1969        )
1970        .parse::<u64>()
1971        .is_ok());
1972    }
1973
1974    #[test]
1975    fn sync_handler_rejects_non_clone_or_missing_write_access() {
1976        let tmp = tempfile::tempdir().unwrap();
1977        let mut config = cfg(tmp.path());
1978        config.allow_write = vec!["none".into()];
1979        let repo = config.repositories_dir.join("group/plain");
1980        git::ensure_bare_repository(&repo).unwrap();
1981        let access = make_access(&config);
1982        let remote = ([0x22; 16], [0u8; 64]);
1983
1984        let no_write = handle_sync(
1985            &config,
1986            &access,
1987            &protocol::repository_request("group/plain"),
1988            Some(&remote),
1989        )
1990        .unwrap();
1991        assert_eq!(no_write[0], protocol::RES_DISALLOWED);
1992
1993        config.allow_write = vec!["all".into()];
1994        let access = make_access(&config);
1995        let plain = handle_sync(
1996            &config,
1997            &access,
1998            &protocol::repository_request("group/plain"),
1999            Some(&remote),
2000        )
2001        .unwrap();
2002        assert_eq!(plain[0], protocol::RES_INVALID_REQ);
2003        assert_eq!(&plain[1..], b"repository is neither fork nor mirror");
2004    }
2005
2006    #[test]
2007    fn push_rejects_invalid_repository_name_before_create_acl() {
2008        let tmp = tempfile::tempdir().unwrap();
2009        let mut config = cfg(tmp.path());
2010        config.allow_write = vec!["none".into()];
2011        config.allow_create = vec!["all".into()];
2012        let access = Access::new(
2013            &config.allow_read,
2014            &config.allow_write,
2015            &config.allow_create,
2016            &config.allow_stats,
2017            &config.allow_release,
2018            &config.allow_interact,
2019            &config.allow_admin,
2020            config.repositories_dir.clone(),
2021        )
2022        .unwrap();
2023        let req = protocol::push_request("../repo", Vec::new(), Vec::new());
2024        assert!(handle_push(&config, &access, &req, None).is_err());
2025    }
2026
2027    #[test]
2028    fn push_rejects_invalid_ref_before_creating_missing_repo() {
2029        let tmp = tempfile::tempdir().unwrap();
2030        let config = cfg(tmp.path());
2031        let access = make_access(&config);
2032        let req = protocol::push_request(
2033            "group/repo",
2034            Vec::new(),
2035            vec![protocol::RefUpdate {
2036                refname: "-refs/heads/main".into(),
2037                old: None,
2038                new: Some("0123456789abcdef0123456789abcdef01234567".into()),
2039                force: true,
2040            }],
2041        );
2042
2043        let resp = handle_push(&config, &access, &req, None).unwrap();
2044
2045        assert_eq!(resp[0], protocol::RES_INVALID_REQ);
2046        assert!(!config.repositories_dir.join("group/repo").exists());
2047    }
2048
2049    #[test]
2050    fn push_rejects_invalid_sha_before_creating_missing_repo() {
2051        let tmp = tempfile::tempdir().unwrap();
2052        let config = cfg(tmp.path());
2053        let access = make_access(&config);
2054        let req = protocol::push_request(
2055            "group/repo",
2056            Vec::new(),
2057            vec![protocol::RefUpdate {
2058                refname: "refs/heads/main".into(),
2059                old: None,
2060                new: Some("--not-a-sha".into()),
2061                force: true,
2062            }],
2063        );
2064
2065        let resp = handle_push(&config, &access, &req, None).unwrap();
2066
2067        assert_eq!(resp[0], protocol::RES_INVALID_REQ);
2068        assert!(!config.repositories_dir.join("group/repo").exists());
2069    }
2070
2071    #[test]
2072    fn fetch_existing_repo_can_return_ok_status_or_resource() {
2073        let tmp = tempfile::tempdir().unwrap();
2074        let config = cfg(tmp.path());
2075        let access = Access::new(
2076            &config.allow_read,
2077            &config.allow_write,
2078            &config.allow_create,
2079            &config.allow_stats,
2080            &config.allow_release,
2081            &config.allow_interact,
2082            &config.allow_admin,
2083            config.repositories_dir.clone(),
2084        )
2085        .unwrap();
2086        let repo = config.repositories_dir.join("repo");
2087        git::ensure_bare_repository(&repo).unwrap();
2088        let req = protocol::fetch_request("repo", &[]);
2089        match handle_fetch(&config, &access, &req, None).unwrap() {
2090            RequestResponse::Bytes(bytes) => assert_eq!(bytes[0], protocol::RES_OK),
2091            RequestResponse::Resource { metadata, .. } => assert!(metadata.is_some()),
2092        }
2093    }
2094
2095    #[test]
2096    fn fetch_rejects_invalid_have_sha() {
2097        let tmp = tempfile::tempdir().unwrap();
2098        let config = cfg(tmp.path());
2099        let access = make_access(&config);
2100        let repo = config.repositories_dir.join("repo");
2101        git::ensure_bare_repository(&repo).unwrap();
2102        let req = protocol::fetch_request("repo", &["--upload-pack=/tmp/x".into()]);
2103
2104        let response = handle_fetch(&config, &access, &req, None).unwrap();
2105
2106        let RequestResponse::Bytes(bytes) = response else {
2107            panic!("invalid fetch request should return status bytes");
2108        };
2109        assert_eq!(bytes[0], protocol::RES_INVALID_REQ);
2110    }
2111
2112    #[test]
2113    fn git_failures_return_generic_client_errors() {
2114        let tmp = tempfile::tempdir().unwrap();
2115        let config = cfg(tmp.path());
2116        let access = make_access(&config);
2117        let repo = config.repositories_dir.join("broken");
2118        create_corrupt_bare_repo(&repo);
2119
2120        let list = handle_list(
2121            &config,
2122            &access,
2123            &protocol::repository_request("broken"),
2124            None,
2125        )
2126        .unwrap();
2127        assert_generic_remote_error(&list);
2128
2129        let fetch = handle_fetch(
2130            &config,
2131            &access,
2132            &protocol::fetch_request("broken", &[]),
2133            None,
2134        )
2135        .unwrap();
2136        let RequestResponse::Bytes(fetch) = fetch else {
2137            panic!("corrupt repository fetch should return status bytes");
2138        };
2139        assert_generic_remote_error(&fetch);
2140
2141        git::ensure_bare_repository(&config.repositories_dir.join("push-target")).unwrap();
2142        let push = handle_push(
2143            &config,
2144            &access,
2145            &protocol::push_request("push-target", b"not a git bundle".to_vec(), Vec::new()),
2146            None,
2147        )
2148        .unwrap();
2149        assert_generic_remote_error(&push);
2150    }
2151
2152    #[test]
2153    fn work_protocol_enforces_read_interact_and_write_access() {
2154        let tmp = tempfile::tempdir().unwrap();
2155        let mut config = cfg(tmp.path());
2156        git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2157
2158        config.allow_read = vec!["none".into()];
2159        config.allow_write = vec!["all".into()];
2160        config.allow_interact = vec!["all".into()];
2161        let access = make_access(&config);
2162        let list = handle_work(
2163            &config,
2164            &access,
2165            &work_request(&[
2166                ("repository", strv("group/repo")),
2167                ("operation", strv("list")),
2168            ]),
2169            Some(&(REMOTE, REMOTE_SIG)),
2170        )
2171        .unwrap();
2172        assert_eq!(list[0], protocol::RES_NOT_FOUND);
2173
2174        config.allow_read = vec!["all".into()];
2175        config.allow_write = vec!["all".into()];
2176        config.allow_interact = vec!["none".into()];
2177        let access = make_access(&config);
2178        let create = handle_work(
2179            &config,
2180            &access,
2181            &work_request(&[
2182                ("repository", strv("group/repo")),
2183                ("operation", strv("create")),
2184                ("title", strv("Task")),
2185                ("content", strv("Body")),
2186            ]),
2187            Some(&(REMOTE, REMOTE_SIG)),
2188        )
2189        .unwrap();
2190        assert_eq!(create[0], protocol::RES_DISALLOWED);
2191
2192        config.allow_interact = vec!["all".into()];
2193        config.allow_write = vec!["none".into()];
2194        let access = make_access(&config);
2195        let create = handle_work(
2196            &config,
2197            &access,
2198            &work_request(&[
2199                ("repository", strv("group/repo")),
2200                ("operation", strv("create")),
2201                ("title", strv("Task")),
2202                ("content", strv("Body")),
2203            ]),
2204            Some(&(REMOTE, REMOTE_SIG)),
2205        )
2206        .unwrap();
2207        assert_eq!(create[0], protocol::RES_DISALLOWED);
2208
2209        config.allow_write = vec!["all".into()];
2210        let access = make_access(&config);
2211        let create = handle_work(
2212            &config,
2213            &access,
2214            &work_request(&[
2215                ("repository", strv("group/repo")),
2216                ("operation", strv("create")),
2217                ("title", strv("Task")),
2218                ("content", strv("Body")),
2219            ]),
2220            Some(&(REMOTE, REMOTE_SIG)),
2221        )
2222        .unwrap();
2223        assert_eq!(create[0], protocol::RES_OK);
2224
2225        config.allow_write = vec!["none".into()];
2226        let access = make_access(&config);
2227        let comment = handle_work(
2228            &config,
2229            &access,
2230            &work_request(&[
2231                ("repository", strv("group/repo")),
2232                ("operation", strv("comment")),
2233                ("doc_id", uintv(1)),
2234                ("content", strv("Comment")),
2235            ]),
2236            Some(&(REMOTE, REMOTE_SIG)),
2237        )
2238        .unwrap();
2239        assert_eq!(comment[0], protocol::RES_OK);
2240    }
2241
2242    #[test]
2243    fn work_protocol_lifecycle_round_trips_documents_and_comments() {
2244        let tmp = tempfile::tempdir().unwrap();
2245        let mut config = cfg(tmp.path());
2246        config.allow_interact = vec!["all".into()];
2247        git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2248        let access = make_access(&config);
2249
2250        let create = handle_work(
2251            &config,
2252            &access,
2253            &work_request(&[
2254                ("repository", strv("group/repo")),
2255                ("operation", strv("create")),
2256                ("title", strv("Initial title")),
2257                ("content", strv("Initial body")),
2258                ("format", strv("micron")),
2259            ]),
2260            Some(&(REMOTE, REMOTE_SIG)),
2261        )
2262        .unwrap();
2263        let created = body_value(&create);
2264        assert_eq!(created.map_get("id").and_then(Value::as_integer), Some(1));
2265        assert_eq!(
2266            created.map_get("scope").and_then(Value::as_str),
2267            Some("active")
2268        );
2269
2270        let comment = handle_work(
2271            &config,
2272            &access,
2273            &work_request(&[
2274                ("repository", strv("group/repo")),
2275                ("operation", strv("comment")),
2276                ("doc_id", uintv(1)),
2277                ("content", strv("Progress update")),
2278                ("format", strv("markdown")),
2279            ]),
2280            Some(&(REMOTE, REMOTE_SIG)),
2281        )
2282        .unwrap();
2283        assert_eq!(
2284            body_value(&comment)
2285                .map_get("id")
2286                .and_then(Value::as_integer),
2287            Some(1)
2288        );
2289
2290        let view = handle_work(
2291            &config,
2292            &access,
2293            &work_request(&[
2294                ("repository", strv("group/repo")),
2295                ("operation", strv("view")),
2296                ("doc_id", uintv(1)),
2297            ]),
2298            Some(&(REMOTE, REMOTE_SIG)),
2299        )
2300        .unwrap();
2301        let view = body_value(&view);
2302        assert_eq!(
2303            view.map_get("content").and_then(Value::as_str),
2304            Some("Initial body")
2305        );
2306        let comments = view
2307            .map_get("comments")
2308            .and_then(Value::as_array)
2309            .expect("comments array");
2310        assert_eq!(comments.len(), 1);
2311        assert_eq!(
2312            comments[0].map_get("content").and_then(Value::as_str),
2313            Some("Progress update")
2314        );
2315
2316        let edit = handle_work(
2317            &config,
2318            &access,
2319            &work_request(&[
2320                ("repository", strv("group/repo")),
2321                ("operation", strv("edit")),
2322                ("doc_id", uintv(1)),
2323                ("title", strv("Edited title")),
2324                ("content", strv("Edited body")),
2325            ]),
2326            Some(&(REMOTE, REMOTE_SIG)),
2327        )
2328        .unwrap();
2329        assert_eq!(edit, vec![protocol::RES_OK]);
2330
2331        let complete = handle_work(
2332            &config,
2333            &access,
2334            &work_request(&[
2335                ("repository", strv("group/repo")),
2336                ("operation", strv("complete")),
2337                ("doc_id", uintv(1)),
2338            ]),
2339            Some(&(REMOTE, REMOTE_SIG)),
2340        )
2341        .unwrap();
2342        assert_eq!(
2343            body_value(&complete)
2344                .map_get("scope")
2345                .and_then(Value::as_str),
2346            Some("completed")
2347        );
2348
2349        let all = handle_work(
2350            &config,
2351            &access,
2352            &work_request(&[
2353                ("repository", strv("group/repo")),
2354                ("operation", strv("list")),
2355                ("scope", strv("all")),
2356            ]),
2357            Some(&(REMOTE, REMOTE_SIG)),
2358        )
2359        .unwrap();
2360        let all = body_value(&all);
2361        assert_eq!(
2362            all.map_get("active")
2363                .and_then(Value::as_array)
2364                .unwrap()
2365                .len(),
2366            0
2367        );
2368        assert_eq!(
2369            all.map_get("completed")
2370                .and_then(Value::as_array)
2371                .unwrap()
2372                .len(),
2373            1
2374        );
2375
2376        let activate = handle_work(
2377            &config,
2378            &access,
2379            &work_request(&[
2380                ("repository", strv("group/repo")),
2381                ("operation", strv("activate")),
2382                ("doc_id", uintv(1)),
2383            ]),
2384            Some(&(REMOTE, REMOTE_SIG)),
2385        )
2386        .unwrap();
2387        assert_eq!(
2388            body_value(&activate)
2389                .map_get("scope")
2390                .and_then(Value::as_str),
2391            Some("active")
2392        );
2393
2394        let delete = handle_work(
2395            &config,
2396            &access,
2397            &work_request(&[
2398                ("repository", strv("group/repo")),
2399                ("operation", strv("delete")),
2400                ("doc_id", uintv(1)),
2401            ]),
2402            Some(&(REMOTE, REMOTE_SIG)),
2403        )
2404        .unwrap();
2405        assert_eq!(delete, vec![protocol::RES_OK]);
2406    }
2407
2408    #[test]
2409    fn work_view_and_edit_scope_all_return_not_found_for_missing_document() {
2410        let tmp = tempfile::tempdir().unwrap();
2411        let mut config = cfg(tmp.path());
2412        config.allow_write = vec!["all".into()];
2413        config.allow_interact = vec!["all".into()];
2414        git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2415        let access = make_access(&config);
2416
2417        let view = handle_work(
2418            &config,
2419            &access,
2420            &work_request(&[
2421                ("repository", strv("group/repo")),
2422                ("operation", strv("view")),
2423                ("scope", strv("all")),
2424                ("doc_id", uintv(99)),
2425            ]),
2426            Some(&(REMOTE, REMOTE_SIG)),
2427        )
2428        .unwrap();
2429        assert_eq!(view[0], protocol::RES_NOT_FOUND);
2430
2431        let edit = handle_work(
2432            &config,
2433            &access,
2434            &work_request(&[
2435                ("repository", strv("group/repo")),
2436                ("operation", strv("edit")),
2437                ("scope", strv("all")),
2438                ("doc_id", uintv(99)),
2439                ("title", strv("Missing")),
2440            ]),
2441            Some(&(REMOTE, REMOTE_SIG)),
2442        )
2443        .unwrap();
2444        assert_eq!(edit[0], protocol::RES_NOT_FOUND);
2445    }
2446
2447    #[test]
2448    fn work_protocol_proposes_signed_documents_in_proposed_scope() {
2449        let tmp = tempfile::tempdir().unwrap();
2450        let mut config = cfg(tmp.path());
2451        config.allow_write = vec!["none".into()];
2452        config.allow_interact = vec!["none".into()];
2453        config.allow_propose = vec!["all".into()];
2454        git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2455        let access = make_access(&config);
2456        let identity = Identity::new(&mut OsRng);
2457        let remote_hash = *identity.hash();
2458        let remote_pubkey: [u8; 64] = identity.get_public_key().unwrap();
2459        let signature = identity.sign(b"Proposal body").unwrap().to_vec();
2460
2461        let proposed = handle_work(
2462            &config,
2463            &access,
2464            &work_request(&[
2465                ("repository", strv("group/repo")),
2466                ("operation", strv("propose")),
2467                ("title", strv("Proposal")),
2468                ("content", strv("Proposal body")),
2469                ("signature", binv(&signature)),
2470            ]),
2471            Some(&(remote_hash, remote_pubkey)),
2472        )
2473        .unwrap();
2474        let proposed = body_value(&proposed);
2475        assert_eq!(proposed.map_get("id").and_then(Value::as_integer), Some(1));
2476        assert_eq!(
2477            proposed.map_get("scope").and_then(Value::as_str),
2478            Some("proposed")
2479        );
2480
2481        let list = handle_work(
2482            &config,
2483            &access,
2484            &work_request(&[
2485                ("repository", strv("group/repo")),
2486                ("operation", strv("list")),
2487                ("scope", strv("all")),
2488            ]),
2489            Some(&(remote_hash, remote_pubkey)),
2490        )
2491        .unwrap();
2492        let list = body_value(&list);
2493        assert_eq!(
2494            list.map_get("proposed")
2495                .and_then(Value::as_array)
2496                .unwrap()
2497                .len(),
2498            1
2499        );
2500
2501        let edit = handle_work(
2502            &config,
2503            &access,
2504            &work_request(&[
2505                ("repository", strv("group/repo")),
2506                ("operation", strv("edit")),
2507                ("scope", strv("proposed")),
2508                ("doc_id", uintv(1)),
2509                ("title", strv("Updated proposal")),
2510            ]),
2511            Some(&(remote_hash, remote_pubkey)),
2512        )
2513        .unwrap();
2514        assert_eq!(edit, vec![protocol::RES_OK]);
2515
2516        let invalid = handle_work(
2517            &config,
2518            &access,
2519            &work_request(&[
2520                ("repository", strv("group/repo")),
2521                ("operation", strv("propose")),
2522                ("title", strv("Unsigned")),
2523                ("content", strv("Unsigned body")),
2524            ]),
2525            Some(&(remote_hash, remote_pubkey)),
2526        )
2527        .unwrap();
2528        assert_eq!(invalid[0], protocol::RES_INVALID_REQ);
2529    }
2530
2531    #[test]
2532    fn work_protocol_validates_and_exposes_document_signatures() {
2533        let tmp = tempfile::tempdir().unwrap();
2534        let mut config = cfg(tmp.path());
2535        config.allow_interact = vec!["all".into()];
2536        git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2537        let access = make_access(&config);
2538        let identity = Identity::new(&mut OsRng);
2539        let remote_hash = *identity.hash();
2540        let remote_pubkey: [u8; 64] = identity.get_public_key().unwrap();
2541        let signature = identity.sign(b"Signed body").unwrap().to_vec();
2542
2543        let create = handle_work(
2544            &config,
2545            &access,
2546            &work_request(&[
2547                ("repository", strv("group/repo")),
2548                ("operation", strv("create")),
2549                ("title", strv("Signed task")),
2550                ("content", strv("Signed body")),
2551                ("signature", binv(&signature)),
2552            ]),
2553            Some(&(remote_hash, remote_pubkey)),
2554        )
2555        .unwrap();
2556        assert_eq!(create[0], protocol::RES_OK);
2557
2558        let view = handle_work(
2559            &config,
2560            &access,
2561            &work_request(&[
2562                ("repository", strv("group/repo")),
2563                ("operation", strv("view")),
2564                ("doc_id", uintv(1)),
2565            ]),
2566            Some(&(remote_hash, remote_pubkey)),
2567        )
2568        .unwrap();
2569        let view = body_value(&view);
2570        let meta = view.map_get("meta").unwrap();
2571        assert_eq!(
2572            meta.map_get("signature").and_then(Value::as_bin),
2573            Some(signature.as_slice())
2574        );
2575        assert_eq!(
2576            meta.map_get("identity").and_then(Value::as_bin),
2577            Some(remote_pubkey.as_slice())
2578        );
2579
2580        let invalid = handle_work(
2581            &config,
2582            &access,
2583            &work_request(&[
2584                ("repository", strv("group/repo")),
2585                ("operation", strv("create")),
2586                ("title", strv("Invalid task")),
2587                ("content", strv("Different body")),
2588                ("signature", binv(&signature)),
2589            ]),
2590            Some(&(remote_hash, remote_pubkey)),
2591        )
2592        .unwrap();
2593        assert_eq!(invalid[0], protocol::RES_INVALID_REQ);
2594    }
2595
2596    #[test]
2597    fn work_protocol_rejects_non_author_management_operations() {
2598        let tmp = tempfile::tempdir().unwrap();
2599        let mut config = cfg(tmp.path());
2600        config.allow_interact = vec!["all".into()];
2601        git::ensure_bare_repository(&config.repositories_dir.join("group/repo")).unwrap();
2602        let access = make_access(&config);
2603
2604        let create = handle_work(
2605            &config,
2606            &access,
2607            &work_request(&[
2608                ("repository", strv("group/repo")),
2609                ("operation", strv("create")),
2610                ("title", strv("Task")),
2611                ("content", strv("Body")),
2612            ]),
2613            Some(&(REMOTE, REMOTE_SIG)),
2614        )
2615        .unwrap();
2616        assert_eq!(create[0], protocol::RES_OK);
2617
2618        for operation in ["edit", "complete", "delete"] {
2619            let denied = handle_work(
2620                &config,
2621                &access,
2622                &work_request(&[
2623                    ("repository", strv("group/repo")),
2624                    ("operation", strv(operation)),
2625                    ("doc_id", uintv(1)),
2626                    ("content", strv("Other edit")),
2627                ]),
2628                Some(&(OTHER_REMOTE, REMOTE_SIG)),
2629            )
2630            .unwrap();
2631            assert_eq!(denied[0], protocol::RES_DISALLOWED);
2632        }
2633    }
2634
2635    #[test]
2636    fn work_document_local_interact_allows_comments_without_repo_interact() {
2637        let tmp = tempfile::tempdir().unwrap();
2638        let mut config = cfg(tmp.path());
2639        config.allow_write = vec!["none".into()];
2640        config.allow_interact = vec!["none".into()];
2641        let repo_path = config.repositories_dir.join("group/repo");
2642        git::ensure_bare_repository(&repo_path).unwrap();
2643        let work_path = crate::work::work_sidecar_path(&repo_path);
2644        crate::work::create_document(
2645            &work_path,
2646            crate::work::WorkInput {
2647                title: "Task".into(),
2648                content: "Body".into(),
2649                format: "markdown".into(),
2650                signature: None,
2651                identity: None,
2652                author: REMOTE,
2653            },
2654        )
2655        .unwrap();
2656        std::fs::write(work_path.join("1.allowed"), "interact = all\n").unwrap();
2657        let access = make_access(&config);
2658
2659        let comment = handle_work(
2660            &config,
2661            &access,
2662            &work_request(&[
2663                ("repository", strv("group/repo")),
2664                ("operation", strv("comment")),
2665                ("doc_id", uintv(1)),
2666                ("content", strv("Comment from doc ACL")),
2667            ]),
2668            Some(&(OTHER_REMOTE, REMOTE_SIG)),
2669        )
2670        .unwrap();
2671        assert_eq!(comment[0], protocol::RES_OK);
2672
2673        let edit = handle_work(
2674            &config,
2675            &access,
2676            &work_request(&[
2677                ("repository", strv("group/repo")),
2678                ("operation", strv("edit")),
2679                ("doc_id", uintv(1)),
2680                ("content", strv("Not allowed")),
2681            ]),
2682            Some(&(OTHER_REMOTE, REMOTE_SIG)),
2683        )
2684        .unwrap();
2685        assert_eq!(edit[0], protocol::RES_DISALLOWED);
2686    }
2687
2688    #[test]
2689    fn work_permissions_get_set_and_validation_are_author_gated() {
2690        let tmp = tempfile::tempdir().unwrap();
2691        let mut config = cfg(tmp.path());
2692        config.allow_interact = vec!["all".into()];
2693        let repo_path = config.repositories_dir.join("group/repo");
2694        git::ensure_bare_repository(&repo_path).unwrap();
2695        let work_path = crate::work::work_sidecar_path(&repo_path);
2696        crate::work::create_document(
2697            &work_path,
2698            crate::work::WorkInput {
2699                title: "Task".into(),
2700                content: "Body".into(),
2701                format: "markdown".into(),
2702                signature: None,
2703                identity: None,
2704                author: REMOTE,
2705            },
2706        )
2707        .unwrap();
2708        let access = make_access(&config);
2709
2710        let get = handle_work(
2711            &config,
2712            &access,
2713            &work_request(&[
2714                ("repository", strv("group/repo")),
2715                ("operation", strv("perms")),
2716                ("step", strv("get")),
2717                ("doc_id", uintv(1)),
2718            ]),
2719            Some(&(REMOTE, REMOTE_SIG)),
2720        )
2721        .unwrap();
2722        assert_eq!(
2723            body_value(&get).map_get("content").and_then(Value::as_str),
2724            Some("")
2725        );
2726
2727        let content = format!(
2728            "interact = {}\nadmin = {}\n",
2729            crate::util::hex(&OTHER_REMOTE),
2730            crate::util::hex(&OTHER_REMOTE)
2731        );
2732        let set = handle_work(
2733            &config,
2734            &access,
2735            &work_request(&[
2736                ("repository", strv("group/repo")),
2737                ("operation", strv("perms")),
2738                ("step", strv("set")),
2739                ("doc_id", uintv(1)),
2740                ("content", strv(&content)),
2741            ]),
2742            Some(&(REMOTE, REMOTE_SIG)),
2743        )
2744        .unwrap();
2745        assert_eq!(set, vec![protocol::RES_OK]);
2746        assert_eq!(
2747            std::fs::read_to_string(work_path.join("1.allowed")).unwrap(),
2748            content
2749        );
2750
2751        let denied = handle_work(
2752            &config,
2753            &access,
2754            &work_request(&[
2755                ("repository", strv("group/repo")),
2756                ("operation", strv("perms")),
2757                ("step", strv("get")),
2758                ("doc_id", uintv(1)),
2759            ]),
2760            Some(&([0x33; 16], REMOTE_SIG)),
2761        )
2762        .unwrap();
2763        assert_eq!(denied[0], protocol::RES_DISALLOWED);
2764
2765        let invalid = handle_work(
2766            &config,
2767            &access,
2768            &work_request(&[
2769                ("repository", strv("group/repo")),
2770                ("operation", strv("perms")),
2771                ("step", strv("set")),
2772                ("doc_id", uintv(1)),
2773                ("content", strv("interact = not-a-hex-identity\n")),
2774            ]),
2775            Some(&(REMOTE, REMOTE_SIG)),
2776        )
2777        .unwrap();
2778        assert_eq!(invalid[0], protocol::RES_INVALID_REQ);
2779        assert_eq!(
2780            std::fs::read_to_string(work_path.join("1.allowed")).unwrap(),
2781            content
2782        );
2783
2784        let missing = handle_work(
2785            &config,
2786            &access,
2787            &work_request(&[
2788                ("repository", strv("group/repo")),
2789                ("operation", strv("perms")),
2790                ("step", strv("get")),
2791                ("doc_id", uintv(99)),
2792            ]),
2793            Some(&(REMOTE, REMOTE_SIG)),
2794        )
2795        .unwrap();
2796        assert_eq!(missing[0], protocol::RES_NOT_FOUND);
2797    }
2798
2799    #[test]
2800    fn work_document_admin_can_get_and_set_permissions() {
2801        let tmp = tempfile::tempdir().unwrap();
2802        let mut config = cfg(tmp.path());
2803        config.allow_write = vec!["none".into()];
2804        config.allow_interact = vec!["none".into()];
2805        let repo_path = config.repositories_dir.join("group/repo");
2806        git::ensure_bare_repository(&repo_path).unwrap();
2807        let work_path = crate::work::work_sidecar_path(&repo_path);
2808        crate::work::create_document(
2809            &work_path,
2810            crate::work::WorkInput {
2811                title: "Task".into(),
2812                content: "Body".into(),
2813                format: "markdown".into(),
2814                signature: None,
2815                identity: None,
2816                author: REMOTE,
2817            },
2818        )
2819        .unwrap();
2820        std::fs::write(
2821            work_path.join("1.allowed"),
2822            format!("admin = {}\n", crate::util::hex(&OTHER_REMOTE)),
2823        )
2824        .unwrap();
2825        let access = make_access(&config);
2826
2827        let get = handle_work(
2828            &config,
2829            &access,
2830            &work_request(&[
2831                ("repository", strv("group/repo")),
2832                ("operation", strv("perms")),
2833                ("step", strv("get")),
2834                ("doc_id", uintv(1)),
2835            ]),
2836            Some(&(OTHER_REMOTE, REMOTE_SIG)),
2837        )
2838        .unwrap();
2839        assert_eq!(get[0], protocol::RES_OK);
2840
2841        let set = handle_work(
2842            &config,
2843            &access,
2844            &work_request(&[
2845                ("repository", strv("group/repo")),
2846                ("operation", strv("perms")),
2847                ("step", strv("set")),
2848                ("doc_id", uintv(1)),
2849                ("content", strv("interact = all\n")),
2850            ]),
2851            Some(&(OTHER_REMOTE, REMOTE_SIG)),
2852        )
2853        .unwrap();
2854        assert_eq!(set, vec![protocol::RES_OK]);
2855        assert_eq!(
2856            std::fs::read_to_string(work_path.join("1.allowed")).unwrap(),
2857            "interact = all\n"
2858        );
2859    }
2860
2861    #[test]
2862    fn repository_permissions_get_set_and_validate_allowed_file() {
2863        let tmp = tempfile::tempdir().unwrap();
2864        let mut config = cfg(tmp.path());
2865        config.allow_admin = vec![crate::util::hex(&REMOTE)];
2866        let repo_path = config.repositories_dir.join("group/repo");
2867        git::ensure_bare_repository(&repo_path).unwrap();
2868        let access = make_access(&config);
2869
2870        let get = handle_perms(
2871            &config,
2872            &access,
2873            &work_request(&[
2874                ("repository", strv("group/repo")),
2875                ("operation", strv("rperms")),
2876                ("step", strv("get")),
2877            ]),
2878            Some(&(REMOTE, REMOTE_SIG)),
2879        )
2880        .unwrap();
2881        assert_eq!(
2882            body_value(&get).map_get("content").and_then(Value::as_str),
2883            Some("")
2884        );
2885
2886        let set = handle_perms(
2887            &config,
2888            &access,
2889            &work_request(&[
2890                ("repository", strv("group/repo")),
2891                ("operation", strv("rperms")),
2892                ("step", strv("set")),
2893                (
2894                    "content",
2895                    strv(
2896                        "write = all
2897",
2898                    ),
2899                ),
2900            ]),
2901            Some(&(REMOTE, REMOTE_SIG)),
2902        )
2903        .unwrap();
2904        assert_eq!(set, vec![protocol::RES_OK]);
2905        assert_eq!(
2906            std::fs::read_to_string(config.repositories_dir.join("group/repo.allowed")).unwrap(),
2907            "write = all
2908"
2909        );
2910
2911        let invalid = handle_perms(
2912            &config,
2913            &access,
2914            &work_request(&[
2915                ("repository", strv("group/repo")),
2916                ("operation", strv("rperms")),
2917                ("step", strv("set")),
2918                (
2919                    "content",
2920                    strv(
2921                        "write = not-a-hex-identity
2922",
2923                    ),
2924                ),
2925            ]),
2926            Some(&(REMOTE, REMOTE_SIG)),
2927        )
2928        .unwrap();
2929        assert_eq!(invalid[0], protocol::RES_INVALID_REQ);
2930        assert_eq!(
2931            std::fs::read_to_string(config.repositories_dir.join("group/repo.allowed")).unwrap(),
2932            "write = all
2933"
2934        );
2935    }
2936
2937    #[test]
2938    fn group_permissions_write_group_sidecar_allowed_file() {
2939        let tmp = tempfile::tempdir().unwrap();
2940        let mut config = cfg(tmp.path());
2941        config.allow_admin = vec![crate::util::hex(&REMOTE)];
2942        std::fs::create_dir_all(config.repositories_dir.join("group")).unwrap();
2943        let access = make_access(&config);
2944
2945        let request = msgpack::pack(&Value::Map(vec![
2946            (Value::UInt(protocol::IDX_GROUP), strv("group")),
2947            (Value::Str("operation".into()), strv("gperms")),
2948            (Value::Str("step".into()), strv("set")),
2949            (
2950                Value::Str("content".into()),
2951                strv(
2952                    "create = all
2953",
2954                ),
2955            ),
2956        ]));
2957        let set = handle_perms(&config, &access, &request, Some(&(REMOTE, REMOTE_SIG))).unwrap();
2958        assert_eq!(set, vec![protocol::RES_OK]);
2959        assert_eq!(
2960            std::fs::read_to_string(config.repositories_dir.join("group.allowed")).unwrap(),
2961            "create = all
2962"
2963        );
2964    }
2965
2966    #[test]
2967    fn permissions_management_requires_admin_access() {
2968        let tmp = tempfile::tempdir().unwrap();
2969        let config = cfg(tmp.path());
2970        let repo_path = config.repositories_dir.join("group/repo");
2971        git::ensure_bare_repository(&repo_path).unwrap();
2972        let access = make_access(&config);
2973
2974        let denied = handle_perms(
2975            &config,
2976            &access,
2977            &work_request(&[
2978                ("repository", strv("group/repo")),
2979                ("operation", strv("rperms")),
2980                ("step", strv("get")),
2981            ]),
2982            Some(&(REMOTE, REMOTE_SIG)),
2983        )
2984        .unwrap();
2985        assert_eq!(denied[0], protocol::RES_DISALLOWED);
2986    }
2987
2988    fn make_access(config: &ServerConfig) -> Access {
2989        Access::new_with_aliases(
2990            &config.allow_read,
2991            &config.allow_write,
2992            &config.allow_create,
2993            &config.allow_stats,
2994            &config.allow_release,
2995            &config.allow_interact,
2996            &config.allow_admin,
2997            config.repositories_dir.clone(),
2998            config.identity_aliases.clone(),
2999        )
3000        .unwrap()
3001        .with_propose(&config.allow_propose)
3002        .unwrap()
3003    }
3004
3005    fn create_corrupt_bare_repo(path: &std::path::Path) {
3006        std::fs::create_dir_all(path.join("objects")).unwrap();
3007        std::fs::write(path.join("HEAD"), "ref: refs/heads/main\n").unwrap();
3008    }
3009
3010    fn create_source_repo(root: &std::path::Path) -> std::path::PathBuf {
3011        create_source_repo_on_branch(root, "master")
3012    }
3013
3014    fn create_source_repo_on_branch(root: &std::path::Path, branch: &str) -> std::path::PathBuf {
3015        let source = root.join(format!("source-{branch}"));
3016        std::fs::create_dir_all(&source).unwrap();
3017        run_git(&source, &["init", "-b", branch]);
3018        std::fs::write(source.join("README.md"), "source\n").unwrap();
3019        run_git(&source, &["add", "README.md"]);
3020        run_git(
3021            &source,
3022            &[
3023                "-c",
3024                "user.name=rngit test",
3025                "-c",
3026                "user.email=rngit@example.invalid",
3027                "commit",
3028                "-m",
3029                "initial",
3030            ],
3031        );
3032        source
3033    }
3034
3035    fn commit_source_file(repo: &std::path::Path, file: &str, content: &str) -> String {
3036        std::fs::write(repo.join(file), content).unwrap();
3037        run_git(repo, &["add", file]);
3038        run_git(
3039            repo,
3040            &[
3041                "-c",
3042                "user.name=rngit test",
3043                "-c",
3044                "user.email=rngit@example.invalid",
3045                "commit",
3046                "-m",
3047                file,
3048            ],
3049        );
3050        git_rev_parse(repo, "HEAD")
3051    }
3052
3053    fn git_symbolic_ref(repo: &std::path::Path, name: &str) -> String {
3054        let output = std::process::Command::new("git")
3055            .arg("--git-dir")
3056            .arg(repo)
3057            .arg("symbolic-ref")
3058            .arg(name)
3059            .output()
3060            .unwrap();
3061        assert!(
3062            output.status.success(),
3063            "git symbolic-ref failed: {}",
3064            String::from_utf8_lossy(&output.stderr)
3065        );
3066        String::from_utf8_lossy(&output.stdout).trim().to_string()
3067    }
3068
3069    fn git_rev_parse(repo: &std::path::Path, rev: &str) -> String {
3070        let output = if repo.join(".git").is_dir() {
3071            std::process::Command::new("git")
3072                .arg("-C")
3073                .arg(repo)
3074                .arg("rev-parse")
3075                .arg(rev)
3076                .output()
3077                .unwrap()
3078        } else {
3079            std::process::Command::new("git")
3080                .arg("--git-dir")
3081                .arg(repo)
3082                .arg("rev-parse")
3083                .arg(rev)
3084                .output()
3085                .unwrap()
3086        };
3087        assert!(
3088            output.status.success(),
3089            "git rev-parse failed: {}",
3090            String::from_utf8_lossy(&output.stderr)
3091        );
3092        String::from_utf8_lossy(&output.stdout).trim().to_string()
3093    }
3094
3095    fn git_config(repo: &std::path::Path, key: &str) -> String {
3096        let output = std::process::Command::new("git")
3097            .arg("--git-dir")
3098            .arg(repo)
3099            .arg("config")
3100            .arg("--get")
3101            .arg(key)
3102            .output()
3103            .unwrap();
3104        assert!(
3105            output.status.success(),
3106            "git config failed: {}",
3107            String::from_utf8_lossy(&output.stderr)
3108        );
3109        String::from_utf8_lossy(&output.stdout).trim().to_string()
3110    }
3111
3112    fn run_git(cwd: &std::path::Path, args: &[&str]) {
3113        let output = std::process::Command::new("git")
3114            .args(args)
3115            .current_dir(cwd)
3116            .output()
3117            .unwrap();
3118        assert!(
3119            output.status.success(),
3120            "git {:?} failed: {}",
3121            args,
3122            String::from_utf8_lossy(&output.stderr)
3123        );
3124    }
3125
3126    fn assert_generic_remote_error(response: &[u8]) {
3127        assert_eq!(response[0], protocol::RES_REMOTE_FAIL);
3128        assert_eq!(&response[1..], b"Remote error");
3129    }
3130
3131    fn work_request(fields: &[(&str, Value)]) -> Vec<u8> {
3132        msgpack::pack(&Value::Map(
3133            fields
3134                .iter()
3135                .map(|(key, value)| {
3136                    let key = if *key == "repository" {
3137                        Value::UInt(protocol::IDX_REPOSITORY)
3138                    } else {
3139                        Value::Str((*key).into())
3140                    };
3141                    (key, value.clone())
3142                })
3143                .collect(),
3144        ))
3145    }
3146
3147    fn strv(value: &str) -> Value {
3148        Value::Str(value.to_string())
3149    }
3150
3151    fn uintv(value: u64) -> Value {
3152        Value::UInt(value)
3153    }
3154
3155    fn binv(value: &[u8]) -> Value {
3156        Value::Bin(value.to_vec())
3157    }
3158
3159    fn body_value(response: &[u8]) -> Value {
3160        assert_eq!(response[0], protocol::RES_OK);
3161        msgpack::unpack_exact(&response[1..]).unwrap()
3162    }
3163}