Skip to main content

rns_git/
server.rs

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