Skip to main content

rns_git/
pages.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::io::{Read, Seek, SeekFrom};
4use std::path::{Path, PathBuf};
5use std::process::{Command, Output, Stdio};
6use std::thread;
7use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
8
9use rns_core::msgpack::{self, Value};
10use rns_core::types::IdentityHash;
11use rns_crypto::identity::Identity;
12use rns_net::link_manager::ResourceStrategy;
13use rns_net::{Destination, RequestResponse, RnsNode};
14
15use crate::acl::{Access, Operation};
16use crate::config::ServerConfig;
17use crate::protocol;
18use crate::util::validate_repo_name;
19use crate::{Error, Result};
20
21pub const APP_NAME: &str = "nomadnetwork";
22pub const ASPECT_NODE: &str = "node";
23
24pub const PATH_INDEX: &str = "/page/index.mu";
25pub const PATH_GROUP: &str = "/page/group.mu";
26pub const PATH_REPO: &str = "/page/repo.mu";
27pub const PATH_TREE: &str = "/page/tree.mu";
28pub const PATH_BLOB: &str = "/page/blob.mu";
29pub const PATH_COMMITS: &str = "/page/commits.mu";
30pub const PATH_COMMIT: &str = "/page/commit.mu";
31pub const PATH_REFS: &str = "/page/refs.mu";
32pub const PATH_STATS: &str = "/page/stats.mu";
33pub const PATH_RELEASES: &str = "/page/releases.mu";
34pub const PATH_RELEASE: &str = "/page/release.mu";
35pub const PATH_WORK: &str = "/page/work.mu";
36pub const PATH_WORK_DOC: &str = "/page/work_doc.mu";
37pub const PATH_MEDIA: &str = "/media";
38pub const PATH_DOWNLOAD: &str = "/file/download";
39pub const PATH_WORK_DOC_DOWNLOAD: &str = "/file/workdoc";
40
41const GIT_COMMAND_TIMEOUT: Duration = Duration::from_secs(8);
42const RCLR_PUSH: &str = "B9A810";
43const RCLR_PUSH_G: &str = "791212";
44const RCLR_FETCH: &str = "10b981";
45const RCLR_FETCH_G: &str = "1c5e71";
46const RCLR_VIEW: &str = "3b82f6";
47const RCLR_VIEW_G: &str = "13428A";
48const RCLR_DOWNLOAD: &str = "7831E0";
49const RCLR_DOWNLOAD_G: &str = "c5754d";
50const NULL_IDENTITY_HASH: [u8; 16] = [
51    0xd7, 0xdb, 0x22, 0xf6, 0x3b, 0x45, 0x3c, 0x23, 0xbb, 0x06, 0x88, 0xdd, 0xe5, 0x65, 0xb7, 0xc1,
52];
53
54const PAGE_PATHS: &[&str] = &[
55    PATH_INDEX,
56    PATH_GROUP,
57    PATH_REPO,
58    PATH_TREE,
59    PATH_BLOB,
60    PATH_COMMITS,
61    PATH_COMMIT,
62    PATH_REFS,
63    PATH_STATS,
64    PATH_RELEASES,
65    PATH_RELEASE,
66    PATH_WORK,
67    PATH_WORK_DOC,
68];
69
70pub fn destination_for_identity(identity: &Identity) -> Destination {
71    Destination::single_in(APP_NAME, &[ASPECT_NODE], IdentityHash(*identity.hash()))
72}
73
74pub fn register_nomadnet_destination(
75    node: &RnsNode,
76    config: &ServerConfig,
77    identity: &Identity,
78) -> Result<Destination> {
79    let destination = destination_for_identity(identity);
80    let public_key = identity
81        .get_public_key()
82        .ok_or_else(|| Error::msg("repository identity has no public key"))?;
83    let private_key = identity
84        .get_private_key()
85        .ok_or_else(|| Error::msg("repository identity has no private key"))?;
86    let sig_prv: [u8; 32] = private_key[32..64].try_into().unwrap();
87    let sig_pub: [u8; 32] = public_key[32..64].try_into().unwrap();
88
89    node.register_link_destination(
90        destination.hash.0,
91        sig_prv,
92        sig_pub,
93        ResourceStrategy::AcceptAll as u8,
94    )
95    .map_err(|_| Error::msg("failed to register Nomad Network page destination"))?;
96
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    register_page_handlers(node, config.clone(), access)?;
111
112    Ok(destination)
113}
114
115fn register_page_handlers(node: &RnsNode, config: ServerConfig, access: Access) -> Result<()> {
116    let media_config = config.clone();
117    let media_access = access.clone();
118    node.register_request_handler_response(PATH_MEDIA, None, move |_link, _path, data, remote| {
119        Some(
120            serve_media(
121                &media_config,
122                &media_access,
123                data,
124                remote.map(|(hash, _)| hash),
125            )
126            .unwrap_or_else(|_| RequestResponse::Bytes(msgpack::pack(&Value::Bool(false)))),
127        )
128    })
129    .map_err(|_| Error::msg("failed to register media handler"))?;
130    for path in PAGE_PATHS {
131        let handler_path = *path;
132        let handler_config = config.clone();
133        let handler_access = access.clone();
134        node.register_request_handler(handler_path, None, move |_link, path, data, remote| {
135            let remote_hash = remote.map(|(hash, _)| hash);
136            Some(
137                match render_page(path, &handler_config, &handler_access, data, remote_hash) {
138                    Ok(page) => page.into_bytes(),
139                    Err(err) => error_page(&handler_config.node_name, &err.to_string()),
140                },
141            )
142        })
143        .map_err(|_| Error::msg(format!("failed to register page handler {handler_path}")))?;
144    }
145    let download_config = config.clone();
146    let download_access = access.clone();
147    node.register_request_handler_response(
148        PATH_DOWNLOAD,
149        None,
150        move |_link, _path, data, remote| {
151            let remote_hash = remote.map(|(hash, _)| hash);
152            Some(
153                download_file(&download_config, &download_access, data, remote_hash)
154                    .unwrap_or_else(|err| RequestResponse::Bytes(error_response_bytes(&err))),
155            )
156        },
157    )
158    .map_err(|_| Error::msg("failed to register file download handler"))?;
159    let workdoc_download_config = config.clone();
160    let workdoc_download_access = access.clone();
161    node.register_request_handler_response(
162        PATH_WORK_DOC_DOWNLOAD,
163        None,
164        move |_link, _path, data, remote| {
165            let remote_hash = remote.map(|(hash, _)| hash);
166            Some(
167                download_work_document(
168                    &workdoc_download_config,
169                    &workdoc_download_access,
170                    data,
171                    remote_hash,
172                )
173                .unwrap_or_else(|err| RequestResponse::Bytes(error_response_bytes(&err))),
174            )
175        },
176    )
177    .map_err(|_| Error::msg("failed to register work document download handler"))?;
178    Ok(())
179}
180
181pub fn render_page(
182    path: &str,
183    config: &ServerConfig,
184    access: &Access,
185    data: &[u8],
186    remote: Option<&[u8; 16]>,
187) -> Result<String> {
188    let vars = parse_page_vars(data)?;
189    if PAGE_PATHS.contains(&path)
190        && remote.is_none()
191        && config
192            .blocked_identities
193            .iter()
194            .any(|identity| identity == &NULL_IDENTITY_HASH)
195    {
196        record_page_view(path, config, &vars, remote);
197        return Ok(render_template(config, "no_ident", ""));
198    }
199    let (template, content) = match path {
200        PATH_INDEX => ("front", render_front_page(config, access, remote)?),
201        PATH_GROUP => ("group", render_group_page(config, access, remote, &vars)?),
202        PATH_REPO => ("repo", render_repo_page(config, access, remote, &vars)?),
203        PATH_TREE => ("tree", render_tree_page(config, access, remote, &vars)?),
204        PATH_BLOB => ("blob", render_blob_page(config, access, remote, &vars)?),
205        PATH_COMMITS => (
206            "commits",
207            render_commits_page(config, access, remote, &vars)?,
208        ),
209        PATH_COMMIT => ("commit", render_commit_page(config, access, remote, &vars)?),
210        PATH_REFS => ("refs", render_refs_page(config, access, remote, &vars)?),
211        PATH_STATS => ("stats", render_stats_page(config, access, remote, &vars)?),
212        PATH_RELEASES => (
213            "releases",
214            render_releases_page(config, access, remote, &vars)?,
215        ),
216        PATH_RELEASE => (
217            "release",
218            render_release_page(config, access, remote, &vars)?,
219        ),
220        PATH_WORK => ("work", render_work_page(config, access, remote, &vars)?),
221        PATH_WORK_DOC => (
222            "work_doc",
223            render_work_doc_page(config, access, remote, &vars)?,
224        ),
225        _ => return Err(Error::msg("unknown page path")),
226    };
227    record_page_view(path, config, &vars, remote);
228    Ok(render_template(config, template, &content))
229}
230
231fn record_page_view(
232    path: &str,
233    config: &ServerConfig,
234    vars: &BTreeMap<String, String>,
235    remote: Option<&[u8; 16]>,
236) {
237    match path {
238        PATH_INDEX => crate::stats::record_front_view(config, remote),
239        PATH_GROUP => {
240            if let Some(group) = var(vars, "g") {
241                crate::stats::record_group_view(config, group, remote);
242            }
243        }
244        PATH_REPO | PATH_TREE | PATH_BLOB | PATH_COMMITS | PATH_COMMIT | PATH_REFS
245        | PATH_RELEASES | PATH_RELEASE | PATH_WORK | PATH_WORK_DOC => {
246            if let (Some(group), Some(repo)) = (var(vars, "g"), var(vars, "r")) {
247                crate::stats::record_repository_view(config, group, repo, remote);
248            }
249        }
250        _ => {}
251    }
252}
253
254fn parse_page_vars(data: &[u8]) -> Result<BTreeMap<String, String>> {
255    if data.is_empty() {
256        return Ok(BTreeMap::new());
257    }
258    let value =
259        msgpack::unpack_exact(data).map_err(|e| Error::msg(format!("invalid msgpack: {e}")))?;
260    let map = value
261        .as_map()
262        .ok_or_else(|| Error::msg("page request must be a msgpack map"))?;
263    let mut out = BTreeMap::new();
264    for (key, value) in map {
265        let Some(key) = key.as_str() else {
266            continue;
267        };
268        let value = match value {
269            Value::Str(value) => value.clone(),
270            Value::UInt(value) => value.to_string(),
271            Value::Int(value) => value.to_string(),
272            Value::Bool(value) => {
273                if *value {
274                    "true".to_string()
275                } else {
276                    "false".to_string()
277                }
278            }
279            _ => continue,
280        };
281        out.insert(key.to_string(), value);
282    }
283    Ok(out)
284}
285
286fn render_template(config: &ServerConfig, template: &str, content: &str) -> String {
287    let page = apply_template(&page_template(config, template), content, config);
288    apply_template(&base_template(config), &page, config)
289}
290
291fn error_page(node_name: &str, message: &str) -> Vec<u8> {
292    default_base_template()
293        .replace("{PAGE_CONTENT}", &format!(">Error\n\n{message}\n"))
294        .replace("{NODE_NAME}", &m_escape(node_name))
295        .replace("{VERSION}", env!("CARGO_PKG_VERSION"))
296        .replace("{NAVIGATION}", "")
297        .replace("{GEN_TIME}", "0ms")
298        .into_bytes()
299}
300
301fn error_response_bytes(err: &Error) -> Vec<u8> {
302    protocol::status_bytes(protocol::RES_INVALID_REQ, err.to_string())
303}
304
305fn base_template(config: &ServerConfig) -> String {
306    load_template(config, "base").unwrap_or_else(|| default_base_template().to_string())
307}
308
309fn page_template(config: &ServerConfig, template: &str) -> String {
310    load_template(config, template).unwrap_or_else(|| default_page_template(template).to_string())
311}
312
313fn default_page_template(template: &str) -> &'static str {
314    match template {
315        "no_ident" => {
316            ">>No Identity\n\nThis page requires identification, and none was received.\n"
317        }
318        _ => "{PAGE_CONTENT}",
319    }
320}
321
322fn load_template(config: &ServerConfig, template: &str) -> Option<String> {
323    if !template
324        .chars()
325        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
326    {
327        return None;
328    }
329    let path = config.templates_dir.join(format!("{template}.mu"));
330    fs::read_to_string(path)
331        .ok()
332        .map(|template| template.trim_end().to_string())
333}
334
335fn apply_template(template: &str, content: &str, config: &ServerConfig) -> String {
336    template
337        .replace("{NODE_NAME}", &m_escape(&config.node_name))
338        .replace("{VERSION}", env!("CARGO_PKG_VERSION"))
339        .replace("{NAVIGATION}", "")
340        .replace("{GEN_TIME}", "0ms")
341        .replace("{PAGE_CONTENT}", content)
342}
343
344fn default_base_template() -> &'static str {
345    "#!c=0\n>{NODE_NAME}\n\n{PAGE_CONTENT}\n<\n-\n`a`F666`[Served by rngit`:/page/index.mu] - Generated by rngit`f\n"
346}
347
348fn icon(config: &ServerConfig, name: &str) -> &'static str {
349    if !config.unicode_icons {
350        return "";
351    }
352    match name {
353        "sep" => "•",
354        "folder" => "🗀",
355        "file" => "🗎",
356        "branch" => "⑃",
357        "tag" => "⌆",
358        "commits" => "🖹",
359        "package" => "▣",
360        "stats" => "🗠",
361        "heart" => "♥",
362        _ => "",
363    }
364}
365
366fn icon_label(config: &ServerConfig, name: &str, label: &str) -> String {
367    let icon = icon(config, name);
368    if icon.is_empty() {
369        label.to_string()
370    } else {
371        format!("{icon} {label}")
372    }
373}
374
375fn icon_sep(config: &ServerConfig) -> &'static str {
376    let sep = icon(config, "sep");
377    if sep.is_empty() {
378        "•"
379    } else {
380        sep
381    }
382}
383
384fn m_link(label: &str, path: &str, fields: &[(&str, &str)]) -> String {
385    format!("`!{}`!", m_link_raw(label, path, fields))
386}
387
388fn m_link_raw(label: &str, path: &str, fields: &[(&str, &str)]) -> String {
389    let mut out = String::from("`[");
390    out.push_str(&sanitize_label(label));
391    out.push_str("`:");
392    out.push_str(path);
393    if !fields.is_empty() {
394        out.push('`');
395        for (i, (key, value)) in fields.iter().enumerate() {
396            if i > 0 {
397                out.push('|');
398            }
399            out.push_str(key);
400            out.push('=');
401            out.push_str(&sanitize_field(value));
402        }
403    }
404    out.push(']');
405    out
406}
407
408fn markdown_blob_url_scope(group: &str, repo: &str, reference: &str, current_path: &str) -> String {
409    let directory = current_path
410        .rsplit_once('/')
411        .map(|(directory, _)| format!("{directory}/"))
412        .unwrap_or_default();
413    let mut out = format!(":{PATH_BLOB}`");
414    for (index, (key, value)) in [
415        ("g", group),
416        ("r", repo),
417        ("ref", reference),
418        ("path", directory.as_str()),
419    ]
420    .iter()
421    .enumerate()
422    {
423        if index > 0 {
424            out.push('|');
425        }
426        out.push_str(key);
427        out.push('=');
428        out.push_str(&sanitize_field(value));
429    }
430    out
431}
432
433fn sanitize_label(value: &str) -> String {
434    value
435        .chars()
436        .filter(|ch| *ch != '[' && *ch != ']' && *ch != '`')
437        .collect()
438}
439
440fn sanitize_field(value: &str) -> String {
441    let mut out = String::new();
442    for byte in value.bytes() {
443        match byte {
444            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'/' => {
445                out.push(byte as char)
446            }
447            b' ' => out.push('+'),
448            _ => out.push_str(&format!("%{byte:02X}")),
449        }
450    }
451    out
452}
453
454fn m_escape(value: &str) -> String {
455    value
456        .replace('\\', "\\\\")
457        .replace('`', "\\`")
458        .replace('\t', "   ")
459}
460
461fn m_escape_line_start_controls(value: &str) -> String {
462    let escaped = m_escape(value);
463    if value.starts_with('-') || value.starts_with('>') || value.starts_with('<') {
464        format!("\\{escaped}")
465    } else {
466        escaped
467    }
468}
469
470fn format_commit_message(value: &str) -> String {
471    value
472        .split('\n')
473        .map(|line| {
474            let escaped = m_escape(line);
475            if line.starts_with('-') {
476                format!("\\{escaped}")
477            } else {
478                escaped
479            }
480        })
481        .collect::<Vec<_>>()
482        .join("\n")
483}
484
485fn join_git_path(parent: &str, child: &str) -> String {
486    if parent.is_empty() {
487        child.to_string()
488    } else {
489        format!("{parent}/{child}")
490    }
491}
492
493fn format_size(size: u64) -> String {
494    const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB"];
495    let mut value = size as f64;
496    let mut unit = 0;
497    while value >= 1024.0 && unit + 1 < UNITS.len() {
498        value /= 1024.0;
499        unit += 1;
500    }
501    if unit == 0 {
502        format!("{size} B")
503    } else {
504        format!("{value:.1} {}", UNITS[unit])
505    }
506}
507
508fn format_unix_time(timestamp: i64) -> String {
509    timestamp.to_string()
510}
511
512fn render_front_page(
513    config: &ServerConfig,
514    access: &Access,
515    remote: Option<&[u8; 16]>,
516) -> Result<String> {
517    let groups = accessible_groups(config, access, remote)?;
518    let mut out = format!(">>\n{} /\n\n>Groups\n\n", m_link("Node", PATH_INDEX, &[]));
519    if groups.is_empty() {
520        out.push_str("No repository groups available.\n");
521        return Ok(out);
522    }
523    for (group, repos) in groups {
524        out.push_str(&format!(
525            "{} ({} repositories)\n",
526            m_link(&group, PATH_GROUP, &[("g", &group)]),
527            repos.len()
528        ));
529        for repo in repos {
530            out.push_str(&format!(
531                "  - {}\n",
532                m_link_raw(&repo, PATH_REPO, &[("g", &group), ("r", &repo)])
533            ));
534        }
535    }
536    Ok(out)
537}
538
539fn render_group_page(
540    config: &ServerConfig,
541    access: &Access,
542    remote: Option<&[u8; 16]>,
543    vars: &BTreeMap<String, String>,
544) -> Result<String> {
545    let group = required_var(vars, "g")?;
546    validate_group_name(group)?;
547    let repos = accessible_repositories(config, access, remote, group)?;
548    if repos.is_empty() {
549        return Ok(">Group Not Found\n\nThe requested group was not found.\n".to_string());
550    }
551
552    let mut out = format!(
553        ">>\n{} / {}\n\n>Repositories\n\n",
554        m_link("Node", PATH_INDEX, &[]),
555        m_escape(group)
556    );
557    for repo in repos {
558        let repo_path = config.repositories_dir.join(group).join(&repo);
559        let description = repository_description(&repo_path)?;
560        out.push_str(&format!(
561            "- {}",
562            m_link(&repo, PATH_REPO, &[("g", group), ("r", &repo)])
563        ));
564        if !description.is_empty() {
565            out.push_str(&format!(" - {}", m_escape(&description)));
566        }
567        out.push('\n');
568    }
569    Ok(out)
570}
571
572fn render_repo_page(
573    config: &ServerConfig,
574    access: &Access,
575    remote: Option<&[u8; 16]>,
576    vars: &BTreeMap<String, String>,
577) -> Result<String> {
578    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
579    let description = repository_description(&repository)?;
580    let origin = repository_origin(&repository)?;
581    let refs = git_refs(&repository)?;
582    let readme = readme_content(&repository)?;
583    let repo_url = format!("rns://<repository-destination>/{group}/{repo}");
584    let thanks = repository_thanks(&repository, var(vars, "thanks").is_some())?;
585    let releases_path = crate::release::release_sidecar_path(&repository);
586    let release_count = crate::release::list_releases(&releases_path)?
587        .into_iter()
588        .filter(|release| release.status == "published")
589        .count();
590    let work_path = crate::work::work_sidecar_path(&repository);
591    let work_lists = crate::work::list_documents(&work_path, crate::work::WorkListScope::All)?;
592    let work_count = work_lists.active.len() + work_lists.completed.len();
593
594    let branch_count = refs.heads.len().to_string();
595    let tag_count = refs.tags.len().to_string();
596    let mut out = format!(
597        ">>\n{} / {} / {} `F666{}`f\n",
598        m_link("Node", PATH_INDEX, &[]),
599        m_link(&group, PATH_GROUP, &[("g", &group)]),
600        m_escape(&repo),
601        m_escape(&repo_url)
602    );
603    if let Some(origin) = origin {
604        out.push_str(&format!(
605            "`F666{} from {}`f",
606            origin.label(),
607            m_escape(&origin.source)
608        ));
609        if let Some(synced_at) = origin.synced_at {
610            out.push_str(&format!(
611                " `*`F666synced {} ago`f`*",
612                format_sync_age(synced_at)
613            ));
614        }
615        out.push('\n');
616    }
617    out.push('\n');
618    if !description.is_empty() {
619        out.push_str(&format!("{}\n\n", m_escape(&description)));
620    }
621    let mut nav_links = vec![
622        m_link_raw(
623            &icon_label(config, "folder", "Files"),
624            PATH_TREE,
625            &[("g", &group), ("r", &repo), ("ref", "HEAD")],
626        ),
627        m_link_raw(
628            &icon_label(config, "commits", "Commits"),
629            PATH_COMMITS,
630            &[("g", &group), ("r", &repo), ("ref", "HEAD")],
631        ),
632        m_link_raw(
633            &icon_label(config, "branch", &format!("Branches ({branch_count})")),
634            PATH_REFS,
635            &[("g", &group), ("r", &repo), ("type", "heads")],
636        ),
637        m_link_raw(
638            &icon_label(config, "tag", &format!("Tags ({tag_count})")),
639            PATH_REFS,
640            &[("g", &group), ("r", &repo), ("type", "tags")],
641        ),
642        m_link_raw(
643            &icon_label(config, "heart", &format!("Thanks ({thanks})")),
644            PATH_REPO,
645            &[("g", &group), ("r", &repo), ("thanks", "y")],
646        ),
647    ];
648    if release_count > 0 {
649        nav_links.push(m_link_raw(
650            &icon_label(config, "package", &format!("Releases ({release_count})")),
651            PATH_RELEASES,
652            &[("g", &group), ("r", &repo)],
653        ));
654    }
655    if work_count > 0 {
656        nav_links.push(m_link_raw(
657            &icon_label(config, "work", &format!("Work ({work_count})")),
658            PATH_WORK,
659            &[("g", &group), ("r", &repo)],
660        ));
661    }
662    if access.allows(Operation::Stats, &format!("{group}/{repo}"), remote)? {
663        nav_links.push(m_link_raw(
664            &icon_label(config, "stats", "Stats"),
665            PATH_STATS,
666            &[("g", &group), ("r", &repo)],
667        ));
668    }
669    let sep = format!(" {} ", icon_sep(config));
670    out.push_str(&format!("{}\n\n", nav_links.join(&sep)));
671    if let Some(readme) = readme {
672        if !readme.content.trim_start().starts_with('>') {
673            out.push_str("-\n");
674        }
675        if readme.markdown {
676            out.push_str(&markdown_to_micron_scoped(
677                &readme.content,
678                Some(&markdown_blob_url_scope(&group, &repo, "HEAD", "README.md")),
679            ));
680            if !out.ends_with('\n') {
681                out.push('\n');
682            }
683        } else {
684            out.push_str(readme.content.trim_end());
685            out.push('\n');
686        }
687    } else {
688        out.push_str("No README file found in this repository.\n");
689    }
690    Ok(out)
691}
692
693fn render_tree_page(
694    config: &ServerConfig,
695    access: &Access,
696    remote: Option<&[u8; 16]>,
697    vars: &BTreeMap<String, String>,
698) -> Result<String> {
699    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
700    let reference = var(vars, "ref").unwrap_or("HEAD");
701    let path = var(vars, "path").unwrap_or("");
702    validate_git_path(path)?;
703    let resolved = resolve_ref(&repository, reference)?;
704    let entries = tree_entries(&repository, &resolved, path)?;
705    let title_path = if path.is_empty() { "/" } else { path };
706
707    let mut out = format!(
708        ">>\n{} / {} / {} / files / {}\n\n>Contents: {} ({})\n\n",
709        m_link("Node", PATH_INDEX, &[]),
710        m_link(&group, PATH_GROUP, &[("g", &group)]),
711        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
712        m_escape(title_path),
713        m_escape(reference),
714        &resolved[..8]
715    );
716    if entries.is_empty() {
717        out.push_str("Empty directory.\n");
718    } else {
719        for entry in entries {
720            let entry_path = join_git_path(path, &entry.name);
721            if entry.kind == "tree" {
722                let label = icon_label(config, "folder", &format!("{}/", entry.name));
723                out.push_str(&format!(
724                    "{}\n",
725                    m_link_raw(
726                        &label,
727                        PATH_TREE,
728                        &[
729                            ("g", &group),
730                            ("r", &repo),
731                            ("ref", reference),
732                            ("path", &entry_path)
733                        ]
734                    )
735                ));
736            } else {
737                let label = icon_label(config, "file", &entry.name);
738                out.push_str(&format!(
739                    "{} {}\n",
740                    m_link_raw(
741                        &label,
742                        PATH_BLOB,
743                        &[
744                            ("g", &group),
745                            ("r", &repo),
746                            ("ref", reference),
747                            ("path", &entry_path)
748                        ]
749                    ),
750                    entry.size_label()
751                ));
752            }
753        }
754    }
755    Ok(out)
756}
757
758fn render_blob_page(
759    config: &ServerConfig,
760    access: &Access,
761    remote: Option<&[u8; 16]>,
762    vars: &BTreeMap<String, String>,
763) -> Result<String> {
764    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
765    let reference = var(vars, "ref").unwrap_or("HEAD");
766    let path = normalize_blob_path(required_var(vars, "path")?);
767    validate_git_path(&path)?;
768    let resolved = resolve_ref(&repository, reference)?;
769    if git_object_kind(&repository, &resolved, &path)? == "tree" {
770        return render_tree_page(config, access, remote, vars);
771    }
772    let blob = blob_info(&repository, &resolved, &path)?;
773    let renderable = renderable_blob(&path);
774    let raw_requested = var(vars, "raw").is_some();
775    let render_requested = var(vars, "render").is_some();
776    let render =
777        renderable.is_some() && !raw_requested && (render_requested || render_by_default(&path));
778    let download_link = m_link(
779        "Download",
780        PATH_DOWNLOAD,
781        &[
782            ("g", &group),
783            ("r", &repo),
784            ("ref", reference),
785            ("path", path.as_str()),
786        ],
787    );
788    let converted_download_link = matches!(renderable, Some(RenderableBlob::Markdown)).then(|| {
789        m_link(
790            "as micron",
791            PATH_DOWNLOAD,
792            &[
793                ("g", &group),
794                ("r", &repo),
795                ("ref", reference),
796                ("path", path.as_str()),
797                ("fmt", "mu"),
798            ],
799        )
800    });
801    let controls = if renderable.is_some() {
802        let render_link = m_link(
803            "View rendered",
804            PATH_BLOB,
805            &[
806                ("g", &group),
807                ("r", &repo),
808                ("ref", reference),
809                ("path", path.as_str()),
810                ("render", "y"),
811            ],
812        );
813        let raw_link = m_link(
814            "View raw",
815            PATH_BLOB,
816            &[
817                ("g", &group),
818                ("r", &repo),
819                ("ref", reference),
820                ("path", path.as_str()),
821                ("raw", "y"),
822            ],
823        );
824        let sep = icon_sep(config);
825        let display_controls = if render {
826            format!("Displaying Rendered {sep} {raw_link}")
827        } else {
828            format!("Displaying Raw {sep} {render_link}")
829        };
830        Some(match converted_download_link {
831            Some(converted) => {
832                format!("{display_controls} {sep} {download_link} {sep} {converted}")
833            }
834            None => format!("{display_controls} {sep} {download_link}"),
835        })
836    } else {
837        Some(format!(
838            "Displaying Raw {} {download_link}",
839            icon_sep(config)
840        ))
841    };
842    let content = if blob.binary && is_image_path(&path) {
843        format!(
844            "`(Image file`w=n`a=c`:/media/{group}/{repo}/{reference}/{})\n",
845            quote_media_path(&path)
846        )
847    } else if !blob.displayable {
848        crate::highlight::plain_literal_block(&blob.content)
849    } else if render {
850        match renderable {
851            Some(RenderableBlob::Markdown) => markdown_to_micron_scoped(
852                &blob.content,
853                Some(&markdown_blob_url_scope(&group, &repo, reference, &path)),
854            ),
855            Some(RenderableBlob::Micron) => {
856                let mut out = blob.content.clone();
857                if !out.ends_with('\n') {
858                    out.push('\n');
859                }
860                out
861            }
862            None => crate::highlight::literal_block(&blob.content, Some(&path), None),
863        }
864    } else {
865        crate::highlight::literal_block(&blob.content, Some(&path), None)
866    };
867    let controls = controls
868        .map(|controls| format!("{controls}\n\n"))
869        .unwrap_or_default();
870    Ok(format!(
871        ">>\n{} / {} / {} / {}\n\n>{} `F666{} ({}, {})`f\n\n{}{}",
872        m_link("Node", PATH_INDEX, &[]),
873        m_link(&group, PATH_GROUP, &[("g", &group)]),
874        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
875        m_escape(&path),
876        m_escape(&path),
877        &resolved[..8],
878        if blob.binary { "Binary" } else { "Text" },
879        format_size(blob.size),
880        controls,
881        content
882    ))
883}
884
885#[derive(Debug, Clone, Copy)]
886enum RenderableBlob {
887    Markdown,
888    Micron,
889}
890
891fn renderable_blob(path: &str) -> Option<RenderableBlob> {
892    match Path::new(path)
893        .extension()
894        .and_then(|extension| extension.to_str())
895        .map(str::to_ascii_lowercase)
896        .as_deref()
897    {
898        Some("md") => Some(RenderableBlob::Markdown),
899        Some("mu") => Some(RenderableBlob::Micron),
900        _ => None,
901    }
902}
903
904fn render_by_default(path: &str) -> bool {
905    renderable_blob(path).is_some()
906}
907
908fn render_commits_page(
909    config: &ServerConfig,
910    access: &Access,
911    remote: Option<&[u8; 16]>,
912    vars: &BTreeMap<String, String>,
913) -> Result<String> {
914    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
915    let reference = var(vars, "ref").unwrap_or("HEAD");
916    let resolved = resolve_ref(&repository, reference)?;
917    let commits = commits(&repository, &resolved, 100)?;
918
919    let mut out = format!(
920        ">>\n{} / {} / {} / commits\n\n>Commits `F666{} ({})`f\n\n",
921        m_link("Node", PATH_INDEX, &[]),
922        m_link(&group, PATH_GROUP, &[("g", &group)]),
923        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
924        m_escape(reference),
925        &resolved[..8]
926    );
927    if commits.is_empty() {
928        out.push_str("No commits found.\n");
929    } else {
930        for commit in commits {
931            out.push_str(&format!(
932                "{} {} `F666{}`f\n{}\n\n",
933                m_link_raw(
934                    &commit.hash[..7],
935                    PATH_COMMIT,
936                    &[
937                        ("g", &group),
938                        ("r", &repo),
939                        ("ref", reference),
940                        ("h", &commit.hash),
941                    ],
942                ),
943                m_escape(&commit.author),
944                format_unix_time(commit.timestamp),
945                m_escape(&commit.subject)
946            ));
947        }
948    }
949    Ok(out)
950}
951
952fn render_commit_page(
953    config: &ServerConfig,
954    access: &Access,
955    remote: Option<&[u8; 16]>,
956    vars: &BTreeMap<String, String>,
957) -> Result<String> {
958    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
959    let reference = var(vars, "ref").unwrap_or("HEAD");
960    let _resolved = resolve_ref(&repository, reference)?;
961    let hash = required_var(vars, "h")?;
962    validate_refish(hash)?;
963    let commit = commit_info(&repository, hash)?;
964    let signature = commit_signature_status(&repository, hash)?;
965    let commits_link = m_link(
966        "commits",
967        PATH_COMMITS,
968        &[("g", &group), ("r", &repo), ("ref", reference)],
969    );
970    let mut out = format!(
971        ">>\n{} / {} / {} / {} / commit {}\n\n>Commit {}\n\n{}\nAuthor: {} <{}>\nDate: {}\n",
972        m_link("Node", PATH_INDEX, &[]),
973        m_link(&group, PATH_GROUP, &[("g", &group)]),
974        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
975        commits_link,
976        &commit.hash[..7],
977        commit.hash,
978        m_escape(&commit.subject),
979        m_escape(&commit.author),
980        m_escape(&commit.email),
981        format_unix_time(commit.timestamp)
982    );
983    if let Some(signature) = signature {
984        out.push_str(&format!("Signature: {}\n", m_escape(&signature)));
985    }
986    if !commit.body.is_empty() {
987        out.push_str(&format!("\n{}\n", format_commit_message(&commit.body)));
988    }
989    out.push_str("\n>Files changed\n\n");
990    for file in commit.files {
991        out.push_str(&format!("{} {}\n", file.status, m_escape(&file.path)));
992    }
993    Ok(out)
994}
995
996fn commit_signature_status(repo: &Path, hash: &str) -> Result<Option<String>> {
997    let commit = raw_commit_object(repo, hash)?;
998    let (Some(signature), signed_content) = crate::commitsigs::split_commit_signature(&commit)
999    else {
1000        return Ok(None);
1001    };
1002    let status = crate::commitsigs::verify_message_signature(&signature, &signed_content, None);
1003    if status.valid && status.author_match {
1004        Ok(Some("Valid, signed by author".into()))
1005    } else if status.valid {
1006        Ok(Some(status.message))
1007    } else {
1008        Ok(Some(format!("Invalid signature: {}", status.message)))
1009    }
1010}
1011
1012fn render_refs_page(
1013    config: &ServerConfig,
1014    access: &Access,
1015    remote: Option<&[u8; 16]>,
1016    vars: &BTreeMap<String, String>,
1017) -> Result<String> {
1018    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1019    let refs = git_refs(&repository)?;
1020    let mut out = format!(
1021        ">>\n{} / {} / {} / refs\n\n>Refs\n\n>Branches\n",
1022        m_link("Node", PATH_INDEX, &[]),
1023        m_link(&group, PATH_GROUP, &[("g", &group)]),
1024        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)])
1025    );
1026    for branch in refs.heads {
1027        out.push_str(&format!(
1028            "- {} `F666{}`f\n",
1029            m_link_raw(
1030                &branch.name,
1031                PATH_TREE,
1032                &[("g", &group), ("r", &repo), ("ref", &branch.name)]
1033            ),
1034            &branch.sha[..8]
1035        ));
1036    }
1037    out.push_str("\n>Tags\n");
1038    for tag in refs.tags {
1039        out.push_str(&format!(
1040            "- {} `F666{}`f\n",
1041            m_link_raw(
1042                &tag.name,
1043                PATH_TREE,
1044                &[("g", &group), ("r", &repo), ("ref", &tag.name)]
1045            ),
1046            &tag.sha[..8]
1047        ));
1048    }
1049    Ok(out)
1050}
1051
1052fn render_stats_page(
1053    config: &ServerConfig,
1054    access: &Access,
1055    remote: Option<&[u8; 16]>,
1056    vars: &BTreeMap<String, String>,
1057) -> Result<String> {
1058    let (group, repo, _repository) = accessible_repository(config, access, remote, vars)?;
1059    if !access.allows(Operation::Stats, &format!("{group}/{repo}"), remote)? {
1060        return Ok(">>\n>Error\n\nThe requested repository was not found.\n".to_string());
1061    }
1062    let Some(stats) = crate::stats::repository_stats(config, access, remote, &group, &repo, 90)?
1063    else {
1064        return Ok(
1065            ">>\n>Stats Unavailable\n\nCould not retrieve statistics for this repository.\n"
1066                .to_string(),
1067        );
1068    };
1069
1070    let mut out = format!(
1071        ">>\n{} / {} / {} / stats\n\n>Stats for {}\n\n",
1072        m_link("Node", PATH_INDEX, &[]),
1073        m_link(&group, PATH_GROUP, &[("g", &group)]),
1074        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1075        m_escape(&stats.repository)
1076    );
1077    out.push_str(&format!(
1078        "`FT{RCLR_FETCH}Fetches`f   : {:>5}  total `F666  today: {:>3}  peak: {:>3} `f\n",
1079        stats.fetches.total,
1080        stats.fetches.daily.last().copied().unwrap_or(0),
1081        stats.fetches.peak
1082    ));
1083    out.push_str(&format!(
1084        "`FT{RCLR_PUSH}Pushes`f    : {:>5}  total `F666  today: {:>3}  peak: {:>3} `f\n",
1085        stats.pushes.total,
1086        stats.pushes.daily.last().copied().unwrap_or(0),
1087        stats.pushes.peak
1088    ));
1089    out.push_str(&format!(
1090        "`FT{RCLR_VIEW}Views`f     : {:>5}  total `F666  today: {:>3}  peak: {:>3} `f\n",
1091        stats.views.total,
1092        stats.views.daily.last().copied().unwrap_or(0),
1093        stats.views.peak
1094    ));
1095    out.push_str(&format!(
1096        "`FT{RCLR_DOWNLOAD}Downloads`f : {:>5}  total `F666  today: {:>3}  peak: {:>3} `f\n",
1097        stats.downloads_combined.total,
1098        stats.downloads_combined.daily.last().copied().unwrap_or(0),
1099        stats.downloads_combined.peak
1100    ));
1101    out.push_str(&format!(
1102        "`F0aaActivity`f  : {:>5} points\n\n",
1103        stats.activity_score
1104    ));
1105    out.push_str(&format!(
1106        "{}{}`f over the last {} days ({})\n\n",
1107        stats.activity_level.color(),
1108        stats.activity_level.label(),
1109        stats.actual_days,
1110        stats.date_range
1111    ));
1112
1113    if stats.fetches.total > 0 {
1114        out.push_str(">Fetches\n\n");
1115        out.push_str(&render_chart(
1116            &stats.fetches.daily,
1117            &stats.timeline_labels,
1118            RCLR_FETCH,
1119            Some(RCLR_FETCH_G),
1120            1.3,
1121            10,
1122        ));
1123        out.push('\n');
1124    }
1125    if stats.pushes.total > 0 {
1126        out.push_str(">Pushes\n\n");
1127        out.push_str(&render_chart(
1128            &stats.pushes.daily,
1129            &stats.timeline_labels,
1130            RCLR_PUSH,
1131            Some(RCLR_PUSH_G),
1132            1.3,
1133            10,
1134        ));
1135        out.push('\n');
1136    }
1137    if stats.views.total > 0 {
1138        out.push_str(">Views\n\n");
1139        out.push_str(&render_chart(
1140            &stats.views.daily,
1141            &stats.timeline_labels,
1142            RCLR_VIEW,
1143            Some(RCLR_VIEW_G),
1144            1.3,
1145            10,
1146        ));
1147        out.push('\n');
1148    }
1149    if stats.downloads_combined.total > 0 {
1150        out.push_str(">Downloads\n\n");
1151        out.push_str(&render_chart(
1152            &stats.downloads_combined.daily,
1153            &stats.timeline_labels,
1154            RCLR_DOWNLOAD,
1155            Some(RCLR_DOWNLOAD_G),
1156            1.7,
1157            10,
1158        ));
1159        out.push('\n');
1160    }
1161    if stats.activity_score > 0 {
1162        out.push_str(">Combined Activity\n\n");
1163        out.push_str(&render_combined_chart(
1164            &stats.views.daily,
1165            &stats.fetches.daily,
1166            &stats.pushes.daily,
1167            &stats.downloads_combined.daily,
1168            &stats.timeline_labels,
1169            6,
1170        ));
1171    } else {
1172        out.push_str(
1173            "`*\nNo development activity recorded for this repository in the selected time period.\n\n`*",
1174        );
1175    }
1176    Ok(out)
1177}
1178
1179fn render_releases_page(
1180    config: &ServerConfig,
1181    access: &Access,
1182    remote: Option<&[u8; 16]>,
1183    vars: &BTreeMap<String, String>,
1184) -> Result<String> {
1185    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1186    let releases_path = crate::release::release_sidecar_path(&repository);
1187    let releases: Vec<_> = crate::release::list_releases(&releases_path)?
1188        .into_iter()
1189        .filter(|release| release.status == "published")
1190        .collect();
1191    let latest_release = crate::release::configured_latest_tag(&releases_path)?;
1192    let mut out = format!(
1193        ">>\n{} / {} / {} / releases\n\n>Releases ({})\n\n",
1194        m_link("Node", PATH_INDEX, &[]),
1195        m_link(&group, PATH_GROUP, &[("g", &group)]),
1196        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1197        releases.len()
1198    );
1199    if releases.is_empty() {
1200        out.push_str("No releases available for this repository.\n");
1201        return Ok(out);
1202    }
1203    for release in releases {
1204        let latest = if latest_release.as_deref() == Some(release.tag.as_str()) {
1205            format!(" {} `FT537855`*Latest`*`f", icon_sep(config))
1206        } else {
1207            String::new()
1208        };
1209        out.push_str(&format!(
1210            "{} `F666{} artifacts{}`f\n",
1211            m_link(
1212                &release.tag,
1213                PATH_RELEASE,
1214                &[("g", &group), ("r", &repo), ("tag", &release.tag)]
1215            ),
1216            release.artifacts,
1217            latest
1218        ));
1219        if !release.preview.is_empty() {
1220            append_release_preview(&mut out, &release.preview_format, &release.preview);
1221        }
1222        out.push('\n');
1223    }
1224    Ok(out)
1225}
1226
1227fn append_release_preview(out: &mut String, format: &str, preview: &str) {
1228    match format {
1229        "markdown" => out.push_str(&markdown_to_micron(preview)),
1230        "micron" => {
1231            out.push_str(preview);
1232            if !preview.ends_with('\n') {
1233                out.push('\n');
1234            }
1235        }
1236        _ => {
1237            out.push_str(&m_escape_line_start_controls(preview));
1238            out.push('\n');
1239        }
1240    }
1241}
1242
1243fn render_release_page(
1244    config: &ServerConfig,
1245    access: &Access,
1246    remote: Option<&[u8; 16]>,
1247    vars: &BTreeMap<String, String>,
1248) -> Result<String> {
1249    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1250    let releases_path = crate::release::release_sidecar_path(&repository);
1251    let requested_tag = var(vars, "tag").unwrap_or("latest");
1252    let tag = if requested_tag == "latest" {
1253        let Some(tag) = crate::release::latest_published_tag(&releases_path)? else {
1254            return Ok(">Release Not Found\n\nNo latest release exists.\n".to_string());
1255        };
1256        tag
1257    } else {
1258        requested_tag.to_string()
1259    };
1260    let Some(release) = crate::release::release_data(&releases_path, &tag)? else {
1261        return Ok(">Release Not Found\n\nThe requested release was not found.\n".to_string());
1262    };
1263    if release.status != "published" {
1264        return Ok(">Release Not Found\n\nThe requested release was not found.\n".to_string());
1265    }
1266    let release_dir = releases_path.join(&release.tag);
1267    let thanks = crate::release::release_thanks(&release_dir, var(vars, "thanks").is_some())?;
1268    let mut out = format!(
1269        ">>\n{} / {} / {} / {} / {}\n\n>Release {}\n\n",
1270        m_link("Node", PATH_INDEX, &[]),
1271        m_link(&group, PATH_GROUP, &[("g", &group)]),
1272        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1273        m_link("releases", PATH_RELEASES, &[("g", &group), ("r", &repo)]),
1274        m_escape(&release.tag),
1275        m_escape(&release.tag)
1276    );
1277    out.push_str(&format!(
1278        "{}\n\n",
1279        m_link(
1280            &icon_label(config, "heart", &format!("Thanks ({thanks})")),
1281            PATH_RELEASE,
1282            &[
1283                ("g", &group),
1284                ("r", &repo),
1285                ("tag", &release.tag),
1286                ("thanks", "y")
1287            ]
1288        )
1289    ));
1290    if !release.notes.is_empty() {
1291        match release.notes_format.as_str() {
1292            "markdown" => out.push_str(&markdown_to_micron(&release.notes)),
1293            "micron" => out.push_str(&release.notes),
1294            _ => out.push_str(&m_escape(&release.notes)),
1295        }
1296        if !out.ends_with('\n') {
1297            out.push('\n');
1298        }
1299        out.push('\n');
1300    }
1301    out.push_str(">Artifacts\n\n");
1302    if release.artifacts.is_empty() {
1303        out.push_str("`*No artifacts for this release`*\n");
1304    } else {
1305        for artifact in release.artifacts {
1306            out.push_str(&format!(
1307                "{} ({})\n",
1308                m_link(
1309                    &artifact.name,
1310                    PATH_DOWNLOAD,
1311                    &[
1312                        ("g", &group),
1313                        ("r", &repo),
1314                        ("tag", &release.tag),
1315                        ("artifact", &artifact.name)
1316                    ]
1317                ),
1318                format_size(artifact.size)
1319            ));
1320        }
1321    }
1322    Ok(out)
1323}
1324
1325fn render_work_page(
1326    config: &ServerConfig,
1327    access: &Access,
1328    remote: Option<&[u8; 16]>,
1329    vars: &BTreeMap<String, String>,
1330) -> Result<String> {
1331    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1332    let scope = var(vars, "scope").unwrap_or("active");
1333    let list_scope =
1334        crate::work::WorkListScope::parse(scope).ok_or_else(|| Error::msg("invalid scope"))?;
1335    let work_path = crate::work::work_sidecar_path(&repository);
1336    let mut lists = crate::work::list_documents(&work_path, crate::work::WorkListScope::All)?;
1337    retain_readable_work_documents(&work_path, &mut lists.active)?;
1338    retain_readable_work_documents(&work_path, &mut lists.completed)?;
1339    retain_readable_work_documents(&work_path, &mut lists.proposed)?;
1340    let total = lists.active.len() + lists.completed.len() + lists.proposed.len();
1341    let mut out = format!(
1342        ">>\n{} / {} / {} / work\n\n>Work Documents\n\n",
1343        m_link("Node", PATH_INDEX, &[]),
1344        m_link(&group, PATH_GROUP, &[("g", &group)]),
1345        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)])
1346    );
1347    let tabs = [
1348        m_link_raw(
1349            &format!("Active ({})", lists.active.len()),
1350            PATH_WORK,
1351            &[("g", &group), ("r", &repo), ("scope", "active")],
1352        ),
1353        m_link_raw(
1354            &format!("Completed ({})", lists.completed.len()),
1355            PATH_WORK,
1356            &[("g", &group), ("r", &repo), ("scope", "completed")],
1357        ),
1358        m_link_raw(
1359            &format!("Proposed ({})", lists.proposed.len()),
1360            PATH_WORK,
1361            &[("g", &group), ("r", &repo), ("scope", "proposed")],
1362        ),
1363        m_link_raw(
1364            &format!("All ({total})"),
1365            PATH_WORK,
1366            &[("g", &group), ("r", &repo), ("scope", "all")],
1367        ),
1368    ];
1369    out.push_str(&format!(
1370        "{}\n\n",
1371        tabs.join(&format!(" {} ", icon_sep(config)))
1372    ));
1373    if matches!(
1374        list_scope,
1375        crate::work::WorkListScope::Active | crate::work::WorkListScope::All
1376    ) {
1377        append_work_section(
1378            &mut out,
1379            &group,
1380            &repo,
1381            crate::work::WorkScope::Active,
1382            &lists.active,
1383        );
1384    }
1385    if matches!(
1386        list_scope,
1387        crate::work::WorkListScope::Completed | crate::work::WorkListScope::All
1388    ) {
1389        append_work_section(
1390            &mut out,
1391            &group,
1392            &repo,
1393            crate::work::WorkScope::Completed,
1394            &lists.completed,
1395        );
1396    }
1397    if matches!(
1398        list_scope,
1399        crate::work::WorkListScope::Proposed | crate::work::WorkListScope::All
1400    ) {
1401        append_work_section(
1402            &mut out,
1403            &group,
1404            &repo,
1405            crate::work::WorkScope::Proposed,
1406            &lists.proposed,
1407        );
1408    }
1409    Ok(out)
1410}
1411
1412fn retain_readable_work_documents(
1413    work_path: &Path,
1414    docs: &mut Vec<crate::work::WorkSummary>,
1415) -> Result<()> {
1416    let mut readable = Vec::with_capacity(docs.len());
1417    for doc in docs.drain(..) {
1418        if !crate::work::document_permission_denies(work_path, doc.id, Operation::Read)? {
1419            readable.push(doc);
1420        }
1421    }
1422    *docs = readable;
1423    Ok(())
1424}
1425
1426fn append_work_section(
1427    out: &mut String,
1428    group: &str,
1429    repo: &str,
1430    scope: crate::work::WorkScope,
1431    docs: &[crate::work::WorkSummary],
1432) {
1433    let title = match scope {
1434        crate::work::WorkScope::Active => "Active Work Documents",
1435        crate::work::WorkScope::Completed => "Completed Work Documents",
1436        crate::work::WorkScope::Proposed => "Proposed Work Documents",
1437    };
1438    out.push_str(&format!(">{title}\n\n"));
1439    if docs.is_empty() {
1440        out.push_str("No work documents found.\n\n");
1441        return;
1442    }
1443    for doc in docs {
1444        let doc_id = doc.id.to_string();
1445        out.push_str(&format!(
1446            "{} `F666{} updates`f\n",
1447            m_link(
1448                &format!("#{} {}", doc.id, doc.title),
1449                PATH_WORK_DOC,
1450                &[
1451                    ("g", group),
1452                    ("r", repo),
1453                    ("scope", scope.as_str()),
1454                    ("id", &doc_id)
1455                ]
1456            ),
1457            doc.comments
1458        ));
1459        out.push_str(&format!(
1460            "`F666{} by {}`f\n\n",
1461            format_unix_time(doc.created as i64),
1462            m_escape(&doc.author)
1463        ));
1464    }
1465}
1466
1467fn render_work_doc_page(
1468    config: &ServerConfig,
1469    access: &Access,
1470    remote: Option<&[u8; 16]>,
1471    vars: &BTreeMap<String, String>,
1472) -> Result<String> {
1473    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1474    let id = required_var(vars, "id")?
1475        .parse::<u64>()
1476        .map_err(|_| Error::msg("invalid work document ID"))?;
1477    let work_path = crate::work::work_sidecar_path(&repository);
1478    let (scope, document) =
1479        resolve_work_document(&work_path, var(vars, "scope").unwrap_or("all"), id)?;
1480    let Some(document) = document else {
1481        return Ok(
1482            ">Work Document Not Found\n\nThe requested work document was not found.\n".into(),
1483        );
1484    };
1485    if crate::work::document_permission_denies(&work_path, id, Operation::Read)? {
1486        return Ok(
1487            ">Work Document Not Found\n\nThe requested work document was not found.\n".into(),
1488        );
1489    }
1490    let mut out = format!(
1491        ">>\n{} / {} / {} / {} / #{}\n\n>{}\n\n",
1492        m_link("Node", PATH_INDEX, &[]),
1493        m_link(&group, PATH_GROUP, &[("g", &group)]),
1494        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1495        m_link(
1496            "work",
1497            PATH_WORK,
1498            &[("g", &group), ("r", &repo), ("scope", scope.as_str())]
1499        ),
1500        document.id,
1501        m_escape(&document.title)
1502    );
1503    out.push_str(&format!(
1504        "`F666Status: {} | Author: {} | Signature: {} | Created: {} | Edited: {}`f\n\n",
1505        scope.as_str(),
1506        m_escape(&document.author),
1507        work_signature_status(&document),
1508        format_unix_time(document.created as i64),
1509        format_unix_time(document.edited as i64)
1510    ));
1511    let doc_id = document.id.to_string();
1512    out.push_str(&format!(
1513        "{}\n\n",
1514        m_link(
1515            "Download",
1516            PATH_WORK_DOC_DOWNLOAD,
1517            &[
1518                ("g", &group),
1519                ("r", &repo),
1520                ("scope", scope.as_str()),
1521                ("id", &doc_id)
1522            ]
1523        )
1524    ));
1525    append_formatted_content(&mut out, &document.format, &document.content);
1526    out.push_str(&format!("\n>Updates ({})\n\n", document.comments.len()));
1527    if document.comments.is_empty() {
1528        out.push_str("No updates for this work document.\n");
1529    } else {
1530        for comment in document.comments {
1531            out.push_str(&format!(
1532                ">#{} by {} at {}\n\n",
1533                comment.id,
1534                m_escape(&comment.author),
1535                format_unix_time(comment.created as i64)
1536            ));
1537            append_formatted_content(&mut out, &comment.format, &comment.content);
1538            out.push('\n');
1539        }
1540    }
1541    Ok(out)
1542}
1543
1544fn work_signature_status(document: &crate::work::WorkDocument) -> &'static str {
1545    let Some(signature) = document.signature.as_deref() else {
1546        return "Document not signed";
1547    };
1548    if signature.len() != 64 {
1549        return "Not valid";
1550    }
1551    let Ok(signature) = <&[u8; 64]>::try_from(signature) else {
1552        return "Not valid";
1553    };
1554    let Some(identity_bytes) = document.identity.as_deref() else {
1555        return "Not valid";
1556    };
1557    let Ok(public_key) = <[u8; 64]>::try_from(identity_bytes) else {
1558        return "Not valid";
1559    };
1560    let Ok(identity) = Identity::from_public_key(&public_key) else {
1561        return "Not valid";
1562    };
1563    if identity.verify(signature, document.content.as_bytes()) {
1564        "Valid"
1565    } else {
1566        "Not valid"
1567    }
1568}
1569
1570fn resolve_work_document(
1571    work_path: &Path,
1572    requested_scope: &str,
1573    id: u64,
1574) -> Result<(crate::work::WorkScope, Option<crate::work::WorkDocument>)> {
1575    let requested_scope = if matches!(requested_scope, "active" | "completed" | "proposed" | "all")
1576    {
1577        requested_scope
1578    } else {
1579        "active"
1580    };
1581    match requested_scope {
1582        "active" | "completed" | "proposed" => {
1583            let scope = crate::work::WorkScope::parse(requested_scope).unwrap();
1584            Ok((scope, crate::work::view_document(work_path, scope, id)?))
1585        }
1586        "all" => {
1587            if let Some(document) =
1588                crate::work::view_document(work_path, crate::work::WorkScope::Active, id)?
1589            {
1590                Ok((crate::work::WorkScope::Active, Some(document)))
1591            } else if let Some(document) =
1592                crate::work::view_document(work_path, crate::work::WorkScope::Completed, id)?
1593            {
1594                Ok((crate::work::WorkScope::Completed, Some(document)))
1595            } else {
1596                Ok((
1597                    crate::work::WorkScope::Proposed,
1598                    crate::work::view_document(work_path, crate::work::WorkScope::Proposed, id)?,
1599                ))
1600            }
1601        }
1602        _ => unreachable!(),
1603    }
1604}
1605
1606fn append_formatted_content(out: &mut String, format: &str, content: &str) {
1607    match format {
1608        "micron" => out.push_str(content),
1609        "markdown" => out.push_str(&markdown_to_micron(content)),
1610        _ => out.push_str(&m_escape(content)),
1611    }
1612    if !out.ends_with('\n') {
1613        out.push('\n');
1614    }
1615}
1616
1617pub fn download_work_document(
1618    config: &ServerConfig,
1619    access: &Access,
1620    data: &[u8],
1621    remote: Option<&[u8; 16]>,
1622) -> Result<RequestResponse> {
1623    let vars = parse_page_vars(data)?;
1624    let (group, repo, repository) = match accessible_repository(config, access, remote, &vars) {
1625        Ok(repo) => repo,
1626        Err(_) => {
1627            return Ok(RequestResponse::Bytes(protocol::status_bytes(
1628                protocol::RES_NOT_FOUND,
1629                b"repository not found",
1630            )));
1631        }
1632    };
1633    let id = required_var(&vars, "id")?
1634        .parse::<u64>()
1635        .map_err(|_| Error::msg("invalid work document ID"))?;
1636    let work_path = crate::work::work_sidecar_path(&repository);
1637    let Some(document) =
1638        resolve_work_document(&work_path, var(&vars, "scope").unwrap_or("all"), id)?.1
1639    else {
1640        return Ok(RequestResponse::Bytes(protocol::status_bytes(
1641            protocol::RES_NOT_FOUND,
1642            b"work document not found",
1643        )));
1644    };
1645    if crate::work::document_permission_denies(&work_path, id, Operation::Read)? {
1646        return Ok(RequestResponse::Bytes(protocol::status_bytes(
1647            protocol::RES_NOT_FOUND,
1648            b"work document not found",
1649        )));
1650    }
1651    crate::stats::record_download(config, &format!("{group}/{repo}"), remote);
1652    Ok(RequestResponse::Resource {
1653        data: document.content.into_bytes(),
1654        metadata: Some(protocol::metadata_status(protocol::RES_OK)),
1655        auto_compress: true,
1656    })
1657}
1658
1659fn is_image_path(path: &str) -> bool {
1660    Path::new(path)
1661        .extension()
1662        .and_then(|ext| ext.to_str())
1663        .is_some_and(|ext| {
1664            matches!(
1665                ext.to_ascii_lowercase().as_str(),
1666                "webp" | "png" | "jpg" | "jpeg" | "gif" | "tiff" | "tif" | "bmp"
1667            )
1668        })
1669}
1670
1671fn quote_media_path(path: &str) -> String {
1672    let mut out = String::new();
1673    for byte in path.bytes() {
1674        match byte {
1675            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'.' | b'-' | b'~' => {
1676                out.push(byte as char)
1677            }
1678            b' ' => out.push('+'),
1679            _ => out.push_str(&format!("%{byte:02X}")),
1680        }
1681    }
1682    out
1683}
1684
1685/// Nomad Network media requests carry a presence-only key and an encoded blob
1686/// path. The repository ACL is the authority, just as for ordinary downloads.
1687pub fn serve_media(
1688    config: &ServerConfig,
1689    access: &Access,
1690    data: &[u8],
1691    remote: Option<&[u8; 16]>,
1692) -> Result<RequestResponse> {
1693    let rejected = || RequestResponse::Bytes(msgpack::pack(&Value::Bool(false)));
1694    let Ok(Value::Map(fields)) = msgpack::unpack_exact(data) else {
1695        return Ok(rejected());
1696    };
1697    if !fields.iter().any(|(key, _)| key.as_str() == Some("key")) {
1698        return Ok(rejected());
1699    }
1700    let Some(path) = fields
1701        .iter()
1702        .find(|(key, _)| key.as_str() == Some("path"))
1703        .and_then(|(_, value)| value.as_str())
1704    else {
1705        return Ok(rejected());
1706    };
1707    let components: Vec<_> = path
1708        .strip_prefix("/media")
1709        .unwrap_or(path)
1710        .trim_start_matches('/')
1711        .splitn(4, '/')
1712        .collect();
1713    if components.len() != 4 {
1714        return Ok(rejected());
1715    }
1716    let vars = BTreeMap::from([
1717        ("g".into(), components[0].into()),
1718        ("r".into(), components[1].into()),
1719    ]);
1720    let Ok((_, _, repository)) = accessible_repository(config, access, remote, &vars) else {
1721        return Ok(rejected());
1722    };
1723    let path = decode_url_component(components[3]);
1724    if path.is_empty() || validate_git_path(&path).is_err() {
1725        return Ok(rejected());
1726    }
1727    let Ok(resolved) = resolve_ref(&repository, components[2]) else {
1728        return Ok(rejected());
1729    };
1730    if !matches!(
1731        git_object_kind(&repository, &resolved, &path).as_deref(),
1732        Ok("blob")
1733    ) {
1734        return Ok(rejected());
1735    }
1736    let output = run_git_output(
1737        Command::new("git")
1738            .arg("--git-dir")
1739            .arg(&repository)
1740            .arg("show")
1741            .arg(format!("{resolved}:{path}")),
1742        GIT_COMMAND_TIMEOUT,
1743    )?;
1744    if !output.status.success() {
1745        return Ok(rejected());
1746    }
1747    let name = path.rsplit('/').next().unwrap_or(&path);
1748    let (data, name) = media_response_data(config.media_conversion, &path, name, output.stdout);
1749    Ok(RequestResponse::File {
1750        data,
1751        metadata: msgpack::pack(&Value::Map(vec![(
1752            Value::Str("name".into()),
1753            Value::Bin(name.as_bytes().to_vec()),
1754        )])),
1755        auto_compress: false,
1756    })
1757}
1758
1759fn media_response_data(
1760    enabled: bool,
1761    path: &str,
1762    name: &str,
1763    original: Vec<u8>,
1764) -> (Vec<u8>, String) {
1765    if enabled && is_image_path(path) && !path.to_ascii_lowercase().ends_with(".webp") {
1766        if let Some(converted) = crate::media::convert_to_webp(&original) {
1767            let stem = name.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(name);
1768            return (converted, format!("{stem}.webp"));
1769        }
1770    }
1771    (original, name.to_string())
1772}
1773
1774pub fn download_file(
1775    config: &ServerConfig,
1776    access: &Access,
1777    data: &[u8],
1778    remote: Option<&[u8; 16]>,
1779) -> Result<RequestResponse> {
1780    let vars = parse_page_vars(data)?;
1781    let (group, repo, repository) = match accessible_repository(config, access, remote, &vars) {
1782        Ok(repo) => repo,
1783        Err(_) => {
1784            return Ok(RequestResponse::Bytes(protocol::status_bytes(
1785                protocol::RES_NOT_FOUND,
1786                b"repository not found",
1787            )));
1788        }
1789    };
1790    if let Some(artifact) = var(&vars, "artifact") {
1791        let artifact = decode_url_component(artifact);
1792        let tag = var(&vars, "tag").unwrap_or("latest");
1793        let releases_path = crate::release::release_sidecar_path(&repository);
1794        let Some(path) = crate::release::artifact_path(&releases_path, tag, &artifact)? else {
1795            return Ok(RequestResponse::Bytes(protocol::status_bytes(
1796                protocol::RES_NOT_FOUND,
1797                b"file not found",
1798            )));
1799        };
1800        let data = fs::read(path)?;
1801        crate::stats::record_release_download(config, &format!("{group}/{repo}"), remote);
1802        return Ok(RequestResponse::Resource {
1803            data,
1804            metadata: Some(protocol::metadata_status(protocol::RES_OK)),
1805            auto_compress: true,
1806        });
1807    }
1808
1809    let reference = var(&vars, "ref").unwrap_or("HEAD");
1810    let path = required_var(&vars, "path")?;
1811    let format = var(&vars, "fmt");
1812    if format.is_some() && (format != Some("mu") || !path.to_ascii_lowercase().ends_with(".md")) {
1813        return Ok(RequestResponse::Bytes(protocol::status_bytes(
1814            protocol::RES_NOT_FOUND,
1815            b"unsupported conversion",
1816        )));
1817    }
1818    validate_git_path(path)?;
1819    let resolved = resolve_ref(&repository, reference)?;
1820    let spec = format!("{resolved}:{path}");
1821    let output = run_git_output(
1822        Command::new("git")
1823            .arg("--git-dir")
1824            .arg(&repository)
1825            .arg("show")
1826            .arg(spec),
1827        GIT_COMMAND_TIMEOUT,
1828    )?;
1829    if !output.status.success() {
1830        return Ok(RequestResponse::Bytes(protocol::status_bytes(
1831            protocol::RES_NOT_FOUND,
1832            b"file not found",
1833        )));
1834    }
1835    let (data, metadata) = if format == Some("mu") {
1836        let markdown = String::from_utf8(output.stdout)
1837            .map_err(|_| Error::msg("markdown download is not UTF-8"))?;
1838        let converted = markdown_to_micron_scoped(
1839            &markdown,
1840            Some(&markdown_blob_url_scope(&group, &repo, reference, path)),
1841        );
1842        let filename = path.rsplit('/').next().unwrap_or(path);
1843        let stem = filename
1844            .rsplit_once('.')
1845            .map(|(stem, _)| stem)
1846            .unwrap_or(filename);
1847        (
1848            converted.trim_end().as_bytes().to_vec(),
1849            msgpack::pack(&Value::Map(vec![
1850                (
1851                    Value::UInt(protocol::IDX_RESULT_CODE),
1852                    Value::UInt(protocol::RES_OK as u64),
1853                ),
1854                (
1855                    Value::Str("name".into()),
1856                    Value::Bin(format!("{stem}.mu").into_bytes()),
1857                ),
1858            ])),
1859        )
1860    } else {
1861        (output.stdout, protocol::metadata_status(protocol::RES_OK))
1862    };
1863    crate::stats::record_download(config, &format!("{group}/{repo}"), remote);
1864    Ok(RequestResponse::Resource {
1865        data,
1866        metadata: Some(metadata),
1867        auto_compress: true,
1868    })
1869}
1870
1871fn render_chart(
1872    data: &[u64],
1873    labels: &[String; 2],
1874    color: &str,
1875    secondary_color: Option<&str>,
1876    gradient_factor: f64,
1877    height: u64,
1878) -> String {
1879    if data.is_empty() || data.iter().all(|value| *value == 0) {
1880        return "No data available\n".to_string();
1881    }
1882    let max = data.iter().copied().max().unwrap_or(1).max(1) as f64;
1883    let primary = expand_chart_color(color);
1884    let secondary = secondary_color
1885        .map(expand_chart_color)
1886        .unwrap_or_else(|| dim_chart_color(&primary));
1887    let mut out = format!("`FT{primary}Peak: {}`f\n", max as u64);
1888    for row in (1..=height).rev() {
1889        let row_top = row as f64 / height as f64 * max;
1890        let row_mid = (row as f64 - 0.5) / height as f64 * max;
1891        let grad_top = row as f64 / height as f64;
1892        let grad_mid = (row as f64 - 0.5) / height as f64;
1893        let top_color = gradient_chart_color(&primary, &secondary, grad_top, gradient_factor);
1894        let mid_color = gradient_chart_color(&primary, &secondary, grad_mid, gradient_factor);
1895        out.push('│');
1896        for value in data {
1897            let value = *value as f64;
1898            if value >= row_top {
1899                out.push_str(&format!("`FT{top_color}`BT{mid_color}▀`f`b"));
1900            } else if value >= row_mid || (row == 1 && value > 0.0) {
1901                out.push_str(&format!("`FT{mid_color}▄`f"));
1902            } else {
1903                out.push(' ');
1904            }
1905        }
1906        out.push('\n');
1907    }
1908    out.push('└');
1909    for _ in data {
1910        out.push('─');
1911    }
1912    out.push_str("┘\n");
1913    out.push_str(&format!("`F666{:<12}`f", labels[0]));
1914    let chart_width = data.len() + 2;
1915    let spacing = chart_width.saturating_sub(24);
1916    out.push_str(&" ".repeat(spacing));
1917    out.push_str(&format!("`F666{:>12}`f\n", labels[1]));
1918    out
1919}
1920
1921fn expand_chart_color(color: &str) -> String {
1922    let color = color.trim_start_matches('#');
1923    if color.len() == 3 {
1924        color.chars().flat_map(|ch| [ch, ch]).collect()
1925    } else {
1926        color.chars().take(6).collect()
1927    }
1928}
1929
1930fn dim_chart_color(primary: &str) -> String {
1931    let rgb = chart_rgb(primary);
1932    format!(
1933        "{:02x}{:02x}{:02x}",
1934        (rgb[0] as f64 * 0.42) as u8,
1935        (rgb[1] as f64 * 0.42) as u8,
1936        (rgb[2] as f64 * 0.42) as u8
1937    )
1938}
1939
1940fn gradient_chart_color(primary: &str, secondary: &str, t: f64, factor: f64) -> String {
1941    let primary = chart_rgb(primary);
1942    let secondary = chart_rgb(secondary);
1943    let t = (t * factor).min(1.0);
1944    format!(
1945        "{:02x}{:02x}{:02x}",
1946        (secondary[0] as f64 + (primary[0] as f64 - secondary[0] as f64) * t) as u8,
1947        (secondary[1] as f64 + (primary[1] as f64 - secondary[1] as f64) * t) as u8,
1948        (secondary[2] as f64 + (primary[2] as f64 - secondary[2] as f64) * t) as u8
1949    )
1950}
1951
1952fn chart_rgb(color: &str) -> [u8; 3] {
1953    let color = expand_chart_color(color);
1954    [
1955        u8::from_str_radix(color.get(0..2).unwrap_or("00"), 16).unwrap_or(0),
1956        u8::from_str_radix(color.get(2..4).unwrap_or("00"), 16).unwrap_or(0),
1957        u8::from_str_radix(color.get(4..6).unwrap_or("00"), 16).unwrap_or(0),
1958    ]
1959}
1960
1961fn render_combined_chart(
1962    views: &[u64],
1963    fetches: &[u64],
1964    pushes: &[u64],
1965    downloads: &[u64],
1966    labels: &[String; 2],
1967    height: u64,
1968) -> String {
1969    if views.is_empty() || fetches.is_empty() || pushes.is_empty() || downloads.is_empty() {
1970        return "No data available\n".to_string();
1971    }
1972
1973    let categories = [
1974        ("Pushes", dim_to_black(RCLR_PUSH, 0.87), pushes),
1975        ("Fetches", dim_to_black(RCLR_FETCH, 0.87), fetches),
1976        ("Views", dim_to_black(RCLR_VIEW, 0.87), views),
1977        ("Downloads", dim_to_black(RCLR_DOWNLOAD, 0.87), downloads),
1978    ];
1979    let mut out = categories
1980        .iter()
1981        .map(|(label, color, _)| format!("`FT{color}`BT{color}██`f`b {label}"))
1982        .collect::<Vec<_>>()
1983        .join("  ");
1984    out.push_str("\n\n");
1985    let num_points = views.len();
1986
1987    for row in (1..=height).rev() {
1988        let lower_min = (row as f64 - 1.0) / height as f64;
1989        let lower_max = (row as f64 - 0.5) / height as f64;
1990        let upper_min = (row as f64 - 0.5) / height as f64;
1991        let upper_max = row as f64 / height as f64;
1992        out.push('│');
1993        for index in 0..num_points {
1994            let total: u64 = categories
1995                .iter()
1996                .map(|(_, _, data)| data.get(index).copied().unwrap_or(0))
1997                .sum();
1998            if total == 0 {
1999                out.push(' ');
2000                continue;
2001            }
2002
2003            let upper_cat = stacked_pixel_category(&categories, index, total, upper_min, upper_max);
2004            let lower_cat = stacked_pixel_category(&categories, index, total, lower_min, lower_max);
2005            match (upper_cat, lower_cat) {
2006                (Some(upper), Some(lower)) if upper == lower => {
2007                    let color = &categories[upper].1;
2008                    out.push_str(&format!("`FT{color}`BT{color}█`f`b"));
2009                }
2010                (Some(upper), Some(lower)) => {
2011                    let upper_color = &categories[upper].1;
2012                    let lower_color = &categories[lower].1;
2013                    out.push_str(&format!("`FT{upper_color}`BT{lower_color}▀`f`b"));
2014                }
2015                (Some(upper), None) => {
2016                    let color = &categories[upper].1;
2017                    out.push_str(&format!("`FT{color}▀`f"));
2018                }
2019                (None, Some(lower)) => {
2020                    let color = &categories[lower].1;
2021                    out.push_str(&format!("`FT{color}▄`f"));
2022                }
2023                (None, None) => out.push(' '),
2024            }
2025        }
2026        out.push('\n');
2027    }
2028    out.push('└');
2029    for _ in 0..num_points {
2030        out.push('─');
2031    }
2032    out.push_str("┘\n");
2033    out.push_str(&format!(
2034        "`F666{:<12}`f",
2035        labels[0].chars().take(12).collect::<String>()
2036    ));
2037    let chart_width = num_points + 2;
2038    let spacing = chart_width.saturating_sub(24);
2039    out.push_str(&" ".repeat(spacing));
2040    out.push_str(&format!(
2041        "`F666{:>12}`f\n",
2042        labels[1].chars().take(12).collect::<String>()
2043    ));
2044    out
2045}
2046
2047fn stacked_pixel_category(
2048    categories: &[(&str, String, &[u64])],
2049    index: usize,
2050    total: u64,
2051    pixel_min: f64,
2052    pixel_max: f64,
2053) -> Option<usize> {
2054    let mut cumulative = 0u64;
2055    for (category_index, (_, _, data)) in categories.iter().enumerate() {
2056        let value = data.get(index).copied().unwrap_or(0);
2057        if value == 0 {
2058            continue;
2059        }
2060        let start = cumulative as f64 / total as f64;
2061        cumulative += value;
2062        let end = cumulative as f64 / total as f64;
2063        if pixel_min < end && pixel_max > start {
2064            return Some(category_index);
2065        }
2066    }
2067    None
2068}
2069
2070fn dim_to_black(color: &str, dim: f64) -> String {
2071    gradient_chart_color(&expand_chart_color(color), "000000", dim, 1.0)
2072}
2073
2074fn accessible_groups(
2075    config: &ServerConfig,
2076    access: &Access,
2077    remote: Option<&[u8; 16]>,
2078) -> Result<BTreeMap<String, Vec<String>>> {
2079    let mut groups = BTreeMap::new();
2080    if !config.repositories_dir.exists() {
2081        return Ok(groups);
2082    }
2083    for group_entry in fs::read_dir(&config.repositories_dir)? {
2084        let group_entry = group_entry?;
2085        if !group_entry.file_type()?.is_dir() {
2086            continue;
2087        }
2088        let group = group_entry.file_name().to_string_lossy().to_string();
2089        if validate_group_name(&group).is_err() {
2090            continue;
2091        }
2092        let repos = accessible_repositories(config, access, remote, &group)?;
2093        if !repos.is_empty() {
2094            groups.insert(group, repos);
2095        }
2096    }
2097    Ok(groups)
2098}
2099
2100fn accessible_repositories(
2101    config: &ServerConfig,
2102    access: &Access,
2103    remote: Option<&[u8; 16]>,
2104    group: &str,
2105) -> Result<Vec<String>> {
2106    validate_group_name(group)?;
2107    let group_dir = config.repositories_dir.join(group);
2108    let mut repos = Vec::new();
2109    if !group_dir.exists() {
2110        return Ok(repos);
2111    }
2112    for repo_entry in fs::read_dir(group_dir)? {
2113        let repo_entry = repo_entry?;
2114        if !repo_entry.file_type()?.is_dir() {
2115            continue;
2116        }
2117        let repo = repo_entry.file_name().to_string_lossy().to_string();
2118        let Ok(name) = repository_name(group, &repo) else {
2119            continue;
2120        };
2121        if crate::git::is_bare_repository(&config.repositories_dir.join(&name))
2122            && access.allows(Operation::Read, &name, remote)?
2123        {
2124            repos.push(repo);
2125        }
2126    }
2127    repos.sort();
2128    Ok(repos)
2129}
2130
2131fn accessible_repository(
2132    config: &ServerConfig,
2133    access: &Access,
2134    remote: Option<&[u8; 16]>,
2135    vars: &BTreeMap<String, String>,
2136) -> Result<(String, String, PathBuf)> {
2137    let group = required_var(vars, "g")?;
2138    let repo = required_var(vars, "r")?;
2139    let name = repository_name(group, repo)?;
2140    let path = config.repositories_dir.join(&name);
2141    if !crate::git::is_bare_repository(&path) || !access.allows(Operation::Read, &name, remote)? {
2142        return Err(Error::msg("repository not found"));
2143    }
2144    Ok((group.to_string(), repo.to_string(), path))
2145}
2146
2147fn required_var<'a>(vars: &'a BTreeMap<String, String>, key: &str) -> Result<&'a str> {
2148    var(vars, key).ok_or_else(|| Error::msg(format!("missing page variable {key}")))
2149}
2150
2151fn decode_url_component(value: &str) -> String {
2152    let bytes = value.as_bytes();
2153    let mut out = Vec::with_capacity(bytes.len());
2154    let mut i = 0;
2155    while i < bytes.len() {
2156        match bytes[i] {
2157            b'+' => {
2158                out.push(b' ');
2159                i += 1;
2160            }
2161            b'%' if i + 2 < bytes.len() => {
2162                if let (Some(high), Some(low)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2]))
2163                {
2164                    out.push((high << 4) | low);
2165                    i += 3;
2166                } else {
2167                    out.push(bytes[i]);
2168                    i += 1;
2169                }
2170            }
2171            byte => {
2172                out.push(byte);
2173                i += 1;
2174            }
2175        }
2176    }
2177    String::from_utf8_lossy(&out).into_owned()
2178}
2179
2180fn hex_value(byte: u8) -> Option<u8> {
2181    match byte {
2182        b'0'..=b'9' => Some(byte - b'0'),
2183        b'a'..=b'f' => Some(byte - b'a' + 10),
2184        b'A'..=b'F' => Some(byte - b'A' + 10),
2185        _ => None,
2186    }
2187}
2188
2189fn var<'a>(vars: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {
2190    vars.get(&format!("var_{key}"))
2191        .or_else(|| vars.get(key))
2192        .map(String::as_str)
2193}
2194
2195fn repository_name(group: &str, repo: &str) -> Result<String> {
2196    validate_group_name(group)?;
2197    validate_repo_component(repo)?;
2198    let name = format!("{group}/{repo}");
2199    validate_repo_name(&name)?;
2200    Ok(name)
2201}
2202
2203fn validate_group_name(group: &str) -> Result<()> {
2204    validate_repo_component(group)
2205}
2206
2207fn validate_repo_component(component: &str) -> Result<()> {
2208    if component.is_empty()
2209        || component == "."
2210        || component == ".."
2211        || component.contains('/')
2212        || component.contains('\\')
2213        || component.len() > 256
2214    {
2215        Err(Error::msg("invalid repository name"))
2216    } else {
2217        Ok(())
2218    }
2219}
2220
2221fn validate_git_path(path: &str) -> Result<()> {
2222    if path.starts_with('/') || path.contains('\\') {
2223        return Err(Error::msg("invalid repository path"));
2224    }
2225    for component in path.split('/') {
2226        if component == "." || component == ".." {
2227            return Err(Error::msg("invalid repository path"));
2228        }
2229    }
2230    Ok(())
2231}
2232
2233fn normalize_blob_path(path: &str) -> String {
2234    path.strip_prefix("./").unwrap_or(path).replace("/./", "/")
2235}
2236
2237fn validate_refish(value: &str) -> Result<()> {
2238    if value.is_empty()
2239        || value.starts_with('-')
2240        || value.contains("..")
2241        || value.contains('\\')
2242        || value.contains(' ')
2243        || value.contains('~')
2244        || value.contains('^')
2245        || value.contains(':')
2246    {
2247        Err(Error::msg("invalid git ref"))
2248    } else {
2249        Ok(())
2250    }
2251}
2252
2253fn repository_description(repo: &Path) -> Result<String> {
2254    let path = repo.join("description");
2255    if !path.exists() {
2256        return Ok(String::new());
2257    }
2258    Ok(fs::read_to_string(path)?.trim().to_string())
2259}
2260
2261#[derive(Debug, Clone, PartialEq, Eq)]
2262struct RepositoryOrigin {
2263    kind: RepositoryOriginKind,
2264    source: String,
2265    synced_at: Option<u64>,
2266}
2267
2268#[derive(Debug, Clone, PartialEq, Eq)]
2269enum RepositoryOriginKind {
2270    Fork,
2271    Mirror,
2272}
2273
2274impl RepositoryOrigin {
2275    fn label(&self) -> &'static str {
2276        match self.kind {
2277            RepositoryOriginKind::Fork => "Forked",
2278            RepositoryOriginKind::Mirror => "Mirrored",
2279        }
2280    }
2281}
2282
2283fn repository_origin(repo: &Path) -> Result<Option<RepositoryOrigin>> {
2284    let kind_output = run_git_output(
2285        Command::new("git")
2286            .arg("--git-dir")
2287            .arg(repo)
2288            .arg("config")
2289            .arg("--get")
2290            .arg("repository.rngit.type"),
2291        GIT_COMMAND_TIMEOUT,
2292    )?;
2293    if !kind_output.status.success() {
2294        return Ok(None);
2295    }
2296    let kind = match String::from_utf8_lossy(&kind_output.stdout).trim() {
2297        "fork" => RepositoryOriginKind::Fork,
2298        "mirror" => RepositoryOriginKind::Mirror,
2299        _ => return Ok(None),
2300    };
2301    let source_output = run_git_output(
2302        Command::new("git")
2303            .arg("--git-dir")
2304            .arg(repo)
2305            .arg("config")
2306            .arg("--get")
2307            .arg("repository.rngit.upstream.source"),
2308        GIT_COMMAND_TIMEOUT,
2309    )?;
2310    if !source_output.status.success() {
2311        return Ok(None);
2312    }
2313    let source = String::from_utf8_lossy(&source_output.stdout)
2314        .trim()
2315        .to_string();
2316    if source.is_empty() {
2317        return Ok(None);
2318    }
2319    let synced_at = run_git_output(
2320        Command::new("git")
2321            .arg("--git-dir")
2322            .arg(repo)
2323            .arg("config")
2324            .arg("--get")
2325            .arg("repository.rngit.upstream.sync"),
2326        GIT_COMMAND_TIMEOUT,
2327    )
2328    .ok()
2329    .and_then(|output| output.status.success().then_some(output))
2330    .and_then(|output| {
2331        String::from_utf8_lossy(&output.stdout)
2332            .trim()
2333            .parse::<u64>()
2334            .ok()
2335    });
2336
2337    Ok(Some(RepositoryOrigin {
2338        kind,
2339        source,
2340        synced_at,
2341    }))
2342}
2343
2344fn format_sync_age(synced_at: u64) -> String {
2345    let now = SystemTime::now()
2346        .duration_since(UNIX_EPOCH)
2347        .unwrap_or_default()
2348        .as_secs();
2349    let seconds = now.saturating_sub(synced_at);
2350    if seconds < 60 {
2351        format!("{seconds}s")
2352    } else if seconds < 3_600 {
2353        format!("{}m", seconds / 60)
2354    } else if seconds < 86_400 {
2355        format!("{}h", seconds / 3_600)
2356    } else {
2357        format!("{}d", seconds / 86_400)
2358    }
2359}
2360
2361fn repository_thanks(repo: &Path, add: bool) -> Result<u64> {
2362    let path = repo.with_extension("thanks");
2363    let mut count = if path.exists() {
2364        fs::read_to_string(&path)?
2365            .trim()
2366            .parse::<u64>()
2367            .unwrap_or(0)
2368    } else {
2369        0
2370    };
2371    if add {
2372        count = count.saturating_add(1);
2373        fs::write(path, format!("{count}\n"))?;
2374    }
2375    Ok(count)
2376}
2377
2378#[derive(Debug)]
2379struct ReadmeContent {
2380    content: String,
2381    markdown: bool,
2382}
2383
2384fn readme_content(repo: &Path) -> Result<Option<ReadmeContent>> {
2385    const NAMES: &[&str] = &[
2386        "README.mu",
2387        "Readme.mu",
2388        "readme.mu",
2389        "README",
2390        "readme",
2391        "README.md",
2392        "readme.md",
2393        "README.rst",
2394        "README.txt",
2395        "readme.rst",
2396        "readme.txt",
2397    ];
2398    for name in NAMES {
2399        match run_git_output(
2400            Command::new("git")
2401                .arg("--git-dir")
2402                .arg(repo)
2403                .arg("show")
2404                .arg(format!("HEAD:{name}")),
2405            GIT_COMMAND_TIMEOUT,
2406        ) {
2407            Ok(output) if output.status.success() => {
2408                let content = String::from_utf8_lossy(&output.stdout).into_owned();
2409                let markdown = name.ends_with(".md");
2410                let content = if markdown || name.ends_with(".mu") {
2411                    content
2412                } else {
2413                    m_escape(&content)
2414                };
2415                return Ok(Some(ReadmeContent { content, markdown }));
2416            }
2417            Ok(_) => {}
2418            Err(err) => {
2419                log::warn!("Git command execution failed while reading README: {err}");
2420            }
2421        }
2422    }
2423    Ok(None)
2424}
2425
2426fn markdown_to_micron(input: &str) -> String {
2427    markdown_to_micron_scoped(input, None)
2428}
2429
2430fn markdown_to_micron_scoped(input: &str, url_scope: Option<&str>) -> String {
2431    let lines: Vec<&str> = input.lines().collect();
2432    let mut out = String::new();
2433    let mut code_block: Option<(Option<String>, String)> = None;
2434    let mut index = 0;
2435
2436    while index < lines.len() {
2437        let line = lines[index];
2438        let trimmed = line.trim();
2439        if trimmed.starts_with("```") {
2440            if let Some((language, content)) = code_block.take() {
2441                out.push_str(&markdown_code_block_to_micron(
2442                    language.as_deref(),
2443                    &content,
2444                ));
2445            } else {
2446                code_block = Some((markdown_fence_language(trimmed), String::new()));
2447            }
2448            index += 1;
2449            continue;
2450        }
2451        if let Some((_, content)) = code_block.as_mut() {
2452            content.push_str(line);
2453            content.push('\n');
2454            index += 1;
2455            continue;
2456        }
2457        if let Some(quote) = markdown_quote(line) {
2458            let mut parts = vec![quote.to_string()];
2459            index += 1;
2460            while index < lines.len() {
2461                if let Some(quote) = markdown_quote(lines[index]) {
2462                    parts.push(quote.to_string());
2463                    index += 1;
2464                } else {
2465                    break;
2466                }
2467            }
2468            let formatted = format_markdown_inline_scoped(&parts.join(" "), url_scope);
2469            for wrapped in markdown_wrap_text(&formatted, 77) {
2470                out.push_str(" │ ");
2471                out.push_str(&wrapped);
2472                out.push('\n');
2473            }
2474            continue;
2475        }
2476        if is_table_start(&lines, index) {
2477            let mut table = Vec::new();
2478            while index < lines.len() && lines[index].contains('|') {
2479                table.push(lines[index]);
2480                index += 1;
2481            }
2482            out.push_str(&format_markdown_table(&table, url_scope));
2483            continue;
2484        }
2485        if let Some((level, text)) = markdown_heading(line) {
2486            out.push_str(&">".repeat(level));
2487            out.push_str(&format_markdown_inline_scoped(text.trim(), url_scope));
2488            out.push('\n');
2489        } else if is_markdown_rule(trimmed) {
2490            out.push_str("-\n");
2491        } else if let Some((indent, text)) = unordered_list_item(line) {
2492            out.push_str(indent);
2493            out.push_str(" • ");
2494            out.push_str(&format_markdown_inline_scoped(text, url_scope));
2495            out.push('\n');
2496        } else if let Some((indent, number, text)) = ordered_list_item(line) {
2497            out.push_str(indent);
2498            out.push_str(number);
2499            out.push_str(". ");
2500            out.push_str(&format_markdown_inline_scoped(text, url_scope));
2501            out.push('\n');
2502        } else {
2503            out.push_str(&format_markdown_line(line, url_scope));
2504            out.push('\n');
2505        }
2506        index += 1;
2507    }
2508    if let Some((language, content)) = code_block {
2509        out.push_str(&markdown_code_block_to_micron(
2510            language.as_deref(),
2511            &content,
2512        ));
2513    }
2514
2515    out
2516}
2517
2518fn markdown_code_block_to_micron(language: Option<&str>, content: &str) -> String {
2519    if language.is_some_and(|language| language.eq_ignore_ascii_case("rawmu")) {
2520        return content.to_string();
2521    }
2522    crate::highlight::literal_block(content, None, language)
2523}
2524
2525fn markdown_fence_language(trimmed: &str) -> Option<String> {
2526    trimmed
2527        .strip_prefix("```")
2528        .and_then(|info| info.split_whitespace().next())
2529        .filter(|language| !language.is_empty())
2530        .map(str::to_string)
2531}
2532
2533fn markdown_quote(line: &str) -> Option<&str> {
2534    line.strip_prefix('>')
2535        .map(|quote| quote.strip_prefix(' ').unwrap_or(quote))
2536}
2537
2538fn markdown_heading(line: &str) -> Option<(usize, &str)> {
2539    let level = line.chars().take_while(|ch| *ch == '#').count();
2540    if (1..=6).contains(&level) && line.as_bytes().get(level) == Some(&b' ') {
2541        Some((level, &line[level + 1..]))
2542    } else {
2543        None
2544    }
2545}
2546
2547fn is_markdown_rule(trimmed: &str) -> bool {
2548    trimmed.len() >= 3
2549        && ["-", "*", "_", "="]
2550            .iter()
2551            .any(|marker| trimmed.chars().all(|ch| ch.to_string() == *marker))
2552}
2553
2554fn unordered_list_item(line: &str) -> Option<(&str, &str)> {
2555    let trimmed = line.trim_start();
2556    let indent = &line[..line.len() - trimmed.len()];
2557    for marker in ["- ", "* ", "+ "] {
2558        if let Some(text) = trimmed.strip_prefix(marker) {
2559            return Some((indent, text));
2560        }
2561    }
2562    None
2563}
2564
2565fn ordered_list_item(line: &str) -> Option<(&str, &str, &str)> {
2566    let trimmed = line.trim_start();
2567    let indent = &line[..line.len() - trimmed.len()];
2568    let (number, text) = trimmed.split_once(". ")?;
2569    if !number.is_empty() && number.chars().all(|ch| ch.is_ascii_digit()) {
2570        Some((indent, number, text))
2571    } else {
2572        None
2573    }
2574}
2575
2576fn format_markdown_line(line: &str, url_scope: Option<&str>) -> String {
2577    let formatted = format_markdown_inline_scoped(line, url_scope);
2578    if (line.starts_with('-') && !line.starts_with("---") && !line.starts_with("- "))
2579        || line.starts_with('<')
2580    {
2581        format!("\\{formatted}")
2582    } else {
2583        formatted
2584    }
2585}
2586
2587fn is_table_start(lines: &[&str], index: usize) -> bool {
2588    index + 1 < lines.len()
2589        && lines[index].contains('|')
2590        && lines[index + 1].contains('|')
2591        && is_table_separator(lines[index + 1])
2592}
2593
2594fn is_table_separator(line: &str) -> bool {
2595    parse_table_row(line)
2596        .iter()
2597        .all(|cell| !cell.is_empty() && cell.chars().all(|ch| matches!(ch, '-' | ':' | ' ')))
2598}
2599
2600#[derive(Clone, Copy)]
2601enum TableAlign {
2602    Left,
2603    Right,
2604    Center,
2605}
2606
2607fn format_markdown_table(lines: &[&str], url_scope: Option<&str>) -> String {
2608    if lines.len() < 2 {
2609        return String::new();
2610    }
2611    let header: Vec<String> = parse_table_row(lines[0])
2612        .into_iter()
2613        .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
2614        .collect();
2615    if header.is_empty() {
2616        return String::new();
2617    }
2618    let columns = header.len();
2619    let mut alignments = parse_table_alignments(lines[1]);
2620    while alignments.len() < columns {
2621        alignments.push(TableAlign::Left);
2622    }
2623    alignments.truncate(columns);
2624
2625    let rows: Vec<Vec<String>> = lines
2626        .iter()
2627        .skip(2)
2628        .map(|line| {
2629            let mut row: Vec<String> = parse_table_row(line)
2630                .into_iter()
2631                .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
2632                .collect();
2633            while row.len() < columns {
2634                row.push(String::new());
2635            }
2636            row.truncate(columns);
2637            row
2638        })
2639        .collect();
2640
2641    let mut widths = vec![3; columns];
2642    for (index, cell) in header.iter().enumerate() {
2643        widths[index] = widths[index].max(markdown_visible_width(cell));
2644    }
2645    for row in &rows {
2646        for (index, cell) in row.iter().enumerate().take(columns) {
2647            widths[index] = widths[index].max(markdown_visible_width(cell));
2648        }
2649    }
2650
2651    let mut out = String::new();
2652    out.push_str(&table_border('┌', '┬', '┐', &widths));
2653    out.push_str(&table_row(
2654        &header,
2655        &widths,
2656        &vec![TableAlign::Left; columns],
2657    ));
2658    out.push_str(&table_border('├', '┼', '┤', &widths));
2659    for row in rows {
2660        out.push_str(&table_row(&row, &widths, &alignments));
2661    }
2662    out.push_str(&table_border('└', '┴', '┘', &widths));
2663    out
2664}
2665
2666fn parse_table_row(line: &str) -> Vec<String> {
2667    let mut trimmed = line.trim();
2668    if let Some(rest) = trimmed.strip_prefix('|') {
2669        trimmed = rest;
2670    }
2671    if let Some(rest) = trimmed.strip_suffix('|') {
2672        trimmed = rest;
2673    }
2674    let mut cells = Vec::new();
2675    let mut current = String::new();
2676    let mut escaped = false;
2677    for ch in trimmed.chars() {
2678        if escaped {
2679            current.push(ch);
2680            escaped = false;
2681        } else if ch == '\\' {
2682            escaped = true;
2683        } else if ch == '|' {
2684            cells.push(current.trim().to_string());
2685            current.clear();
2686        } else {
2687            current.push(ch);
2688        }
2689    }
2690    if escaped {
2691        current.push('\\');
2692    }
2693    cells.push(current.trim().to_string());
2694    cells
2695}
2696
2697fn parse_table_alignments(line: &str) -> Vec<TableAlign> {
2698    parse_table_row(line)
2699        .into_iter()
2700        .map(|cell| {
2701            let cell = cell.trim();
2702            if cell.starts_with(':') && cell.ends_with(':') {
2703                TableAlign::Center
2704            } else if cell.ends_with(':') {
2705                TableAlign::Right
2706            } else {
2707                TableAlign::Left
2708            }
2709        })
2710        .collect()
2711}
2712
2713fn table_border(left: char, middle: char, right: char, widths: &[usize]) -> String {
2714    let mut out = String::new();
2715    out.push(left);
2716    for (index, width) in widths.iter().enumerate() {
2717        out.push_str(&"─".repeat(width + 2));
2718        if index + 1 == widths.len() {
2719            out.push(right);
2720        } else {
2721            out.push(middle);
2722        }
2723    }
2724    out.push('\n');
2725    out
2726}
2727
2728fn table_row(cells: &[String], widths: &[usize], alignments: &[TableAlign]) -> String {
2729    let mut out = String::from("│");
2730    for (index, width) in widths.iter().enumerate() {
2731        let cell = cells.get(index).map(String::as_str).unwrap_or("");
2732        out.push(' ');
2733        out.push_str(&pad_table_cell(
2734            cell,
2735            *width,
2736            alignments.get(index).copied().unwrap_or(TableAlign::Left),
2737        ));
2738        out.push(' ');
2739        out.push('│');
2740    }
2741    out.push('\n');
2742    out
2743}
2744
2745fn pad_table_cell(cell: &str, width: usize, alignment: TableAlign) -> String {
2746    let visible = markdown_visible_width(cell);
2747    let padding = width.saturating_sub(visible);
2748    match alignment {
2749        TableAlign::Left => format!("{cell}{}", " ".repeat(padding)),
2750        TableAlign::Right => format!("{}{cell}", " ".repeat(padding)),
2751        TableAlign::Center => {
2752            let left = padding / 2;
2753            let right = padding - left;
2754            format!("{}{cell}{}", " ".repeat(left), " ".repeat(right))
2755        }
2756    }
2757}
2758
2759fn markdown_visible_width(value: &str) -> usize {
2760    let mut width = 0;
2761    let mut chars = value.chars().peekable();
2762    while let Some(ch) = chars.next() {
2763        if ch == '`' {
2764            if matches!(chars.peek(), Some('F') | Some('B')) {
2765                chars.next();
2766                if matches!(chars.peek(), Some('T')) {
2767                    chars.next();
2768                    for _ in 0..6 {
2769                        chars.next();
2770                    }
2771                } else {
2772                    for _ in 0..3 {
2773                        chars.next();
2774                    }
2775                }
2776                continue;
2777            }
2778            if matches!(chars.peek(), Some('!') | Some('*') | Some('_') | Some('=')) {
2779                chars.next();
2780                continue;
2781            }
2782            if matches!(chars.peek(), Some('f') | Some('b') | Some('a')) {
2783                chars.next();
2784                continue;
2785            }
2786            if matches!(chars.peek(), Some('[')) {
2787                let mut label_width = 0;
2788                for link_ch in chars.by_ref() {
2789                    if link_ch == '`' {
2790                        for target_ch in chars.by_ref() {
2791                            if target_ch == ']' {
2792                                break;
2793                            }
2794                        }
2795                        break;
2796                    }
2797                    label_width += 1;
2798                }
2799                width += label_width;
2800                continue;
2801            }
2802        }
2803        width += 1;
2804    }
2805    width
2806}
2807
2808fn markdown_wrap_text(text: &str, width: usize) -> Vec<String> {
2809    if text.is_empty() {
2810        return vec![String::new()];
2811    }
2812    let mut lines = Vec::new();
2813    let mut current = String::new();
2814    let mut current_width = 0;
2815    for word in text.split_whitespace() {
2816        let word_width = markdown_visible_width(word);
2817        if !current.is_empty() && current_width + 1 + word_width > width {
2818            lines.push(current);
2819            current = String::new();
2820            current_width = 0;
2821        }
2822        if !current.is_empty() {
2823            current.push(' ');
2824            current_width += 1;
2825        }
2826        current.push_str(word);
2827        current_width += word_width;
2828    }
2829    if !current.is_empty() {
2830        lines.push(current);
2831    }
2832    if lines.is_empty() {
2833        lines.push(String::new());
2834    }
2835    lines
2836}
2837
2838#[derive(Debug)]
2839enum InlineToken {
2840    Link { label: String, target: String },
2841    Code(String),
2842}
2843
2844#[derive(Debug, Clone, Copy)]
2845struct MarkdownLinkStyle<'a> {
2846    bold: bool,
2847    underline: bool,
2848    color: Option<&'a str>,
2849}
2850
2851impl Default for MarkdownLinkStyle<'_> {
2852    fn default() -> Self {
2853        Self {
2854            bold: true,
2855            underline: true,
2856            color: None,
2857        }
2858    }
2859}
2860
2861fn format_markdown_inline_scoped(input: &str, url_scope: Option<&str>) -> String {
2862    let (with_placeholders, tokens) = extract_markdown_inline_tokens(input);
2863    let escaped = m_escape(&with_placeholders);
2864    let styled = apply_markdown_style(&escaped, "**", "`!", "`!");
2865    let styled = apply_markdown_style(&styled, "__", "`!", "`!");
2866    let styled = apply_markdown_style(&styled, "*", "`*", "`*");
2867    let styled = apply_markdown_style(&styled, "_", "`*", "`*");
2868    restore_markdown_inline_tokens(&styled, &tokens, url_scope)
2869}
2870
2871fn extract_markdown_inline_tokens(input: &str) -> (String, Vec<InlineToken>) {
2872    let mut out = String::new();
2873    let mut tokens = Vec::new();
2874    let mut index = 0;
2875
2876    while index < input.len() {
2877        let rest = &input[index..];
2878        if let Some(stripped) = rest.strip_prefix('`') {
2879            if let Some(end) = stripped.find('`') {
2880                let content = &stripped[..end];
2881                if is_markdown_inline_code(content) {
2882                    tokens.push(InlineToken::Code(content.to_string()));
2883                    out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2884                    index += end + 2;
2885                    continue;
2886                }
2887            }
2888        }
2889        if rest.starts_with('[') {
2890            if let Some((consumed, label, target)) = parse_markdown_link(rest) {
2891                tokens.push(InlineToken::Link { label, target });
2892                out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2893                index += consumed;
2894                continue;
2895            }
2896        }
2897        let Some(ch) = rest.chars().next() else {
2898            break;
2899        };
2900        out.push(ch);
2901        index += ch.len_utf8();
2902    }
2903
2904    (out, tokens)
2905}
2906
2907fn is_markdown_inline_code(content: &str) -> bool {
2908    !content.is_empty()
2909        && !content
2910            .chars()
2911            .next()
2912            .is_some_and(|ch| matches!(ch, '!' | '*' | '_' | '=' | 'F' | 'B' | 'f' | 'b' | 'a'))
2913}
2914
2915fn parse_markdown_link(input: &str) -> Option<(usize, String, String)> {
2916    let label_end = input[1..].find(']')? + 1;
2917    if !input[label_end + 1..].starts_with('(') {
2918        return None;
2919    }
2920    let target_start = label_end + 2;
2921    let target_end = input[target_start..].find(')')? + target_start;
2922    Some((
2923        target_end + 1,
2924        input[1..label_end].replace('`', ""),
2925        input[target_start..target_end].to_string(),
2926    ))
2927}
2928
2929fn markdown_token_placeholder(index: usize) -> String {
2930    format!("\u{1f}{index}\u{1f}")
2931}
2932
2933fn restore_markdown_inline_tokens(
2934    input: &str,
2935    tokens: &[InlineToken],
2936    url_scope: Option<&str>,
2937) -> String {
2938    let mut out = input.to_string();
2939    for (index, token) in tokens.iter().enumerate() {
2940        let replacement = match token {
2941            InlineToken::Link { label, target } => {
2942                format_markdown_link(label, target, url_scope, MarkdownLinkStyle::default())
2943            }
2944            InlineToken::Code(value) => format!("`BT383838`Fddd{}`f`b", m_escape(value)),
2945        };
2946        out = out.replace(&markdown_token_placeholder(index), &replacement);
2947    }
2948    out
2949}
2950
2951fn format_markdown_link(
2952    label: &str,
2953    target: &str,
2954    url_scope: Option<&str>,
2955    style: MarkdownLinkStyle<'_>,
2956) -> String {
2957    let mut link = String::new();
2958    if style.underline {
2959        link.push_str("`_");
2960    }
2961    if style.bold {
2962        link.push_str("`!");
2963    }
2964    link.push_str("`[");
2965    link.push_str(sanitize_label(label).trim());
2966    link.push('`');
2967    link.push_str(&markdown_link_target(target, url_scope));
2968    link.push(']');
2969    if style.bold {
2970        link.push_str("`!");
2971    }
2972    if style.underline {
2973        link.push_str("`_");
2974    }
2975
2976    match style.color {
2977        Some(color) if color.len() == 3 => format!("`F{color}{link}`f"),
2978        Some(color) if color.len() == 6 => format!("`FT{color}{link}`f"),
2979        _ => link,
2980    }
2981}
2982
2983fn markdown_link_target(value: &str, url_scope: Option<&str>) -> String {
2984    if let Some(scope) = url_scope {
2985        if !value.contains(":/") {
2986            let (path, anchor) = value
2987                .split_once('#')
2988                .map(|(path, anchor)| (path, Some(anchor)))
2989                .unwrap_or((value, None));
2990            let mut out = format!("{scope}{}", sanitize_field(path));
2991            if let Some(anchor) = anchor.filter(|anchor| !anchor.is_empty()) {
2992                out.push_str("|anchor=");
2993                out.push_str(&sanitize_field(anchor));
2994            }
2995            return out;
2996        }
2997    }
2998    sanitize_markdown_link_target(value)
2999}
3000
3001fn sanitize_markdown_link_target(value: &str) -> String {
3002    value
3003        .chars()
3004        .filter(|ch| *ch != '[' && *ch != ']' && *ch != '`')
3005        .collect()
3006}
3007
3008fn apply_markdown_style(input: &str, marker: &str, open: &str, close: &str) -> String {
3009    let mut out = String::new();
3010    let mut rest = input;
3011
3012    while let Some(start) = rest.find(marker) {
3013        let content_start = start + marker.len();
3014        let Some(end) = rest[content_start..].find(marker) else {
3015            out.push_str(rest);
3016            return out;
3017        };
3018        let content_end = content_start + end;
3019        out.push_str(&rest[..start]);
3020        out.push_str(open);
3021        out.push_str(&rest[content_start..content_end]);
3022        out.push_str(close);
3023        rest = &rest[content_end + marker.len()..];
3024    }
3025    out.push_str(rest);
3026    out
3027}
3028
3029#[derive(Debug)]
3030struct Refs {
3031    heads: Vec<RefInfo>,
3032    tags: Vec<RefInfo>,
3033}
3034
3035#[derive(Debug)]
3036struct RefInfo {
3037    sha: String,
3038    name: String,
3039}
3040
3041fn git_refs(repo: &Path) -> Result<Refs> {
3042    let output = run_git(
3043        Command::new("git")
3044            .arg("--git-dir")
3045            .arg(repo)
3046            .args(["for-each-ref", "--format=%(objectname) %(refname)"]),
3047    )?;
3048    let mut refs = Refs {
3049        heads: Vec::new(),
3050        tags: Vec::new(),
3051    };
3052    for line in output.lines() {
3053        let Some((sha, name)) = line.split_once(' ') else {
3054            continue;
3055        };
3056        if let Some(short) = name.strip_prefix("refs/heads/") {
3057            refs.heads.push(RefInfo {
3058                sha: sha.to_string(),
3059                name: short.to_string(),
3060            });
3061        } else if let Some(short) = name.strip_prefix("refs/tags/") {
3062            refs.tags.push(RefInfo {
3063                sha: sha.to_string(),
3064                name: short.to_string(),
3065            });
3066        }
3067    }
3068    Ok(refs)
3069}
3070
3071fn resolve_ref(repo: &Path, reference: &str) -> Result<String> {
3072    validate_refish(reference)?;
3073    let output = run_git(
3074        Command::new("git")
3075            .arg("--git-dir")
3076            .arg(repo)
3077            .arg("rev-parse")
3078            .arg("--verify")
3079            .arg(format!("{reference}^{{commit}}")),
3080    )?;
3081    Ok(output.trim().to_string())
3082}
3083
3084#[derive(Debug)]
3085struct TreeEntry {
3086    kind: String,
3087    name: String,
3088    size: Option<u64>,
3089}
3090
3091impl TreeEntry {
3092    fn size_label(&self) -> String {
3093        self.size
3094            .map(|size| format!(" ({size} bytes)"))
3095            .unwrap_or_default()
3096    }
3097}
3098
3099fn tree_entries(repo: &Path, reference: &str, path: &str) -> Result<Vec<TreeEntry>> {
3100    validate_git_path(path)?;
3101    let spec = if path.is_empty() {
3102        reference.to_string()
3103    } else {
3104        format!("{reference}:{path}")
3105    };
3106    let output = run_git(
3107        Command::new("git")
3108            .arg("--git-dir")
3109            .arg(repo)
3110            .arg("ls-tree")
3111            .arg("-l")
3112            .arg(spec),
3113    )?;
3114    let mut entries = Vec::new();
3115    for line in output.lines() {
3116        let Some((meta, name)) = line.split_once('\t') else {
3117            continue;
3118        };
3119        let parts: Vec<&str> = meta.split_whitespace().collect();
3120        if parts.len() < 4 {
3121            continue;
3122        }
3123        let size = parts[3].parse::<u64>().ok();
3124        entries.push(TreeEntry {
3125            kind: parts[1].to_string(),
3126            name: name.to_string(),
3127            size,
3128        });
3129    }
3130    entries.sort_by_key(|entry| (entry.kind != "tree", entry.name.to_ascii_lowercase()));
3131    Ok(entries)
3132}
3133
3134fn git_object_kind(repo: &Path, reference: &str, path: &str) -> Result<String> {
3135    validate_git_path(path)?;
3136    let spec = if path.is_empty() {
3137        reference.to_string()
3138    } else {
3139        format!("{reference}:{path}")
3140    };
3141    let output = run_git(
3142        Command::new("git")
3143            .arg("--git-dir")
3144            .arg(repo)
3145            .arg("cat-file")
3146            .arg("-t")
3147            .arg(spec),
3148    )?;
3149    Ok(output.trim().to_string())
3150}
3151
3152struct BlobInfo {
3153    content: String,
3154    size: u64,
3155    binary: bool,
3156    displayable: bool,
3157}
3158
3159fn blob_info(repo: &Path, reference: &str, path: &str) -> Result<BlobInfo> {
3160    validate_git_path(path)?;
3161    let spec = format!("{reference}:{path}");
3162    let size = run_git(
3163        Command::new("git")
3164            .arg("--git-dir")
3165            .arg(repo)
3166            .arg("cat-file")
3167            .arg("-s")
3168            .arg(&spec),
3169    )?
3170    .trim()
3171    .parse::<u64>()
3172    .unwrap_or(0);
3173    if size > 256 * 1024 && !is_image_path(path) {
3174        return Ok(BlobInfo {
3175            content: format!("File is too large to display ({size} bytes)."),
3176            size,
3177            binary: false,
3178            displayable: false,
3179        });
3180    }
3181    let output = run_git_output(
3182        Command::new("git")
3183            .arg("--git-dir")
3184            .arg(repo)
3185            .arg("show")
3186            .arg(spec),
3187        GIT_COMMAND_TIMEOUT,
3188    )?;
3189    if !output.status.success() {
3190        return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
3191    }
3192    let binary = output.stdout.contains(&0);
3193    if size > 256 * 1024 && !binary {
3194        return Ok(BlobInfo {
3195            content: format!("File is too large to display ({size} bytes)."),
3196            size,
3197            binary: false,
3198            displayable: false,
3199        });
3200    }
3201    Ok(BlobInfo {
3202        content: if binary {
3203            "Binary file is not displayed.".to_string()
3204        } else {
3205            String::from_utf8_lossy(&output.stdout).into_owned()
3206        },
3207        size,
3208        binary,
3209        displayable: !binary,
3210    })
3211}
3212
3213#[derive(Debug)]
3214struct CommitSummary {
3215    hash: String,
3216    subject: String,
3217    author: String,
3218    timestamp: i64,
3219}
3220
3221fn commits(repo: &Path, reference: &str, limit: usize) -> Result<Vec<CommitSummary>> {
3222    let format = "%H%x1f%s%x1f%an%x1f%at";
3223    let output = run_git(
3224        Command::new("git")
3225            .arg("--git-dir")
3226            .arg(repo)
3227            .arg("log")
3228            .arg(format!("--format={format}"))
3229            .arg("-n")
3230            .arg(limit.to_string())
3231            .arg(reference),
3232    )?;
3233    let mut commits = Vec::new();
3234    for line in output.lines() {
3235        let parts: Vec<&str> = line.split('\x1f').collect();
3236        if parts.len() == 4 {
3237            commits.push(CommitSummary {
3238                hash: parts[0].to_string(),
3239                subject: parts[1].to_string(),
3240                author: parts[2].to_string(),
3241                timestamp: parts[3].parse().unwrap_or_default(),
3242            });
3243        }
3244    }
3245    Ok(commits)
3246}
3247
3248#[derive(Debug)]
3249struct CommitInfo {
3250    hash: String,
3251    subject: String,
3252    body: String,
3253    author: String,
3254    email: String,
3255    timestamp: i64,
3256    files: Vec<CommitFile>,
3257}
3258
3259#[derive(Debug)]
3260struct CommitFile {
3261    status: String,
3262    path: String,
3263}
3264
3265fn commit_info(repo: &Path, hash: &str) -> Result<CommitInfo> {
3266    validate_refish(hash)?;
3267    let output = run_git(
3268        Command::new("git")
3269            .arg("--git-dir")
3270            .arg(repo)
3271            .arg("show")
3272            .arg("--no-patch")
3273            .arg("--format=%H%x1f%s%x1f%an%x1f%ae%x1f%at%x1f%B")
3274            .arg(hash),
3275    )?;
3276    let parts: Vec<&str> = output.splitn(6, '\x1f').collect();
3277    if parts.len() != 6 {
3278        return Err(Error::msg("invalid commit output"));
3279    }
3280    let files_output = run_git(
3281        Command::new("git")
3282            .arg("--git-dir")
3283            .arg(repo)
3284            .arg("diff-tree")
3285            .arg("--root")
3286            .arg("--no-commit-id")
3287            .arg("--name-status")
3288            .arg("-r")
3289            .arg(hash),
3290    )?;
3291    let mut files = Vec::new();
3292    for line in files_output.lines() {
3293        let Some((status, path)) = line.split_once('\t') else {
3294            continue;
3295        };
3296        files.push(CommitFile {
3297            status: status.to_string(),
3298            path: path.to_string(),
3299        });
3300    }
3301    Ok(CommitInfo {
3302        hash: parts[0].trim().to_string(),
3303        subject: parts[1].trim().to_string(),
3304        author: parts[2].trim().to_string(),
3305        email: parts[3].trim().to_string(),
3306        timestamp: parts[4].trim().parse().unwrap_or_default(),
3307        body: parts[5].trim().to_string(),
3308        files,
3309    })
3310}
3311
3312fn raw_commit_object(repo: &Path, hash: &str) -> Result<Vec<u8>> {
3313    validate_refish(hash)?;
3314    let output = run_git_output(
3315        Command::new("git")
3316            .arg("--git-dir")
3317            .arg(repo)
3318            .arg("cat-file")
3319            .arg("-p")
3320            .arg(hash),
3321        GIT_COMMAND_TIMEOUT,
3322    )?;
3323    if !output.status.success() {
3324        return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
3325    }
3326    Ok(output.stdout)
3327}
3328
3329fn run_git(cmd: &mut Command) -> Result<String> {
3330    let output = run_git_output(cmd, GIT_COMMAND_TIMEOUT)?;
3331    if !output.status.success() {
3332        return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
3333    }
3334    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
3335}
3336
3337fn run_git_output(cmd: &mut Command, timeout: Duration) -> Result<Output> {
3338    // Spool both outputs while Git runs: waiting before draining a pipe would
3339    // deadlock once a media blob fills the pipe buffer. Owned files also let
3340    // timeout cleanup finish without waiting on pipe-reader threads.
3341    let mut stdout = tempfile::tempfile()?;
3342    let mut stderr = tempfile::tempfile()?;
3343    let mut child = cmd
3344        .stdout(Stdio::from(stdout.try_clone()?))
3345        .stderr(Stdio::from(stderr.try_clone()?))
3346        .spawn()?;
3347    let deadline = Instant::now() + timeout;
3348
3349    loop {
3350        if let Some(status) = child.try_wait()? {
3351            stdout.seek(SeekFrom::Start(0))?;
3352            stderr.seek(SeekFrom::Start(0))?;
3353            let mut output = Output {
3354                status,
3355                stdout: Vec::new(),
3356                stderr: Vec::new(),
3357            };
3358            stdout.read_to_end(&mut output.stdout)?;
3359            stderr.read_to_end(&mut output.stderr)?;
3360            return Ok(output);
3361        }
3362
3363        if Instant::now() >= deadline {
3364            let _ = child.kill();
3365            let _ = child.wait();
3366            log::warn!("Git command execution timed out");
3367            return Err(Error::msg("Git command execution timed out"));
3368        }
3369
3370        let sleep_for = deadline
3371            .saturating_duration_since(Instant::now())
3372            .min(Duration::from_millis(10));
3373        if !sleep_for.is_zero() {
3374            thread::sleep(sleep_for);
3375        }
3376    }
3377}
3378
3379#[cfg(test)]
3380mod tests {
3381    use super::*;
3382    use crate::acl::Access;
3383    use crate::config::ServerConfig;
3384
3385    #[test]
3386    fn media_preserves_original_for_disabled_webp_nonimage_and_failed_conversion() {
3387        for (enabled, name) in [
3388            (false, "test.png"),
3389            (true, "test.WEBP"),
3390            (true, "test.txt"),
3391            (true, "bad.png"),
3392        ] {
3393            let original = b"not an image".to_vec();
3394            let (bytes, response_name) = media_response_data(enabled, name, name, original.clone());
3395            assert_eq!(bytes, original);
3396            assert_eq!(response_name, name);
3397        }
3398    }
3399
3400    #[test]
3401    fn media_serves_decoded_blob_with_filename_and_checks_access() {
3402        let tmp = tempfile::tempdir().unwrap();
3403        let mut config = cfg(tmp.path());
3404        let payload = b"RIFF\0image bytes";
3405        create_repo_bytes(
3406            config.repositories_dir.join("public/images"),
3407            "docs/a + b.webp",
3408            payload,
3409        );
3410        let request = msgpack::pack(&Value::Map(vec![
3411            (Value::Str("key".into()), Value::Bin(vec![1, 2, 3])),
3412            (
3413                Value::Str("path".into()),
3414                Value::Str("/media/public/images/HEAD/docs%2Fa+%2B+b.webp".into()),
3415            ),
3416        ]));
3417        match serve_media(&config, &access(&config), &request, None).unwrap() {
3418            RequestResponse::File {
3419                data,
3420                metadata,
3421                auto_compress,
3422            } => {
3423                assert!(!auto_compress, "media responses must not auto-compress");
3424                assert_eq!(data, payload);
3425                assert_eq!(
3426                    msgpack::unpack_exact(&metadata).unwrap(),
3427                    Value::Map(vec![(
3428                        Value::Str("name".into()),
3429                        Value::Bin(b"a + b.webp".to_vec())
3430                    ),])
3431                );
3432            }
3433            _ => panic!("expected media resource"),
3434        }
3435        config.allow_read = vec!["none".into()];
3436        assert!(
3437            matches!(serve_media(&config, &access(&config), &request, None).unwrap(), RequestResponse::Bytes(data) if data == msgpack::pack(&Value::Bool(false)))
3438        );
3439    }
3440
3441    #[test]
3442    fn media_rejects_missing_key_bad_paths_and_unknown_objects() {
3443        let tmp = tempfile::tempdir().unwrap();
3444        let config = cfg(tmp.path());
3445        create_repo_bytes(
3446            config.repositories_dir.join("public/images"),
3447            "image.webp",
3448            b"RIFF\0data",
3449        );
3450        let access = access(&config);
3451        let mut requests = vec![
3452            page_request(&[("path", "/media/public/images/HEAD/image.webp")]),
3453            msgpack::pack(&Value::Bool(true)),
3454        ];
3455        for path in [
3456            "/media/public/images",
3457            "/media/public/images/HEAD/../secret",
3458            "/media/public/images/missing/image.webp",
3459            "/media/public/images/HEAD/missing.webp",
3460            "/media/../images/HEAD/image.webp",
3461        ] {
3462            requests.push(page_request(&[("key", "k"), ("path", path)]));
3463        }
3464        for request in requests {
3465            assert!(
3466                matches!(serve_media(&config, &access, &request, None).unwrap(), RequestResponse::Bytes(data) if data == msgpack::pack(&Value::Bool(false)))
3467            );
3468        }
3469    }
3470
3471    #[test]
3472    fn binary_image_blob_renders_micron_media_reference() {
3473        let tmp = tempfile::tempdir().unwrap();
3474        let config = cfg(tmp.path());
3475        create_repo_bytes(
3476            config.repositories_dir.join("public/images"),
3477            "docs/a + b.PNG",
3478            &vec![0; 256 * 1024 + 1],
3479        );
3480        let page = render_page(
3481            PATH_BLOB,
3482            &config,
3483            &access(&config),
3484            &page_request(&[
3485                ("var_g", "public"),
3486                ("var_r", "images"),
3487                ("var_path", "docs/a + b.PNG"),
3488            ]),
3489            None,
3490        )
3491        .unwrap();
3492        assert!(
3493            page.contains("`(Image file`w=n`a=c`:/media/public/images/HEAD/docs%2Fa+%2B+b.PNG)")
3494        );
3495        assert!(!page.contains("Binary file is not displayed"));
3496    }
3497
3498    #[test]
3499    fn blob_path_normalization_removes_only_the_explicit_relative_prefix() {
3500        assert_eq!(normalize_blob_path("./docs/guide.md"), "docs/guide.md");
3501        assert_eq!(normalize_blob_path("docs/./guide.md"), "docs/guide.md");
3502        assert_eq!(normalize_blob_path(".hidden/config"), ".hidden/config");
3503        assert_eq!(normalize_blob_path(".../guide.md"), ".../guide.md");
3504        assert_eq!(normalize_blob_path("/absolute.md"), "/absolute.md");
3505        assert!(validate_git_path(&normalize_blob_path("/absolute.md")).is_err());
3506    }
3507    use crate::logging;
3508    use rns_core::msgpack::{self, Value};
3509    use rns_crypto::OsRng;
3510    use std::fs;
3511    use std::process::Command;
3512
3513    #[test]
3514    fn nomadnet_destination_uses_upstream_name() {
3515        let identity = Identity::new(&mut OsRng);
3516        let destination = destination_for_identity(&identity);
3517        let expected =
3518            Destination::single_in("nomadnetwork", &["node"], IdentityHash(*identity.hash()));
3519        assert_eq!(destination.hash, expected.hash);
3520    }
3521
3522    #[test]
3523    fn chart_renders_positive_values_on_bottom_row() {
3524        let labels = ["start".to_string(), "end".to_string()];
3525        let chart = render_chart(&[1, 100], &labels, "57c", None, 1.3, 10);
3526        let bottom_row = chart.lines().nth(10).unwrap();
3527
3528        assert!(bottom_row.starts_with("│`FT"));
3529        assert!(bottom_row.contains('▄'));
3530    }
3531
3532    #[test]
3533    fn git_command_timeout_kills_slow_process() {
3534        let start = Instant::now();
3535
3536        let err =
3537            run_git_output(Command::new("sleep").arg("5"), Duration::from_millis(30)).unwrap_err();
3538
3539        assert!(start.elapsed() < Duration::from_secs(1));
3540        assert!(err.to_string().contains("timed out"));
3541    }
3542
3543    #[test]
3544    fn commit_message_formatter_escapes_leading_dash_lines() {
3545        let formatted = format_commit_message("subject\n- rendered literally\npath\\with`tick`");
3546
3547        assert_eq!(
3548            formatted,
3549            "subject\n\\- rendered literally\npath\\\\with\\`tick\\`"
3550        );
3551    }
3552
3553    #[test]
3554    fn front_page_lists_only_readable_groups() {
3555        let tmp = tempfile::tempdir().unwrap();
3556        let mut config = cfg(tmp.path());
3557        config.allow_read = vec!["none".into()];
3558        create_repo(
3559            config.repositories_dir.join("public/alpha"),
3560            "README.md",
3561            "alpha readme\n",
3562        );
3563        create_repo(
3564            config.repositories_dir.join("private/secret"),
3565            "README.md",
3566            "secret\n",
3567        );
3568        fs::write(
3569            config.repositories_dir.join("public/group.allowed"),
3570            "read = all\n",
3571        )
3572        .unwrap();
3573        fs::write(
3574            config.repositories_dir.join("private/group.allowed"),
3575            "read = none\n",
3576        )
3577        .unwrap();
3578        let access = access(&config);
3579
3580        let page = render_page(PATH_INDEX, &config, &access, &page_request(&[]), None).unwrap();
3581        assert!(page.contains("public"));
3582        assert!(page.contains("alpha"));
3583        assert!(!page.contains("private"));
3584        assert!(!page.contains("secret"));
3585    }
3586
3587    #[test]
3588    fn group_and_repo_pages_use_repository_metadata() {
3589        let tmp = tempfile::tempdir().unwrap();
3590        let config = cfg(tmp.path());
3591        let repo = create_repo(
3592            config.repositories_dir.join("public/alpha"),
3593            "README.md",
3594            "# Alpha\n",
3595        );
3596        fs::write(repo.join("description"), "Alpha repository\n").unwrap();
3597        let access = access(&config);
3598
3599        let group = render_page(
3600            PATH_GROUP,
3601            &config,
3602            &access,
3603            &page_request(&[("var_g", "public")]),
3604            None,
3605        )
3606        .unwrap();
3607        assert!(group.contains("Alpha repository"));
3608        assert!(group.contains("alpha"));
3609
3610        let repo = render_page(
3611            PATH_REPO,
3612            &config,
3613            &access,
3614            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3615            None,
3616        )
3617        .unwrap();
3618        assert!(repo.contains("rns://"));
3619        assert!(repo.contains("Alpha repository"));
3620        assert!(repo.contains(">Alpha"));
3621    }
3622
3623    #[test]
3624    fn repo_page_renders_fork_and_mirror_sources() {
3625        let tmp = tempfile::tempdir().unwrap();
3626        let config = cfg(tmp.path());
3627        let fork = create_repo(
3628            config.repositories_dir.join("public/forked"),
3629            "README.md",
3630            "# Forked\n",
3631        );
3632        let mirror = create_repo(
3633            config.repositories_dir.join("public/mirrored"),
3634            "README.md",
3635            "# Mirrored\n",
3636        );
3637        run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3638            "config",
3639            "repository.rngit.type",
3640            "fork",
3641        ]));
3642        run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3643            "config",
3644            "repository.rngit.upstream.source",
3645            "rns://00112233445566778899aabbccddeeff/source/repo",
3646        ]));
3647        run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3648            "config",
3649            "repository.rngit.upstream.sync",
3650            "1",
3651        ]));
3652        run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3653            "config",
3654            "repository.rngit.type",
3655            "mirror",
3656        ]));
3657        run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3658            "config",
3659            "repository.rngit.upstream.source",
3660            "https://example.invalid/upstream.git",
3661        ]));
3662        run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3663            "config",
3664            "repository.rngit.upstream.sync",
3665            "1",
3666        ]));
3667        let access = access(&config);
3668
3669        let fork_page = render_page(
3670            PATH_REPO,
3671            &config,
3672            &access,
3673            &page_request(&[("var_g", "public"), ("var_r", "forked")]),
3674            None,
3675        )
3676        .unwrap();
3677        assert!(fork_page.contains("Forked from"));
3678        assert!(fork_page.contains("rns://00112233445566778899aabbccddeeff/source/repo"));
3679        assert!(fork_page.contains("synced"));
3680        assert!(fork_page.contains("ago"));
3681
3682        let mirror_page = render_page(
3683            PATH_REPO,
3684            &config,
3685            &access,
3686            &page_request(&[("var_g", "public"), ("var_r", "mirrored")]),
3687            None,
3688        )
3689        .unwrap();
3690        assert!(mirror_page.contains("Mirrored from"));
3691        assert!(mirror_page.contains("https://example.invalid/upstream.git"));
3692        assert!(mirror_page.contains("synced"));
3693        assert!(mirror_page.contains("ago"));
3694    }
3695
3696    #[test]
3697    fn tree_blob_commits_commit_and_refs_pages_are_git_backed() {
3698        let tmp = tempfile::tempdir().unwrap();
3699        let config = cfg(tmp.path());
3700        let repo = create_repo(
3701            config.repositories_dir.join("public/alpha"),
3702            "src/lib.rs",
3703            "pub fn answer() -> u8 { 42 }\n",
3704        );
3705        run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3706            "tag",
3707            "v1",
3708            "refs/heads/main",
3709        ]));
3710        let access = access(&config);
3711
3712        let tree = render_page(
3713            PATH_TREE,
3714            &config,
3715            &access,
3716            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3717            None,
3718        )
3719        .unwrap();
3720        assert!(tree.contains("src/"));
3721
3722        let blob = render_page(
3723            PATH_BLOB,
3724            &config,
3725            &access,
3726            &page_request(&[
3727                ("var_g", "public"),
3728                ("var_r", "alpha"),
3729                ("var_path", "src/lib.rs"),
3730            ]),
3731            None,
3732        )
3733        .unwrap();
3734        assert!(blob.contains("answer"));
3735        assert!(blob.contains("pub fn answer()"));
3736
3737        let commits = render_page(
3738            PATH_COMMITS,
3739            &config,
3740            &access,
3741            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3742            None,
3743        )
3744        .unwrap();
3745        assert!(commits.contains("initial"));
3746
3747        let commit_hash = run_git(
3748            Command::new("git")
3749                .arg("--git-dir")
3750                .arg(&repo)
3751                .args(["rev-parse", "refs/heads/main"]),
3752        );
3753        run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3754            "update-ref",
3755            "refs/heads/feature",
3756            commit_hash.trim(),
3757        ]));
3758        let branch_commits = render_page(
3759            PATH_COMMITS,
3760            &config,
3761            &access,
3762            &page_request(&[
3763                ("var_g", "public"),
3764                ("var_r", "alpha"),
3765                ("var_ref", "feature"),
3766            ]),
3767            None,
3768        )
3769        .unwrap();
3770        assert!(branch_commits.contains("|ref=feature|h="));
3771        let commit = render_page(
3772            PATH_COMMIT,
3773            &config,
3774            &access,
3775            &page_request(&[
3776                ("var_g", "public"),
3777                ("var_r", "alpha"),
3778                ("var_ref", "feature"),
3779                ("var_h", commit_hash.trim()),
3780            ]),
3781            None,
3782        )
3783        .unwrap();
3784        assert!(commit.contains("initial"));
3785        assert!(commit.contains("src/lib.rs"));
3786        assert!(commit.contains("/page/commits.mu`g=public|r=alpha|ref=feature"));
3787        assert!(!commit.contains("Signature:"));
3788
3789        let refs = render_page(
3790            PATH_REFS,
3791            &config,
3792            &access,
3793            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3794            None,
3795        )
3796        .unwrap();
3797        assert!(refs.contains("main"));
3798        assert!(refs.contains("v1"));
3799    }
3800
3801    #[test]
3802    fn commit_page_shows_valid_reticulum_signature_status() {
3803        let tmp = tempfile::tempdir().unwrap();
3804        let config = cfg(tmp.path());
3805        let repo = create_repo(
3806            config.repositories_dir.join("public/alpha"),
3807            "src/lib.rs",
3808            "pub fn answer() -> u8 { 42 }\n",
3809        );
3810        let access = access(&config);
3811        let tree = run_git(
3812            Command::new("git")
3813                .arg("--git-dir")
3814                .arg(&repo)
3815                .args(["rev-parse", "refs/heads/main^{tree}"]),
3816        );
3817        let identity = Identity::new(&mut OsRng);
3818        let author = crate::util::hex(identity.hash());
3819        let signed_content = format!(
3820            "tree {}\nauthor RNS Page Test <{}> 0 +0000\ncommitter RNS Page Test <{}> 0 +0000\n\nsigned commit\n",
3821            tree.trim(),
3822            author,
3823            author
3824        );
3825        let armored =
3826            crate::commitsigs::sign_message(&identity, signed_content.as_bytes()).unwrap();
3827        let (headers, rest) = signed_content.split_once("\n\n").unwrap();
3828        let mut raw_commit = format!("{headers}\n");
3829        let mut sig_lines = armored.lines();
3830        raw_commit.push_str("gpgsig ");
3831        raw_commit.push_str(sig_lines.next().unwrap());
3832        raw_commit.push('\n');
3833        for line in sig_lines {
3834            raw_commit.push(' ');
3835            raw_commit.push_str(line);
3836            raw_commit.push('\n');
3837        }
3838        raw_commit.push('\n');
3839        raw_commit.push_str(rest);
3840        let commit_file = tmp.path().join("signed-commit");
3841        fs::write(&commit_file, raw_commit).unwrap();
3842        let commit_hash = run_git(
3843            Command::new("git")
3844                .arg("--git-dir")
3845                .arg(&repo)
3846                .args(["hash-object", "-t", "commit", "-w"])
3847                .arg(&commit_file),
3848        );
3849        run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3850            "update-ref",
3851            "refs/heads/main",
3852            commit_hash.trim(),
3853        ]));
3854
3855        let page = render_page(
3856            PATH_COMMIT,
3857            &config,
3858            &access,
3859            &page_request(&[
3860                ("var_g", "public"),
3861                ("var_r", "alpha"),
3862                ("var_h", commit_hash.trim()),
3863            ]),
3864            None,
3865        )
3866        .unwrap();
3867
3868        assert!(page.contains("signed commit"));
3869        assert!(page.contains("Signature: Valid, signed by author"));
3870    }
3871
3872    #[test]
3873    fn blob_page_renders_tree_when_path_is_directory() {
3874        let tmp = tempfile::tempdir().unwrap();
3875        let config = cfg(tmp.path());
3876        create_repo(
3877            config.repositories_dir.join("public/alpha"),
3878            "src/lib.rs",
3879            "pub fn answer() -> u8 { 42 }\n",
3880        );
3881        let access = access(&config);
3882
3883        let page = render_page(
3884            PATH_BLOB,
3885            &config,
3886            &access,
3887            &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_path", "src")]),
3888            None,
3889        )
3890        .unwrap();
3891
3892        assert!(page.contains("files / src"));
3893        assert!(page.contains("lib.rs"));
3894        assert!(page.contains(PATH_BLOB));
3895        assert!(!page.contains("Displaying Raw"));
3896    }
3897
3898    #[test]
3899    fn page_requests_reject_invalid_repository_names() {
3900        let tmp = tempfile::tempdir().unwrap();
3901        let config = cfg(tmp.path());
3902        let access = access(&config);
3903        let err = render_page(
3904            PATH_REPO,
3905            &config,
3906            &access,
3907            &page_request(&[("var_g", ".."), ("var_r", "repo")]),
3908            None,
3909        )
3910        .unwrap_err();
3911        assert!(err.to_string().contains("invalid repository name"));
3912    }
3913
3914    #[test]
3915    fn page_ref_resolution_rejects_invalid_refs_before_git_lookup() {
3916        let tmp = tempfile::tempdir().unwrap();
3917        let config = cfg(tmp.path());
3918        create_repo(
3919            config.repositories_dir.join("public/alpha"),
3920            "README.md",
3921            "# Alpha\n",
3922        );
3923        let access = access(&config);
3924
3925        for (path, vars) in [
3926            (
3927                PATH_TREE,
3928                vec![
3929                    ("var_g", "public"),
3930                    ("var_r", "alpha"),
3931                    ("var_ref", "--upload-pack=/tmp/x"),
3932                ],
3933            ),
3934            (
3935                PATH_BLOB,
3936                vec![
3937                    ("var_g", "public"),
3938                    ("var_r", "alpha"),
3939                    ("var_path", "README.md"),
3940                    ("var_ref", "refs/heads/main:README.md"),
3941                ],
3942            ),
3943            (
3944                PATH_COMMITS,
3945                vec![
3946                    ("var_g", "public"),
3947                    ("var_r", "alpha"),
3948                    ("var_ref", "refs/heads/main..refs/heads/other"),
3949                ],
3950            ),
3951        ] {
3952            let err = render_page(path, &config, &access, &page_request(&vars), None).unwrap_err();
3953            assert_eq!(err.to_string(), "invalid git ref");
3954        }
3955    }
3956
3957    #[test]
3958    fn pages_use_base_template_breadcrumbs_and_micron_links() {
3959        let tmp = tempfile::tempdir().unwrap();
3960        let config = cfg(tmp.path());
3961        create_repo(
3962            config.repositories_dir.join("public/alpha"),
3963            "README.md",
3964            "# Alpha\n",
3965        );
3966        let access = access(&config);
3967
3968        let repo = render_page(
3969            PATH_REPO,
3970            &config,
3971            &access,
3972            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3973            None,
3974        )
3975        .unwrap();
3976
3977        assert!(repo.starts_with("#!c=0\n>Test Git Node\n\n>>\n"));
3978        assert!(repo.contains("`!`[Node`:/page/index.mu]`!"));
3979        assert!(repo.contains("`!`[public`:/page/group.mu`g=public]`!"));
3980        assert!(repo.contains("`[Files`:/page/tree.mu`g=public|r=alpha|ref=HEAD]"));
3981        assert!(repo.contains("`[Commits"));
3982        assert!(repo.contains("`[Branches (1)`:/page/refs.mu`g=public|r=alpha|type=heads]"));
3983        assert!(repo.contains("`[Tags (0)`:/page/refs.mu`g=public|r=alpha|type=tags]"));
3984        assert!(repo.contains("<\n-\n`a`F666`[Served by rngit`:/page/index.mu]"));
3985    }
3986
3987    #[test]
3988    fn custom_templates_wrap_page_content_and_replace_variables() {
3989        let tmp = tempfile::tempdir().unwrap();
3990        let config = cfg(tmp.path());
3991        fs::create_dir_all(&config.templates_dir).unwrap();
3992        fs::write(
3993            config.templates_dir.join("base.mu"),
3994            "BASE {NODE_NAME} {VERSION} {GEN_TIME}\n{PAGE_CONTENT}\n{NAVIGATION}   \n",
3995        )
3996        .unwrap();
3997        fs::write(
3998            config.templates_dir.join("repo.mu"),
3999            "REPO-BEGIN\n{PAGE_CONTENT}\nREPO-END   \n",
4000        )
4001        .unwrap();
4002        create_repo(
4003            config.repositories_dir.join("public/alpha"),
4004            "README.md",
4005            "# Alpha\n",
4006        );
4007        let access = access(&config);
4008
4009        let repo = render_page(
4010            PATH_REPO,
4011            &config,
4012            &access,
4013            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4014            None,
4015        )
4016        .unwrap();
4017
4018        assert!(repo.starts_with("BASE Test Git Node "));
4019        assert!(repo.contains("\nREPO-BEGIN\n"));
4020        assert!(repo.contains(">Alpha"));
4021        assert!(repo.contains("\nREPO-END\n"));
4022        assert!(repo.contains(env!("CARGO_PKG_VERSION")));
4023        assert!(repo.contains("0ms"));
4024        assert!(!repo.ends_with("   \n"));
4025    }
4026
4027    #[test]
4028    fn blocked_unidentified_requests_use_no_ident_page_for_every_page_path() {
4029        let tmp = tempfile::tempdir().unwrap();
4030        let mut config = cfg(tmp.path());
4031        config.blocked_identities.push(NULL_IDENTITY_HASH);
4032        let access = access(&config);
4033
4034        for path in PAGE_PATHS {
4035            let page = render_page(path, &config, &access, &page_request(&[]), None).unwrap();
4036            assert!(
4037                page.contains(
4038                    ">>No Identity\n\nThis page requires identification, and none was received."
4039                ),
4040                "{path} did not render the no-ident page: {page}"
4041            );
4042        }
4043    }
4044
4045    #[test]
4046    fn no_ident_page_requires_both_missing_identity_and_blocked_null_hash() {
4047        let tmp = tempfile::tempdir().unwrap();
4048        let mut config = cfg(tmp.path());
4049        let access = access(&config);
4050
4051        let allowed_unidentified =
4052            render_page(PATH_INDEX, &config, &access, &page_request(&[]), None).unwrap();
4053        assert!(!allowed_unidentified.contains(">>No Identity"));
4054
4055        config.blocked_identities.push(NULL_IDENTITY_HASH);
4056        let identified = [0x42; 16];
4057        let allowed_identified = render_page(
4058            PATH_INDEX,
4059            &config,
4060            &access,
4061            &page_request(&[]),
4062            Some(&identified),
4063        )
4064        .unwrap();
4065        assert!(!allowed_identified.contains(">>No Identity"));
4066    }
4067
4068    #[test]
4069    fn custom_no_ident_template_overrides_the_default() {
4070        let tmp = tempfile::tempdir().unwrap();
4071        let mut config = cfg(tmp.path());
4072        config.blocked_identities.push(NULL_IDENTITY_HASH);
4073        fs::create_dir_all(&config.templates_dir).unwrap();
4074        fs::write(
4075            config.templates_dir.join("no_ident.mu"),
4076            ">>Identify First\n\nCustom unidentified response for {NODE_NAME}.\n",
4077        )
4078        .unwrap();
4079        let access = access(&config);
4080
4081        let page = render_page(PATH_INDEX, &config, &access, &page_request(&[]), None).unwrap();
4082
4083        assert!(page.contains(">>Identify First"));
4084        assert!(page.contains("Custom unidentified response for Test Git Node."));
4085        assert!(!page.contains(">>No Identity"));
4086    }
4087
4088    #[test]
4089    fn template_substitution_does_not_rewrite_page_content_placeholders() {
4090        let tmp = tempfile::tempdir().unwrap();
4091        let config = cfg(tmp.path());
4092
4093        let out = apply_template(
4094            "Template {NODE_NAME}\n{PAGE_CONTENT}",
4095            "Literal {NODE_NAME}",
4096            &config,
4097        );
4098
4099        assert!(out.starts_with("Template Test Git Node\n"));
4100        assert!(out.ends_with("Literal {NODE_NAME}"));
4101    }
4102
4103    #[test]
4104    fn unicode_icon_config_changes_repository_navigation_labels() {
4105        let tmp = tempfile::tempdir().unwrap();
4106        let mut config = cfg(tmp.path());
4107        config.unicode_icons = true;
4108        create_repo(
4109            config.repositories_dir.join("public/alpha"),
4110            "README.md",
4111            "# Alpha\n",
4112        );
4113        let access = access(&config);
4114
4115        let repo = render_page(
4116            PATH_REPO,
4117            &config,
4118            &access,
4119            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4120            None,
4121        )
4122        .unwrap();
4123
4124        assert!(repo.contains("🗀 Files"));
4125        assert!(repo.contains("🖹 Commits"));
4126        assert!(repo.contains("⑃ Branches"));
4127        assert!(repo.contains("⌆ Tags"));
4128    }
4129
4130    #[test]
4131    fn repo_page_renders_and_persists_thanks_count() {
4132        let tmp = tempfile::tempdir().unwrap();
4133        let config = cfg(tmp.path());
4134        let repo_path = create_repo(
4135            config.repositories_dir.join("public/alpha"),
4136            "README.md",
4137            "# Alpha\n",
4138        );
4139        let access = access(&config);
4140
4141        let first = render_page(
4142            PATH_REPO,
4143            &config,
4144            &access,
4145            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4146            None,
4147        )
4148        .unwrap();
4149        assert!(first.contains("Thanks (0)"));
4150        assert!(!repo_path.with_extension("thanks").exists());
4151
4152        let thanked = render_page(
4153            PATH_REPO,
4154            &config,
4155            &access,
4156            &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
4157            None,
4158        )
4159        .unwrap();
4160        assert!(thanked.contains("Thanks (1)"));
4161        assert_eq!(
4162            fs::read_to_string(repo_path.with_extension("thanks")).unwrap(),
4163            "1\n"
4164        );
4165
4166        let incremented = render_page(
4167            PATH_REPO,
4168            &config,
4169            &access,
4170            &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
4171            None,
4172        )
4173        .unwrap();
4174        assert!(incremented.contains("Thanks (2)"));
4175
4176        let viewed = render_page(
4177            PATH_REPO,
4178            &config,
4179            &access,
4180            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4181            None,
4182        )
4183        .unwrap();
4184        assert!(viewed.contains("Thanks (2)"));
4185    }
4186
4187    #[test]
4188    fn repo_page_uses_unicode_heart_for_thanks_when_enabled() {
4189        let tmp = tempfile::tempdir().unwrap();
4190        let mut config = cfg(tmp.path());
4191        config.unicode_icons = true;
4192        create_repo(
4193            config.repositories_dir.join("public/alpha"),
4194            "README.md",
4195            "# Alpha\n",
4196        );
4197        let access = access(&config);
4198
4199        let page = render_page(
4200            PATH_REPO,
4201            &config,
4202            &access,
4203            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4204            None,
4205        )
4206        .unwrap();
4207
4208        assert!(page.contains("♥ Thanks (0)"));
4209        assert!(page.contains("thanks=y"));
4210    }
4211
4212    #[test]
4213    fn empty_page_request_data_is_deterministic() {
4214        let tmp = tempfile::tempdir().unwrap();
4215        let config = cfg(tmp.path());
4216        let access = access(&config);
4217
4218        let index = render_page(PATH_INDEX, &config, &access, &[], None).unwrap();
4219        assert!(index.contains(">Groups"));
4220
4221        let err = render_page(PATH_REPO, &config, &access, &[], None).unwrap_err();
4222        assert!(err.to_string().contains("missing page variable g"));
4223    }
4224
4225    #[test]
4226    fn tree_blob_and_commit_pages_escape_content_and_format_metadata() {
4227        let tmp = tempfile::tempdir().unwrap();
4228        let config = cfg(tmp.path());
4229        let repo = create_repo(
4230            config.repositories_dir.join("public/alpha"),
4231            "src/lib.rs",
4232            "pub fn `answer`() -> u8 { 42 }\n",
4233        );
4234        let access = access(&config);
4235
4236        let tree = render_page(
4237            PATH_TREE,
4238            &config,
4239            &access,
4240            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4241            None,
4242        )
4243        .unwrap();
4244        assert!(tree.contains("`[src/`:/page/tree.mu`g=public|r=alpha|ref=HEAD|path=src]"));
4245
4246        let blob = render_page(
4247            PATH_BLOB,
4248            &config,
4249            &access,
4250            &page_request(&[
4251                ("var_g", "public"),
4252                ("var_r", "alpha"),
4253                ("var_path", "src/lib.rs"),
4254            ]),
4255            None,
4256        )
4257        .unwrap();
4258        assert!(blob.contains("Text, "));
4259        assert!(!blob.contains("View raw"));
4260        assert!(blob.contains("answer"));
4261        assert!(blob.contains("\\`"));
4262
4263        let silly_blob = render_page(
4264            PATH_BLOB,
4265            &config,
4266            &access,
4267            &page_request(&[
4268                ("var_g", "public"),
4269                ("var_r", "alpha"),
4270                ("var_path", "./src/./lib.rs"),
4271            ]),
4272            None,
4273        )
4274        .unwrap();
4275        assert!(silly_blob.contains(">src/lib.rs `F666"));
4276        assert!(silly_blob.contains("answer"));
4277
4278        let commit_hash = run_git(
4279            Command::new("git")
4280                .arg("--git-dir")
4281                .arg(&repo)
4282                .args(["rev-parse", "refs/heads/main"]),
4283        );
4284        let commit = render_page(
4285            PATH_COMMIT,
4286            &config,
4287            &access,
4288            &page_request(&[
4289                ("var_g", "public"),
4290                ("var_r", "alpha"),
4291                ("var_h", commit_hash.trim()),
4292            ]),
4293            None,
4294        )
4295        .unwrap();
4296        assert!(commit.contains("Author: RNS Page Test"));
4297        assert!(commit.contains("Files changed"));
4298        assert!(commit.contains("A src/lib.rs"));
4299    }
4300
4301    #[test]
4302    fn markdown_converter_handles_links_code_lists_rules_and_tables() {
4303        let input = "# Title\n\
4304\n\
4305See [the docs](https://example.invalid/a_b) and `literal *code*`.\n\
4306\n\
4307- **bold** and *italic*\n\
4308---\n\
4309| Name | Link |\n\
4310| --- | --- |\n\
4311| Row | [go](rns://abc) |\n";
4312
4313        let out = markdown_to_micron(input);
4314        assert!(out.contains(">Title"));
4315        assert!(out.contains("`_`!`[the docs`https://example.invalid/a_b]`!`_"));
4316        assert!(out.contains("`BT383838`Fdddliteral *code*`f`b"));
4317        assert!(out.contains(" • `!bold`! and `*italic`*"));
4318        assert!(out.contains("\n-\n"));
4319        assert!(out.contains("│ Name"));
4320        assert!(out.contains("`_`!`[go`rns://abc]`!`_"));
4321    }
4322
4323    #[test]
4324    fn markdown_converter_handles_blockquotes_and_heading_inline_markup() {
4325        let out = markdown_to_micron(
4326            "# **Important**\n\
4327> **Quoted** text\n\
4328> with [link](rns://abc)\n\
4329\n\
4330after\n",
4331        );
4332        assert!(out.contains(">`!Important`!"));
4333        assert!(out.contains(" │ `!Quoted`! text with `_`!`[link`rns://abc]`!`_"));
4334        assert!(out.contains("\nafter\n"));
4335    }
4336
4337    #[test]
4338    fn markdown_link_conversion_is_isolated_from_later_substitutions() {
4339        let out = markdown_to_micron("[**bold** `code`](https://example.invalid?q=*x*)");
4340        assert!(out.contains("`_`!`[**bold** code`https://example.invalid?q=*x*]`!`_"));
4341        assert!(!out.contains("`!`!"));
4342        assert!(!out.contains("`*x`*"));
4343    }
4344
4345    #[test]
4346    fn markdown_link_style_can_disable_wrappers_and_apply_color() {
4347        let plain = format_markdown_link(
4348            "label",
4349            "rns://abc",
4350            None,
4351            MarkdownLinkStyle {
4352                bold: false,
4353                underline: false,
4354                color: None,
4355            },
4356        );
4357        assert_eq!(plain, "`[label`rns://abc]");
4358
4359        let short_color = format_markdown_link(
4360            "label",
4361            "rns://abc",
4362            None,
4363            MarkdownLinkStyle {
4364                bold: true,
4365                underline: false,
4366                color: Some("abc"),
4367            },
4368        );
4369        assert_eq!(short_color, "`Fabc`!`[label`rns://abc]`!`f");
4370
4371        let true_color = format_markdown_link(
4372            "label",
4373            "rns://abc",
4374            None,
4375            MarkdownLinkStyle {
4376                bold: false,
4377                underline: true,
4378                color: Some("aabbcc"),
4379            },
4380        );
4381        assert_eq!(true_color, "`FTaabbcc`_`[label`rns://abc]`_`f");
4382    }
4383
4384    #[test]
4385    fn markdown_inline_code_that_looks_like_link_does_not_hide_real_links() {
4386        let out = markdown_to_micron(
4387            "`[literal](https://example.invalid/no)` and [real](https://example.invalid/yes)",
4388        );
4389
4390        assert!(out.contains("`BT383838`Fddd[literal](https://example.invalid/no)`f`b"));
4391        assert!(out.contains("`_`!`[real`https://example.invalid/yes]`!`_"));
4392        assert!(!out.contains("`_`!`[literal`https://example.invalid/no]`!`_"));
4393    }
4394
4395    #[test]
4396    fn markdown_tables_handle_empty_cells_escaped_pipes_alignment_and_visible_width() {
4397        let input = "| Name | Status | Notes |\n\
4398| :--- | ---: | :---: |\n\
4399| `code` | **ok** | a\\|b |\n\
4400| empty | | [go](rns://abc) |\n";
4401
4402        let out = markdown_to_micron(input);
4403        assert!(out.contains("┌"));
4404        assert!(out.contains("├"));
4405        assert!(out.contains("└"));
4406        assert!(out.contains("│ Name"));
4407        assert!(out.contains("`BT383838`Fdddcode`f`b"));
4408        assert!(out.contains("`!ok`!"));
4409        assert!(out.contains("a|b"));
4410        assert!(out.contains("empty"));
4411        assert!(out.contains("`_`!`[go`rns://abc]`!`_"));
4412        assert!(!out.contains("a\\|b"));
4413    }
4414
4415    #[test]
4416    fn markdown_table_width_ignores_generated_micron_tags() {
4417        let input = "| A | B |\n\
4418| --- | --- |\n\
4419| [x](rns://abc) | `y` |\n";
4420
4421        let out = markdown_to_micron(input);
4422        assert!(out.contains("│ `_`!`[x`rns://abc]`!`_ "));
4423        assert!(out.contains("│ `BT383838`Fdddy`f`b "));
4424        assert!(out.contains("┌─────┬─────┐"));
4425    }
4426
4427    #[test]
4428    fn markdown_escaping_does_not_create_or_corrupt_micron_tags() {
4429        let out =
4430            markdown_to_micron("literal `!not bold`! and [bad`label](rns://abc`def) plus `raw`");
4431        assert!(out.contains("literal \\`!not bold\\`!"));
4432        assert!(out.contains("`_`!`[badlabel`rns://abcdef]`!`_"));
4433        assert!(out.contains("`BT383838`Fdddraw`f`b"));
4434        assert!(!out.contains("`!not bold`!"));
4435    }
4436
4437    #[test]
4438    fn markdown_escapes_backslashes_and_expands_tabs_before_inline_formatting() {
4439        let out = markdown_to_micron("plain\\path\t**bold\\path** and `code\\path`\n");
4440
4441        assert!(out.contains("plain\\\\path   `!bold\\\\path`!"));
4442        assert!(out.contains("`BT383838`Fdddcode\\\\path`f`b"));
4443    }
4444
4445    #[test]
4446    fn markdown_plain_lines_escape_micron_line_start_controls() {
4447        let out = markdown_to_micron("-not a list\n<not alignment\n- list item\n---\n");
4448        assert!(out.contains("\\-not a list\n"));
4449        assert!(out.contains("\\<not alignment\n"));
4450        assert!(out.contains(" • list item\n"));
4451        assert!(out.contains("-\n"));
4452    }
4453
4454    #[test]
4455    fn markdown_converter_keeps_code_blocks_and_unmatched_markers_literal() {
4456        let out = markdown_to_micron(
4457            "```rust\n\
4458**not bold** and `not inline`\n\
4459```\n\
4460Unmatched * marker\n\
44611. numbered\n",
4462        );
4463        assert!(out.contains("not bold"));
4464        assert!(out.contains("\\`not inline\\`"));
4465        assert!(out.contains("`=\n**not bold** and \\`not inline\\`\n`=\n"));
4466        assert!(!out.contains("`!not bold`!"));
4467        assert!(out.contains("Unmatched * marker"));
4468        assert!(out.contains("1. numbered"));
4469    }
4470
4471    #[test]
4472    fn rawmu_fenced_blocks_pass_through_without_escaping() {
4473        let markdown = markdown_to_micron(
4474            "```rawmu
4475`!raw micron`!
4476>Heading
4477```
4478",
4479        );
4480
4481        assert!(markdown.contains(
4482            "`!raw micron`!
4483>Heading
4484"
4485        ));
4486        assert!(!markdown.contains(r"\`!raw micron"));
4487        assert!(!markdown.contains(
4488            "`=
4489"
4490        ));
4491    }
4492
4493    #[test]
4494    fn unknown_extension_and_fence_language_fall_back_to_plain_literals() {
4495        let blob = crate::highlight::literal_block("value `tick`\n", Some("blob.unknown"), None);
4496        assert!(!blob.contains("`FT"));
4497        assert!(blob.contains("value \\`tick\\`"));
4498
4499        let markdown = markdown_to_micron("```not-a-language\nvalue `tick`\n```\n");
4500        assert!(!markdown.contains("`FT"));
4501        assert!(markdown.contains("value \\`tick\\`"));
4502    }
4503
4504    #[test]
4505    fn binary_and_oversized_blobs_use_plain_fallback_messages() {
4506        let tmp = tempfile::tempdir().unwrap();
4507        let config = cfg(tmp.path());
4508        create_repo_bytes(
4509            config.repositories_dir.join("public/binary"),
4510            "payload.bin",
4511            b"abc\0def",
4512        );
4513        let large = vec![b'x'; 256 * 1024 + 1];
4514        create_repo_bytes(
4515            config.repositories_dir.join("public/large"),
4516            "payload.rs",
4517            &large,
4518        );
4519        let access = access(&config);
4520
4521        let binary = render_page(
4522            PATH_BLOB,
4523            &config,
4524            &access,
4525            &page_request(&[
4526                ("var_g", "public"),
4527                ("var_r", "binary"),
4528                ("var_path", "payload.bin"),
4529            ]),
4530            None,
4531        )
4532        .unwrap();
4533        assert!(binary.contains("Binary"));
4534        assert!(binary.contains("Binary file is not displayed."));
4535        assert!(!binary.contains("`FT"));
4536
4537        let oversized = render_page(
4538            PATH_BLOB,
4539            &config,
4540            &access,
4541            &page_request(&[
4542                ("var_g", "public"),
4543                ("var_r", "large"),
4544                ("var_path", "payload.rs"),
4545            ]),
4546            None,
4547        )
4548        .unwrap();
4549        assert!(oversized.contains("File is too large to display"));
4550        assert!(!oversized.contains("`FT"));
4551    }
4552
4553    #[test]
4554    fn markdown_blob_defaults_to_rendered_view_and_raw_view_escapes_source() {
4555        let tmp = tempfile::tempdir().unwrap();
4556        let config = cfg(tmp.path());
4557        create_repo(
4558            config.repositories_dir.join("public/docs"),
4559            "docs/readme.md",
4560            "# Title\n\nSee [docs](https://example.invalid), [guide](guide.md#intro) and `tick`.\n",
4561        );
4562        let access = access(&config);
4563
4564        let rendered = render_page(
4565            PATH_BLOB,
4566            &config,
4567            &access,
4568            &page_request(&[
4569                ("var_g", "public"),
4570                ("var_r", "docs"),
4571                ("var_path", "docs/readme.md"),
4572            ]),
4573            None,
4574        )
4575        .unwrap();
4576        assert!(rendered.contains("Displaying Rendered"));
4577        assert!(rendered.contains("View raw"));
4578        assert!(rendered.contains(">Title"));
4579        assert!(rendered.contains("`_`!`[docs`https://example.invalid]`!`_"));
4580        assert!(rendered.contains(
4581            "`_`!`[guide`:/page/blob.mu`g=public|r=docs|ref=HEAD|path=docs/guide.md|anchor=intro]`!`_"
4582        ));
4583        assert!(rendered.contains("`BT383838`Fdddtick`f`b"));
4584        assert!(!rendered.contains("# Title"));
4585
4586        let raw = render_page(
4587            PATH_BLOB,
4588            &config,
4589            &access,
4590            &page_request(&[
4591                ("var_g", "public"),
4592                ("var_r", "docs"),
4593                ("var_path", "docs/readme.md"),
4594                ("var_raw", "y"),
4595            ]),
4596            None,
4597        )
4598        .unwrap();
4599        assert!(raw.contains("Displaying Raw"));
4600        assert!(raw.contains("View rendered"));
4601        assert!(raw.contains("Title"));
4602        assert!(raw.contains("\\`"));
4603        assert!(raw.contains("tick"));
4604        assert!(!raw.contains("`_`!`[docs`https://example.invalid]`!`_"));
4605    }
4606
4607    #[test]
4608    fn micron_blob_defaults_to_rendered_passthrough_and_raw_view_is_literal() {
4609        let tmp = tempfile::tempdir().unwrap();
4610        let config = cfg(tmp.path());
4611        create_repo(
4612            config.repositories_dir.join("public/micron"),
4613            "README.mu",
4614            ">Micron\n\n`!Already formatted`!\n",
4615        );
4616        let access = access(&config);
4617
4618        let rendered = render_page(
4619            PATH_BLOB,
4620            &config,
4621            &access,
4622            &page_request(&[
4623                ("var_g", "public"),
4624                ("var_r", "micron"),
4625                ("var_path", "README.mu"),
4626            ]),
4627            None,
4628        )
4629        .unwrap();
4630        assert!(rendered.contains("Displaying Rendered"));
4631        assert!(rendered.contains(">Micron"));
4632        assert!(rendered.contains("`!Already formatted`!"));
4633        assert!(!rendered.contains("\\`!Already formatted\\`!"));
4634
4635        let raw = render_page(
4636            PATH_BLOB,
4637            &config,
4638            &access,
4639            &page_request(&[
4640                ("var_g", "public"),
4641                ("var_r", "micron"),
4642                ("var_path", "README.mu"),
4643                ("var_raw", "y"),
4644            ]),
4645            None,
4646        )
4647        .unwrap();
4648        assert!(raw.contains("Displaying Raw"));
4649        assert!(raw.contains("\\`!Already formatted\\`!"));
4650    }
4651
4652    #[test]
4653    fn explicit_render_parameter_renders_markdown_blob() {
4654        let tmp = tempfile::tempdir().unwrap();
4655        let config = cfg(tmp.path());
4656        create_repo(
4657            config.repositories_dir.join("public/docs"),
4658            "docs/readme.md",
4659            "# Explicit\n",
4660        );
4661        let access = access(&config);
4662
4663        let rendered = render_page(
4664            PATH_BLOB,
4665            &config,
4666            &access,
4667            &page_request(&[
4668                ("var_g", "public"),
4669                ("var_r", "docs"),
4670                ("var_path", "docs/readme.md"),
4671                ("var_raw", "y"),
4672                ("var_render", "y"),
4673            ]),
4674            None,
4675        )
4676        .unwrap();
4677        assert!(rendered.contains("Displaying Raw"));
4678        assert!(rendered.contains("#"));
4679        assert!(rendered.contains("Explicit"));
4680
4681        let rendered = render_page(
4682            PATH_BLOB,
4683            &config,
4684            &access,
4685            &page_request(&[
4686                ("var_g", "public"),
4687                ("var_r", "docs"),
4688                ("var_path", "docs/readme.md"),
4689                ("var_render", "y"),
4690            ]),
4691            None,
4692        )
4693        .unwrap();
4694        assert!(rendered.contains("Displaying Rendered"));
4695        assert!(rendered.contains(">Explicit"));
4696        assert!(!rendered.contains("# Explicit"));
4697    }
4698
4699    #[test]
4700    fn markdown_blob_downloads_as_scoped_micron() {
4701        let tmp = tempfile::tempdir().unwrap();
4702        let config = cfg(tmp.path());
4703        create_repo(
4704            config.repositories_dir.join("public/docs"),
4705            "docs/readme.md",
4706            "# Guide\n\n[Next](next.md)\n",
4707        );
4708        let access = access(&config);
4709        let page = render_page(
4710            PATH_BLOB,
4711            &config,
4712            &access,
4713            &page_request(&[
4714                ("var_g", "public"),
4715                ("var_r", "docs"),
4716                ("var_path", "docs/readme.md"),
4717            ]),
4718            None,
4719        )
4720        .unwrap();
4721        assert!(page.contains("Download"));
4722        assert!(page.contains("as micron"));
4723        assert!(page.contains("fmt=mu"));
4724
4725        let request = page_request(&[
4726            ("var_g", "public"),
4727            ("var_r", "docs"),
4728            ("var_path", "docs/readme.md"),
4729            ("var_fmt", "mu"),
4730        ]);
4731        let response = download_file(&config, &access, &request, None).unwrap();
4732        let RequestResponse::Resource { data, metadata, .. } = response else {
4733            panic!("expected converted resource");
4734        };
4735        let converted = String::from_utf8(data).unwrap();
4736        assert!(converted.contains(">Guide"));
4737        assert!(converted.contains("path=docs/next.md"));
4738        let metadata = msgpack::unpack_exact(&metadata.unwrap()).unwrap();
4739        assert_eq!(
4740            metadata.map_get("name").and_then(Value::as_bin),
4741            Some(&b"readme.mu"[..])
4742        );
4743
4744        let raw = download_file(
4745            &config,
4746            &access,
4747            &page_request(&[
4748                ("var_g", "public"),
4749                ("var_r", "docs"),
4750                ("var_path", "docs/readme.md"),
4751            ]),
4752            None,
4753        )
4754        .unwrap();
4755        assert_resource_response(raw, b"# Guide\n\n[Next](next.md)\n");
4756    }
4757
4758    #[test]
4759    fn unsupported_blob_extension_ignores_render_parameter_and_uses_raw_literal() {
4760        let tmp = tempfile::tempdir().unwrap();
4761        let config = cfg(tmp.path());
4762        create_repo(
4763            config.repositories_dir.join("public/text"),
4764            "notes.txt",
4765            "# Not a heading\npath\\name\t`literal`\n",
4766        );
4767        let access = access(&config);
4768
4769        let page = render_page(
4770            PATH_BLOB,
4771            &config,
4772            &access,
4773            &page_request(&[
4774                ("var_g", "public"),
4775                ("var_r", "text"),
4776                ("var_path", "notes.txt"),
4777                ("var_render", "y"),
4778            ]),
4779            None,
4780        )
4781        .unwrap();
4782        assert!(!page.contains("Displaying Rendered"));
4783        assert!(!page.contains("View raw"));
4784        assert!(page.contains("Displaying Raw"));
4785        assert!(page.contains("Download"));
4786        assert!(page.contains("Not a heading"));
4787        assert!(page.contains("path\\\\name   \\`literal\\`"));
4788        assert!(page.contains("\\`literal\\`"));
4789    }
4790
4791    #[test]
4792    fn renderable_binary_and_oversized_blobs_do_not_render_content() {
4793        let tmp = tempfile::tempdir().unwrap();
4794        let config = cfg(tmp.path());
4795        create_repo_bytes(
4796            config.repositories_dir.join("public/binary-md"),
4797            "payload.md",
4798            b"# Binary\0payload\n",
4799        );
4800        let large = vec![b'#'; 256 * 1024 + 1];
4801        create_repo_bytes(
4802            config.repositories_dir.join("public/large-md"),
4803            "payload.md",
4804            &large,
4805        );
4806        let access = access(&config);
4807
4808        let binary = render_page(
4809            PATH_BLOB,
4810            &config,
4811            &access,
4812            &page_request(&[
4813                ("var_g", "public"),
4814                ("var_r", "binary-md"),
4815                ("var_path", "payload.md"),
4816            ]),
4817            None,
4818        )
4819        .unwrap();
4820        assert!(binary.contains("Binary"));
4821        assert!(binary.contains("Binary file is not displayed."));
4822        assert!(!binary.contains(">Binary"));
4823
4824        let oversized = render_page(
4825            PATH_BLOB,
4826            &config,
4827            &access,
4828            &page_request(&[
4829                ("var_g", "public"),
4830                ("var_r", "large-md"),
4831                ("var_path", "payload.md"),
4832                ("var_render", "y"),
4833            ]),
4834            None,
4835        )
4836        .unwrap();
4837        assert!(oversized.contains("File is too large to display"));
4838        assert!(!oversized.contains("Displaying Rendered\n\n>"));
4839    }
4840
4841    #[test]
4842    fn code_blocks_render_plain_literal_blocks() {
4843        let blob = crate::highlight::literal_block("pub fn main() {}\n", Some("main.rs"), None);
4844        assert!(!blob.contains("`FT"));
4845        assert!(blob.contains("pub fn main() {}"));
4846
4847        let markdown = markdown_to_micron("```rust\npub fn main() {}\n```\n");
4848        assert!(!markdown.contains("`FT"));
4849        assert!(markdown.contains("pub fn main() {}"));
4850    }
4851
4852    #[test]
4853    fn repo_page_renders_markdown_readme_and_passes_micron_readme_through() {
4854        let tmp = tempfile::tempdir().unwrap();
4855        let config = cfg(tmp.path());
4856        create_repo(
4857            config.repositories_dir.join("public/markdown"),
4858            "README.md",
4859            "# Markdown\n\nSee [docs](https://example.invalid) and [local](docs/intro.md).\n",
4860        );
4861        create_repo(
4862            config.repositories_dir.join("public/micron"),
4863            "README.mu",
4864            ">Micron\n\n`!Already formatted`!\n",
4865        );
4866        create_repo(
4867            config.repositories_dir.join("public/microntight"),
4868            "README.mu",
4869            ">Micron without trailing newline",
4870        );
4871        create_repo(
4872            config.repositories_dir.join("public/micronblank"),
4873            "README.mu",
4874            ">Micron with trailing blanks\n\n\n",
4875        );
4876        let access = access(&config);
4877
4878        let markdown = render_page(
4879            PATH_REPO,
4880            &config,
4881            &access,
4882            &page_request(&[("var_g", "public"), ("var_r", "markdown")]),
4883            None,
4884        )
4885        .unwrap();
4886        assert!(markdown.contains(">Markdown"));
4887        assert!(markdown.contains("`_`!`[docs`https://example.invalid]`!`_"));
4888        assert!(markdown.contains(
4889            "`_`!`[local`:/page/blob.mu`g=public|r=markdown|ref=HEAD|path=docs/intro.md]`!`_"
4890        ));
4891
4892        let micron = render_page(
4893            PATH_REPO,
4894            &config,
4895            &access,
4896            &page_request(&[("var_g", "public"), ("var_r", "micron")]),
4897            None,
4898        )
4899        .unwrap();
4900        assert!(micron.contains(">Micron"));
4901        assert!(micron.contains("`!Already formatted`!"));
4902
4903        let micron = render_page(
4904            PATH_REPO,
4905            &config,
4906            &access,
4907            &page_request(&[("var_g", "public"), ("var_r", "microntight")]),
4908            None,
4909        )
4910        .unwrap();
4911        assert!(micron.contains(">Micron without trailing newline\n"));
4912        assert!(!micron.contains(">Micron without trailing newline<"));
4913
4914        let micron = render_page(
4915            PATH_REPO,
4916            &config,
4917            &access,
4918            &page_request(&[("var_g", "public"), ("var_r", "micronblank")]),
4919            None,
4920        )
4921        .unwrap();
4922        assert!(micron.contains(">Micron with trailing blanks\n"));
4923        assert!(!micron.contains(">Micron with trailing blanks\n\n\n"));
4924    }
4925
4926    #[test]
4927    fn work_pages_render_documents_comments_and_repo_links() {
4928        let tmp = tempfile::tempdir().unwrap();
4929        let config = cfg(tmp.path());
4930        let repo_path = create_repo(
4931            config.repositories_dir.join("public/worked"),
4932            "README.md",
4933            "# Worked\n",
4934        );
4935        let work_path = crate::work::work_sidecar_path(&repo_path);
4936        let created = crate::work::create_document(
4937            &work_path,
4938            crate::work::WorkInput {
4939                title: "Active task".into(),
4940                content: "# Active Body\n".into(),
4941                format: "markdown".into(),
4942                signature: None,
4943                identity: None,
4944                author: [0x11; 16],
4945            },
4946        )
4947        .unwrap();
4948        crate::work::add_comment(
4949            &work_path,
4950            crate::work::WorkScope::Active,
4951            created.id,
4952            crate::work::WorkCommentInput {
4953                content: "Progress update".into(),
4954                format: "markdown".into(),
4955                signature: None,
4956                author: [0x22; 16],
4957            },
4958        )
4959        .unwrap();
4960        let completed = crate::work::create_document(
4961            &work_path,
4962            crate::work::WorkInput {
4963                title: "Completed task".into(),
4964                content: "Done".into(),
4965                format: "micron".into(),
4966                signature: None,
4967                identity: None,
4968                author: [0x11; 16],
4969            },
4970        )
4971        .unwrap();
4972        crate::work::complete_document(&work_path, completed.id, &[0x11; 16]).unwrap();
4973        let access = access(&config);
4974
4975        let repo = render_page(
4976            PATH_REPO,
4977            &config,
4978            &access,
4979            &page_request(&[("var_g", "public"), ("var_r", "worked")]),
4980            None,
4981        )
4982        .unwrap();
4983        assert!(repo.contains("Work (2)"));
4984        assert!(repo.contains(PATH_WORK));
4985
4986        let active = render_page(
4987            PATH_WORK,
4988            &config,
4989            &access,
4990            &page_request(&[("var_g", "public"), ("var_r", "worked")]),
4991            None,
4992        )
4993        .unwrap();
4994        assert!(active.contains(">Active Work Documents"));
4995        assert!(active.contains("[Active (1)`:/page/work.mu"));
4996        assert!(active.contains("[Completed (1)`:/page/work.mu"));
4997        assert!(active.contains("[Proposed (0)`:/page/work.mu"));
4998        assert!(active.contains("[All (2)`:/page/work.mu"));
4999        assert!(active.contains("#1 Active task"));
5000        assert!(active.contains("1 updates"));
5001        assert!(!active.contains("Completed task"));
5002
5003        let completed_page = render_page(
5004            PATH_WORK,
5005            &config,
5006            &access,
5007            &page_request(&[
5008                ("var_g", "public"),
5009                ("var_r", "worked"),
5010                ("var_scope", "completed"),
5011            ]),
5012            None,
5013        )
5014        .unwrap();
5015        assert!(completed_page.contains(">Completed Work Documents"));
5016        assert!(completed_page.contains("[Active (1)`:/page/work.mu"));
5017        assert!(completed_page.contains("Completed task"));
5018
5019        let doc = render_page(
5020            PATH_WORK_DOC,
5021            &config,
5022            &access,
5023            &page_request(&[("var_g", "public"), ("var_r", "worked"), ("var_id", "1")]),
5024            None,
5025        )
5026        .unwrap();
5027        assert!(doc.contains(">Active task"));
5028        assert!(doc.contains(">Active Body"));
5029        assert!(doc.contains(">Updates (1)"));
5030        assert!(doc.contains("Progress update"));
5031        assert!(doc.contains("`[Download`:/file/workdoc`g=public|r=worked|scope=active|id=1]"));
5032
5033        let completed_doc = render_page(
5034            PATH_WORK_DOC,
5035            &config,
5036            &access,
5037            &page_request(&[
5038                ("var_g", "public"),
5039                ("var_r", "worked"),
5040                ("var_id", &completed.id.to_string()),
5041            ]),
5042            None,
5043        )
5044        .unwrap();
5045        assert!(completed_doc.contains(">Completed task"));
5046        assert!(completed_doc.contains("Status: completed"));
5047        assert!(completed_doc.contains("`[work`:/page/work.mu`g=public|r=worked|scope=completed]"));
5048        assert!(completed_doc.contains(&format!(
5049            "`[Download`:/file/workdoc`g=public|r=worked|scope=completed|id={}]",
5050            completed.id
5051        )));
5052
5053        let active_download = download_work_document(
5054            &config,
5055            &access,
5056            &page_request(&[
5057                ("var_g", "public"),
5058                ("var_r", "worked"),
5059                ("var_scope", "active"),
5060                ("var_id", "1"),
5061            ]),
5062            None,
5063        )
5064        .unwrap();
5065        assert_resource_response(active_download, b"# Active Body");
5066
5067        let completed_download = download_work_document(
5068            &config,
5069            &access,
5070            &page_request(&[
5071                ("var_g", "public"),
5072                ("var_r", "worked"),
5073                ("var_id", &completed.id.to_string()),
5074            ]),
5075            None,
5076        )
5077        .unwrap();
5078        assert_resource_response(completed_download, b"Done");
5079
5080        crate::work::set_document_permissions(&work_path, created.id, "read = none\n").unwrap();
5081        let active_after_deny = render_page(
5082            PATH_WORK,
5083            &config,
5084            &access,
5085            &page_request(&[("var_g", "public"), ("var_r", "worked")]),
5086            None,
5087        )
5088        .unwrap();
5089        assert!(active_after_deny.contains("[Active (0)`:/page/work.mu"));
5090        assert!(active_after_deny.contains("[All (1)`:/page/work.mu"));
5091        assert!(!active_after_deny.contains("Active task"));
5092        let denied_doc = render_page(
5093            PATH_WORK_DOC,
5094            &config,
5095            &access,
5096            &page_request(&[("var_g", "public"), ("var_r", "worked"), ("var_id", "1")]),
5097            None,
5098        )
5099        .unwrap();
5100        assert!(denied_doc.contains("Work Document Not Found"));
5101        let denied_download = download_work_document(
5102            &config,
5103            &access,
5104            &page_request(&[("var_g", "public"), ("var_r", "worked"), ("var_id", "1")]),
5105            None,
5106        )
5107        .unwrap();
5108        assert!(matches!(
5109            denied_download,
5110            RequestResponse::Bytes(bytes) if bytes.first() == Some(&protocol::RES_NOT_FOUND)
5111        ));
5112    }
5113
5114    #[test]
5115    fn work_pages_respect_read_access() {
5116        let tmp = tempfile::tempdir().unwrap();
5117        let mut config = cfg(tmp.path());
5118        create_repo(
5119            config.repositories_dir.join("public/private-work"),
5120            "README.md",
5121            "# Private\n",
5122        );
5123        config.allow_read = vec!["none".into()];
5124        let access = access(&config);
5125
5126        assert!(render_page(
5127            PATH_WORK,
5128            &config,
5129            &access,
5130            &page_request(&[("var_g", "public"), ("var_r", "private-work")]),
5131            None,
5132        )
5133        .is_err());
5134    }
5135
5136    fn cfg(root: &std::path::Path) -> ServerConfig {
5137        ServerConfig {
5138            dir: root.to_path_buf(),
5139            reticulum_dir: None,
5140            repositories_dir: root.join("repositories"),
5141            identity_path: root.join("repositories_identity"),
5142            client_identity_path: root.join("client_identity"),
5143            node_name: "Test Git Node".into(),
5144            announce_interval_secs: 300,
5145            serve_nomadnet: true,
5146            templates_dir: root.join("templates"),
5147            unicode_icons: false,
5148            media_conversion: true,
5149            record_stats: false,
5150            stats_ignore_identities: Vec::new(),
5151            stats_push_ignore_identities: Vec::new(),
5152            blocked_identities: Vec::new(),
5153            identity_aliases: std::collections::BTreeMap::new(),
5154            allow_read: vec!["all".into()],
5155            allow_write: vec!["none".into()],
5156            allow_create: vec!["none".into()],
5157            allow_stats: vec!["none".into()],
5158            allow_release: vec!["none".into()],
5159            allow_interact: vec!["none".into()],
5160            allow_propose: vec!["none".into()],
5161            allow_admin: vec!["none".into()],
5162            log_level: logging::DEFAULT_LOG_LEVEL,
5163        }
5164    }
5165
5166    fn access(config: &ServerConfig) -> Access {
5167        Access::new(
5168            &config.allow_read,
5169            &config.allow_write,
5170            &config.allow_create,
5171            &config.allow_stats,
5172            &config.allow_release,
5173            &config.allow_interact,
5174            &config.allow_admin,
5175            config.repositories_dir.clone(),
5176        )
5177        .unwrap()
5178    }
5179
5180    fn page_request(fields: &[(&str, &str)]) -> Vec<u8> {
5181        msgpack::pack(&Value::Map(
5182            fields
5183                .iter()
5184                .map(|(k, v)| (Value::Str((*k).into()), Value::Str((*v).into())))
5185                .collect(),
5186        ))
5187    }
5188
5189    fn assert_resource_response(response: RequestResponse, expected: &[u8]) {
5190        match response {
5191            RequestResponse::Resource { data, metadata, .. } => {
5192                assert_eq!(data, expected);
5193                assert_eq!(
5194                    metadata.as_deref(),
5195                    Some(&protocol::metadata_status(protocol::RES_OK)[..])
5196                );
5197            }
5198            RequestResponse::Bytes(bytes) => panic!("expected resource response, got {bytes:?}"),
5199            RequestResponse::File { .. } => panic!("expected value response, got file"),
5200        }
5201    }
5202
5203    fn create_repo(path: std::path::PathBuf, file: &str, content: &str) -> std::path::PathBuf {
5204        create_repo_bytes(path, file, content.as_bytes())
5205    }
5206
5207    fn create_repo_bytes(
5208        path: std::path::PathBuf,
5209        file: &str,
5210        content: &[u8],
5211    ) -> std::path::PathBuf {
5212        let work = path.with_extension("work");
5213        fs::create_dir_all(&work).unwrap();
5214        run_git(Command::new("git").arg("init").arg(&work));
5215        let file_path = work.join(file);
5216        fs::create_dir_all(file_path.parent().unwrap()).unwrap();
5217        fs::write(&file_path, content).unwrap();
5218        run_git(Command::new("git").arg("-C").arg(&work).arg("add").arg("."));
5219        run_git(
5220            Command::new("git")
5221                .arg("-C")
5222                .arg(&work)
5223                .arg("-c")
5224                .arg("user.name=RNS Page Test")
5225                .arg("-c")
5226                .arg("user.email=rns-page-test@example.invalid")
5227                .arg("commit")
5228                .arg("-m")
5229                .arg("initial"),
5230        );
5231        run_git(
5232            Command::new("git")
5233                .arg("-C")
5234                .arg(&work)
5235                .args(["branch", "-M", "main"]),
5236        );
5237        run_git(
5238            Command::new("git")
5239                .arg("clone")
5240                .arg("--bare")
5241                .arg(&work)
5242                .arg(&path),
5243        );
5244        path
5245    }
5246
5247    fn run_git(cmd: &mut Command) -> String {
5248        let output = cmd.output().unwrap();
5249        assert!(
5250            output.status.success(),
5251            "git command failed: {}",
5252            String::from_utf8_lossy(&output.stderr)
5253        );
5254        String::from_utf8_lossy(&output.stdout).into_owned()
5255    }
5256}