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 identity = Identity::from_public_key(&public_key);
1561    if identity.verify(signature, document.content.as_bytes()) {
1562        "Valid"
1563    } else {
1564        "Not valid"
1565    }
1566}
1567
1568fn resolve_work_document(
1569    work_path: &Path,
1570    requested_scope: &str,
1571    id: u64,
1572) -> Result<(crate::work::WorkScope, Option<crate::work::WorkDocument>)> {
1573    let requested_scope = if matches!(requested_scope, "active" | "completed" | "proposed" | "all")
1574    {
1575        requested_scope
1576    } else {
1577        "active"
1578    };
1579    match requested_scope {
1580        "active" | "completed" | "proposed" => {
1581            let scope = crate::work::WorkScope::parse(requested_scope).unwrap();
1582            Ok((scope, crate::work::view_document(work_path, scope, id)?))
1583        }
1584        "all" => {
1585            if let Some(document) =
1586                crate::work::view_document(work_path, crate::work::WorkScope::Active, id)?
1587            {
1588                Ok((crate::work::WorkScope::Active, Some(document)))
1589            } else if let Some(document) =
1590                crate::work::view_document(work_path, crate::work::WorkScope::Completed, id)?
1591            {
1592                Ok((crate::work::WorkScope::Completed, Some(document)))
1593            } else {
1594                Ok((
1595                    crate::work::WorkScope::Proposed,
1596                    crate::work::view_document(work_path, crate::work::WorkScope::Proposed, id)?,
1597                ))
1598            }
1599        }
1600        _ => unreachable!(),
1601    }
1602}
1603
1604fn append_formatted_content(out: &mut String, format: &str, content: &str) {
1605    match format {
1606        "micron" => out.push_str(content),
1607        "markdown" => out.push_str(&markdown_to_micron(content)),
1608        _ => out.push_str(&m_escape(content)),
1609    }
1610    if !out.ends_with('\n') {
1611        out.push('\n');
1612    }
1613}
1614
1615pub fn download_work_document(
1616    config: &ServerConfig,
1617    access: &Access,
1618    data: &[u8],
1619    remote: Option<&[u8; 16]>,
1620) -> Result<RequestResponse> {
1621    let vars = parse_page_vars(data)?;
1622    let (group, repo, repository) = match accessible_repository(config, access, remote, &vars) {
1623        Ok(repo) => repo,
1624        Err(_) => {
1625            return Ok(RequestResponse::Bytes(protocol::status_bytes(
1626                protocol::RES_NOT_FOUND,
1627                b"repository not found",
1628            )));
1629        }
1630    };
1631    let id = required_var(&vars, "id")?
1632        .parse::<u64>()
1633        .map_err(|_| Error::msg("invalid work document ID"))?;
1634    let work_path = crate::work::work_sidecar_path(&repository);
1635    let Some(document) =
1636        resolve_work_document(&work_path, var(&vars, "scope").unwrap_or("all"), id)?.1
1637    else {
1638        return Ok(RequestResponse::Bytes(protocol::status_bytes(
1639            protocol::RES_NOT_FOUND,
1640            b"work document not found",
1641        )));
1642    };
1643    if crate::work::document_permission_denies(&work_path, id, Operation::Read)? {
1644        return Ok(RequestResponse::Bytes(protocol::status_bytes(
1645            protocol::RES_NOT_FOUND,
1646            b"work document not found",
1647        )));
1648    }
1649    crate::stats::record_download(config, &format!("{group}/{repo}"), remote);
1650    Ok(RequestResponse::Resource {
1651        data: document.content.into_bytes(),
1652        metadata: Some(protocol::metadata_status(protocol::RES_OK)),
1653        auto_compress: true,
1654    })
1655}
1656
1657fn is_image_path(path: &str) -> bool {
1658    Path::new(path)
1659        .extension()
1660        .and_then(|ext| ext.to_str())
1661        .is_some_and(|ext| {
1662            matches!(
1663                ext.to_ascii_lowercase().as_str(),
1664                "webp" | "png" | "jpg" | "jpeg" | "gif" | "tiff" | "tif" | "bmp"
1665            )
1666        })
1667}
1668
1669fn quote_media_path(path: &str) -> String {
1670    let mut out = String::new();
1671    for byte in path.bytes() {
1672        match byte {
1673            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'.' | b'-' | b'~' => {
1674                out.push(byte as char)
1675            }
1676            b' ' => out.push('+'),
1677            _ => out.push_str(&format!("%{byte:02X}")),
1678        }
1679    }
1680    out
1681}
1682
1683/// Nomad Network media requests carry a presence-only key and an encoded blob
1684/// path. The repository ACL is the authority, just as for ordinary downloads.
1685pub fn serve_media(
1686    config: &ServerConfig,
1687    access: &Access,
1688    data: &[u8],
1689    remote: Option<&[u8; 16]>,
1690) -> Result<RequestResponse> {
1691    let rejected = || RequestResponse::Bytes(msgpack::pack(&Value::Bool(false)));
1692    let Ok(Value::Map(fields)) = msgpack::unpack_exact(data) else {
1693        return Ok(rejected());
1694    };
1695    if !fields.iter().any(|(key, _)| key.as_str() == Some("key")) {
1696        return Ok(rejected());
1697    }
1698    let Some(path) = fields
1699        .iter()
1700        .find(|(key, _)| key.as_str() == Some("path"))
1701        .and_then(|(_, value)| value.as_str())
1702    else {
1703        return Ok(rejected());
1704    };
1705    let components: Vec<_> = path
1706        .strip_prefix("/media")
1707        .unwrap_or(path)
1708        .trim_start_matches('/')
1709        .splitn(4, '/')
1710        .collect();
1711    if components.len() != 4 {
1712        return Ok(rejected());
1713    }
1714    let vars = BTreeMap::from([
1715        ("g".into(), components[0].into()),
1716        ("r".into(), components[1].into()),
1717    ]);
1718    let Ok((_, _, repository)) = accessible_repository(config, access, remote, &vars) else {
1719        return Ok(rejected());
1720    };
1721    let path = decode_url_component(components[3]);
1722    if path.is_empty() || validate_git_path(&path).is_err() {
1723        return Ok(rejected());
1724    }
1725    let Ok(resolved) = resolve_ref(&repository, components[2]) else {
1726        return Ok(rejected());
1727    };
1728    if !matches!(
1729        git_object_kind(&repository, &resolved, &path).as_deref(),
1730        Ok("blob")
1731    ) {
1732        return Ok(rejected());
1733    }
1734    let output = run_git_output(
1735        Command::new("git")
1736            .arg("--git-dir")
1737            .arg(&repository)
1738            .arg("show")
1739            .arg(format!("{resolved}:{path}")),
1740        GIT_COMMAND_TIMEOUT,
1741    )?;
1742    if !output.status.success() {
1743        return Ok(rejected());
1744    }
1745    let name = path.rsplit('/').next().unwrap_or(&path);
1746    let (data, name) = media_response_data(config.media_conversion, &path, name, output.stdout);
1747    Ok(RequestResponse::File {
1748        data,
1749        metadata: msgpack::pack(&Value::Map(vec![(
1750            Value::Str("name".into()),
1751            Value::Bin(name.as_bytes().to_vec()),
1752        )])),
1753        auto_compress: false,
1754    })
1755}
1756
1757fn media_response_data(
1758    enabled: bool,
1759    path: &str,
1760    name: &str,
1761    original: Vec<u8>,
1762) -> (Vec<u8>, String) {
1763    if enabled && is_image_path(path) && !path.to_ascii_lowercase().ends_with(".webp") {
1764        if let Some(converted) = crate::media::convert_to_webp(&original) {
1765            let stem = name.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(name);
1766            return (converted, format!("{stem}.webp"));
1767        }
1768    }
1769    (original, name.to_string())
1770}
1771
1772pub fn download_file(
1773    config: &ServerConfig,
1774    access: &Access,
1775    data: &[u8],
1776    remote: Option<&[u8; 16]>,
1777) -> Result<RequestResponse> {
1778    let vars = parse_page_vars(data)?;
1779    let (group, repo, repository) = match accessible_repository(config, access, remote, &vars) {
1780        Ok(repo) => repo,
1781        Err(_) => {
1782            return Ok(RequestResponse::Bytes(protocol::status_bytes(
1783                protocol::RES_NOT_FOUND,
1784                b"repository not found",
1785            )));
1786        }
1787    };
1788    if let Some(artifact) = var(&vars, "artifact") {
1789        let artifact = decode_url_component(artifact);
1790        let tag = var(&vars, "tag").unwrap_or("latest");
1791        let releases_path = crate::release::release_sidecar_path(&repository);
1792        let Some(path) = crate::release::artifact_path(&releases_path, tag, &artifact)? else {
1793            return Ok(RequestResponse::Bytes(protocol::status_bytes(
1794                protocol::RES_NOT_FOUND,
1795                b"file not found",
1796            )));
1797        };
1798        let data = fs::read(path)?;
1799        crate::stats::record_release_download(config, &format!("{group}/{repo}"), remote);
1800        return Ok(RequestResponse::Resource {
1801            data,
1802            metadata: Some(protocol::metadata_status(protocol::RES_OK)),
1803            auto_compress: true,
1804        });
1805    }
1806
1807    let reference = var(&vars, "ref").unwrap_or("HEAD");
1808    let path = required_var(&vars, "path")?;
1809    let format = var(&vars, "fmt");
1810    if format.is_some() && (format != Some("mu") || !path.to_ascii_lowercase().ends_with(".md")) {
1811        return Ok(RequestResponse::Bytes(protocol::status_bytes(
1812            protocol::RES_NOT_FOUND,
1813            b"unsupported conversion",
1814        )));
1815    }
1816    validate_git_path(path)?;
1817    let resolved = resolve_ref(&repository, reference)?;
1818    let spec = format!("{resolved}:{path}");
1819    let output = run_git_output(
1820        Command::new("git")
1821            .arg("--git-dir")
1822            .arg(&repository)
1823            .arg("show")
1824            .arg(spec),
1825        GIT_COMMAND_TIMEOUT,
1826    )?;
1827    if !output.status.success() {
1828        return Ok(RequestResponse::Bytes(protocol::status_bytes(
1829            protocol::RES_NOT_FOUND,
1830            b"file not found",
1831        )));
1832    }
1833    let (data, metadata) = if format == Some("mu") {
1834        let markdown = String::from_utf8(output.stdout)
1835            .map_err(|_| Error::msg("markdown download is not UTF-8"))?;
1836        let converted = markdown_to_micron_scoped(
1837            &markdown,
1838            Some(&markdown_blob_url_scope(&group, &repo, reference, path)),
1839        );
1840        let filename = path.rsplit('/').next().unwrap_or(path);
1841        let stem = filename
1842            .rsplit_once('.')
1843            .map(|(stem, _)| stem)
1844            .unwrap_or(filename);
1845        (
1846            converted.trim_end().as_bytes().to_vec(),
1847            msgpack::pack(&Value::Map(vec![
1848                (
1849                    Value::UInt(protocol::IDX_RESULT_CODE),
1850                    Value::UInt(protocol::RES_OK as u64),
1851                ),
1852                (
1853                    Value::Str("name".into()),
1854                    Value::Bin(format!("{stem}.mu").into_bytes()),
1855                ),
1856            ])),
1857        )
1858    } else {
1859        (output.stdout, protocol::metadata_status(protocol::RES_OK))
1860    };
1861    crate::stats::record_download(config, &format!("{group}/{repo}"), remote);
1862    Ok(RequestResponse::Resource {
1863        data,
1864        metadata: Some(metadata),
1865        auto_compress: true,
1866    })
1867}
1868
1869fn render_chart(
1870    data: &[u64],
1871    labels: &[String; 2],
1872    color: &str,
1873    secondary_color: Option<&str>,
1874    gradient_factor: f64,
1875    height: u64,
1876) -> String {
1877    if data.is_empty() || data.iter().all(|value| *value == 0) {
1878        return "No data available\n".to_string();
1879    }
1880    let max = data.iter().copied().max().unwrap_or(1).max(1) as f64;
1881    let primary = expand_chart_color(color);
1882    let secondary = secondary_color
1883        .map(expand_chart_color)
1884        .unwrap_or_else(|| dim_chart_color(&primary));
1885    let mut out = format!("`FT{primary}Peak: {}`f\n", max as u64);
1886    for row in (1..=height).rev() {
1887        let row_top = row as f64 / height as f64 * max;
1888        let row_mid = (row as f64 - 0.5) / height as f64 * max;
1889        let grad_top = row as f64 / height as f64;
1890        let grad_mid = (row as f64 - 0.5) / height as f64;
1891        let top_color = gradient_chart_color(&primary, &secondary, grad_top, gradient_factor);
1892        let mid_color = gradient_chart_color(&primary, &secondary, grad_mid, gradient_factor);
1893        out.push('│');
1894        for value in data {
1895            let value = *value as f64;
1896            if value >= row_top {
1897                out.push_str(&format!("`FT{top_color}`BT{mid_color}▀`f`b"));
1898            } else if value >= row_mid || (row == 1 && value > 0.0) {
1899                out.push_str(&format!("`FT{mid_color}▄`f"));
1900            } else {
1901                out.push(' ');
1902            }
1903        }
1904        out.push('\n');
1905    }
1906    out.push('└');
1907    for _ in data {
1908        out.push('─');
1909    }
1910    out.push_str("┘\n");
1911    out.push_str(&format!("`F666{:<12}`f", labels[0]));
1912    let chart_width = data.len() + 2;
1913    let spacing = chart_width.saturating_sub(24);
1914    out.push_str(&" ".repeat(spacing));
1915    out.push_str(&format!("`F666{:>12}`f\n", labels[1]));
1916    out
1917}
1918
1919fn expand_chart_color(color: &str) -> String {
1920    let color = color.trim_start_matches('#');
1921    if color.len() == 3 {
1922        color.chars().flat_map(|ch| [ch, ch]).collect()
1923    } else {
1924        color.chars().take(6).collect()
1925    }
1926}
1927
1928fn dim_chart_color(primary: &str) -> String {
1929    let rgb = chart_rgb(primary);
1930    format!(
1931        "{:02x}{:02x}{:02x}",
1932        (rgb[0] as f64 * 0.42) as u8,
1933        (rgb[1] as f64 * 0.42) as u8,
1934        (rgb[2] as f64 * 0.42) as u8
1935    )
1936}
1937
1938fn gradient_chart_color(primary: &str, secondary: &str, t: f64, factor: f64) -> String {
1939    let primary = chart_rgb(primary);
1940    let secondary = chart_rgb(secondary);
1941    let t = (t * factor).min(1.0);
1942    format!(
1943        "{:02x}{:02x}{:02x}",
1944        (secondary[0] as f64 + (primary[0] as f64 - secondary[0] as f64) * t) as u8,
1945        (secondary[1] as f64 + (primary[1] as f64 - secondary[1] as f64) * t) as u8,
1946        (secondary[2] as f64 + (primary[2] as f64 - secondary[2] as f64) * t) as u8
1947    )
1948}
1949
1950fn chart_rgb(color: &str) -> [u8; 3] {
1951    let color = expand_chart_color(color);
1952    [
1953        u8::from_str_radix(color.get(0..2).unwrap_or("00"), 16).unwrap_or(0),
1954        u8::from_str_radix(color.get(2..4).unwrap_or("00"), 16).unwrap_or(0),
1955        u8::from_str_radix(color.get(4..6).unwrap_or("00"), 16).unwrap_or(0),
1956    ]
1957}
1958
1959fn render_combined_chart(
1960    views: &[u64],
1961    fetches: &[u64],
1962    pushes: &[u64],
1963    downloads: &[u64],
1964    labels: &[String; 2],
1965    height: u64,
1966) -> String {
1967    if views.is_empty() || fetches.is_empty() || pushes.is_empty() || downloads.is_empty() {
1968        return "No data available\n".to_string();
1969    }
1970
1971    let categories = [
1972        ("Pushes", dim_to_black(RCLR_PUSH, 0.87), pushes),
1973        ("Fetches", dim_to_black(RCLR_FETCH, 0.87), fetches),
1974        ("Views", dim_to_black(RCLR_VIEW, 0.87), views),
1975        ("Downloads", dim_to_black(RCLR_DOWNLOAD, 0.87), downloads),
1976    ];
1977    let mut out = categories
1978        .iter()
1979        .map(|(label, color, _)| format!("`FT{color}`BT{color}██`f`b {label}"))
1980        .collect::<Vec<_>>()
1981        .join("  ");
1982    out.push_str("\n\n");
1983    let num_points = views.len();
1984
1985    for row in (1..=height).rev() {
1986        let lower_min = (row as f64 - 1.0) / height as f64;
1987        let lower_max = (row as f64 - 0.5) / height as f64;
1988        let upper_min = (row as f64 - 0.5) / height as f64;
1989        let upper_max = row as f64 / height as f64;
1990        out.push('│');
1991        for index in 0..num_points {
1992            let total: u64 = categories
1993                .iter()
1994                .map(|(_, _, data)| data.get(index).copied().unwrap_or(0))
1995                .sum();
1996            if total == 0 {
1997                out.push(' ');
1998                continue;
1999            }
2000
2001            let upper_cat = stacked_pixel_category(&categories, index, total, upper_min, upper_max);
2002            let lower_cat = stacked_pixel_category(&categories, index, total, lower_min, lower_max);
2003            match (upper_cat, lower_cat) {
2004                (Some(upper), Some(lower)) if upper == lower => {
2005                    let color = &categories[upper].1;
2006                    out.push_str(&format!("`FT{color}`BT{color}█`f`b"));
2007                }
2008                (Some(upper), Some(lower)) => {
2009                    let upper_color = &categories[upper].1;
2010                    let lower_color = &categories[lower].1;
2011                    out.push_str(&format!("`FT{upper_color}`BT{lower_color}▀`f`b"));
2012                }
2013                (Some(upper), None) => {
2014                    let color = &categories[upper].1;
2015                    out.push_str(&format!("`FT{color}▀`f"));
2016                }
2017                (None, Some(lower)) => {
2018                    let color = &categories[lower].1;
2019                    out.push_str(&format!("`FT{color}▄`f"));
2020                }
2021                (None, None) => out.push(' '),
2022            }
2023        }
2024        out.push('\n');
2025    }
2026    out.push('└');
2027    for _ in 0..num_points {
2028        out.push('─');
2029    }
2030    out.push_str("┘\n");
2031    out.push_str(&format!(
2032        "`F666{:<12}`f",
2033        labels[0].chars().take(12).collect::<String>()
2034    ));
2035    let chart_width = num_points + 2;
2036    let spacing = chart_width.saturating_sub(24);
2037    out.push_str(&" ".repeat(spacing));
2038    out.push_str(&format!(
2039        "`F666{:>12}`f\n",
2040        labels[1].chars().take(12).collect::<String>()
2041    ));
2042    out
2043}
2044
2045fn stacked_pixel_category(
2046    categories: &[(&str, String, &[u64])],
2047    index: usize,
2048    total: u64,
2049    pixel_min: f64,
2050    pixel_max: f64,
2051) -> Option<usize> {
2052    let mut cumulative = 0u64;
2053    for (category_index, (_, _, data)) in categories.iter().enumerate() {
2054        let value = data.get(index).copied().unwrap_or(0);
2055        if value == 0 {
2056            continue;
2057        }
2058        let start = cumulative as f64 / total as f64;
2059        cumulative += value;
2060        let end = cumulative as f64 / total as f64;
2061        if pixel_min < end && pixel_max > start {
2062            return Some(category_index);
2063        }
2064    }
2065    None
2066}
2067
2068fn dim_to_black(color: &str, dim: f64) -> String {
2069    gradient_chart_color(&expand_chart_color(color), "000000", dim, 1.0)
2070}
2071
2072fn accessible_groups(
2073    config: &ServerConfig,
2074    access: &Access,
2075    remote: Option<&[u8; 16]>,
2076) -> Result<BTreeMap<String, Vec<String>>> {
2077    let mut groups = BTreeMap::new();
2078    if !config.repositories_dir.exists() {
2079        return Ok(groups);
2080    }
2081    for group_entry in fs::read_dir(&config.repositories_dir)? {
2082        let group_entry = group_entry?;
2083        if !group_entry.file_type()?.is_dir() {
2084            continue;
2085        }
2086        let group = group_entry.file_name().to_string_lossy().to_string();
2087        if validate_group_name(&group).is_err() {
2088            continue;
2089        }
2090        let repos = accessible_repositories(config, access, remote, &group)?;
2091        if !repos.is_empty() {
2092            groups.insert(group, repos);
2093        }
2094    }
2095    Ok(groups)
2096}
2097
2098fn accessible_repositories(
2099    config: &ServerConfig,
2100    access: &Access,
2101    remote: Option<&[u8; 16]>,
2102    group: &str,
2103) -> Result<Vec<String>> {
2104    validate_group_name(group)?;
2105    let group_dir = config.repositories_dir.join(group);
2106    let mut repos = Vec::new();
2107    if !group_dir.exists() {
2108        return Ok(repos);
2109    }
2110    for repo_entry in fs::read_dir(group_dir)? {
2111        let repo_entry = repo_entry?;
2112        if !repo_entry.file_type()?.is_dir() {
2113            continue;
2114        }
2115        let repo = repo_entry.file_name().to_string_lossy().to_string();
2116        let Ok(name) = repository_name(group, &repo) else {
2117            continue;
2118        };
2119        if crate::git::is_bare_repository(&config.repositories_dir.join(&name))
2120            && access.allows(Operation::Read, &name, remote)?
2121        {
2122            repos.push(repo);
2123        }
2124    }
2125    repos.sort();
2126    Ok(repos)
2127}
2128
2129fn accessible_repository(
2130    config: &ServerConfig,
2131    access: &Access,
2132    remote: Option<&[u8; 16]>,
2133    vars: &BTreeMap<String, String>,
2134) -> Result<(String, String, PathBuf)> {
2135    let group = required_var(vars, "g")?;
2136    let repo = required_var(vars, "r")?;
2137    let name = repository_name(group, repo)?;
2138    let path = config.repositories_dir.join(&name);
2139    if !crate::git::is_bare_repository(&path) || !access.allows(Operation::Read, &name, remote)? {
2140        return Err(Error::msg("repository not found"));
2141    }
2142    Ok((group.to_string(), repo.to_string(), path))
2143}
2144
2145fn required_var<'a>(vars: &'a BTreeMap<String, String>, key: &str) -> Result<&'a str> {
2146    var(vars, key).ok_or_else(|| Error::msg(format!("missing page variable {key}")))
2147}
2148
2149fn decode_url_component(value: &str) -> String {
2150    let bytes = value.as_bytes();
2151    let mut out = Vec::with_capacity(bytes.len());
2152    let mut i = 0;
2153    while i < bytes.len() {
2154        match bytes[i] {
2155            b'+' => {
2156                out.push(b' ');
2157                i += 1;
2158            }
2159            b'%' if i + 2 < bytes.len() => {
2160                if let (Some(high), Some(low)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2]))
2161                {
2162                    out.push((high << 4) | low);
2163                    i += 3;
2164                } else {
2165                    out.push(bytes[i]);
2166                    i += 1;
2167                }
2168            }
2169            byte => {
2170                out.push(byte);
2171                i += 1;
2172            }
2173        }
2174    }
2175    String::from_utf8_lossy(&out).into_owned()
2176}
2177
2178fn hex_value(byte: u8) -> Option<u8> {
2179    match byte {
2180        b'0'..=b'9' => Some(byte - b'0'),
2181        b'a'..=b'f' => Some(byte - b'a' + 10),
2182        b'A'..=b'F' => Some(byte - b'A' + 10),
2183        _ => None,
2184    }
2185}
2186
2187fn var<'a>(vars: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {
2188    vars.get(&format!("var_{key}"))
2189        .or_else(|| vars.get(key))
2190        .map(String::as_str)
2191}
2192
2193fn repository_name(group: &str, repo: &str) -> Result<String> {
2194    validate_group_name(group)?;
2195    validate_repo_component(repo)?;
2196    let name = format!("{group}/{repo}");
2197    validate_repo_name(&name)?;
2198    Ok(name)
2199}
2200
2201fn validate_group_name(group: &str) -> Result<()> {
2202    validate_repo_component(group)
2203}
2204
2205fn validate_repo_component(component: &str) -> Result<()> {
2206    if component.is_empty()
2207        || component == "."
2208        || component == ".."
2209        || component.contains('/')
2210        || component.contains('\\')
2211        || component.len() > 256
2212    {
2213        Err(Error::msg("invalid repository name"))
2214    } else {
2215        Ok(())
2216    }
2217}
2218
2219fn validate_git_path(path: &str) -> Result<()> {
2220    if path.starts_with('/') || path.contains('\\') {
2221        return Err(Error::msg("invalid repository path"));
2222    }
2223    for component in path.split('/') {
2224        if component == "." || component == ".." {
2225            return Err(Error::msg("invalid repository path"));
2226        }
2227    }
2228    Ok(())
2229}
2230
2231fn normalize_blob_path(path: &str) -> String {
2232    path.strip_prefix("./").unwrap_or(path).replace("/./", "/")
2233}
2234
2235fn validate_refish(value: &str) -> Result<()> {
2236    if value.is_empty()
2237        || value.starts_with('-')
2238        || value.contains("..")
2239        || value.contains('\\')
2240        || value.contains(' ')
2241        || value.contains('~')
2242        || value.contains('^')
2243        || value.contains(':')
2244    {
2245        Err(Error::msg("invalid git ref"))
2246    } else {
2247        Ok(())
2248    }
2249}
2250
2251fn repository_description(repo: &Path) -> Result<String> {
2252    let path = repo.join("description");
2253    if !path.exists() {
2254        return Ok(String::new());
2255    }
2256    Ok(fs::read_to_string(path)?.trim().to_string())
2257}
2258
2259#[derive(Debug, Clone, PartialEq, Eq)]
2260struct RepositoryOrigin {
2261    kind: RepositoryOriginKind,
2262    source: String,
2263    synced_at: Option<u64>,
2264}
2265
2266#[derive(Debug, Clone, PartialEq, Eq)]
2267enum RepositoryOriginKind {
2268    Fork,
2269    Mirror,
2270}
2271
2272impl RepositoryOrigin {
2273    fn label(&self) -> &'static str {
2274        match self.kind {
2275            RepositoryOriginKind::Fork => "Forked",
2276            RepositoryOriginKind::Mirror => "Mirrored",
2277        }
2278    }
2279}
2280
2281fn repository_origin(repo: &Path) -> Result<Option<RepositoryOrigin>> {
2282    let kind_output = run_git_output(
2283        Command::new("git")
2284            .arg("--git-dir")
2285            .arg(repo)
2286            .arg("config")
2287            .arg("--get")
2288            .arg("repository.rngit.type"),
2289        GIT_COMMAND_TIMEOUT,
2290    )?;
2291    if !kind_output.status.success() {
2292        return Ok(None);
2293    }
2294    let kind = match String::from_utf8_lossy(&kind_output.stdout).trim() {
2295        "fork" => RepositoryOriginKind::Fork,
2296        "mirror" => RepositoryOriginKind::Mirror,
2297        _ => return Ok(None),
2298    };
2299    let source_output = run_git_output(
2300        Command::new("git")
2301            .arg("--git-dir")
2302            .arg(repo)
2303            .arg("config")
2304            .arg("--get")
2305            .arg("repository.rngit.upstream.source"),
2306        GIT_COMMAND_TIMEOUT,
2307    )?;
2308    if !source_output.status.success() {
2309        return Ok(None);
2310    }
2311    let source = String::from_utf8_lossy(&source_output.stdout)
2312        .trim()
2313        .to_string();
2314    if source.is_empty() {
2315        return Ok(None);
2316    }
2317    let synced_at = run_git_output(
2318        Command::new("git")
2319            .arg("--git-dir")
2320            .arg(repo)
2321            .arg("config")
2322            .arg("--get")
2323            .arg("repository.rngit.upstream.sync"),
2324        GIT_COMMAND_TIMEOUT,
2325    )
2326    .ok()
2327    .and_then(|output| output.status.success().then_some(output))
2328    .and_then(|output| {
2329        String::from_utf8_lossy(&output.stdout)
2330            .trim()
2331            .parse::<u64>()
2332            .ok()
2333    });
2334
2335    Ok(Some(RepositoryOrigin {
2336        kind,
2337        source,
2338        synced_at,
2339    }))
2340}
2341
2342fn format_sync_age(synced_at: u64) -> String {
2343    let now = SystemTime::now()
2344        .duration_since(UNIX_EPOCH)
2345        .unwrap_or_default()
2346        .as_secs();
2347    let seconds = now.saturating_sub(synced_at);
2348    if seconds < 60 {
2349        format!("{seconds}s")
2350    } else if seconds < 3_600 {
2351        format!("{}m", seconds / 60)
2352    } else if seconds < 86_400 {
2353        format!("{}h", seconds / 3_600)
2354    } else {
2355        format!("{}d", seconds / 86_400)
2356    }
2357}
2358
2359fn repository_thanks(repo: &Path, add: bool) -> Result<u64> {
2360    let path = repo.with_extension("thanks");
2361    let mut count = if path.exists() {
2362        fs::read_to_string(&path)?
2363            .trim()
2364            .parse::<u64>()
2365            .unwrap_or(0)
2366    } else {
2367        0
2368    };
2369    if add {
2370        count = count.saturating_add(1);
2371        fs::write(path, format!("{count}\n"))?;
2372    }
2373    Ok(count)
2374}
2375
2376#[derive(Debug)]
2377struct ReadmeContent {
2378    content: String,
2379    markdown: bool,
2380}
2381
2382fn readme_content(repo: &Path) -> Result<Option<ReadmeContent>> {
2383    const NAMES: &[&str] = &[
2384        "README.mu",
2385        "Readme.mu",
2386        "readme.mu",
2387        "README",
2388        "readme",
2389        "README.md",
2390        "readme.md",
2391        "README.rst",
2392        "README.txt",
2393        "readme.rst",
2394        "readme.txt",
2395    ];
2396    for name in NAMES {
2397        match run_git_output(
2398            Command::new("git")
2399                .arg("--git-dir")
2400                .arg(repo)
2401                .arg("show")
2402                .arg(format!("HEAD:{name}")),
2403            GIT_COMMAND_TIMEOUT,
2404        ) {
2405            Ok(output) if output.status.success() => {
2406                let content = String::from_utf8_lossy(&output.stdout).into_owned();
2407                let markdown = name.ends_with(".md");
2408                let content = if markdown || name.ends_with(".mu") {
2409                    content
2410                } else {
2411                    m_escape(&content)
2412                };
2413                return Ok(Some(ReadmeContent { content, markdown }));
2414            }
2415            Ok(_) => {}
2416            Err(err) => {
2417                log::warn!("Git command execution failed while reading README: {err}");
2418            }
2419        }
2420    }
2421    Ok(None)
2422}
2423
2424fn markdown_to_micron(input: &str) -> String {
2425    markdown_to_micron_scoped(input, None)
2426}
2427
2428fn markdown_to_micron_scoped(input: &str, url_scope: Option<&str>) -> String {
2429    let lines: Vec<&str> = input.lines().collect();
2430    let mut out = String::new();
2431    let mut code_block: Option<(Option<String>, String)> = None;
2432    let mut index = 0;
2433
2434    while index < lines.len() {
2435        let line = lines[index];
2436        let trimmed = line.trim();
2437        if trimmed.starts_with("```") {
2438            if let Some((language, content)) = code_block.take() {
2439                out.push_str(&markdown_code_block_to_micron(
2440                    language.as_deref(),
2441                    &content,
2442                ));
2443            } else {
2444                code_block = Some((markdown_fence_language(trimmed), String::new()));
2445            }
2446            index += 1;
2447            continue;
2448        }
2449        if let Some((_, content)) = code_block.as_mut() {
2450            content.push_str(line);
2451            content.push('\n');
2452            index += 1;
2453            continue;
2454        }
2455        if let Some(quote) = markdown_quote(line) {
2456            let mut parts = vec![quote.to_string()];
2457            index += 1;
2458            while index < lines.len() {
2459                if let Some(quote) = markdown_quote(lines[index]) {
2460                    parts.push(quote.to_string());
2461                    index += 1;
2462                } else {
2463                    break;
2464                }
2465            }
2466            let formatted = format_markdown_inline_scoped(&parts.join(" "), url_scope);
2467            for wrapped in markdown_wrap_text(&formatted, 77) {
2468                out.push_str(" │ ");
2469                out.push_str(&wrapped);
2470                out.push('\n');
2471            }
2472            continue;
2473        }
2474        if is_table_start(&lines, index) {
2475            let mut table = Vec::new();
2476            while index < lines.len() && lines[index].contains('|') {
2477                table.push(lines[index]);
2478                index += 1;
2479            }
2480            out.push_str(&format_markdown_table(&table, url_scope));
2481            continue;
2482        }
2483        if let Some((level, text)) = markdown_heading(line) {
2484            out.push_str(&">".repeat(level));
2485            out.push_str(&format_markdown_inline_scoped(text.trim(), url_scope));
2486            out.push('\n');
2487        } else if is_markdown_rule(trimmed) {
2488            out.push_str("-\n");
2489        } else if let Some((indent, text)) = unordered_list_item(line) {
2490            out.push_str(indent);
2491            out.push_str(" • ");
2492            out.push_str(&format_markdown_inline_scoped(text, url_scope));
2493            out.push('\n');
2494        } else if let Some((indent, number, text)) = ordered_list_item(line) {
2495            out.push_str(indent);
2496            out.push_str(number);
2497            out.push_str(". ");
2498            out.push_str(&format_markdown_inline_scoped(text, url_scope));
2499            out.push('\n');
2500        } else {
2501            out.push_str(&format_markdown_line(line, url_scope));
2502            out.push('\n');
2503        }
2504        index += 1;
2505    }
2506    if let Some((language, content)) = code_block {
2507        out.push_str(&markdown_code_block_to_micron(
2508            language.as_deref(),
2509            &content,
2510        ));
2511    }
2512
2513    out
2514}
2515
2516fn markdown_code_block_to_micron(language: Option<&str>, content: &str) -> String {
2517    if language.is_some_and(|language| language.eq_ignore_ascii_case("rawmu")) {
2518        return content.to_string();
2519    }
2520    crate::highlight::literal_block(content, None, language)
2521}
2522
2523fn markdown_fence_language(trimmed: &str) -> Option<String> {
2524    trimmed
2525        .strip_prefix("```")
2526        .and_then(|info| info.split_whitespace().next())
2527        .filter(|language| !language.is_empty())
2528        .map(str::to_string)
2529}
2530
2531fn markdown_quote(line: &str) -> Option<&str> {
2532    line.strip_prefix('>')
2533        .map(|quote| quote.strip_prefix(' ').unwrap_or(quote))
2534}
2535
2536fn markdown_heading(line: &str) -> Option<(usize, &str)> {
2537    let level = line.chars().take_while(|ch| *ch == '#').count();
2538    if (1..=6).contains(&level) && line.as_bytes().get(level) == Some(&b' ') {
2539        Some((level, &line[level + 1..]))
2540    } else {
2541        None
2542    }
2543}
2544
2545fn is_markdown_rule(trimmed: &str) -> bool {
2546    trimmed.len() >= 3
2547        && ["-", "*", "_", "="]
2548            .iter()
2549            .any(|marker| trimmed.chars().all(|ch| ch.to_string() == *marker))
2550}
2551
2552fn unordered_list_item(line: &str) -> Option<(&str, &str)> {
2553    let trimmed = line.trim_start();
2554    let indent = &line[..line.len() - trimmed.len()];
2555    for marker in ["- ", "* ", "+ "] {
2556        if let Some(text) = trimmed.strip_prefix(marker) {
2557            return Some((indent, text));
2558        }
2559    }
2560    None
2561}
2562
2563fn ordered_list_item(line: &str) -> Option<(&str, &str, &str)> {
2564    let trimmed = line.trim_start();
2565    let indent = &line[..line.len() - trimmed.len()];
2566    let (number, text) = trimmed.split_once(". ")?;
2567    if !number.is_empty() && number.chars().all(|ch| ch.is_ascii_digit()) {
2568        Some((indent, number, text))
2569    } else {
2570        None
2571    }
2572}
2573
2574fn format_markdown_line(line: &str, url_scope: Option<&str>) -> String {
2575    let formatted = format_markdown_inline_scoped(line, url_scope);
2576    if (line.starts_with('-') && !line.starts_with("---") && !line.starts_with("- "))
2577        || line.starts_with('<')
2578    {
2579        format!("\\{formatted}")
2580    } else {
2581        formatted
2582    }
2583}
2584
2585fn is_table_start(lines: &[&str], index: usize) -> bool {
2586    index + 1 < lines.len()
2587        && lines[index].contains('|')
2588        && lines[index + 1].contains('|')
2589        && is_table_separator(lines[index + 1])
2590}
2591
2592fn is_table_separator(line: &str) -> bool {
2593    parse_table_row(line)
2594        .iter()
2595        .all(|cell| !cell.is_empty() && cell.chars().all(|ch| matches!(ch, '-' | ':' | ' ')))
2596}
2597
2598#[derive(Clone, Copy)]
2599enum TableAlign {
2600    Left,
2601    Right,
2602    Center,
2603}
2604
2605fn format_markdown_table(lines: &[&str], url_scope: Option<&str>) -> String {
2606    if lines.len() < 2 {
2607        return String::new();
2608    }
2609    let header: Vec<String> = parse_table_row(lines[0])
2610        .into_iter()
2611        .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
2612        .collect();
2613    if header.is_empty() {
2614        return String::new();
2615    }
2616    let columns = header.len();
2617    let mut alignments = parse_table_alignments(lines[1]);
2618    while alignments.len() < columns {
2619        alignments.push(TableAlign::Left);
2620    }
2621    alignments.truncate(columns);
2622
2623    let rows: Vec<Vec<String>> = lines
2624        .iter()
2625        .skip(2)
2626        .map(|line| {
2627            let mut row: Vec<String> = parse_table_row(line)
2628                .into_iter()
2629                .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
2630                .collect();
2631            while row.len() < columns {
2632                row.push(String::new());
2633            }
2634            row.truncate(columns);
2635            row
2636        })
2637        .collect();
2638
2639    let mut widths = vec![3; columns];
2640    for (index, cell) in header.iter().enumerate() {
2641        widths[index] = widths[index].max(markdown_visible_width(cell));
2642    }
2643    for row in &rows {
2644        for (index, cell) in row.iter().enumerate().take(columns) {
2645            widths[index] = widths[index].max(markdown_visible_width(cell));
2646        }
2647    }
2648
2649    let mut out = String::new();
2650    out.push_str(&table_border('┌', '┬', '┐', &widths));
2651    out.push_str(&table_row(
2652        &header,
2653        &widths,
2654        &vec![TableAlign::Left; columns],
2655    ));
2656    out.push_str(&table_border('├', '┼', '┤', &widths));
2657    for row in rows {
2658        out.push_str(&table_row(&row, &widths, &alignments));
2659    }
2660    out.push_str(&table_border('└', '┴', '┘', &widths));
2661    out
2662}
2663
2664fn parse_table_row(line: &str) -> Vec<String> {
2665    let mut trimmed = line.trim();
2666    if let Some(rest) = trimmed.strip_prefix('|') {
2667        trimmed = rest;
2668    }
2669    if let Some(rest) = trimmed.strip_suffix('|') {
2670        trimmed = rest;
2671    }
2672    let mut cells = Vec::new();
2673    let mut current = String::new();
2674    let mut escaped = false;
2675    for ch in trimmed.chars() {
2676        if escaped {
2677            current.push(ch);
2678            escaped = false;
2679        } else if ch == '\\' {
2680            escaped = true;
2681        } else if ch == '|' {
2682            cells.push(current.trim().to_string());
2683            current.clear();
2684        } else {
2685            current.push(ch);
2686        }
2687    }
2688    if escaped {
2689        current.push('\\');
2690    }
2691    cells.push(current.trim().to_string());
2692    cells
2693}
2694
2695fn parse_table_alignments(line: &str) -> Vec<TableAlign> {
2696    parse_table_row(line)
2697        .into_iter()
2698        .map(|cell| {
2699            let cell = cell.trim();
2700            if cell.starts_with(':') && cell.ends_with(':') {
2701                TableAlign::Center
2702            } else if cell.ends_with(':') {
2703                TableAlign::Right
2704            } else {
2705                TableAlign::Left
2706            }
2707        })
2708        .collect()
2709}
2710
2711fn table_border(left: char, middle: char, right: char, widths: &[usize]) -> String {
2712    let mut out = String::new();
2713    out.push(left);
2714    for (index, width) in widths.iter().enumerate() {
2715        out.push_str(&"─".repeat(width + 2));
2716        if index + 1 == widths.len() {
2717            out.push(right);
2718        } else {
2719            out.push(middle);
2720        }
2721    }
2722    out.push('\n');
2723    out
2724}
2725
2726fn table_row(cells: &[String], widths: &[usize], alignments: &[TableAlign]) -> String {
2727    let mut out = String::from("│");
2728    for (index, width) in widths.iter().enumerate() {
2729        let cell = cells.get(index).map(String::as_str).unwrap_or("");
2730        out.push(' ');
2731        out.push_str(&pad_table_cell(
2732            cell,
2733            *width,
2734            alignments.get(index).copied().unwrap_or(TableAlign::Left),
2735        ));
2736        out.push(' ');
2737        out.push('│');
2738    }
2739    out.push('\n');
2740    out
2741}
2742
2743fn pad_table_cell(cell: &str, width: usize, alignment: TableAlign) -> String {
2744    let visible = markdown_visible_width(cell);
2745    let padding = width.saturating_sub(visible);
2746    match alignment {
2747        TableAlign::Left => format!("{cell}{}", " ".repeat(padding)),
2748        TableAlign::Right => format!("{}{cell}", " ".repeat(padding)),
2749        TableAlign::Center => {
2750            let left = padding / 2;
2751            let right = padding - left;
2752            format!("{}{cell}{}", " ".repeat(left), " ".repeat(right))
2753        }
2754    }
2755}
2756
2757fn markdown_visible_width(value: &str) -> usize {
2758    let mut width = 0;
2759    let mut chars = value.chars().peekable();
2760    while let Some(ch) = chars.next() {
2761        if ch == '`' {
2762            if matches!(chars.peek(), Some('F') | Some('B')) {
2763                chars.next();
2764                if matches!(chars.peek(), Some('T')) {
2765                    chars.next();
2766                    for _ in 0..6 {
2767                        chars.next();
2768                    }
2769                } else {
2770                    for _ in 0..3 {
2771                        chars.next();
2772                    }
2773                }
2774                continue;
2775            }
2776            if matches!(chars.peek(), Some('!') | Some('*') | Some('_') | Some('=')) {
2777                chars.next();
2778                continue;
2779            }
2780            if matches!(chars.peek(), Some('f') | Some('b') | Some('a')) {
2781                chars.next();
2782                continue;
2783            }
2784            if matches!(chars.peek(), Some('[')) {
2785                let mut label_width = 0;
2786                for link_ch in chars.by_ref() {
2787                    if link_ch == '`' {
2788                        for target_ch in chars.by_ref() {
2789                            if target_ch == ']' {
2790                                break;
2791                            }
2792                        }
2793                        break;
2794                    }
2795                    label_width += 1;
2796                }
2797                width += label_width;
2798                continue;
2799            }
2800        }
2801        width += 1;
2802    }
2803    width
2804}
2805
2806fn markdown_wrap_text(text: &str, width: usize) -> Vec<String> {
2807    if text.is_empty() {
2808        return vec![String::new()];
2809    }
2810    let mut lines = Vec::new();
2811    let mut current = String::new();
2812    let mut current_width = 0;
2813    for word in text.split_whitespace() {
2814        let word_width = markdown_visible_width(word);
2815        if !current.is_empty() && current_width + 1 + word_width > width {
2816            lines.push(current);
2817            current = String::new();
2818            current_width = 0;
2819        }
2820        if !current.is_empty() {
2821            current.push(' ');
2822            current_width += 1;
2823        }
2824        current.push_str(word);
2825        current_width += word_width;
2826    }
2827    if !current.is_empty() {
2828        lines.push(current);
2829    }
2830    if lines.is_empty() {
2831        lines.push(String::new());
2832    }
2833    lines
2834}
2835
2836#[derive(Debug)]
2837enum InlineToken {
2838    Link { label: String, target: String },
2839    Code(String),
2840}
2841
2842#[derive(Debug, Clone, Copy)]
2843struct MarkdownLinkStyle<'a> {
2844    bold: bool,
2845    underline: bool,
2846    color: Option<&'a str>,
2847}
2848
2849impl Default for MarkdownLinkStyle<'_> {
2850    fn default() -> Self {
2851        Self {
2852            bold: true,
2853            underline: true,
2854            color: None,
2855        }
2856    }
2857}
2858
2859fn format_markdown_inline_scoped(input: &str, url_scope: Option<&str>) -> String {
2860    let (with_placeholders, tokens) = extract_markdown_inline_tokens(input);
2861    let escaped = m_escape(&with_placeholders);
2862    let styled = apply_markdown_style(&escaped, "**", "`!", "`!");
2863    let styled = apply_markdown_style(&styled, "__", "`!", "`!");
2864    let styled = apply_markdown_style(&styled, "*", "`*", "`*");
2865    let styled = apply_markdown_style(&styled, "_", "`*", "`*");
2866    restore_markdown_inline_tokens(&styled, &tokens, url_scope)
2867}
2868
2869fn extract_markdown_inline_tokens(input: &str) -> (String, Vec<InlineToken>) {
2870    let mut out = String::new();
2871    let mut tokens = Vec::new();
2872    let mut index = 0;
2873
2874    while index < input.len() {
2875        let rest = &input[index..];
2876        if let Some(stripped) = rest.strip_prefix('`') {
2877            if let Some(end) = stripped.find('`') {
2878                let content = &stripped[..end];
2879                if is_markdown_inline_code(content) {
2880                    tokens.push(InlineToken::Code(content.to_string()));
2881                    out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2882                    index += end + 2;
2883                    continue;
2884                }
2885            }
2886        }
2887        if rest.starts_with('[') {
2888            if let Some((consumed, label, target)) = parse_markdown_link(rest) {
2889                tokens.push(InlineToken::Link { label, target });
2890                out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2891                index += consumed;
2892                continue;
2893            }
2894        }
2895        let Some(ch) = rest.chars().next() else {
2896            break;
2897        };
2898        out.push(ch);
2899        index += ch.len_utf8();
2900    }
2901
2902    (out, tokens)
2903}
2904
2905fn is_markdown_inline_code(content: &str) -> bool {
2906    !content.is_empty()
2907        && !content
2908            .chars()
2909            .next()
2910            .is_some_and(|ch| matches!(ch, '!' | '*' | '_' | '=' | 'F' | 'B' | 'f' | 'b' | 'a'))
2911}
2912
2913fn parse_markdown_link(input: &str) -> Option<(usize, String, String)> {
2914    let label_end = input[1..].find(']')? + 1;
2915    if !input[label_end + 1..].starts_with('(') {
2916        return None;
2917    }
2918    let target_start = label_end + 2;
2919    let target_end = input[target_start..].find(')')? + target_start;
2920    Some((
2921        target_end + 1,
2922        input[1..label_end].replace('`', ""),
2923        input[target_start..target_end].to_string(),
2924    ))
2925}
2926
2927fn markdown_token_placeholder(index: usize) -> String {
2928    format!("\u{1f}{index}\u{1f}")
2929}
2930
2931fn restore_markdown_inline_tokens(
2932    input: &str,
2933    tokens: &[InlineToken],
2934    url_scope: Option<&str>,
2935) -> String {
2936    let mut out = input.to_string();
2937    for (index, token) in tokens.iter().enumerate() {
2938        let replacement = match token {
2939            InlineToken::Link { label, target } => {
2940                format_markdown_link(label, target, url_scope, MarkdownLinkStyle::default())
2941            }
2942            InlineToken::Code(value) => format!("`BT383838`Fddd{}`f`b", m_escape(value)),
2943        };
2944        out = out.replace(&markdown_token_placeholder(index), &replacement);
2945    }
2946    out
2947}
2948
2949fn format_markdown_link(
2950    label: &str,
2951    target: &str,
2952    url_scope: Option<&str>,
2953    style: MarkdownLinkStyle<'_>,
2954) -> String {
2955    let mut link = String::new();
2956    if style.underline {
2957        link.push_str("`_");
2958    }
2959    if style.bold {
2960        link.push_str("`!");
2961    }
2962    link.push_str("`[");
2963    link.push_str(sanitize_label(label).trim());
2964    link.push('`');
2965    link.push_str(&markdown_link_target(target, url_scope));
2966    link.push(']');
2967    if style.bold {
2968        link.push_str("`!");
2969    }
2970    if style.underline {
2971        link.push_str("`_");
2972    }
2973
2974    match style.color {
2975        Some(color) if color.len() == 3 => format!("`F{color}{link}`f"),
2976        Some(color) if color.len() == 6 => format!("`FT{color}{link}`f"),
2977        _ => link,
2978    }
2979}
2980
2981fn markdown_link_target(value: &str, url_scope: Option<&str>) -> String {
2982    if let Some(scope) = url_scope {
2983        if !value.contains(":/") {
2984            let (path, anchor) = value
2985                .split_once('#')
2986                .map(|(path, anchor)| (path, Some(anchor)))
2987                .unwrap_or((value, None));
2988            let mut out = format!("{scope}{}", sanitize_field(path));
2989            if let Some(anchor) = anchor.filter(|anchor| !anchor.is_empty()) {
2990                out.push_str("|anchor=");
2991                out.push_str(&sanitize_field(anchor));
2992            }
2993            return out;
2994        }
2995    }
2996    sanitize_markdown_link_target(value)
2997}
2998
2999fn sanitize_markdown_link_target(value: &str) -> String {
3000    value
3001        .chars()
3002        .filter(|ch| *ch != '[' && *ch != ']' && *ch != '`')
3003        .collect()
3004}
3005
3006fn apply_markdown_style(input: &str, marker: &str, open: &str, close: &str) -> String {
3007    let mut out = String::new();
3008    let mut rest = input;
3009
3010    while let Some(start) = rest.find(marker) {
3011        let content_start = start + marker.len();
3012        let Some(end) = rest[content_start..].find(marker) else {
3013            out.push_str(rest);
3014            return out;
3015        };
3016        let content_end = content_start + end;
3017        out.push_str(&rest[..start]);
3018        out.push_str(open);
3019        out.push_str(&rest[content_start..content_end]);
3020        out.push_str(close);
3021        rest = &rest[content_end + marker.len()..];
3022    }
3023    out.push_str(rest);
3024    out
3025}
3026
3027#[derive(Debug)]
3028struct Refs {
3029    heads: Vec<RefInfo>,
3030    tags: Vec<RefInfo>,
3031}
3032
3033#[derive(Debug)]
3034struct RefInfo {
3035    sha: String,
3036    name: String,
3037}
3038
3039fn git_refs(repo: &Path) -> Result<Refs> {
3040    let output = run_git(
3041        Command::new("git")
3042            .arg("--git-dir")
3043            .arg(repo)
3044            .args(["for-each-ref", "--format=%(objectname) %(refname)"]),
3045    )?;
3046    let mut refs = Refs {
3047        heads: Vec::new(),
3048        tags: Vec::new(),
3049    };
3050    for line in output.lines() {
3051        let Some((sha, name)) = line.split_once(' ') else {
3052            continue;
3053        };
3054        if let Some(short) = name.strip_prefix("refs/heads/") {
3055            refs.heads.push(RefInfo {
3056                sha: sha.to_string(),
3057                name: short.to_string(),
3058            });
3059        } else if let Some(short) = name.strip_prefix("refs/tags/") {
3060            refs.tags.push(RefInfo {
3061                sha: sha.to_string(),
3062                name: short.to_string(),
3063            });
3064        }
3065    }
3066    Ok(refs)
3067}
3068
3069fn resolve_ref(repo: &Path, reference: &str) -> Result<String> {
3070    validate_refish(reference)?;
3071    let output = run_git(
3072        Command::new("git")
3073            .arg("--git-dir")
3074            .arg(repo)
3075            .arg("rev-parse")
3076            .arg("--verify")
3077            .arg(format!("{reference}^{{commit}}")),
3078    )?;
3079    Ok(output.trim().to_string())
3080}
3081
3082#[derive(Debug)]
3083struct TreeEntry {
3084    kind: String,
3085    name: String,
3086    size: Option<u64>,
3087}
3088
3089impl TreeEntry {
3090    fn size_label(&self) -> String {
3091        self.size
3092            .map(|size| format!(" ({size} bytes)"))
3093            .unwrap_or_default()
3094    }
3095}
3096
3097fn tree_entries(repo: &Path, reference: &str, path: &str) -> Result<Vec<TreeEntry>> {
3098    validate_git_path(path)?;
3099    let spec = if path.is_empty() {
3100        reference.to_string()
3101    } else {
3102        format!("{reference}:{path}")
3103    };
3104    let output = run_git(
3105        Command::new("git")
3106            .arg("--git-dir")
3107            .arg(repo)
3108            .arg("ls-tree")
3109            .arg("-l")
3110            .arg(spec),
3111    )?;
3112    let mut entries = Vec::new();
3113    for line in output.lines() {
3114        let Some((meta, name)) = line.split_once('\t') else {
3115            continue;
3116        };
3117        let parts: Vec<&str> = meta.split_whitespace().collect();
3118        if parts.len() < 4 {
3119            continue;
3120        }
3121        let size = parts[3].parse::<u64>().ok();
3122        entries.push(TreeEntry {
3123            kind: parts[1].to_string(),
3124            name: name.to_string(),
3125            size,
3126        });
3127    }
3128    entries.sort_by_key(|entry| (entry.kind != "tree", entry.name.to_ascii_lowercase()));
3129    Ok(entries)
3130}
3131
3132fn git_object_kind(repo: &Path, reference: &str, path: &str) -> Result<String> {
3133    validate_git_path(path)?;
3134    let spec = if path.is_empty() {
3135        reference.to_string()
3136    } else {
3137        format!("{reference}:{path}")
3138    };
3139    let output = run_git(
3140        Command::new("git")
3141            .arg("--git-dir")
3142            .arg(repo)
3143            .arg("cat-file")
3144            .arg("-t")
3145            .arg(spec),
3146    )?;
3147    Ok(output.trim().to_string())
3148}
3149
3150struct BlobInfo {
3151    content: String,
3152    size: u64,
3153    binary: bool,
3154    displayable: bool,
3155}
3156
3157fn blob_info(repo: &Path, reference: &str, path: &str) -> Result<BlobInfo> {
3158    validate_git_path(path)?;
3159    let spec = format!("{reference}:{path}");
3160    let size = run_git(
3161        Command::new("git")
3162            .arg("--git-dir")
3163            .arg(repo)
3164            .arg("cat-file")
3165            .arg("-s")
3166            .arg(&spec),
3167    )?
3168    .trim()
3169    .parse::<u64>()
3170    .unwrap_or(0);
3171    if size > 256 * 1024 && !is_image_path(path) {
3172        return Ok(BlobInfo {
3173            content: format!("File is too large to display ({size} bytes)."),
3174            size,
3175            binary: false,
3176            displayable: false,
3177        });
3178    }
3179    let output = run_git_output(
3180        Command::new("git")
3181            .arg("--git-dir")
3182            .arg(repo)
3183            .arg("show")
3184            .arg(spec),
3185        GIT_COMMAND_TIMEOUT,
3186    )?;
3187    if !output.status.success() {
3188        return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
3189    }
3190    let binary = output.stdout.contains(&0);
3191    if size > 256 * 1024 && !binary {
3192        return Ok(BlobInfo {
3193            content: format!("File is too large to display ({size} bytes)."),
3194            size,
3195            binary: false,
3196            displayable: false,
3197        });
3198    }
3199    Ok(BlobInfo {
3200        content: if binary {
3201            "Binary file is not displayed.".to_string()
3202        } else {
3203            String::from_utf8_lossy(&output.stdout).into_owned()
3204        },
3205        size,
3206        binary,
3207        displayable: !binary,
3208    })
3209}
3210
3211#[derive(Debug)]
3212struct CommitSummary {
3213    hash: String,
3214    subject: String,
3215    author: String,
3216    timestamp: i64,
3217}
3218
3219fn commits(repo: &Path, reference: &str, limit: usize) -> Result<Vec<CommitSummary>> {
3220    let format = "%H%x1f%s%x1f%an%x1f%at";
3221    let output = run_git(
3222        Command::new("git")
3223            .arg("--git-dir")
3224            .arg(repo)
3225            .arg("log")
3226            .arg(format!("--format={format}"))
3227            .arg("-n")
3228            .arg(limit.to_string())
3229            .arg(reference),
3230    )?;
3231    let mut commits = Vec::new();
3232    for line in output.lines() {
3233        let parts: Vec<&str> = line.split('\x1f').collect();
3234        if parts.len() == 4 {
3235            commits.push(CommitSummary {
3236                hash: parts[0].to_string(),
3237                subject: parts[1].to_string(),
3238                author: parts[2].to_string(),
3239                timestamp: parts[3].parse().unwrap_or_default(),
3240            });
3241        }
3242    }
3243    Ok(commits)
3244}
3245
3246#[derive(Debug)]
3247struct CommitInfo {
3248    hash: String,
3249    subject: String,
3250    body: String,
3251    author: String,
3252    email: String,
3253    timestamp: i64,
3254    files: Vec<CommitFile>,
3255}
3256
3257#[derive(Debug)]
3258struct CommitFile {
3259    status: String,
3260    path: String,
3261}
3262
3263fn commit_info(repo: &Path, hash: &str) -> Result<CommitInfo> {
3264    validate_refish(hash)?;
3265    let output = run_git(
3266        Command::new("git")
3267            .arg("--git-dir")
3268            .arg(repo)
3269            .arg("show")
3270            .arg("--no-patch")
3271            .arg("--format=%H%x1f%s%x1f%an%x1f%ae%x1f%at%x1f%B")
3272            .arg(hash),
3273    )?;
3274    let parts: Vec<&str> = output.splitn(6, '\x1f').collect();
3275    if parts.len() != 6 {
3276        return Err(Error::msg("invalid commit output"));
3277    }
3278    let files_output = run_git(
3279        Command::new("git")
3280            .arg("--git-dir")
3281            .arg(repo)
3282            .arg("diff-tree")
3283            .arg("--root")
3284            .arg("--no-commit-id")
3285            .arg("--name-status")
3286            .arg("-r")
3287            .arg(hash),
3288    )?;
3289    let mut files = Vec::new();
3290    for line in files_output.lines() {
3291        let Some((status, path)) = line.split_once('\t') else {
3292            continue;
3293        };
3294        files.push(CommitFile {
3295            status: status.to_string(),
3296            path: path.to_string(),
3297        });
3298    }
3299    Ok(CommitInfo {
3300        hash: parts[0].trim().to_string(),
3301        subject: parts[1].trim().to_string(),
3302        author: parts[2].trim().to_string(),
3303        email: parts[3].trim().to_string(),
3304        timestamp: parts[4].trim().parse().unwrap_or_default(),
3305        body: parts[5].trim().to_string(),
3306        files,
3307    })
3308}
3309
3310fn raw_commit_object(repo: &Path, hash: &str) -> Result<Vec<u8>> {
3311    validate_refish(hash)?;
3312    let output = run_git_output(
3313        Command::new("git")
3314            .arg("--git-dir")
3315            .arg(repo)
3316            .arg("cat-file")
3317            .arg("-p")
3318            .arg(hash),
3319        GIT_COMMAND_TIMEOUT,
3320    )?;
3321    if !output.status.success() {
3322        return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
3323    }
3324    Ok(output.stdout)
3325}
3326
3327fn run_git(cmd: &mut Command) -> Result<String> {
3328    let output = run_git_output(cmd, GIT_COMMAND_TIMEOUT)?;
3329    if !output.status.success() {
3330        return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
3331    }
3332    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
3333}
3334
3335fn run_git_output(cmd: &mut Command, timeout: Duration) -> Result<Output> {
3336    // Spool both outputs while Git runs: waiting before draining a pipe would
3337    // deadlock once a media blob fills the pipe buffer. Owned files also let
3338    // timeout cleanup finish without waiting on pipe-reader threads.
3339    let mut stdout = tempfile::tempfile()?;
3340    let mut stderr = tempfile::tempfile()?;
3341    let mut child = cmd
3342        .stdout(Stdio::from(stdout.try_clone()?))
3343        .stderr(Stdio::from(stderr.try_clone()?))
3344        .spawn()?;
3345    let deadline = Instant::now() + timeout;
3346
3347    loop {
3348        if let Some(status) = child.try_wait()? {
3349            stdout.seek(SeekFrom::Start(0))?;
3350            stderr.seek(SeekFrom::Start(0))?;
3351            let mut output = Output {
3352                status,
3353                stdout: Vec::new(),
3354                stderr: Vec::new(),
3355            };
3356            stdout.read_to_end(&mut output.stdout)?;
3357            stderr.read_to_end(&mut output.stderr)?;
3358            return Ok(output);
3359        }
3360
3361        if Instant::now() >= deadline {
3362            let _ = child.kill();
3363            let _ = child.wait();
3364            log::warn!("Git command execution timed out");
3365            return Err(Error::msg("Git command execution timed out"));
3366        }
3367
3368        let sleep_for = deadline
3369            .saturating_duration_since(Instant::now())
3370            .min(Duration::from_millis(10));
3371        if !sleep_for.is_zero() {
3372            thread::sleep(sleep_for);
3373        }
3374    }
3375}
3376
3377#[cfg(test)]
3378mod tests {
3379    use super::*;
3380    use crate::acl::Access;
3381    use crate::config::ServerConfig;
3382
3383    #[test]
3384    fn media_preserves_original_for_disabled_webp_nonimage_and_failed_conversion() {
3385        for (enabled, name) in [
3386            (false, "test.png"),
3387            (true, "test.WEBP"),
3388            (true, "test.txt"),
3389            (true, "bad.png"),
3390        ] {
3391            let original = b"not an image".to_vec();
3392            let (bytes, response_name) = media_response_data(enabled, name, name, original.clone());
3393            assert_eq!(bytes, original);
3394            assert_eq!(response_name, name);
3395        }
3396    }
3397
3398    #[test]
3399    fn media_serves_decoded_blob_with_filename_and_checks_access() {
3400        let tmp = tempfile::tempdir().unwrap();
3401        let mut config = cfg(tmp.path());
3402        let payload = b"RIFF\0image bytes";
3403        create_repo_bytes(
3404            config.repositories_dir.join("public/images"),
3405            "docs/a + b.webp",
3406            payload,
3407        );
3408        let request = msgpack::pack(&Value::Map(vec![
3409            (Value::Str("key".into()), Value::Bin(vec![1, 2, 3])),
3410            (
3411                Value::Str("path".into()),
3412                Value::Str("/media/public/images/HEAD/docs%2Fa+%2B+b.webp".into()),
3413            ),
3414        ]));
3415        match serve_media(&config, &access(&config), &request, None).unwrap() {
3416            RequestResponse::File {
3417                data,
3418                metadata,
3419                auto_compress,
3420            } => {
3421                assert!(!auto_compress, "media responses must not auto-compress");
3422                assert_eq!(data, payload);
3423                assert_eq!(
3424                    msgpack::unpack_exact(&metadata).unwrap(),
3425                    Value::Map(vec![(
3426                        Value::Str("name".into()),
3427                        Value::Bin(b"a + b.webp".to_vec())
3428                    ),])
3429                );
3430            }
3431            _ => panic!("expected media resource"),
3432        }
3433        config.allow_read = vec!["none".into()];
3434        assert!(
3435            matches!(serve_media(&config, &access(&config), &request, None).unwrap(), RequestResponse::Bytes(data) if data == msgpack::pack(&Value::Bool(false)))
3436        );
3437    }
3438
3439    #[test]
3440    fn media_rejects_missing_key_bad_paths_and_unknown_objects() {
3441        let tmp = tempfile::tempdir().unwrap();
3442        let config = cfg(tmp.path());
3443        create_repo_bytes(
3444            config.repositories_dir.join("public/images"),
3445            "image.webp",
3446            b"RIFF\0data",
3447        );
3448        let access = access(&config);
3449        let mut requests = vec![
3450            page_request(&[("path", "/media/public/images/HEAD/image.webp")]),
3451            msgpack::pack(&Value::Bool(true)),
3452        ];
3453        for path in [
3454            "/media/public/images",
3455            "/media/public/images/HEAD/../secret",
3456            "/media/public/images/missing/image.webp",
3457            "/media/public/images/HEAD/missing.webp",
3458            "/media/../images/HEAD/image.webp",
3459        ] {
3460            requests.push(page_request(&[("key", "k"), ("path", path)]));
3461        }
3462        for request in requests {
3463            assert!(
3464                matches!(serve_media(&config, &access, &request, None).unwrap(), RequestResponse::Bytes(data) if data == msgpack::pack(&Value::Bool(false)))
3465            );
3466        }
3467    }
3468
3469    #[test]
3470    fn binary_image_blob_renders_micron_media_reference() {
3471        let tmp = tempfile::tempdir().unwrap();
3472        let config = cfg(tmp.path());
3473        create_repo_bytes(
3474            config.repositories_dir.join("public/images"),
3475            "docs/a + b.PNG",
3476            &vec![0; 256 * 1024 + 1],
3477        );
3478        let page = render_page(
3479            PATH_BLOB,
3480            &config,
3481            &access(&config),
3482            &page_request(&[
3483                ("var_g", "public"),
3484                ("var_r", "images"),
3485                ("var_path", "docs/a + b.PNG"),
3486            ]),
3487            None,
3488        )
3489        .unwrap();
3490        assert!(
3491            page.contains("`(Image file`w=n`a=c`:/media/public/images/HEAD/docs%2Fa+%2B+b.PNG)")
3492        );
3493        assert!(!page.contains("Binary file is not displayed"));
3494    }
3495
3496    #[test]
3497    fn blob_path_normalization_removes_only_the_explicit_relative_prefix() {
3498        assert_eq!(normalize_blob_path("./docs/guide.md"), "docs/guide.md");
3499        assert_eq!(normalize_blob_path("docs/./guide.md"), "docs/guide.md");
3500        assert_eq!(normalize_blob_path(".hidden/config"), ".hidden/config");
3501        assert_eq!(normalize_blob_path(".../guide.md"), ".../guide.md");
3502        assert_eq!(normalize_blob_path("/absolute.md"), "/absolute.md");
3503        assert!(validate_git_path(&normalize_blob_path("/absolute.md")).is_err());
3504    }
3505    use crate::logging;
3506    use rns_core::msgpack::{self, Value};
3507    use rns_crypto::OsRng;
3508    use std::fs;
3509    use std::process::Command;
3510
3511    #[test]
3512    fn nomadnet_destination_uses_upstream_name() {
3513        let identity = Identity::new(&mut OsRng);
3514        let destination = destination_for_identity(&identity);
3515        let expected =
3516            Destination::single_in("nomadnetwork", &["node"], IdentityHash(*identity.hash()));
3517        assert_eq!(destination.hash, expected.hash);
3518    }
3519
3520    #[test]
3521    fn chart_renders_positive_values_on_bottom_row() {
3522        let labels = ["start".to_string(), "end".to_string()];
3523        let chart = render_chart(&[1, 100], &labels, "57c", None, 1.3, 10);
3524        let bottom_row = chart.lines().nth(10).unwrap();
3525
3526        assert!(bottom_row.starts_with("│`FT"));
3527        assert!(bottom_row.contains('▄'));
3528    }
3529
3530    #[test]
3531    fn git_command_timeout_kills_slow_process() {
3532        let start = Instant::now();
3533
3534        let err =
3535            run_git_output(Command::new("sleep").arg("5"), Duration::from_millis(30)).unwrap_err();
3536
3537        assert!(start.elapsed() < Duration::from_secs(1));
3538        assert!(err.to_string().contains("timed out"));
3539    }
3540
3541    #[test]
3542    fn commit_message_formatter_escapes_leading_dash_lines() {
3543        let formatted = format_commit_message("subject\n- rendered literally\npath\\with`tick`");
3544
3545        assert_eq!(
3546            formatted,
3547            "subject\n\\- rendered literally\npath\\\\with\\`tick\\`"
3548        );
3549    }
3550
3551    #[test]
3552    fn front_page_lists_only_readable_groups() {
3553        let tmp = tempfile::tempdir().unwrap();
3554        let mut config = cfg(tmp.path());
3555        config.allow_read = vec!["none".into()];
3556        create_repo(
3557            config.repositories_dir.join("public/alpha"),
3558            "README.md",
3559            "alpha readme\n",
3560        );
3561        create_repo(
3562            config.repositories_dir.join("private/secret"),
3563            "README.md",
3564            "secret\n",
3565        );
3566        fs::write(
3567            config.repositories_dir.join("public/group.allowed"),
3568            "read = all\n",
3569        )
3570        .unwrap();
3571        fs::write(
3572            config.repositories_dir.join("private/group.allowed"),
3573            "read = none\n",
3574        )
3575        .unwrap();
3576        let access = access(&config);
3577
3578        let page = render_page(PATH_INDEX, &config, &access, &page_request(&[]), None).unwrap();
3579        assert!(page.contains("public"));
3580        assert!(page.contains("alpha"));
3581        assert!(!page.contains("private"));
3582        assert!(!page.contains("secret"));
3583    }
3584
3585    #[test]
3586    fn group_and_repo_pages_use_repository_metadata() {
3587        let tmp = tempfile::tempdir().unwrap();
3588        let config = cfg(tmp.path());
3589        let repo = create_repo(
3590            config.repositories_dir.join("public/alpha"),
3591            "README.md",
3592            "# Alpha\n",
3593        );
3594        fs::write(repo.join("description"), "Alpha repository\n").unwrap();
3595        let access = access(&config);
3596
3597        let group = render_page(
3598            PATH_GROUP,
3599            &config,
3600            &access,
3601            &page_request(&[("var_g", "public")]),
3602            None,
3603        )
3604        .unwrap();
3605        assert!(group.contains("Alpha repository"));
3606        assert!(group.contains("alpha"));
3607
3608        let repo = render_page(
3609            PATH_REPO,
3610            &config,
3611            &access,
3612            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3613            None,
3614        )
3615        .unwrap();
3616        assert!(repo.contains("rns://"));
3617        assert!(repo.contains("Alpha repository"));
3618        assert!(repo.contains(">Alpha"));
3619    }
3620
3621    #[test]
3622    fn repo_page_renders_fork_and_mirror_sources() {
3623        let tmp = tempfile::tempdir().unwrap();
3624        let config = cfg(tmp.path());
3625        let fork = create_repo(
3626            config.repositories_dir.join("public/forked"),
3627            "README.md",
3628            "# Forked\n",
3629        );
3630        let mirror = create_repo(
3631            config.repositories_dir.join("public/mirrored"),
3632            "README.md",
3633            "# Mirrored\n",
3634        );
3635        run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3636            "config",
3637            "repository.rngit.type",
3638            "fork",
3639        ]));
3640        run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3641            "config",
3642            "repository.rngit.upstream.source",
3643            "rns://00112233445566778899aabbccddeeff/source/repo",
3644        ]));
3645        run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3646            "config",
3647            "repository.rngit.upstream.sync",
3648            "1",
3649        ]));
3650        run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3651            "config",
3652            "repository.rngit.type",
3653            "mirror",
3654        ]));
3655        run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3656            "config",
3657            "repository.rngit.upstream.source",
3658            "https://example.invalid/upstream.git",
3659        ]));
3660        run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3661            "config",
3662            "repository.rngit.upstream.sync",
3663            "1",
3664        ]));
3665        let access = access(&config);
3666
3667        let fork_page = render_page(
3668            PATH_REPO,
3669            &config,
3670            &access,
3671            &page_request(&[("var_g", "public"), ("var_r", "forked")]),
3672            None,
3673        )
3674        .unwrap();
3675        assert!(fork_page.contains("Forked from"));
3676        assert!(fork_page.contains("rns://00112233445566778899aabbccddeeff/source/repo"));
3677        assert!(fork_page.contains("synced"));
3678        assert!(fork_page.contains("ago"));
3679
3680        let mirror_page = render_page(
3681            PATH_REPO,
3682            &config,
3683            &access,
3684            &page_request(&[("var_g", "public"), ("var_r", "mirrored")]),
3685            None,
3686        )
3687        .unwrap();
3688        assert!(mirror_page.contains("Mirrored from"));
3689        assert!(mirror_page.contains("https://example.invalid/upstream.git"));
3690        assert!(mirror_page.contains("synced"));
3691        assert!(mirror_page.contains("ago"));
3692    }
3693
3694    #[test]
3695    fn tree_blob_commits_commit_and_refs_pages_are_git_backed() {
3696        let tmp = tempfile::tempdir().unwrap();
3697        let config = cfg(tmp.path());
3698        let repo = create_repo(
3699            config.repositories_dir.join("public/alpha"),
3700            "src/lib.rs",
3701            "pub fn answer() -> u8 { 42 }\n",
3702        );
3703        run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3704            "tag",
3705            "v1",
3706            "refs/heads/main",
3707        ]));
3708        let access = access(&config);
3709
3710        let tree = render_page(
3711            PATH_TREE,
3712            &config,
3713            &access,
3714            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3715            None,
3716        )
3717        .unwrap();
3718        assert!(tree.contains("src/"));
3719
3720        let blob = render_page(
3721            PATH_BLOB,
3722            &config,
3723            &access,
3724            &page_request(&[
3725                ("var_g", "public"),
3726                ("var_r", "alpha"),
3727                ("var_path", "src/lib.rs"),
3728            ]),
3729            None,
3730        )
3731        .unwrap();
3732        assert!(blob.contains("answer"));
3733        assert!(blob.contains("pub fn answer()"));
3734
3735        let commits = render_page(
3736            PATH_COMMITS,
3737            &config,
3738            &access,
3739            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3740            None,
3741        )
3742        .unwrap();
3743        assert!(commits.contains("initial"));
3744
3745        let commit_hash = run_git(
3746            Command::new("git")
3747                .arg("--git-dir")
3748                .arg(&repo)
3749                .args(["rev-parse", "refs/heads/main"]),
3750        );
3751        run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3752            "update-ref",
3753            "refs/heads/feature",
3754            commit_hash.trim(),
3755        ]));
3756        let branch_commits = render_page(
3757            PATH_COMMITS,
3758            &config,
3759            &access,
3760            &page_request(&[
3761                ("var_g", "public"),
3762                ("var_r", "alpha"),
3763                ("var_ref", "feature"),
3764            ]),
3765            None,
3766        )
3767        .unwrap();
3768        assert!(branch_commits.contains("|ref=feature|h="));
3769        let commit = render_page(
3770            PATH_COMMIT,
3771            &config,
3772            &access,
3773            &page_request(&[
3774                ("var_g", "public"),
3775                ("var_r", "alpha"),
3776                ("var_ref", "feature"),
3777                ("var_h", commit_hash.trim()),
3778            ]),
3779            None,
3780        )
3781        .unwrap();
3782        assert!(commit.contains("initial"));
3783        assert!(commit.contains("src/lib.rs"));
3784        assert!(commit.contains("/page/commits.mu`g=public|r=alpha|ref=feature"));
3785        assert!(!commit.contains("Signature:"));
3786
3787        let refs = render_page(
3788            PATH_REFS,
3789            &config,
3790            &access,
3791            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3792            None,
3793        )
3794        .unwrap();
3795        assert!(refs.contains("main"));
3796        assert!(refs.contains("v1"));
3797    }
3798
3799    #[test]
3800    fn commit_page_shows_valid_reticulum_signature_status() {
3801        let tmp = tempfile::tempdir().unwrap();
3802        let config = cfg(tmp.path());
3803        let repo = create_repo(
3804            config.repositories_dir.join("public/alpha"),
3805            "src/lib.rs",
3806            "pub fn answer() -> u8 { 42 }\n",
3807        );
3808        let access = access(&config);
3809        let tree = run_git(
3810            Command::new("git")
3811                .arg("--git-dir")
3812                .arg(&repo)
3813                .args(["rev-parse", "refs/heads/main^{tree}"]),
3814        );
3815        let identity = Identity::new(&mut OsRng);
3816        let author = crate::util::hex(identity.hash());
3817        let signed_content = format!(
3818            "tree {}\nauthor RNS Page Test <{}> 0 +0000\ncommitter RNS Page Test <{}> 0 +0000\n\nsigned commit\n",
3819            tree.trim(),
3820            author,
3821            author
3822        );
3823        let armored =
3824            crate::commitsigs::sign_message(&identity, signed_content.as_bytes()).unwrap();
3825        let (headers, rest) = signed_content.split_once("\n\n").unwrap();
3826        let mut raw_commit = format!("{headers}\n");
3827        let mut sig_lines = armored.lines();
3828        raw_commit.push_str("gpgsig ");
3829        raw_commit.push_str(sig_lines.next().unwrap());
3830        raw_commit.push('\n');
3831        for line in sig_lines {
3832            raw_commit.push(' ');
3833            raw_commit.push_str(line);
3834            raw_commit.push('\n');
3835        }
3836        raw_commit.push('\n');
3837        raw_commit.push_str(rest);
3838        let commit_file = tmp.path().join("signed-commit");
3839        fs::write(&commit_file, raw_commit).unwrap();
3840        let commit_hash = run_git(
3841            Command::new("git")
3842                .arg("--git-dir")
3843                .arg(&repo)
3844                .args(["hash-object", "-t", "commit", "-w"])
3845                .arg(&commit_file),
3846        );
3847        run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3848            "update-ref",
3849            "refs/heads/main",
3850            commit_hash.trim(),
3851        ]));
3852
3853        let page = render_page(
3854            PATH_COMMIT,
3855            &config,
3856            &access,
3857            &page_request(&[
3858                ("var_g", "public"),
3859                ("var_r", "alpha"),
3860                ("var_h", commit_hash.trim()),
3861            ]),
3862            None,
3863        )
3864        .unwrap();
3865
3866        assert!(page.contains("signed commit"));
3867        assert!(page.contains("Signature: Valid, signed by author"));
3868    }
3869
3870    #[test]
3871    fn blob_page_renders_tree_when_path_is_directory() {
3872        let tmp = tempfile::tempdir().unwrap();
3873        let config = cfg(tmp.path());
3874        create_repo(
3875            config.repositories_dir.join("public/alpha"),
3876            "src/lib.rs",
3877            "pub fn answer() -> u8 { 42 }\n",
3878        );
3879        let access = access(&config);
3880
3881        let page = render_page(
3882            PATH_BLOB,
3883            &config,
3884            &access,
3885            &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_path", "src")]),
3886            None,
3887        )
3888        .unwrap();
3889
3890        assert!(page.contains("files / src"));
3891        assert!(page.contains("lib.rs"));
3892        assert!(page.contains(PATH_BLOB));
3893        assert!(!page.contains("Displaying Raw"));
3894    }
3895
3896    #[test]
3897    fn page_requests_reject_invalid_repository_names() {
3898        let tmp = tempfile::tempdir().unwrap();
3899        let config = cfg(tmp.path());
3900        let access = access(&config);
3901        let err = render_page(
3902            PATH_REPO,
3903            &config,
3904            &access,
3905            &page_request(&[("var_g", ".."), ("var_r", "repo")]),
3906            None,
3907        )
3908        .unwrap_err();
3909        assert!(err.to_string().contains("invalid repository name"));
3910    }
3911
3912    #[test]
3913    fn page_ref_resolution_rejects_invalid_refs_before_git_lookup() {
3914        let tmp = tempfile::tempdir().unwrap();
3915        let config = cfg(tmp.path());
3916        create_repo(
3917            config.repositories_dir.join("public/alpha"),
3918            "README.md",
3919            "# Alpha\n",
3920        );
3921        let access = access(&config);
3922
3923        for (path, vars) in [
3924            (
3925                PATH_TREE,
3926                vec![
3927                    ("var_g", "public"),
3928                    ("var_r", "alpha"),
3929                    ("var_ref", "--upload-pack=/tmp/x"),
3930                ],
3931            ),
3932            (
3933                PATH_BLOB,
3934                vec![
3935                    ("var_g", "public"),
3936                    ("var_r", "alpha"),
3937                    ("var_path", "README.md"),
3938                    ("var_ref", "refs/heads/main:README.md"),
3939                ],
3940            ),
3941            (
3942                PATH_COMMITS,
3943                vec![
3944                    ("var_g", "public"),
3945                    ("var_r", "alpha"),
3946                    ("var_ref", "refs/heads/main..refs/heads/other"),
3947                ],
3948            ),
3949        ] {
3950            let err = render_page(path, &config, &access, &page_request(&vars), None).unwrap_err();
3951            assert_eq!(err.to_string(), "invalid git ref");
3952        }
3953    }
3954
3955    #[test]
3956    fn pages_use_base_template_breadcrumbs_and_micron_links() {
3957        let tmp = tempfile::tempdir().unwrap();
3958        let config = cfg(tmp.path());
3959        create_repo(
3960            config.repositories_dir.join("public/alpha"),
3961            "README.md",
3962            "# Alpha\n",
3963        );
3964        let access = access(&config);
3965
3966        let repo = render_page(
3967            PATH_REPO,
3968            &config,
3969            &access,
3970            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3971            None,
3972        )
3973        .unwrap();
3974
3975        assert!(repo.starts_with("#!c=0\n>Test Git Node\n\n>>\n"));
3976        assert!(repo.contains("`!`[Node`:/page/index.mu]`!"));
3977        assert!(repo.contains("`!`[public`:/page/group.mu`g=public]`!"));
3978        assert!(repo.contains("`[Files`:/page/tree.mu`g=public|r=alpha|ref=HEAD]"));
3979        assert!(repo.contains("`[Commits"));
3980        assert!(repo.contains("`[Branches (1)`:/page/refs.mu`g=public|r=alpha|type=heads]"));
3981        assert!(repo.contains("`[Tags (0)`:/page/refs.mu`g=public|r=alpha|type=tags]"));
3982        assert!(repo.contains("<\n-\n`a`F666`[Served by rngit`:/page/index.mu]"));
3983    }
3984
3985    #[test]
3986    fn custom_templates_wrap_page_content_and_replace_variables() {
3987        let tmp = tempfile::tempdir().unwrap();
3988        let config = cfg(tmp.path());
3989        fs::create_dir_all(&config.templates_dir).unwrap();
3990        fs::write(
3991            config.templates_dir.join("base.mu"),
3992            "BASE {NODE_NAME} {VERSION} {GEN_TIME}\n{PAGE_CONTENT}\n{NAVIGATION}   \n",
3993        )
3994        .unwrap();
3995        fs::write(
3996            config.templates_dir.join("repo.mu"),
3997            "REPO-BEGIN\n{PAGE_CONTENT}\nREPO-END   \n",
3998        )
3999        .unwrap();
4000        create_repo(
4001            config.repositories_dir.join("public/alpha"),
4002            "README.md",
4003            "# Alpha\n",
4004        );
4005        let access = access(&config);
4006
4007        let repo = render_page(
4008            PATH_REPO,
4009            &config,
4010            &access,
4011            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4012            None,
4013        )
4014        .unwrap();
4015
4016        assert!(repo.starts_with("BASE Test Git Node "));
4017        assert!(repo.contains("\nREPO-BEGIN\n"));
4018        assert!(repo.contains(">Alpha"));
4019        assert!(repo.contains("\nREPO-END\n"));
4020        assert!(repo.contains(env!("CARGO_PKG_VERSION")));
4021        assert!(repo.contains("0ms"));
4022        assert!(!repo.ends_with("   \n"));
4023    }
4024
4025    #[test]
4026    fn blocked_unidentified_requests_use_no_ident_page_for_every_page_path() {
4027        let tmp = tempfile::tempdir().unwrap();
4028        let mut config = cfg(tmp.path());
4029        config.blocked_identities.push(NULL_IDENTITY_HASH);
4030        let access = access(&config);
4031
4032        for path in PAGE_PATHS {
4033            let page = render_page(path, &config, &access, &page_request(&[]), None).unwrap();
4034            assert!(
4035                page.contains(
4036                    ">>No Identity\n\nThis page requires identification, and none was received."
4037                ),
4038                "{path} did not render the no-ident page: {page}"
4039            );
4040        }
4041    }
4042
4043    #[test]
4044    fn no_ident_page_requires_both_missing_identity_and_blocked_null_hash() {
4045        let tmp = tempfile::tempdir().unwrap();
4046        let mut config = cfg(tmp.path());
4047        let access = access(&config);
4048
4049        let allowed_unidentified =
4050            render_page(PATH_INDEX, &config, &access, &page_request(&[]), None).unwrap();
4051        assert!(!allowed_unidentified.contains(">>No Identity"));
4052
4053        config.blocked_identities.push(NULL_IDENTITY_HASH);
4054        let identified = [0x42; 16];
4055        let allowed_identified = render_page(
4056            PATH_INDEX,
4057            &config,
4058            &access,
4059            &page_request(&[]),
4060            Some(&identified),
4061        )
4062        .unwrap();
4063        assert!(!allowed_identified.contains(">>No Identity"));
4064    }
4065
4066    #[test]
4067    fn custom_no_ident_template_overrides_the_default() {
4068        let tmp = tempfile::tempdir().unwrap();
4069        let mut config = cfg(tmp.path());
4070        config.blocked_identities.push(NULL_IDENTITY_HASH);
4071        fs::create_dir_all(&config.templates_dir).unwrap();
4072        fs::write(
4073            config.templates_dir.join("no_ident.mu"),
4074            ">>Identify First\n\nCustom unidentified response for {NODE_NAME}.\n",
4075        )
4076        .unwrap();
4077        let access = access(&config);
4078
4079        let page = render_page(PATH_INDEX, &config, &access, &page_request(&[]), None).unwrap();
4080
4081        assert!(page.contains(">>Identify First"));
4082        assert!(page.contains("Custom unidentified response for Test Git Node."));
4083        assert!(!page.contains(">>No Identity"));
4084    }
4085
4086    #[test]
4087    fn template_substitution_does_not_rewrite_page_content_placeholders() {
4088        let tmp = tempfile::tempdir().unwrap();
4089        let config = cfg(tmp.path());
4090
4091        let out = apply_template(
4092            "Template {NODE_NAME}\n{PAGE_CONTENT}",
4093            "Literal {NODE_NAME}",
4094            &config,
4095        );
4096
4097        assert!(out.starts_with("Template Test Git Node\n"));
4098        assert!(out.ends_with("Literal {NODE_NAME}"));
4099    }
4100
4101    #[test]
4102    fn unicode_icon_config_changes_repository_navigation_labels() {
4103        let tmp = tempfile::tempdir().unwrap();
4104        let mut config = cfg(tmp.path());
4105        config.unicode_icons = true;
4106        create_repo(
4107            config.repositories_dir.join("public/alpha"),
4108            "README.md",
4109            "# Alpha\n",
4110        );
4111        let access = access(&config);
4112
4113        let repo = render_page(
4114            PATH_REPO,
4115            &config,
4116            &access,
4117            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4118            None,
4119        )
4120        .unwrap();
4121
4122        assert!(repo.contains("🗀 Files"));
4123        assert!(repo.contains("🖹 Commits"));
4124        assert!(repo.contains("⑃ Branches"));
4125        assert!(repo.contains("⌆ Tags"));
4126    }
4127
4128    #[test]
4129    fn repo_page_renders_and_persists_thanks_count() {
4130        let tmp = tempfile::tempdir().unwrap();
4131        let config = cfg(tmp.path());
4132        let repo_path = create_repo(
4133            config.repositories_dir.join("public/alpha"),
4134            "README.md",
4135            "# Alpha\n",
4136        );
4137        let access = access(&config);
4138
4139        let first = render_page(
4140            PATH_REPO,
4141            &config,
4142            &access,
4143            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4144            None,
4145        )
4146        .unwrap();
4147        assert!(first.contains("Thanks (0)"));
4148        assert!(!repo_path.with_extension("thanks").exists());
4149
4150        let thanked = render_page(
4151            PATH_REPO,
4152            &config,
4153            &access,
4154            &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
4155            None,
4156        )
4157        .unwrap();
4158        assert!(thanked.contains("Thanks (1)"));
4159        assert_eq!(
4160            fs::read_to_string(repo_path.with_extension("thanks")).unwrap(),
4161            "1\n"
4162        );
4163
4164        let incremented = render_page(
4165            PATH_REPO,
4166            &config,
4167            &access,
4168            &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
4169            None,
4170        )
4171        .unwrap();
4172        assert!(incremented.contains("Thanks (2)"));
4173
4174        let viewed = render_page(
4175            PATH_REPO,
4176            &config,
4177            &access,
4178            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4179            None,
4180        )
4181        .unwrap();
4182        assert!(viewed.contains("Thanks (2)"));
4183    }
4184
4185    #[test]
4186    fn repo_page_uses_unicode_heart_for_thanks_when_enabled() {
4187        let tmp = tempfile::tempdir().unwrap();
4188        let mut config = cfg(tmp.path());
4189        config.unicode_icons = true;
4190        create_repo(
4191            config.repositories_dir.join("public/alpha"),
4192            "README.md",
4193            "# Alpha\n",
4194        );
4195        let access = access(&config);
4196
4197        let page = render_page(
4198            PATH_REPO,
4199            &config,
4200            &access,
4201            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4202            None,
4203        )
4204        .unwrap();
4205
4206        assert!(page.contains("♥ Thanks (0)"));
4207        assert!(page.contains("thanks=y"));
4208    }
4209
4210    #[test]
4211    fn empty_page_request_data_is_deterministic() {
4212        let tmp = tempfile::tempdir().unwrap();
4213        let config = cfg(tmp.path());
4214        let access = access(&config);
4215
4216        let index = render_page(PATH_INDEX, &config, &access, &[], None).unwrap();
4217        assert!(index.contains(">Groups"));
4218
4219        let err = render_page(PATH_REPO, &config, &access, &[], None).unwrap_err();
4220        assert!(err.to_string().contains("missing page variable g"));
4221    }
4222
4223    #[test]
4224    fn tree_blob_and_commit_pages_escape_content_and_format_metadata() {
4225        let tmp = tempfile::tempdir().unwrap();
4226        let config = cfg(tmp.path());
4227        let repo = create_repo(
4228            config.repositories_dir.join("public/alpha"),
4229            "src/lib.rs",
4230            "pub fn `answer`() -> u8 { 42 }\n",
4231        );
4232        let access = access(&config);
4233
4234        let tree = render_page(
4235            PATH_TREE,
4236            &config,
4237            &access,
4238            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4239            None,
4240        )
4241        .unwrap();
4242        assert!(tree.contains("`[src/`:/page/tree.mu`g=public|r=alpha|ref=HEAD|path=src]"));
4243
4244        let blob = render_page(
4245            PATH_BLOB,
4246            &config,
4247            &access,
4248            &page_request(&[
4249                ("var_g", "public"),
4250                ("var_r", "alpha"),
4251                ("var_path", "src/lib.rs"),
4252            ]),
4253            None,
4254        )
4255        .unwrap();
4256        assert!(blob.contains("Text, "));
4257        assert!(!blob.contains("View raw"));
4258        assert!(blob.contains("answer"));
4259        assert!(blob.contains("\\`"));
4260
4261        let silly_blob = render_page(
4262            PATH_BLOB,
4263            &config,
4264            &access,
4265            &page_request(&[
4266                ("var_g", "public"),
4267                ("var_r", "alpha"),
4268                ("var_path", "./src/./lib.rs"),
4269            ]),
4270            None,
4271        )
4272        .unwrap();
4273        assert!(silly_blob.contains(">src/lib.rs `F666"));
4274        assert!(silly_blob.contains("answer"));
4275
4276        let commit_hash = run_git(
4277            Command::new("git")
4278                .arg("--git-dir")
4279                .arg(&repo)
4280                .args(["rev-parse", "refs/heads/main"]),
4281        );
4282        let commit = render_page(
4283            PATH_COMMIT,
4284            &config,
4285            &access,
4286            &page_request(&[
4287                ("var_g", "public"),
4288                ("var_r", "alpha"),
4289                ("var_h", commit_hash.trim()),
4290            ]),
4291            None,
4292        )
4293        .unwrap();
4294        assert!(commit.contains("Author: RNS Page Test"));
4295        assert!(commit.contains("Files changed"));
4296        assert!(commit.contains("A src/lib.rs"));
4297    }
4298
4299    #[test]
4300    fn markdown_converter_handles_links_code_lists_rules_and_tables() {
4301        let input = "# Title\n\
4302\n\
4303See [the docs](https://example.invalid/a_b) and `literal *code*`.\n\
4304\n\
4305- **bold** and *italic*\n\
4306---\n\
4307| Name | Link |\n\
4308| --- | --- |\n\
4309| Row | [go](rns://abc) |\n";
4310
4311        let out = markdown_to_micron(input);
4312        assert!(out.contains(">Title"));
4313        assert!(out.contains("`_`!`[the docs`https://example.invalid/a_b]`!`_"));
4314        assert!(out.contains("`BT383838`Fdddliteral *code*`f`b"));
4315        assert!(out.contains(" • `!bold`! and `*italic`*"));
4316        assert!(out.contains("\n-\n"));
4317        assert!(out.contains("│ Name"));
4318        assert!(out.contains("`_`!`[go`rns://abc]`!`_"));
4319    }
4320
4321    #[test]
4322    fn markdown_converter_handles_blockquotes_and_heading_inline_markup() {
4323        let out = markdown_to_micron(
4324            "# **Important**\n\
4325> **Quoted** text\n\
4326> with [link](rns://abc)\n\
4327\n\
4328after\n",
4329        );
4330        assert!(out.contains(">`!Important`!"));
4331        assert!(out.contains(" │ `!Quoted`! text with `_`!`[link`rns://abc]`!`_"));
4332        assert!(out.contains("\nafter\n"));
4333    }
4334
4335    #[test]
4336    fn markdown_link_conversion_is_isolated_from_later_substitutions() {
4337        let out = markdown_to_micron("[**bold** `code`](https://example.invalid?q=*x*)");
4338        assert!(out.contains("`_`!`[**bold** code`https://example.invalid?q=*x*]`!`_"));
4339        assert!(!out.contains("`!`!"));
4340        assert!(!out.contains("`*x`*"));
4341    }
4342
4343    #[test]
4344    fn markdown_link_style_can_disable_wrappers_and_apply_color() {
4345        let plain = format_markdown_link(
4346            "label",
4347            "rns://abc",
4348            None,
4349            MarkdownLinkStyle {
4350                bold: false,
4351                underline: false,
4352                color: None,
4353            },
4354        );
4355        assert_eq!(plain, "`[label`rns://abc]");
4356
4357        let short_color = format_markdown_link(
4358            "label",
4359            "rns://abc",
4360            None,
4361            MarkdownLinkStyle {
4362                bold: true,
4363                underline: false,
4364                color: Some("abc"),
4365            },
4366        );
4367        assert_eq!(short_color, "`Fabc`!`[label`rns://abc]`!`f");
4368
4369        let true_color = format_markdown_link(
4370            "label",
4371            "rns://abc",
4372            None,
4373            MarkdownLinkStyle {
4374                bold: false,
4375                underline: true,
4376                color: Some("aabbcc"),
4377            },
4378        );
4379        assert_eq!(true_color, "`FTaabbcc`_`[label`rns://abc]`_`f");
4380    }
4381
4382    #[test]
4383    fn markdown_inline_code_that_looks_like_link_does_not_hide_real_links() {
4384        let out = markdown_to_micron(
4385            "`[literal](https://example.invalid/no)` and [real](https://example.invalid/yes)",
4386        );
4387
4388        assert!(out.contains("`BT383838`Fddd[literal](https://example.invalid/no)`f`b"));
4389        assert!(out.contains("`_`!`[real`https://example.invalid/yes]`!`_"));
4390        assert!(!out.contains("`_`!`[literal`https://example.invalid/no]`!`_"));
4391    }
4392
4393    #[test]
4394    fn markdown_tables_handle_empty_cells_escaped_pipes_alignment_and_visible_width() {
4395        let input = "| Name | Status | Notes |\n\
4396| :--- | ---: | :---: |\n\
4397| `code` | **ok** | a\\|b |\n\
4398| empty | | [go](rns://abc) |\n";
4399
4400        let out = markdown_to_micron(input);
4401        assert!(out.contains("┌"));
4402        assert!(out.contains("├"));
4403        assert!(out.contains("└"));
4404        assert!(out.contains("│ Name"));
4405        assert!(out.contains("`BT383838`Fdddcode`f`b"));
4406        assert!(out.contains("`!ok`!"));
4407        assert!(out.contains("a|b"));
4408        assert!(out.contains("empty"));
4409        assert!(out.contains("`_`!`[go`rns://abc]`!`_"));
4410        assert!(!out.contains("a\\|b"));
4411    }
4412
4413    #[test]
4414    fn markdown_table_width_ignores_generated_micron_tags() {
4415        let input = "| A | B |\n\
4416| --- | --- |\n\
4417| [x](rns://abc) | `y` |\n";
4418
4419        let out = markdown_to_micron(input);
4420        assert!(out.contains("│ `_`!`[x`rns://abc]`!`_ "));
4421        assert!(out.contains("│ `BT383838`Fdddy`f`b "));
4422        assert!(out.contains("┌─────┬─────┐"));
4423    }
4424
4425    #[test]
4426    fn markdown_escaping_does_not_create_or_corrupt_micron_tags() {
4427        let out =
4428            markdown_to_micron("literal `!not bold`! and [bad`label](rns://abc`def) plus `raw`");
4429        assert!(out.contains("literal \\`!not bold\\`!"));
4430        assert!(out.contains("`_`!`[badlabel`rns://abcdef]`!`_"));
4431        assert!(out.contains("`BT383838`Fdddraw`f`b"));
4432        assert!(!out.contains("`!not bold`!"));
4433    }
4434
4435    #[test]
4436    fn markdown_escapes_backslashes_and_expands_tabs_before_inline_formatting() {
4437        let out = markdown_to_micron("plain\\path\t**bold\\path** and `code\\path`\n");
4438
4439        assert!(out.contains("plain\\\\path   `!bold\\\\path`!"));
4440        assert!(out.contains("`BT383838`Fdddcode\\\\path`f`b"));
4441    }
4442
4443    #[test]
4444    fn markdown_plain_lines_escape_micron_line_start_controls() {
4445        let out = markdown_to_micron("-not a list\n<not alignment\n- list item\n---\n");
4446        assert!(out.contains("\\-not a list\n"));
4447        assert!(out.contains("\\<not alignment\n"));
4448        assert!(out.contains(" • list item\n"));
4449        assert!(out.contains("-\n"));
4450    }
4451
4452    #[test]
4453    fn markdown_converter_keeps_code_blocks_and_unmatched_markers_literal() {
4454        let out = markdown_to_micron(
4455            "```rust\n\
4456**not bold** and `not inline`\n\
4457```\n\
4458Unmatched * marker\n\
44591. numbered\n",
4460        );
4461        assert!(out.contains("not bold"));
4462        assert!(out.contains("\\`not inline\\`"));
4463        assert!(out.contains("`=\n**not bold** and \\`not inline\\`\n`=\n"));
4464        assert!(!out.contains("`!not bold`!"));
4465        assert!(out.contains("Unmatched * marker"));
4466        assert!(out.contains("1. numbered"));
4467    }
4468
4469    #[test]
4470    fn rawmu_fenced_blocks_pass_through_without_escaping() {
4471        let markdown = markdown_to_micron(
4472            "```rawmu
4473`!raw micron`!
4474>Heading
4475```
4476",
4477        );
4478
4479        assert!(markdown.contains(
4480            "`!raw micron`!
4481>Heading
4482"
4483        ));
4484        assert!(!markdown.contains(r"\`!raw micron"));
4485        assert!(!markdown.contains(
4486            "`=
4487"
4488        ));
4489    }
4490
4491    #[test]
4492    fn unknown_extension_and_fence_language_fall_back_to_plain_literals() {
4493        let blob = crate::highlight::literal_block("value `tick`\n", Some("blob.unknown"), None);
4494        assert!(!blob.contains("`FT"));
4495        assert!(blob.contains("value \\`tick\\`"));
4496
4497        let markdown = markdown_to_micron("```not-a-language\nvalue `tick`\n```\n");
4498        assert!(!markdown.contains("`FT"));
4499        assert!(markdown.contains("value \\`tick\\`"));
4500    }
4501
4502    #[test]
4503    fn binary_and_oversized_blobs_use_plain_fallback_messages() {
4504        let tmp = tempfile::tempdir().unwrap();
4505        let config = cfg(tmp.path());
4506        create_repo_bytes(
4507            config.repositories_dir.join("public/binary"),
4508            "payload.bin",
4509            b"abc\0def",
4510        );
4511        let large = vec![b'x'; 256 * 1024 + 1];
4512        create_repo_bytes(
4513            config.repositories_dir.join("public/large"),
4514            "payload.rs",
4515            &large,
4516        );
4517        let access = access(&config);
4518
4519        let binary = render_page(
4520            PATH_BLOB,
4521            &config,
4522            &access,
4523            &page_request(&[
4524                ("var_g", "public"),
4525                ("var_r", "binary"),
4526                ("var_path", "payload.bin"),
4527            ]),
4528            None,
4529        )
4530        .unwrap();
4531        assert!(binary.contains("Binary"));
4532        assert!(binary.contains("Binary file is not displayed."));
4533        assert!(!binary.contains("`FT"));
4534
4535        let oversized = render_page(
4536            PATH_BLOB,
4537            &config,
4538            &access,
4539            &page_request(&[
4540                ("var_g", "public"),
4541                ("var_r", "large"),
4542                ("var_path", "payload.rs"),
4543            ]),
4544            None,
4545        )
4546        .unwrap();
4547        assert!(oversized.contains("File is too large to display"));
4548        assert!(!oversized.contains("`FT"));
4549    }
4550
4551    #[test]
4552    fn markdown_blob_defaults_to_rendered_view_and_raw_view_escapes_source() {
4553        let tmp = tempfile::tempdir().unwrap();
4554        let config = cfg(tmp.path());
4555        create_repo(
4556            config.repositories_dir.join("public/docs"),
4557            "docs/readme.md",
4558            "# Title\n\nSee [docs](https://example.invalid), [guide](guide.md#intro) and `tick`.\n",
4559        );
4560        let access = access(&config);
4561
4562        let rendered = render_page(
4563            PATH_BLOB,
4564            &config,
4565            &access,
4566            &page_request(&[
4567                ("var_g", "public"),
4568                ("var_r", "docs"),
4569                ("var_path", "docs/readme.md"),
4570            ]),
4571            None,
4572        )
4573        .unwrap();
4574        assert!(rendered.contains("Displaying Rendered"));
4575        assert!(rendered.contains("View raw"));
4576        assert!(rendered.contains(">Title"));
4577        assert!(rendered.contains("`_`!`[docs`https://example.invalid]`!`_"));
4578        assert!(rendered.contains(
4579            "`_`!`[guide`:/page/blob.mu`g=public|r=docs|ref=HEAD|path=docs/guide.md|anchor=intro]`!`_"
4580        ));
4581        assert!(rendered.contains("`BT383838`Fdddtick`f`b"));
4582        assert!(!rendered.contains("# Title"));
4583
4584        let raw = render_page(
4585            PATH_BLOB,
4586            &config,
4587            &access,
4588            &page_request(&[
4589                ("var_g", "public"),
4590                ("var_r", "docs"),
4591                ("var_path", "docs/readme.md"),
4592                ("var_raw", "y"),
4593            ]),
4594            None,
4595        )
4596        .unwrap();
4597        assert!(raw.contains("Displaying Raw"));
4598        assert!(raw.contains("View rendered"));
4599        assert!(raw.contains("Title"));
4600        assert!(raw.contains("\\`"));
4601        assert!(raw.contains("tick"));
4602        assert!(!raw.contains("`_`!`[docs`https://example.invalid]`!`_"));
4603    }
4604
4605    #[test]
4606    fn micron_blob_defaults_to_rendered_passthrough_and_raw_view_is_literal() {
4607        let tmp = tempfile::tempdir().unwrap();
4608        let config = cfg(tmp.path());
4609        create_repo(
4610            config.repositories_dir.join("public/micron"),
4611            "README.mu",
4612            ">Micron\n\n`!Already formatted`!\n",
4613        );
4614        let access = access(&config);
4615
4616        let rendered = render_page(
4617            PATH_BLOB,
4618            &config,
4619            &access,
4620            &page_request(&[
4621                ("var_g", "public"),
4622                ("var_r", "micron"),
4623                ("var_path", "README.mu"),
4624            ]),
4625            None,
4626        )
4627        .unwrap();
4628        assert!(rendered.contains("Displaying Rendered"));
4629        assert!(rendered.contains(">Micron"));
4630        assert!(rendered.contains("`!Already formatted`!"));
4631        assert!(!rendered.contains("\\`!Already formatted\\`!"));
4632
4633        let raw = render_page(
4634            PATH_BLOB,
4635            &config,
4636            &access,
4637            &page_request(&[
4638                ("var_g", "public"),
4639                ("var_r", "micron"),
4640                ("var_path", "README.mu"),
4641                ("var_raw", "y"),
4642            ]),
4643            None,
4644        )
4645        .unwrap();
4646        assert!(raw.contains("Displaying Raw"));
4647        assert!(raw.contains("\\`!Already formatted\\`!"));
4648    }
4649
4650    #[test]
4651    fn explicit_render_parameter_renders_markdown_blob() {
4652        let tmp = tempfile::tempdir().unwrap();
4653        let config = cfg(tmp.path());
4654        create_repo(
4655            config.repositories_dir.join("public/docs"),
4656            "docs/readme.md",
4657            "# Explicit\n",
4658        );
4659        let access = access(&config);
4660
4661        let rendered = render_page(
4662            PATH_BLOB,
4663            &config,
4664            &access,
4665            &page_request(&[
4666                ("var_g", "public"),
4667                ("var_r", "docs"),
4668                ("var_path", "docs/readme.md"),
4669                ("var_raw", "y"),
4670                ("var_render", "y"),
4671            ]),
4672            None,
4673        )
4674        .unwrap();
4675        assert!(rendered.contains("Displaying Raw"));
4676        assert!(rendered.contains("#"));
4677        assert!(rendered.contains("Explicit"));
4678
4679        let rendered = render_page(
4680            PATH_BLOB,
4681            &config,
4682            &access,
4683            &page_request(&[
4684                ("var_g", "public"),
4685                ("var_r", "docs"),
4686                ("var_path", "docs/readme.md"),
4687                ("var_render", "y"),
4688            ]),
4689            None,
4690        )
4691        .unwrap();
4692        assert!(rendered.contains("Displaying Rendered"));
4693        assert!(rendered.contains(">Explicit"));
4694        assert!(!rendered.contains("# Explicit"));
4695    }
4696
4697    #[test]
4698    fn markdown_blob_downloads_as_scoped_micron() {
4699        let tmp = tempfile::tempdir().unwrap();
4700        let config = cfg(tmp.path());
4701        create_repo(
4702            config.repositories_dir.join("public/docs"),
4703            "docs/readme.md",
4704            "# Guide\n\n[Next](next.md)\n",
4705        );
4706        let access = access(&config);
4707        let page = render_page(
4708            PATH_BLOB,
4709            &config,
4710            &access,
4711            &page_request(&[
4712                ("var_g", "public"),
4713                ("var_r", "docs"),
4714                ("var_path", "docs/readme.md"),
4715            ]),
4716            None,
4717        )
4718        .unwrap();
4719        assert!(page.contains("Download"));
4720        assert!(page.contains("as micron"));
4721        assert!(page.contains("fmt=mu"));
4722
4723        let request = page_request(&[
4724            ("var_g", "public"),
4725            ("var_r", "docs"),
4726            ("var_path", "docs/readme.md"),
4727            ("var_fmt", "mu"),
4728        ]);
4729        let response = download_file(&config, &access, &request, None).unwrap();
4730        let RequestResponse::Resource { data, metadata, .. } = response else {
4731            panic!("expected converted resource");
4732        };
4733        let converted = String::from_utf8(data).unwrap();
4734        assert!(converted.contains(">Guide"));
4735        assert!(converted.contains("path=docs/next.md"));
4736        let metadata = msgpack::unpack_exact(&metadata.unwrap()).unwrap();
4737        assert_eq!(
4738            metadata.map_get("name").and_then(Value::as_bin),
4739            Some(&b"readme.mu"[..])
4740        );
4741
4742        let raw = download_file(
4743            &config,
4744            &access,
4745            &page_request(&[
4746                ("var_g", "public"),
4747                ("var_r", "docs"),
4748                ("var_path", "docs/readme.md"),
4749            ]),
4750            None,
4751        )
4752        .unwrap();
4753        assert_resource_response(raw, b"# Guide\n\n[Next](next.md)\n");
4754    }
4755
4756    #[test]
4757    fn unsupported_blob_extension_ignores_render_parameter_and_uses_raw_literal() {
4758        let tmp = tempfile::tempdir().unwrap();
4759        let config = cfg(tmp.path());
4760        create_repo(
4761            config.repositories_dir.join("public/text"),
4762            "notes.txt",
4763            "# Not a heading\npath\\name\t`literal`\n",
4764        );
4765        let access = access(&config);
4766
4767        let page = render_page(
4768            PATH_BLOB,
4769            &config,
4770            &access,
4771            &page_request(&[
4772                ("var_g", "public"),
4773                ("var_r", "text"),
4774                ("var_path", "notes.txt"),
4775                ("var_render", "y"),
4776            ]),
4777            None,
4778        )
4779        .unwrap();
4780        assert!(!page.contains("Displaying Rendered"));
4781        assert!(!page.contains("View raw"));
4782        assert!(page.contains("Displaying Raw"));
4783        assert!(page.contains("Download"));
4784        assert!(page.contains("Not a heading"));
4785        assert!(page.contains("path\\\\name   \\`literal\\`"));
4786        assert!(page.contains("\\`literal\\`"));
4787    }
4788
4789    #[test]
4790    fn renderable_binary_and_oversized_blobs_do_not_render_content() {
4791        let tmp = tempfile::tempdir().unwrap();
4792        let config = cfg(tmp.path());
4793        create_repo_bytes(
4794            config.repositories_dir.join("public/binary-md"),
4795            "payload.md",
4796            b"# Binary\0payload\n",
4797        );
4798        let large = vec![b'#'; 256 * 1024 + 1];
4799        create_repo_bytes(
4800            config.repositories_dir.join("public/large-md"),
4801            "payload.md",
4802            &large,
4803        );
4804        let access = access(&config);
4805
4806        let binary = render_page(
4807            PATH_BLOB,
4808            &config,
4809            &access,
4810            &page_request(&[
4811                ("var_g", "public"),
4812                ("var_r", "binary-md"),
4813                ("var_path", "payload.md"),
4814            ]),
4815            None,
4816        )
4817        .unwrap();
4818        assert!(binary.contains("Binary"));
4819        assert!(binary.contains("Binary file is not displayed."));
4820        assert!(!binary.contains(">Binary"));
4821
4822        let oversized = render_page(
4823            PATH_BLOB,
4824            &config,
4825            &access,
4826            &page_request(&[
4827                ("var_g", "public"),
4828                ("var_r", "large-md"),
4829                ("var_path", "payload.md"),
4830                ("var_render", "y"),
4831            ]),
4832            None,
4833        )
4834        .unwrap();
4835        assert!(oversized.contains("File is too large to display"));
4836        assert!(!oversized.contains("Displaying Rendered\n\n>"));
4837    }
4838
4839    #[test]
4840    fn code_blocks_render_plain_literal_blocks() {
4841        let blob = crate::highlight::literal_block("pub fn main() {}\n", Some("main.rs"), None);
4842        assert!(!blob.contains("`FT"));
4843        assert!(blob.contains("pub fn main() {}"));
4844
4845        let markdown = markdown_to_micron("```rust\npub fn main() {}\n```\n");
4846        assert!(!markdown.contains("`FT"));
4847        assert!(markdown.contains("pub fn main() {}"));
4848    }
4849
4850    #[test]
4851    fn repo_page_renders_markdown_readme_and_passes_micron_readme_through() {
4852        let tmp = tempfile::tempdir().unwrap();
4853        let config = cfg(tmp.path());
4854        create_repo(
4855            config.repositories_dir.join("public/markdown"),
4856            "README.md",
4857            "# Markdown\n\nSee [docs](https://example.invalid) and [local](docs/intro.md).\n",
4858        );
4859        create_repo(
4860            config.repositories_dir.join("public/micron"),
4861            "README.mu",
4862            ">Micron\n\n`!Already formatted`!\n",
4863        );
4864        create_repo(
4865            config.repositories_dir.join("public/microntight"),
4866            "README.mu",
4867            ">Micron without trailing newline",
4868        );
4869        create_repo(
4870            config.repositories_dir.join("public/micronblank"),
4871            "README.mu",
4872            ">Micron with trailing blanks\n\n\n",
4873        );
4874        let access = access(&config);
4875
4876        let markdown = render_page(
4877            PATH_REPO,
4878            &config,
4879            &access,
4880            &page_request(&[("var_g", "public"), ("var_r", "markdown")]),
4881            None,
4882        )
4883        .unwrap();
4884        assert!(markdown.contains(">Markdown"));
4885        assert!(markdown.contains("`_`!`[docs`https://example.invalid]`!`_"));
4886        assert!(markdown.contains(
4887            "`_`!`[local`:/page/blob.mu`g=public|r=markdown|ref=HEAD|path=docs/intro.md]`!`_"
4888        ));
4889
4890        let micron = render_page(
4891            PATH_REPO,
4892            &config,
4893            &access,
4894            &page_request(&[("var_g", "public"), ("var_r", "micron")]),
4895            None,
4896        )
4897        .unwrap();
4898        assert!(micron.contains(">Micron"));
4899        assert!(micron.contains("`!Already formatted`!"));
4900
4901        let micron = render_page(
4902            PATH_REPO,
4903            &config,
4904            &access,
4905            &page_request(&[("var_g", "public"), ("var_r", "microntight")]),
4906            None,
4907        )
4908        .unwrap();
4909        assert!(micron.contains(">Micron without trailing newline\n"));
4910        assert!(!micron.contains(">Micron without trailing newline<"));
4911
4912        let micron = render_page(
4913            PATH_REPO,
4914            &config,
4915            &access,
4916            &page_request(&[("var_g", "public"), ("var_r", "micronblank")]),
4917            None,
4918        )
4919        .unwrap();
4920        assert!(micron.contains(">Micron with trailing blanks\n"));
4921        assert!(!micron.contains(">Micron with trailing blanks\n\n\n"));
4922    }
4923
4924    #[test]
4925    fn work_pages_render_documents_comments_and_repo_links() {
4926        let tmp = tempfile::tempdir().unwrap();
4927        let config = cfg(tmp.path());
4928        let repo_path = create_repo(
4929            config.repositories_dir.join("public/worked"),
4930            "README.md",
4931            "# Worked\n",
4932        );
4933        let work_path = crate::work::work_sidecar_path(&repo_path);
4934        let created = crate::work::create_document(
4935            &work_path,
4936            crate::work::WorkInput {
4937                title: "Active task".into(),
4938                content: "# Active Body\n".into(),
4939                format: "markdown".into(),
4940                signature: None,
4941                identity: None,
4942                author: [0x11; 16],
4943            },
4944        )
4945        .unwrap();
4946        crate::work::add_comment(
4947            &work_path,
4948            crate::work::WorkScope::Active,
4949            created.id,
4950            crate::work::WorkCommentInput {
4951                content: "Progress update".into(),
4952                format: "markdown".into(),
4953                signature: None,
4954                author: [0x22; 16],
4955            },
4956        )
4957        .unwrap();
4958        let completed = crate::work::create_document(
4959            &work_path,
4960            crate::work::WorkInput {
4961                title: "Completed task".into(),
4962                content: "Done".into(),
4963                format: "micron".into(),
4964                signature: None,
4965                identity: None,
4966                author: [0x11; 16],
4967            },
4968        )
4969        .unwrap();
4970        crate::work::complete_document(&work_path, completed.id, &[0x11; 16]).unwrap();
4971        let access = access(&config);
4972
4973        let repo = render_page(
4974            PATH_REPO,
4975            &config,
4976            &access,
4977            &page_request(&[("var_g", "public"), ("var_r", "worked")]),
4978            None,
4979        )
4980        .unwrap();
4981        assert!(repo.contains("Work (2)"));
4982        assert!(repo.contains(PATH_WORK));
4983
4984        let active = render_page(
4985            PATH_WORK,
4986            &config,
4987            &access,
4988            &page_request(&[("var_g", "public"), ("var_r", "worked")]),
4989            None,
4990        )
4991        .unwrap();
4992        assert!(active.contains(">Active Work Documents"));
4993        assert!(active.contains("[Active (1)`:/page/work.mu"));
4994        assert!(active.contains("[Completed (1)`:/page/work.mu"));
4995        assert!(active.contains("[Proposed (0)`:/page/work.mu"));
4996        assert!(active.contains("[All (2)`:/page/work.mu"));
4997        assert!(active.contains("#1 Active task"));
4998        assert!(active.contains("1 updates"));
4999        assert!(!active.contains("Completed task"));
5000
5001        let completed_page = render_page(
5002            PATH_WORK,
5003            &config,
5004            &access,
5005            &page_request(&[
5006                ("var_g", "public"),
5007                ("var_r", "worked"),
5008                ("var_scope", "completed"),
5009            ]),
5010            None,
5011        )
5012        .unwrap();
5013        assert!(completed_page.contains(">Completed Work Documents"));
5014        assert!(completed_page.contains("[Active (1)`:/page/work.mu"));
5015        assert!(completed_page.contains("Completed task"));
5016
5017        let doc = render_page(
5018            PATH_WORK_DOC,
5019            &config,
5020            &access,
5021            &page_request(&[("var_g", "public"), ("var_r", "worked"), ("var_id", "1")]),
5022            None,
5023        )
5024        .unwrap();
5025        assert!(doc.contains(">Active task"));
5026        assert!(doc.contains(">Active Body"));
5027        assert!(doc.contains(">Updates (1)"));
5028        assert!(doc.contains("Progress update"));
5029        assert!(doc.contains("`[Download`:/file/workdoc`g=public|r=worked|scope=active|id=1]"));
5030
5031        let completed_doc = render_page(
5032            PATH_WORK_DOC,
5033            &config,
5034            &access,
5035            &page_request(&[
5036                ("var_g", "public"),
5037                ("var_r", "worked"),
5038                ("var_id", &completed.id.to_string()),
5039            ]),
5040            None,
5041        )
5042        .unwrap();
5043        assert!(completed_doc.contains(">Completed task"));
5044        assert!(completed_doc.contains("Status: completed"));
5045        assert!(completed_doc.contains("`[work`:/page/work.mu`g=public|r=worked|scope=completed]"));
5046        assert!(completed_doc.contains(&format!(
5047            "`[Download`:/file/workdoc`g=public|r=worked|scope=completed|id={}]",
5048            completed.id
5049        )));
5050
5051        let active_download = download_work_document(
5052            &config,
5053            &access,
5054            &page_request(&[
5055                ("var_g", "public"),
5056                ("var_r", "worked"),
5057                ("var_scope", "active"),
5058                ("var_id", "1"),
5059            ]),
5060            None,
5061        )
5062        .unwrap();
5063        assert_resource_response(active_download, b"# Active Body");
5064
5065        let completed_download = download_work_document(
5066            &config,
5067            &access,
5068            &page_request(&[
5069                ("var_g", "public"),
5070                ("var_r", "worked"),
5071                ("var_id", &completed.id.to_string()),
5072            ]),
5073            None,
5074        )
5075        .unwrap();
5076        assert_resource_response(completed_download, b"Done");
5077
5078        crate::work::set_document_permissions(&work_path, created.id, "read = none\n").unwrap();
5079        let active_after_deny = render_page(
5080            PATH_WORK,
5081            &config,
5082            &access,
5083            &page_request(&[("var_g", "public"), ("var_r", "worked")]),
5084            None,
5085        )
5086        .unwrap();
5087        assert!(active_after_deny.contains("[Active (0)`:/page/work.mu"));
5088        assert!(active_after_deny.contains("[All (1)`:/page/work.mu"));
5089        assert!(!active_after_deny.contains("Active task"));
5090        let denied_doc = render_page(
5091            PATH_WORK_DOC,
5092            &config,
5093            &access,
5094            &page_request(&[("var_g", "public"), ("var_r", "worked"), ("var_id", "1")]),
5095            None,
5096        )
5097        .unwrap();
5098        assert!(denied_doc.contains("Work Document Not Found"));
5099        let denied_download = download_work_document(
5100            &config,
5101            &access,
5102            &page_request(&[("var_g", "public"), ("var_r", "worked"), ("var_id", "1")]),
5103            None,
5104        )
5105        .unwrap();
5106        assert!(matches!(
5107            denied_download,
5108            RequestResponse::Bytes(bytes) if bytes.first() == Some(&protocol::RES_NOT_FOUND)
5109        ));
5110    }
5111
5112    #[test]
5113    fn work_pages_respect_read_access() {
5114        let tmp = tempfile::tempdir().unwrap();
5115        let mut config = cfg(tmp.path());
5116        create_repo(
5117            config.repositories_dir.join("public/private-work"),
5118            "README.md",
5119            "# Private\n",
5120        );
5121        config.allow_read = vec!["none".into()];
5122        let access = access(&config);
5123
5124        assert!(render_page(
5125            PATH_WORK,
5126            &config,
5127            &access,
5128            &page_request(&[("var_g", "public"), ("var_r", "private-work")]),
5129            None,
5130        )
5131        .is_err());
5132    }
5133
5134    fn cfg(root: &std::path::Path) -> ServerConfig {
5135        ServerConfig {
5136            dir: root.to_path_buf(),
5137            reticulum_dir: None,
5138            repositories_dir: root.join("repositories"),
5139            identity_path: root.join("repositories_identity"),
5140            client_identity_path: root.join("client_identity"),
5141            node_name: "Test Git Node".into(),
5142            announce_interval_secs: 300,
5143            serve_nomadnet: true,
5144            templates_dir: root.join("templates"),
5145            unicode_icons: false,
5146            media_conversion: true,
5147            record_stats: false,
5148            stats_ignore_identities: Vec::new(),
5149            stats_push_ignore_identities: Vec::new(),
5150            blocked_identities: Vec::new(),
5151            identity_aliases: std::collections::BTreeMap::new(),
5152            allow_read: vec!["all".into()],
5153            allow_write: vec!["none".into()],
5154            allow_create: vec!["none".into()],
5155            allow_stats: vec!["none".into()],
5156            allow_release: vec!["none".into()],
5157            allow_interact: vec!["none".into()],
5158            allow_propose: vec!["none".into()],
5159            allow_admin: vec!["none".into()],
5160            log_level: logging::DEFAULT_LOG_LEVEL,
5161        }
5162    }
5163
5164    fn access(config: &ServerConfig) -> Access {
5165        Access::new(
5166            &config.allow_read,
5167            &config.allow_write,
5168            &config.allow_create,
5169            &config.allow_stats,
5170            &config.allow_release,
5171            &config.allow_interact,
5172            &config.allow_admin,
5173            config.repositories_dir.clone(),
5174        )
5175        .unwrap()
5176    }
5177
5178    fn page_request(fields: &[(&str, &str)]) -> Vec<u8> {
5179        msgpack::pack(&Value::Map(
5180            fields
5181                .iter()
5182                .map(|(k, v)| (Value::Str((*k).into()), Value::Str((*v).into())))
5183                .collect(),
5184        ))
5185    }
5186
5187    fn assert_resource_response(response: RequestResponse, expected: &[u8]) {
5188        match response {
5189            RequestResponse::Resource { data, metadata, .. } => {
5190                assert_eq!(data, expected);
5191                assert_eq!(
5192                    metadata.as_deref(),
5193                    Some(&protocol::metadata_status(protocol::RES_OK)[..])
5194                );
5195            }
5196            RequestResponse::Bytes(bytes) => panic!("expected resource response, got {bytes:?}"),
5197            RequestResponse::File { .. } => panic!("expected value response, got file"),
5198        }
5199    }
5200
5201    fn create_repo(path: std::path::PathBuf, file: &str, content: &str) -> std::path::PathBuf {
5202        create_repo_bytes(path, file, content.as_bytes())
5203    }
5204
5205    fn create_repo_bytes(
5206        path: std::path::PathBuf,
5207        file: &str,
5208        content: &[u8],
5209    ) -> std::path::PathBuf {
5210        let work = path.with_extension("work");
5211        fs::create_dir_all(&work).unwrap();
5212        run_git(Command::new("git").arg("init").arg(&work));
5213        let file_path = work.join(file);
5214        fs::create_dir_all(file_path.parent().unwrap()).unwrap();
5215        fs::write(&file_path, content).unwrap();
5216        run_git(Command::new("git").arg("-C").arg(&work).arg("add").arg("."));
5217        run_git(
5218            Command::new("git")
5219                .arg("-C")
5220                .arg(&work)
5221                .arg("-c")
5222                .arg("user.name=RNS Page Test")
5223                .arg("-c")
5224                .arg("user.email=rns-page-test@example.invalid")
5225                .arg("commit")
5226                .arg("-m")
5227                .arg("initial"),
5228        );
5229        run_git(
5230            Command::new("git")
5231                .arg("-C")
5232                .arg(&work)
5233                .args(["branch", "-M", "main"]),
5234        );
5235        run_git(
5236            Command::new("git")
5237                .arg("clone")
5238                .arg("--bare")
5239                .arg(&work)
5240                .arg(&path),
5241        );
5242        path
5243    }
5244
5245    fn run_git(cmd: &mut Command) -> String {
5246        let output = cmd.output().unwrap();
5247        assert!(
5248            output.status.success(),
5249            "git command failed: {}",
5250            String::from_utf8_lossy(&output.stderr)
5251        );
5252        String::from_utf8_lossy(&output.stdout).into_owned()
5253    }
5254}