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