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