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