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.strip_prefix("./").unwrap_or(path).replace("/./", "/")
1992}
1993
1994fn validate_refish(value: &str) -> Result<()> {
1995 if value.is_empty()
1996 || value.starts_with('-')
1997 || value.contains("..")
1998 || value.contains('\\')
1999 || value.contains(' ')
2000 || value.contains('~')
2001 || value.contains('^')
2002 || value.contains(':')
2003 {
2004 Err(Error::msg("invalid git ref"))
2005 } else {
2006 Ok(())
2007 }
2008}
2009
2010fn repository_description(repo: &Path) -> Result<String> {
2011 let path = repo.join("description");
2012 if !path.exists() {
2013 return Ok(String::new());
2014 }
2015 Ok(fs::read_to_string(path)?.trim().to_string())
2016}
2017
2018#[derive(Debug, Clone, PartialEq, Eq)]
2019struct RepositoryOrigin {
2020 kind: RepositoryOriginKind,
2021 source: String,
2022 synced_at: Option<u64>,
2023}
2024
2025#[derive(Debug, Clone, PartialEq, Eq)]
2026enum RepositoryOriginKind {
2027 Fork,
2028 Mirror,
2029}
2030
2031impl RepositoryOrigin {
2032 fn label(&self) -> &'static str {
2033 match self.kind {
2034 RepositoryOriginKind::Fork => "Forked",
2035 RepositoryOriginKind::Mirror => "Mirrored",
2036 }
2037 }
2038}
2039
2040fn repository_origin(repo: &Path) -> Result<Option<RepositoryOrigin>> {
2041 let kind_output = run_git_output(
2042 Command::new("git")
2043 .arg("--git-dir")
2044 .arg(repo)
2045 .arg("config")
2046 .arg("--get")
2047 .arg("repository.rngit.type"),
2048 GIT_COMMAND_TIMEOUT,
2049 )?;
2050 if !kind_output.status.success() {
2051 return Ok(None);
2052 }
2053 let kind = match String::from_utf8_lossy(&kind_output.stdout).trim() {
2054 "fork" => RepositoryOriginKind::Fork,
2055 "mirror" => RepositoryOriginKind::Mirror,
2056 _ => return Ok(None),
2057 };
2058 let source_output = run_git_output(
2059 Command::new("git")
2060 .arg("--git-dir")
2061 .arg(repo)
2062 .arg("config")
2063 .arg("--get")
2064 .arg("repository.rngit.upstream.source"),
2065 GIT_COMMAND_TIMEOUT,
2066 )?;
2067 if !source_output.status.success() {
2068 return Ok(None);
2069 }
2070 let source = String::from_utf8_lossy(&source_output.stdout)
2071 .trim()
2072 .to_string();
2073 if source.is_empty() {
2074 return Ok(None);
2075 }
2076 let synced_at = run_git_output(
2077 Command::new("git")
2078 .arg("--git-dir")
2079 .arg(repo)
2080 .arg("config")
2081 .arg("--get")
2082 .arg("repository.rngit.upstream.sync"),
2083 GIT_COMMAND_TIMEOUT,
2084 )
2085 .ok()
2086 .and_then(|output| output.status.success().then_some(output))
2087 .and_then(|output| {
2088 String::from_utf8_lossy(&output.stdout)
2089 .trim()
2090 .parse::<u64>()
2091 .ok()
2092 });
2093
2094 Ok(Some(RepositoryOrigin {
2095 kind,
2096 source,
2097 synced_at,
2098 }))
2099}
2100
2101fn format_sync_age(synced_at: u64) -> String {
2102 let now = SystemTime::now()
2103 .duration_since(UNIX_EPOCH)
2104 .unwrap_or_default()
2105 .as_secs();
2106 let seconds = now.saturating_sub(synced_at);
2107 if seconds < 60 {
2108 format!("{seconds}s")
2109 } else if seconds < 3_600 {
2110 format!("{}m", seconds / 60)
2111 } else if seconds < 86_400 {
2112 format!("{}h", seconds / 3_600)
2113 } else {
2114 format!("{}d", seconds / 86_400)
2115 }
2116}
2117
2118fn repository_thanks(repo: &Path, add: bool) -> Result<u64> {
2119 let path = repo.with_extension("thanks");
2120 let mut count = if path.exists() {
2121 fs::read_to_string(&path)?
2122 .trim()
2123 .parse::<u64>()
2124 .unwrap_or(0)
2125 } else {
2126 0
2127 };
2128 if add {
2129 count = count.saturating_add(1);
2130 fs::write(path, format!("{count}\n"))?;
2131 }
2132 Ok(count)
2133}
2134
2135#[derive(Debug)]
2136struct ReadmeContent {
2137 content: String,
2138 markdown: bool,
2139}
2140
2141fn readme_content(repo: &Path) -> Result<Option<ReadmeContent>> {
2142 const NAMES: &[&str] = &[
2143 "README.mu",
2144 "Readme.mu",
2145 "readme.mu",
2146 "README",
2147 "readme",
2148 "README.md",
2149 "readme.md",
2150 "README.rst",
2151 "README.txt",
2152 "readme.rst",
2153 "readme.txt",
2154 ];
2155 for name in NAMES {
2156 match run_git_output(
2157 Command::new("git")
2158 .arg("--git-dir")
2159 .arg(repo)
2160 .arg("show")
2161 .arg(format!("HEAD:{name}")),
2162 GIT_COMMAND_TIMEOUT,
2163 ) {
2164 Ok(output) if output.status.success() => {
2165 let content = String::from_utf8_lossy(&output.stdout).into_owned();
2166 let markdown = name.ends_with(".md");
2167 let content = if markdown || name.ends_with(".mu") {
2168 content
2169 } else {
2170 m_escape(&content)
2171 };
2172 return Ok(Some(ReadmeContent { content, markdown }));
2173 }
2174 Ok(_) => {}
2175 Err(err) => {
2176 log::warn!("Git command execution failed while reading README: {err}");
2177 }
2178 }
2179 }
2180 Ok(None)
2181}
2182
2183fn markdown_to_micron(input: &str) -> String {
2184 markdown_to_micron_scoped(input, None)
2185}
2186
2187fn markdown_to_micron_scoped(input: &str, url_scope: Option<&str>) -> String {
2188 let lines: Vec<&str> = input.lines().collect();
2189 let mut out = String::new();
2190 let mut code_block: Option<(Option<String>, String)> = None;
2191 let mut index = 0;
2192
2193 while index < lines.len() {
2194 let line = lines[index];
2195 let trimmed = line.trim();
2196 if trimmed.starts_with("```") {
2197 if let Some((language, content)) = code_block.take() {
2198 out.push_str(&markdown_code_block_to_micron(
2199 language.as_deref(),
2200 &content,
2201 ));
2202 } else {
2203 code_block = Some((markdown_fence_language(trimmed), String::new()));
2204 }
2205 index += 1;
2206 continue;
2207 }
2208 if let Some((_, content)) = code_block.as_mut() {
2209 content.push_str(line);
2210 content.push('\n');
2211 index += 1;
2212 continue;
2213 }
2214 if let Some(quote) = markdown_quote(line) {
2215 let mut parts = vec![quote.to_string()];
2216 index += 1;
2217 while index < lines.len() {
2218 if let Some(quote) = markdown_quote(lines[index]) {
2219 parts.push(quote.to_string());
2220 index += 1;
2221 } else {
2222 break;
2223 }
2224 }
2225 let formatted = format_markdown_inline_scoped(&parts.join(" "), url_scope);
2226 for wrapped in markdown_wrap_text(&formatted, 77) {
2227 out.push_str(" │ ");
2228 out.push_str(&wrapped);
2229 out.push('\n');
2230 }
2231 continue;
2232 }
2233 if is_table_start(&lines, index) {
2234 let mut table = Vec::new();
2235 while index < lines.len() && lines[index].contains('|') {
2236 table.push(lines[index]);
2237 index += 1;
2238 }
2239 out.push_str(&format_markdown_table(&table, url_scope));
2240 continue;
2241 }
2242 if let Some((level, text)) = markdown_heading(line) {
2243 out.push_str(&">".repeat(level));
2244 out.push_str(&format_markdown_inline_scoped(text.trim(), url_scope));
2245 out.push('\n');
2246 } else if is_markdown_rule(trimmed) {
2247 out.push_str("-\n");
2248 } else if let Some((indent, text)) = unordered_list_item(line) {
2249 out.push_str(indent);
2250 out.push_str(" • ");
2251 out.push_str(&format_markdown_inline_scoped(text, url_scope));
2252 out.push('\n');
2253 } else if let Some((indent, number, text)) = ordered_list_item(line) {
2254 out.push_str(indent);
2255 out.push_str(number);
2256 out.push_str(". ");
2257 out.push_str(&format_markdown_inline_scoped(text, url_scope));
2258 out.push('\n');
2259 } else {
2260 out.push_str(&format_markdown_line(line, url_scope));
2261 out.push('\n');
2262 }
2263 index += 1;
2264 }
2265 if let Some((language, content)) = code_block {
2266 out.push_str(&markdown_code_block_to_micron(
2267 language.as_deref(),
2268 &content,
2269 ));
2270 }
2271
2272 out
2273}
2274
2275fn markdown_code_block_to_micron(language: Option<&str>, content: &str) -> String {
2276 if language.is_some_and(|language| language.eq_ignore_ascii_case("rawmu")) {
2277 return content.to_string();
2278 }
2279 crate::highlight::literal_block(content, None, language)
2280}
2281
2282fn markdown_fence_language(trimmed: &str) -> Option<String> {
2283 trimmed
2284 .strip_prefix("```")
2285 .and_then(|info| info.split_whitespace().next())
2286 .filter(|language| !language.is_empty())
2287 .map(str::to_string)
2288}
2289
2290fn markdown_quote(line: &str) -> Option<&str> {
2291 line.strip_prefix('>')
2292 .map(|quote| quote.strip_prefix(' ').unwrap_or(quote))
2293}
2294
2295fn markdown_heading(line: &str) -> Option<(usize, &str)> {
2296 let level = line.chars().take_while(|ch| *ch == '#').count();
2297 if (1..=6).contains(&level) && line.as_bytes().get(level) == Some(&b' ') {
2298 Some((level, &line[level + 1..]))
2299 } else {
2300 None
2301 }
2302}
2303
2304fn is_markdown_rule(trimmed: &str) -> bool {
2305 trimmed.len() >= 3
2306 && ["-", "*", "_", "="]
2307 .iter()
2308 .any(|marker| trimmed.chars().all(|ch| ch.to_string() == *marker))
2309}
2310
2311fn unordered_list_item(line: &str) -> Option<(&str, &str)> {
2312 let trimmed = line.trim_start();
2313 let indent = &line[..line.len() - trimmed.len()];
2314 for marker in ["- ", "* ", "+ "] {
2315 if let Some(text) = trimmed.strip_prefix(marker) {
2316 return Some((indent, text));
2317 }
2318 }
2319 None
2320}
2321
2322fn ordered_list_item(line: &str) -> Option<(&str, &str, &str)> {
2323 let trimmed = line.trim_start();
2324 let indent = &line[..line.len() - trimmed.len()];
2325 let (number, text) = trimmed.split_once(". ")?;
2326 if !number.is_empty() && number.chars().all(|ch| ch.is_ascii_digit()) {
2327 Some((indent, number, text))
2328 } else {
2329 None
2330 }
2331}
2332
2333fn format_markdown_line(line: &str, url_scope: Option<&str>) -> String {
2334 let formatted = format_markdown_inline_scoped(line, url_scope);
2335 if (line.starts_with('-') && !line.starts_with("---") && !line.starts_with("- "))
2336 || line.starts_with('<')
2337 {
2338 format!("\\{formatted}")
2339 } else {
2340 formatted
2341 }
2342}
2343
2344fn is_table_start(lines: &[&str], index: usize) -> bool {
2345 index + 1 < lines.len()
2346 && lines[index].contains('|')
2347 && lines[index + 1].contains('|')
2348 && is_table_separator(lines[index + 1])
2349}
2350
2351fn is_table_separator(line: &str) -> bool {
2352 parse_table_row(line)
2353 .iter()
2354 .all(|cell| !cell.is_empty() && cell.chars().all(|ch| matches!(ch, '-' | ':' | ' ')))
2355}
2356
2357#[derive(Clone, Copy)]
2358enum TableAlign {
2359 Left,
2360 Right,
2361 Center,
2362}
2363
2364fn format_markdown_table(lines: &[&str], url_scope: Option<&str>) -> String {
2365 if lines.len() < 2 {
2366 return String::new();
2367 }
2368 let header: Vec<String> = parse_table_row(lines[0])
2369 .into_iter()
2370 .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
2371 .collect();
2372 if header.is_empty() {
2373 return String::new();
2374 }
2375 let columns = header.len();
2376 let mut alignments = parse_table_alignments(lines[1]);
2377 while alignments.len() < columns {
2378 alignments.push(TableAlign::Left);
2379 }
2380 alignments.truncate(columns);
2381
2382 let rows: Vec<Vec<String>> = lines
2383 .iter()
2384 .skip(2)
2385 .map(|line| {
2386 let mut row: Vec<String> = parse_table_row(line)
2387 .into_iter()
2388 .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
2389 .collect();
2390 while row.len() < columns {
2391 row.push(String::new());
2392 }
2393 row.truncate(columns);
2394 row
2395 })
2396 .collect();
2397
2398 let mut widths = vec![3; columns];
2399 for (index, cell) in header.iter().enumerate() {
2400 widths[index] = widths[index].max(markdown_visible_width(cell));
2401 }
2402 for row in &rows {
2403 for (index, cell) in row.iter().enumerate().take(columns) {
2404 widths[index] = widths[index].max(markdown_visible_width(cell));
2405 }
2406 }
2407
2408 let mut out = String::new();
2409 out.push_str(&table_border('┌', '┬', '┐', &widths));
2410 out.push_str(&table_row(
2411 &header,
2412 &widths,
2413 &vec![TableAlign::Left; columns],
2414 ));
2415 out.push_str(&table_border('├', '┼', '┤', &widths));
2416 for row in rows {
2417 out.push_str(&table_row(&row, &widths, &alignments));
2418 }
2419 out.push_str(&table_border('└', '┴', '┘', &widths));
2420 out
2421}
2422
2423fn parse_table_row(line: &str) -> Vec<String> {
2424 let mut trimmed = line.trim();
2425 if let Some(rest) = trimmed.strip_prefix('|') {
2426 trimmed = rest;
2427 }
2428 if let Some(rest) = trimmed.strip_suffix('|') {
2429 trimmed = rest;
2430 }
2431 let mut cells = Vec::new();
2432 let mut current = String::new();
2433 let mut escaped = false;
2434 for ch in trimmed.chars() {
2435 if escaped {
2436 current.push(ch);
2437 escaped = false;
2438 } else if ch == '\\' {
2439 escaped = true;
2440 } else if ch == '|' {
2441 cells.push(current.trim().to_string());
2442 current.clear();
2443 } else {
2444 current.push(ch);
2445 }
2446 }
2447 if escaped {
2448 current.push('\\');
2449 }
2450 cells.push(current.trim().to_string());
2451 cells
2452}
2453
2454fn parse_table_alignments(line: &str) -> Vec<TableAlign> {
2455 parse_table_row(line)
2456 .into_iter()
2457 .map(|cell| {
2458 let cell = cell.trim();
2459 if cell.starts_with(':') && cell.ends_with(':') {
2460 TableAlign::Center
2461 } else if cell.ends_with(':') {
2462 TableAlign::Right
2463 } else {
2464 TableAlign::Left
2465 }
2466 })
2467 .collect()
2468}
2469
2470fn table_border(left: char, middle: char, right: char, widths: &[usize]) -> String {
2471 let mut out = String::new();
2472 out.push(left);
2473 for (index, width) in widths.iter().enumerate() {
2474 out.push_str(&"─".repeat(width + 2));
2475 if index + 1 == widths.len() {
2476 out.push(right);
2477 } else {
2478 out.push(middle);
2479 }
2480 }
2481 out.push('\n');
2482 out
2483}
2484
2485fn table_row(cells: &[String], widths: &[usize], alignments: &[TableAlign]) -> String {
2486 let mut out = String::from("│");
2487 for (index, width) in widths.iter().enumerate() {
2488 let cell = cells.get(index).map(String::as_str).unwrap_or("");
2489 out.push(' ');
2490 out.push_str(&pad_table_cell(
2491 cell,
2492 *width,
2493 alignments.get(index).copied().unwrap_or(TableAlign::Left),
2494 ));
2495 out.push(' ');
2496 out.push('│');
2497 }
2498 out.push('\n');
2499 out
2500}
2501
2502fn pad_table_cell(cell: &str, width: usize, alignment: TableAlign) -> String {
2503 let visible = markdown_visible_width(cell);
2504 let padding = width.saturating_sub(visible);
2505 match alignment {
2506 TableAlign::Left => format!("{cell}{}", " ".repeat(padding)),
2507 TableAlign::Right => format!("{}{cell}", " ".repeat(padding)),
2508 TableAlign::Center => {
2509 let left = padding / 2;
2510 let right = padding - left;
2511 format!("{}{cell}{}", " ".repeat(left), " ".repeat(right))
2512 }
2513 }
2514}
2515
2516fn markdown_visible_width(value: &str) -> usize {
2517 let mut width = 0;
2518 let mut chars = value.chars().peekable();
2519 while let Some(ch) = chars.next() {
2520 if ch == '`' {
2521 if matches!(chars.peek(), Some('F') | Some('B')) {
2522 chars.next();
2523 if matches!(chars.peek(), Some('T')) {
2524 chars.next();
2525 for _ in 0..6 {
2526 chars.next();
2527 }
2528 } else {
2529 for _ in 0..3 {
2530 chars.next();
2531 }
2532 }
2533 continue;
2534 }
2535 if matches!(chars.peek(), Some('!') | Some('*') | Some('_') | Some('=')) {
2536 chars.next();
2537 continue;
2538 }
2539 if matches!(chars.peek(), Some('f') | Some('b') | Some('a')) {
2540 chars.next();
2541 continue;
2542 }
2543 if matches!(chars.peek(), Some('[')) {
2544 let mut label_width = 0;
2545 for link_ch in chars.by_ref() {
2546 if link_ch == '`' {
2547 for target_ch in chars.by_ref() {
2548 if target_ch == ']' {
2549 break;
2550 }
2551 }
2552 break;
2553 }
2554 label_width += 1;
2555 }
2556 width += label_width;
2557 continue;
2558 }
2559 }
2560 width += 1;
2561 }
2562 width
2563}
2564
2565fn markdown_wrap_text(text: &str, width: usize) -> Vec<String> {
2566 if text.is_empty() {
2567 return vec![String::new()];
2568 }
2569 let mut lines = Vec::new();
2570 let mut current = String::new();
2571 let mut current_width = 0;
2572 for word in text.split_whitespace() {
2573 let word_width = markdown_visible_width(word);
2574 if !current.is_empty() && current_width + 1 + word_width > width {
2575 lines.push(current);
2576 current = String::new();
2577 current_width = 0;
2578 }
2579 if !current.is_empty() {
2580 current.push(' ');
2581 current_width += 1;
2582 }
2583 current.push_str(word);
2584 current_width += word_width;
2585 }
2586 if !current.is_empty() {
2587 lines.push(current);
2588 }
2589 if lines.is_empty() {
2590 lines.push(String::new());
2591 }
2592 lines
2593}
2594
2595#[derive(Debug)]
2596enum InlineToken {
2597 Link { label: String, target: String },
2598 Code(String),
2599}
2600
2601#[derive(Debug, Clone, Copy)]
2602struct MarkdownLinkStyle<'a> {
2603 bold: bool,
2604 underline: bool,
2605 color: Option<&'a str>,
2606}
2607
2608impl Default for MarkdownLinkStyle<'_> {
2609 fn default() -> Self {
2610 Self {
2611 bold: true,
2612 underline: true,
2613 color: None,
2614 }
2615 }
2616}
2617
2618fn format_markdown_inline_scoped(input: &str, url_scope: Option<&str>) -> String {
2619 let (with_placeholders, tokens) = extract_markdown_inline_tokens(input);
2620 let escaped = m_escape(&with_placeholders);
2621 let styled = apply_markdown_style(&escaped, "**", "`!", "`!");
2622 let styled = apply_markdown_style(&styled, "__", "`!", "`!");
2623 let styled = apply_markdown_style(&styled, "*", "`*", "`*");
2624 let styled = apply_markdown_style(&styled, "_", "`*", "`*");
2625 restore_markdown_inline_tokens(&styled, &tokens, url_scope)
2626}
2627
2628fn extract_markdown_inline_tokens(input: &str) -> (String, Vec<InlineToken>) {
2629 let mut out = String::new();
2630 let mut tokens = Vec::new();
2631 let mut index = 0;
2632
2633 while index < input.len() {
2634 let rest = &input[index..];
2635 if let Some(stripped) = rest.strip_prefix('`') {
2636 if let Some(end) = stripped.find('`') {
2637 let content = &stripped[..end];
2638 if is_markdown_inline_code(content) {
2639 tokens.push(InlineToken::Code(content.to_string()));
2640 out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2641 index += end + 2;
2642 continue;
2643 }
2644 }
2645 }
2646 if rest.starts_with('[') {
2647 if let Some((consumed, label, target)) = parse_markdown_link(rest) {
2648 tokens.push(InlineToken::Link { label, target });
2649 out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2650 index += consumed;
2651 continue;
2652 }
2653 }
2654 let Some(ch) = rest.chars().next() else {
2655 break;
2656 };
2657 out.push(ch);
2658 index += ch.len_utf8();
2659 }
2660
2661 (out, tokens)
2662}
2663
2664fn is_markdown_inline_code(content: &str) -> bool {
2665 !content.is_empty()
2666 && !content
2667 .chars()
2668 .next()
2669 .is_some_and(|ch| matches!(ch, '!' | '*' | '_' | '=' | 'F' | 'B' | 'f' | 'b' | 'a'))
2670}
2671
2672fn parse_markdown_link(input: &str) -> Option<(usize, String, String)> {
2673 let label_end = input[1..].find(']')? + 1;
2674 if !input[label_end + 1..].starts_with('(') {
2675 return None;
2676 }
2677 let target_start = label_end + 2;
2678 let target_end = input[target_start..].find(')')? + target_start;
2679 Some((
2680 target_end + 1,
2681 input[1..label_end].replace('`', ""),
2682 input[target_start..target_end].to_string(),
2683 ))
2684}
2685
2686fn markdown_token_placeholder(index: usize) -> String {
2687 format!("\u{1f}{index}\u{1f}")
2688}
2689
2690fn restore_markdown_inline_tokens(
2691 input: &str,
2692 tokens: &[InlineToken],
2693 url_scope: Option<&str>,
2694) -> String {
2695 let mut out = input.to_string();
2696 for (index, token) in tokens.iter().enumerate() {
2697 let replacement = match token {
2698 InlineToken::Link { label, target } => {
2699 format_markdown_link(label, target, url_scope, MarkdownLinkStyle::default())
2700 }
2701 InlineToken::Code(value) => format!("`BT383838`Fddd{}`f`b", m_escape(value)),
2702 };
2703 out = out.replace(&markdown_token_placeholder(index), &replacement);
2704 }
2705 out
2706}
2707
2708fn format_markdown_link(
2709 label: &str,
2710 target: &str,
2711 url_scope: Option<&str>,
2712 style: MarkdownLinkStyle<'_>,
2713) -> String {
2714 let mut link = String::new();
2715 if style.underline {
2716 link.push_str("`_");
2717 }
2718 if style.bold {
2719 link.push_str("`!");
2720 }
2721 link.push_str("`[");
2722 link.push_str(sanitize_label(label).trim());
2723 link.push('`');
2724 link.push_str(&markdown_link_target(target, url_scope));
2725 link.push(']');
2726 if style.bold {
2727 link.push_str("`!");
2728 }
2729 if style.underline {
2730 link.push_str("`_");
2731 }
2732
2733 match style.color {
2734 Some(color) if color.len() == 3 => format!("`F{color}{link}`f"),
2735 Some(color) if color.len() == 6 => format!("`FT{color}{link}`f"),
2736 _ => link,
2737 }
2738}
2739
2740fn markdown_link_target(value: &str, url_scope: Option<&str>) -> String {
2741 if let Some(scope) = url_scope {
2742 if !value.contains(":/") {
2743 let (path, anchor) = value
2744 .split_once('#')
2745 .map(|(path, anchor)| (path, Some(anchor)))
2746 .unwrap_or((value, None));
2747 let mut out = format!("{scope}{}", sanitize_field(path));
2748 if let Some(anchor) = anchor.filter(|anchor| !anchor.is_empty()) {
2749 out.push_str("|anchor=");
2750 out.push_str(&sanitize_field(anchor));
2751 }
2752 return out;
2753 }
2754 }
2755 sanitize_markdown_link_target(value)
2756}
2757
2758fn sanitize_markdown_link_target(value: &str) -> String {
2759 value
2760 .chars()
2761 .filter(|ch| *ch != '[' && *ch != ']' && *ch != '`')
2762 .collect()
2763}
2764
2765fn apply_markdown_style(input: &str, marker: &str, open: &str, close: &str) -> String {
2766 let mut out = String::new();
2767 let mut rest = input;
2768
2769 while let Some(start) = rest.find(marker) {
2770 let content_start = start + marker.len();
2771 let Some(end) = rest[content_start..].find(marker) else {
2772 out.push_str(rest);
2773 return out;
2774 };
2775 let content_end = content_start + end;
2776 out.push_str(&rest[..start]);
2777 out.push_str(open);
2778 out.push_str(&rest[content_start..content_end]);
2779 out.push_str(close);
2780 rest = &rest[content_end + marker.len()..];
2781 }
2782 out.push_str(rest);
2783 out
2784}
2785
2786#[derive(Debug)]
2787struct Refs {
2788 heads: Vec<RefInfo>,
2789 tags: Vec<RefInfo>,
2790}
2791
2792#[derive(Debug)]
2793struct RefInfo {
2794 sha: String,
2795 name: String,
2796}
2797
2798fn git_refs(repo: &Path) -> Result<Refs> {
2799 let output = run_git(
2800 Command::new("git")
2801 .arg("--git-dir")
2802 .arg(repo)
2803 .args(["for-each-ref", "--format=%(objectname) %(refname)"]),
2804 )?;
2805 let mut refs = Refs {
2806 heads: Vec::new(),
2807 tags: Vec::new(),
2808 };
2809 for line in output.lines() {
2810 let Some((sha, name)) = line.split_once(' ') else {
2811 continue;
2812 };
2813 if let Some(short) = name.strip_prefix("refs/heads/") {
2814 refs.heads.push(RefInfo {
2815 sha: sha.to_string(),
2816 name: short.to_string(),
2817 });
2818 } else if let Some(short) = name.strip_prefix("refs/tags/") {
2819 refs.tags.push(RefInfo {
2820 sha: sha.to_string(),
2821 name: short.to_string(),
2822 });
2823 }
2824 }
2825 Ok(refs)
2826}
2827
2828fn resolve_ref(repo: &Path, reference: &str) -> Result<String> {
2829 validate_refish(reference)?;
2830 let output = run_git(
2831 Command::new("git")
2832 .arg("--git-dir")
2833 .arg(repo)
2834 .arg("rev-parse")
2835 .arg("--verify")
2836 .arg(format!("{reference}^{{commit}}")),
2837 )?;
2838 Ok(output.trim().to_string())
2839}
2840
2841#[derive(Debug)]
2842struct TreeEntry {
2843 kind: String,
2844 name: String,
2845 size: Option<u64>,
2846}
2847
2848impl TreeEntry {
2849 fn size_label(&self) -> String {
2850 self.size
2851 .map(|size| format!(" ({size} bytes)"))
2852 .unwrap_or_default()
2853 }
2854}
2855
2856fn tree_entries(repo: &Path, reference: &str, path: &str) -> Result<Vec<TreeEntry>> {
2857 validate_git_path(path)?;
2858 let spec = if path.is_empty() {
2859 reference.to_string()
2860 } else {
2861 format!("{reference}:{path}")
2862 };
2863 let output = run_git(
2864 Command::new("git")
2865 .arg("--git-dir")
2866 .arg(repo)
2867 .arg("ls-tree")
2868 .arg("-l")
2869 .arg(spec),
2870 )?;
2871 let mut entries = Vec::new();
2872 for line in output.lines() {
2873 let Some((meta, name)) = line.split_once('\t') else {
2874 continue;
2875 };
2876 let parts: Vec<&str> = meta.split_whitespace().collect();
2877 if parts.len() < 4 {
2878 continue;
2879 }
2880 let size = parts[3].parse::<u64>().ok();
2881 entries.push(TreeEntry {
2882 kind: parts[1].to_string(),
2883 name: name.to_string(),
2884 size,
2885 });
2886 }
2887 entries.sort_by_key(|entry| (entry.kind != "tree", entry.name.to_ascii_lowercase()));
2888 Ok(entries)
2889}
2890
2891fn git_object_kind(repo: &Path, reference: &str, path: &str) -> Result<String> {
2892 validate_git_path(path)?;
2893 let spec = if path.is_empty() {
2894 reference.to_string()
2895 } else {
2896 format!("{reference}:{path}")
2897 };
2898 let output = run_git(
2899 Command::new("git")
2900 .arg("--git-dir")
2901 .arg(repo)
2902 .arg("cat-file")
2903 .arg("-t")
2904 .arg(spec),
2905 )?;
2906 Ok(output.trim().to_string())
2907}
2908
2909struct BlobInfo {
2910 content: String,
2911 size: u64,
2912 binary: bool,
2913 displayable: bool,
2914}
2915
2916fn blob_info(repo: &Path, reference: &str, path: &str) -> Result<BlobInfo> {
2917 validate_git_path(path)?;
2918 let spec = format!("{reference}:{path}");
2919 let size = run_git(
2920 Command::new("git")
2921 .arg("--git-dir")
2922 .arg(repo)
2923 .arg("cat-file")
2924 .arg("-s")
2925 .arg(&spec),
2926 )?
2927 .trim()
2928 .parse::<u64>()
2929 .unwrap_or(0);
2930 if size > 256 * 1024 {
2931 return Ok(BlobInfo {
2932 content: format!("File is too large to display ({size} bytes)."),
2933 size,
2934 binary: false,
2935 displayable: false,
2936 });
2937 }
2938 let output = run_git_output(
2939 Command::new("git")
2940 .arg("--git-dir")
2941 .arg(repo)
2942 .arg("show")
2943 .arg(spec),
2944 GIT_COMMAND_TIMEOUT,
2945 )?;
2946 if !output.status.success() {
2947 return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
2948 }
2949 let binary = output.stdout.contains(&0);
2950 Ok(BlobInfo {
2951 content: if binary {
2952 "Binary file is not displayed.".to_string()
2953 } else {
2954 String::from_utf8_lossy(&output.stdout).into_owned()
2955 },
2956 size,
2957 binary,
2958 displayable: !binary,
2959 })
2960}
2961
2962#[derive(Debug)]
2963struct CommitSummary {
2964 hash: String,
2965 subject: String,
2966 author: String,
2967 timestamp: i64,
2968}
2969
2970fn commits(repo: &Path, reference: &str, limit: usize) -> Result<Vec<CommitSummary>> {
2971 let format = "%H%x1f%s%x1f%an%x1f%at";
2972 let output = run_git(
2973 Command::new("git")
2974 .arg("--git-dir")
2975 .arg(repo)
2976 .arg("log")
2977 .arg(format!("--format={format}"))
2978 .arg("-n")
2979 .arg(limit.to_string())
2980 .arg(reference),
2981 )?;
2982 let mut commits = Vec::new();
2983 for line in output.lines() {
2984 let parts: Vec<&str> = line.split('\x1f').collect();
2985 if parts.len() == 4 {
2986 commits.push(CommitSummary {
2987 hash: parts[0].to_string(),
2988 subject: parts[1].to_string(),
2989 author: parts[2].to_string(),
2990 timestamp: parts[3].parse().unwrap_or_default(),
2991 });
2992 }
2993 }
2994 Ok(commits)
2995}
2996
2997#[derive(Debug)]
2998struct CommitInfo {
2999 hash: String,
3000 subject: String,
3001 body: String,
3002 author: String,
3003 email: String,
3004 timestamp: i64,
3005 files: Vec<CommitFile>,
3006}
3007
3008#[derive(Debug)]
3009struct CommitFile {
3010 status: String,
3011 path: String,
3012}
3013
3014fn commit_info(repo: &Path, hash: &str) -> Result<CommitInfo> {
3015 validate_refish(hash)?;
3016 let output = run_git(
3017 Command::new("git")
3018 .arg("--git-dir")
3019 .arg(repo)
3020 .arg("show")
3021 .arg("--no-patch")
3022 .arg("--format=%H%x1f%s%x1f%an%x1f%ae%x1f%at%x1f%B")
3023 .arg(hash),
3024 )?;
3025 let parts: Vec<&str> = output.splitn(6, '\x1f').collect();
3026 if parts.len() != 6 {
3027 return Err(Error::msg("invalid commit output"));
3028 }
3029 let files_output = run_git(
3030 Command::new("git")
3031 .arg("--git-dir")
3032 .arg(repo)
3033 .arg("diff-tree")
3034 .arg("--root")
3035 .arg("--no-commit-id")
3036 .arg("--name-status")
3037 .arg("-r")
3038 .arg(hash),
3039 )?;
3040 let mut files = Vec::new();
3041 for line in files_output.lines() {
3042 let Some((status, path)) = line.split_once('\t') else {
3043 continue;
3044 };
3045 files.push(CommitFile {
3046 status: status.to_string(),
3047 path: path.to_string(),
3048 });
3049 }
3050 Ok(CommitInfo {
3051 hash: parts[0].trim().to_string(),
3052 subject: parts[1].trim().to_string(),
3053 author: parts[2].trim().to_string(),
3054 email: parts[3].trim().to_string(),
3055 timestamp: parts[4].trim().parse().unwrap_or_default(),
3056 body: parts[5].trim().to_string(),
3057 files,
3058 })
3059}
3060
3061fn raw_commit_object(repo: &Path, hash: &str) -> Result<Vec<u8>> {
3062 validate_refish(hash)?;
3063 let output = run_git_output(
3064 Command::new("git")
3065 .arg("--git-dir")
3066 .arg(repo)
3067 .arg("cat-file")
3068 .arg("-p")
3069 .arg(hash),
3070 GIT_COMMAND_TIMEOUT,
3071 )?;
3072 if !output.status.success() {
3073 return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
3074 }
3075 Ok(output.stdout)
3076}
3077
3078fn run_git(cmd: &mut Command) -> Result<String> {
3079 let output = run_git_output(cmd, GIT_COMMAND_TIMEOUT)?;
3080 if !output.status.success() {
3081 return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
3082 }
3083 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
3084}
3085
3086fn run_git_output(cmd: &mut Command, timeout: Duration) -> Result<Output> {
3087 let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
3088 let deadline = Instant::now() + timeout;
3089
3090 loop {
3091 if child.try_wait()?.is_some() {
3092 return child.wait_with_output().map_err(Error::from);
3093 }
3094
3095 if Instant::now() >= deadline {
3096 let _ = child.kill();
3097 let _ = child.wait();
3098 log::warn!("Git command execution timed out");
3099 return Err(Error::msg("Git command execution timed out"));
3100 }
3101
3102 let sleep_for = deadline
3103 .saturating_duration_since(Instant::now())
3104 .min(Duration::from_millis(10));
3105 if !sleep_for.is_zero() {
3106 thread::sleep(sleep_for);
3107 }
3108 }
3109}
3110
3111#[cfg(test)]
3112mod tests {
3113 use super::*;
3114 use crate::acl::Access;
3115 use crate::config::ServerConfig;
3116
3117 #[test]
3118 fn blob_path_normalization_removes_only_the_explicit_relative_prefix() {
3119 assert_eq!(normalize_blob_path("./docs/guide.md"), "docs/guide.md");
3120 assert_eq!(normalize_blob_path("docs/./guide.md"), "docs/guide.md");
3121 assert_eq!(normalize_blob_path(".hidden/config"), ".hidden/config");
3122 assert_eq!(normalize_blob_path(".../guide.md"), ".../guide.md");
3123 assert_eq!(normalize_blob_path("/absolute.md"), "/absolute.md");
3124 assert!(validate_git_path(&normalize_blob_path("/absolute.md")).is_err());
3125 }
3126 use crate::logging;
3127 use rns_core::msgpack::{self, Value};
3128 use rns_crypto::OsRng;
3129 use std::fs;
3130 use std::process::Command;
3131
3132 #[test]
3133 fn nomadnet_destination_uses_upstream_name() {
3134 let identity = Identity::new(&mut OsRng);
3135 let destination = destination_for_identity(&identity);
3136 let expected =
3137 Destination::single_in("nomadnetwork", &["node"], IdentityHash(*identity.hash()));
3138 assert_eq!(destination.hash, expected.hash);
3139 }
3140
3141 #[test]
3142 fn chart_renders_positive_values_on_bottom_row() {
3143 let labels = ["start".to_string(), "end".to_string()];
3144 let chart = render_chart(&[1, 100], &labels, "57c", None, 1.3, 10);
3145 let bottom_row = chart.lines().nth(10).unwrap();
3146
3147 assert!(bottom_row.starts_with("│`FT"));
3148 assert!(bottom_row.contains('▄'));
3149 }
3150
3151 #[test]
3152 fn git_command_timeout_kills_slow_process() {
3153 let start = Instant::now();
3154
3155 let err =
3156 run_git_output(Command::new("sleep").arg("5"), Duration::from_millis(30)).unwrap_err();
3157
3158 assert!(start.elapsed() < Duration::from_secs(1));
3159 assert!(err.to_string().contains("timed out"));
3160 }
3161
3162 #[test]
3163 fn commit_message_formatter_escapes_leading_dash_lines() {
3164 let formatted = format_commit_message("subject\n- rendered literally\npath\\with`tick`");
3165
3166 assert_eq!(
3167 formatted,
3168 "subject\n\\- rendered literally\npath\\\\with\\`tick\\`"
3169 );
3170 }
3171
3172 #[test]
3173 fn front_page_lists_only_readable_groups() {
3174 let tmp = tempfile::tempdir().unwrap();
3175 let mut config = cfg(tmp.path());
3176 config.allow_read = vec!["none".into()];
3177 create_repo(
3178 config.repositories_dir.join("public/alpha"),
3179 "README.md",
3180 "alpha readme\n",
3181 );
3182 create_repo(
3183 config.repositories_dir.join("private/secret"),
3184 "README.md",
3185 "secret\n",
3186 );
3187 fs::write(
3188 config.repositories_dir.join("public/group.allowed"),
3189 "read = all\n",
3190 )
3191 .unwrap();
3192 fs::write(
3193 config.repositories_dir.join("private/group.allowed"),
3194 "read = none\n",
3195 )
3196 .unwrap();
3197 let access = access(&config);
3198
3199 let page = render_page(PATH_INDEX, &config, &access, &page_request(&[]), None).unwrap();
3200 assert!(page.contains("public"));
3201 assert!(page.contains("alpha"));
3202 assert!(!page.contains("private"));
3203 assert!(!page.contains("secret"));
3204 }
3205
3206 #[test]
3207 fn group_and_repo_pages_use_repository_metadata() {
3208 let tmp = tempfile::tempdir().unwrap();
3209 let config = cfg(tmp.path());
3210 let repo = create_repo(
3211 config.repositories_dir.join("public/alpha"),
3212 "README.md",
3213 "# Alpha\n",
3214 );
3215 fs::write(repo.join("description"), "Alpha repository\n").unwrap();
3216 let access = access(&config);
3217
3218 let group = render_page(
3219 PATH_GROUP,
3220 &config,
3221 &access,
3222 &page_request(&[("var_g", "public")]),
3223 None,
3224 )
3225 .unwrap();
3226 assert!(group.contains("Alpha repository"));
3227 assert!(group.contains("alpha"));
3228
3229 let repo = render_page(
3230 PATH_REPO,
3231 &config,
3232 &access,
3233 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3234 None,
3235 )
3236 .unwrap();
3237 assert!(repo.contains("rns://"));
3238 assert!(repo.contains("Alpha repository"));
3239 assert!(repo.contains(">Alpha"));
3240 }
3241
3242 #[test]
3243 fn repo_page_renders_fork_and_mirror_sources() {
3244 let tmp = tempfile::tempdir().unwrap();
3245 let config = cfg(tmp.path());
3246 let fork = create_repo(
3247 config.repositories_dir.join("public/forked"),
3248 "README.md",
3249 "# Forked\n",
3250 );
3251 let mirror = create_repo(
3252 config.repositories_dir.join("public/mirrored"),
3253 "README.md",
3254 "# Mirrored\n",
3255 );
3256 run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3257 "config",
3258 "repository.rngit.type",
3259 "fork",
3260 ]));
3261 run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3262 "config",
3263 "repository.rngit.upstream.source",
3264 "rns://00112233445566778899aabbccddeeff/source/repo",
3265 ]));
3266 run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3267 "config",
3268 "repository.rngit.upstream.sync",
3269 "1",
3270 ]));
3271 run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3272 "config",
3273 "repository.rngit.type",
3274 "mirror",
3275 ]));
3276 run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3277 "config",
3278 "repository.rngit.upstream.source",
3279 "https://example.invalid/upstream.git",
3280 ]));
3281 run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3282 "config",
3283 "repository.rngit.upstream.sync",
3284 "1",
3285 ]));
3286 let access = access(&config);
3287
3288 let fork_page = render_page(
3289 PATH_REPO,
3290 &config,
3291 &access,
3292 &page_request(&[("var_g", "public"), ("var_r", "forked")]),
3293 None,
3294 )
3295 .unwrap();
3296 assert!(fork_page.contains("Forked from"));
3297 assert!(fork_page.contains("rns://00112233445566778899aabbccddeeff/source/repo"));
3298 assert!(fork_page.contains("synced"));
3299 assert!(fork_page.contains("ago"));
3300
3301 let mirror_page = render_page(
3302 PATH_REPO,
3303 &config,
3304 &access,
3305 &page_request(&[("var_g", "public"), ("var_r", "mirrored")]),
3306 None,
3307 )
3308 .unwrap();
3309 assert!(mirror_page.contains("Mirrored from"));
3310 assert!(mirror_page.contains("https://example.invalid/upstream.git"));
3311 assert!(mirror_page.contains("synced"));
3312 assert!(mirror_page.contains("ago"));
3313 }
3314
3315 #[test]
3316 fn tree_blob_commits_commit_and_refs_pages_are_git_backed() {
3317 let tmp = tempfile::tempdir().unwrap();
3318 let config = cfg(tmp.path());
3319 let repo = create_repo(
3320 config.repositories_dir.join("public/alpha"),
3321 "src/lib.rs",
3322 "pub fn answer() -> u8 { 42 }\n",
3323 );
3324 run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3325 "tag",
3326 "v1",
3327 "refs/heads/main",
3328 ]));
3329 let access = access(&config);
3330
3331 let tree = render_page(
3332 PATH_TREE,
3333 &config,
3334 &access,
3335 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3336 None,
3337 )
3338 .unwrap();
3339 assert!(tree.contains("src/"));
3340
3341 let blob = render_page(
3342 PATH_BLOB,
3343 &config,
3344 &access,
3345 &page_request(&[
3346 ("var_g", "public"),
3347 ("var_r", "alpha"),
3348 ("var_path", "src/lib.rs"),
3349 ]),
3350 None,
3351 )
3352 .unwrap();
3353 assert!(blob.contains("answer"));
3354 assert!(blob.contains("pub fn answer()"));
3355
3356 let commits = render_page(
3357 PATH_COMMITS,
3358 &config,
3359 &access,
3360 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3361 None,
3362 )
3363 .unwrap();
3364 assert!(commits.contains("initial"));
3365
3366 let commit_hash = run_git(
3367 Command::new("git")
3368 .arg("--git-dir")
3369 .arg(&repo)
3370 .args(["rev-parse", "refs/heads/main"]),
3371 );
3372 run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3373 "update-ref",
3374 "refs/heads/feature",
3375 commit_hash.trim(),
3376 ]));
3377 let branch_commits = render_page(
3378 PATH_COMMITS,
3379 &config,
3380 &access,
3381 &page_request(&[
3382 ("var_g", "public"),
3383 ("var_r", "alpha"),
3384 ("var_ref", "feature"),
3385 ]),
3386 None,
3387 )
3388 .unwrap();
3389 assert!(branch_commits.contains("|ref=feature|h="));
3390 let commit = render_page(
3391 PATH_COMMIT,
3392 &config,
3393 &access,
3394 &page_request(&[
3395 ("var_g", "public"),
3396 ("var_r", "alpha"),
3397 ("var_ref", "feature"),
3398 ("var_h", commit_hash.trim()),
3399 ]),
3400 None,
3401 )
3402 .unwrap();
3403 assert!(commit.contains("initial"));
3404 assert!(commit.contains("src/lib.rs"));
3405 assert!(commit.contains("/page/commits.mu`g=public|r=alpha|ref=feature"));
3406 assert!(!commit.contains("Signature:"));
3407
3408 let refs = render_page(
3409 PATH_REFS,
3410 &config,
3411 &access,
3412 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3413 None,
3414 )
3415 .unwrap();
3416 assert!(refs.contains("main"));
3417 assert!(refs.contains("v1"));
3418 }
3419
3420 #[test]
3421 fn commit_page_shows_valid_reticulum_signature_status() {
3422 let tmp = tempfile::tempdir().unwrap();
3423 let config = cfg(tmp.path());
3424 let repo = create_repo(
3425 config.repositories_dir.join("public/alpha"),
3426 "src/lib.rs",
3427 "pub fn answer() -> u8 { 42 }\n",
3428 );
3429 let access = access(&config);
3430 let tree = run_git(
3431 Command::new("git")
3432 .arg("--git-dir")
3433 .arg(&repo)
3434 .args(["rev-parse", "refs/heads/main^{tree}"]),
3435 );
3436 let identity = Identity::new(&mut OsRng);
3437 let author = crate::util::hex(identity.hash());
3438 let signed_content = format!(
3439 "tree {}\nauthor RNS Page Test <{}> 0 +0000\ncommitter RNS Page Test <{}> 0 +0000\n\nsigned commit\n",
3440 tree.trim(),
3441 author,
3442 author
3443 );
3444 let armored =
3445 crate::commitsigs::sign_message(&identity, signed_content.as_bytes()).unwrap();
3446 let (headers, rest) = signed_content.split_once("\n\n").unwrap();
3447 let mut raw_commit = format!("{headers}\n");
3448 let mut sig_lines = armored.lines();
3449 raw_commit.push_str("gpgsig ");
3450 raw_commit.push_str(sig_lines.next().unwrap());
3451 raw_commit.push('\n');
3452 for line in sig_lines {
3453 raw_commit.push(' ');
3454 raw_commit.push_str(line);
3455 raw_commit.push('\n');
3456 }
3457 raw_commit.push('\n');
3458 raw_commit.push_str(rest);
3459 let commit_file = tmp.path().join("signed-commit");
3460 fs::write(&commit_file, raw_commit).unwrap();
3461 let commit_hash = run_git(
3462 Command::new("git")
3463 .arg("--git-dir")
3464 .arg(&repo)
3465 .args(["hash-object", "-t", "commit", "-w"])
3466 .arg(&commit_file),
3467 );
3468 run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3469 "update-ref",
3470 "refs/heads/main",
3471 commit_hash.trim(),
3472 ]));
3473
3474 let page = render_page(
3475 PATH_COMMIT,
3476 &config,
3477 &access,
3478 &page_request(&[
3479 ("var_g", "public"),
3480 ("var_r", "alpha"),
3481 ("var_h", commit_hash.trim()),
3482 ]),
3483 None,
3484 )
3485 .unwrap();
3486
3487 assert!(page.contains("signed commit"));
3488 assert!(page.contains("Signature: Valid, signed by author"));
3489 }
3490
3491 #[test]
3492 fn blob_page_renders_tree_when_path_is_directory() {
3493 let tmp = tempfile::tempdir().unwrap();
3494 let config = cfg(tmp.path());
3495 create_repo(
3496 config.repositories_dir.join("public/alpha"),
3497 "src/lib.rs",
3498 "pub fn answer() -> u8 { 42 }\n",
3499 );
3500 let access = access(&config);
3501
3502 let page = render_page(
3503 PATH_BLOB,
3504 &config,
3505 &access,
3506 &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_path", "src")]),
3507 None,
3508 )
3509 .unwrap();
3510
3511 assert!(page.contains("files / src"));
3512 assert!(page.contains("lib.rs"));
3513 assert!(page.contains(PATH_BLOB));
3514 assert!(!page.contains("Displaying Raw"));
3515 }
3516
3517 #[test]
3518 fn page_requests_reject_invalid_repository_names() {
3519 let tmp = tempfile::tempdir().unwrap();
3520 let config = cfg(tmp.path());
3521 let access = access(&config);
3522 let err = render_page(
3523 PATH_REPO,
3524 &config,
3525 &access,
3526 &page_request(&[("var_g", ".."), ("var_r", "repo")]),
3527 None,
3528 )
3529 .unwrap_err();
3530 assert!(err.to_string().contains("invalid repository name"));
3531 }
3532
3533 #[test]
3534 fn page_ref_resolution_rejects_invalid_refs_before_git_lookup() {
3535 let tmp = tempfile::tempdir().unwrap();
3536 let config = cfg(tmp.path());
3537 create_repo(
3538 config.repositories_dir.join("public/alpha"),
3539 "README.md",
3540 "# Alpha\n",
3541 );
3542 let access = access(&config);
3543
3544 for (path, vars) in [
3545 (
3546 PATH_TREE,
3547 vec![
3548 ("var_g", "public"),
3549 ("var_r", "alpha"),
3550 ("var_ref", "--upload-pack=/tmp/x"),
3551 ],
3552 ),
3553 (
3554 PATH_BLOB,
3555 vec![
3556 ("var_g", "public"),
3557 ("var_r", "alpha"),
3558 ("var_path", "README.md"),
3559 ("var_ref", "refs/heads/main:README.md"),
3560 ],
3561 ),
3562 (
3563 PATH_COMMITS,
3564 vec![
3565 ("var_g", "public"),
3566 ("var_r", "alpha"),
3567 ("var_ref", "refs/heads/main..refs/heads/other"),
3568 ],
3569 ),
3570 ] {
3571 let err = render_page(path, &config, &access, &page_request(&vars), None).unwrap_err();
3572 assert_eq!(err.to_string(), "invalid git ref");
3573 }
3574 }
3575
3576 #[test]
3577 fn pages_use_base_template_breadcrumbs_and_micron_links() {
3578 let tmp = tempfile::tempdir().unwrap();
3579 let config = cfg(tmp.path());
3580 create_repo(
3581 config.repositories_dir.join("public/alpha"),
3582 "README.md",
3583 "# Alpha\n",
3584 );
3585 let access = access(&config);
3586
3587 let repo = render_page(
3588 PATH_REPO,
3589 &config,
3590 &access,
3591 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3592 None,
3593 )
3594 .unwrap();
3595
3596 assert!(repo.starts_with("#!c=0\n>Test Git Node\n\n>>\n"));
3597 assert!(repo.contains("`!`[Node`:/page/index.mu]`!"));
3598 assert!(repo.contains("`!`[public`:/page/group.mu`g=public]`!"));
3599 assert!(repo.contains("`[Files`:/page/tree.mu`g=public|r=alpha|ref=HEAD]"));
3600 assert!(repo.contains("`[Commits"));
3601 assert!(repo.contains("`[Branches (1)`:/page/refs.mu`g=public|r=alpha|type=heads]"));
3602 assert!(repo.contains("`[Tags (0)`:/page/refs.mu`g=public|r=alpha|type=tags]"));
3603 assert!(repo.contains("<\n-\n`a`F666`[Served by rngit`:/page/index.mu]"));
3604 }
3605
3606 #[test]
3607 fn custom_templates_wrap_page_content_and_replace_variables() {
3608 let tmp = tempfile::tempdir().unwrap();
3609 let config = cfg(tmp.path());
3610 fs::create_dir_all(&config.templates_dir).unwrap();
3611 fs::write(
3612 config.templates_dir.join("base.mu"),
3613 "BASE {NODE_NAME} {VERSION} {GEN_TIME}\n{PAGE_CONTENT}\n{NAVIGATION} \n",
3614 )
3615 .unwrap();
3616 fs::write(
3617 config.templates_dir.join("repo.mu"),
3618 "REPO-BEGIN\n{PAGE_CONTENT}\nREPO-END \n",
3619 )
3620 .unwrap();
3621 create_repo(
3622 config.repositories_dir.join("public/alpha"),
3623 "README.md",
3624 "# Alpha\n",
3625 );
3626 let access = access(&config);
3627
3628 let repo = render_page(
3629 PATH_REPO,
3630 &config,
3631 &access,
3632 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3633 None,
3634 )
3635 .unwrap();
3636
3637 assert!(repo.starts_with("BASE Test Git Node "));
3638 assert!(repo.contains("\nREPO-BEGIN\n"));
3639 assert!(repo.contains(">Alpha"));
3640 assert!(repo.contains("\nREPO-END\n"));
3641 assert!(repo.contains(env!("CARGO_PKG_VERSION")));
3642 assert!(repo.contains("0ms"));
3643 assert!(!repo.ends_with(" \n"));
3644 }
3645
3646 #[test]
3647 fn template_substitution_does_not_rewrite_page_content_placeholders() {
3648 let tmp = tempfile::tempdir().unwrap();
3649 let config = cfg(tmp.path());
3650
3651 let out = apply_template(
3652 "Template {NODE_NAME}\n{PAGE_CONTENT}",
3653 "Literal {NODE_NAME}",
3654 &config,
3655 );
3656
3657 assert!(out.starts_with("Template Test Git Node\n"));
3658 assert!(out.ends_with("Literal {NODE_NAME}"));
3659 }
3660
3661 #[test]
3662 fn unicode_icon_config_changes_repository_navigation_labels() {
3663 let tmp = tempfile::tempdir().unwrap();
3664 let mut config = cfg(tmp.path());
3665 config.unicode_icons = true;
3666 create_repo(
3667 config.repositories_dir.join("public/alpha"),
3668 "README.md",
3669 "# Alpha\n",
3670 );
3671 let access = access(&config);
3672
3673 let repo = render_page(
3674 PATH_REPO,
3675 &config,
3676 &access,
3677 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3678 None,
3679 )
3680 .unwrap();
3681
3682 assert!(repo.contains("🗀 Files"));
3683 assert!(repo.contains("🖹 Commits"));
3684 assert!(repo.contains("⑃ Branches"));
3685 assert!(repo.contains("⌆ Tags"));
3686 }
3687
3688 #[test]
3689 fn repo_page_renders_and_persists_thanks_count() {
3690 let tmp = tempfile::tempdir().unwrap();
3691 let config = cfg(tmp.path());
3692 let repo_path = create_repo(
3693 config.repositories_dir.join("public/alpha"),
3694 "README.md",
3695 "# Alpha\n",
3696 );
3697 let access = access(&config);
3698
3699 let first = render_page(
3700 PATH_REPO,
3701 &config,
3702 &access,
3703 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3704 None,
3705 )
3706 .unwrap();
3707 assert!(first.contains("Thanks (0)"));
3708 assert!(!repo_path.with_extension("thanks").exists());
3709
3710 let thanked = render_page(
3711 PATH_REPO,
3712 &config,
3713 &access,
3714 &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
3715 None,
3716 )
3717 .unwrap();
3718 assert!(thanked.contains("Thanks (1)"));
3719 assert_eq!(
3720 fs::read_to_string(repo_path.with_extension("thanks")).unwrap(),
3721 "1\n"
3722 );
3723
3724 let incremented = render_page(
3725 PATH_REPO,
3726 &config,
3727 &access,
3728 &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
3729 None,
3730 )
3731 .unwrap();
3732 assert!(incremented.contains("Thanks (2)"));
3733
3734 let viewed = render_page(
3735 PATH_REPO,
3736 &config,
3737 &access,
3738 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3739 None,
3740 )
3741 .unwrap();
3742 assert!(viewed.contains("Thanks (2)"));
3743 }
3744
3745 #[test]
3746 fn repo_page_uses_unicode_heart_for_thanks_when_enabled() {
3747 let tmp = tempfile::tempdir().unwrap();
3748 let mut config = cfg(tmp.path());
3749 config.unicode_icons = true;
3750 create_repo(
3751 config.repositories_dir.join("public/alpha"),
3752 "README.md",
3753 "# Alpha\n",
3754 );
3755 let access = access(&config);
3756
3757 let page = render_page(
3758 PATH_REPO,
3759 &config,
3760 &access,
3761 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3762 None,
3763 )
3764 .unwrap();
3765
3766 assert!(page.contains("♥ Thanks (0)"));
3767 assert!(page.contains("thanks=y"));
3768 }
3769
3770 #[test]
3771 fn empty_page_request_data_is_deterministic() {
3772 let tmp = tempfile::tempdir().unwrap();
3773 let config = cfg(tmp.path());
3774 let access = access(&config);
3775
3776 let index = render_page(PATH_INDEX, &config, &access, &[], None).unwrap();
3777 assert!(index.contains(">Groups"));
3778
3779 let err = render_page(PATH_REPO, &config, &access, &[], None).unwrap_err();
3780 assert!(err.to_string().contains("missing page variable g"));
3781 }
3782
3783 #[test]
3784 fn tree_blob_and_commit_pages_escape_content_and_format_metadata() {
3785 let tmp = tempfile::tempdir().unwrap();
3786 let config = cfg(tmp.path());
3787 let repo = create_repo(
3788 config.repositories_dir.join("public/alpha"),
3789 "src/lib.rs",
3790 "pub fn `answer`() -> u8 { 42 }\n",
3791 );
3792 let access = access(&config);
3793
3794 let tree = render_page(
3795 PATH_TREE,
3796 &config,
3797 &access,
3798 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3799 None,
3800 )
3801 .unwrap();
3802 assert!(tree.contains("`[src/`:/page/tree.mu`g=public|r=alpha|ref=HEAD|path=src]"));
3803
3804 let blob = render_page(
3805 PATH_BLOB,
3806 &config,
3807 &access,
3808 &page_request(&[
3809 ("var_g", "public"),
3810 ("var_r", "alpha"),
3811 ("var_path", "src/lib.rs"),
3812 ]),
3813 None,
3814 )
3815 .unwrap();
3816 assert!(blob.contains("Text, "));
3817 assert!(!blob.contains("View raw"));
3818 assert!(blob.contains("answer"));
3819 assert!(blob.contains("\\`"));
3820
3821 let silly_blob = render_page(
3822 PATH_BLOB,
3823 &config,
3824 &access,
3825 &page_request(&[
3826 ("var_g", "public"),
3827 ("var_r", "alpha"),
3828 ("var_path", "./src/./lib.rs"),
3829 ]),
3830 None,
3831 )
3832 .unwrap();
3833 assert!(silly_blob.contains(">src/lib.rs `F666"));
3834 assert!(silly_blob.contains("answer"));
3835
3836 let commit_hash = run_git(
3837 Command::new("git")
3838 .arg("--git-dir")
3839 .arg(&repo)
3840 .args(["rev-parse", "refs/heads/main"]),
3841 );
3842 let commit = render_page(
3843 PATH_COMMIT,
3844 &config,
3845 &access,
3846 &page_request(&[
3847 ("var_g", "public"),
3848 ("var_r", "alpha"),
3849 ("var_h", commit_hash.trim()),
3850 ]),
3851 None,
3852 )
3853 .unwrap();
3854 assert!(commit.contains("Author: RNS Page Test"));
3855 assert!(commit.contains("Files changed"));
3856 assert!(commit.contains("A src/lib.rs"));
3857 }
3858
3859 #[test]
3860 fn markdown_converter_handles_links_code_lists_rules_and_tables() {
3861 let input = "# Title\n\
3862\n\
3863See [the docs](https://example.invalid/a_b) and `literal *code*`.\n\
3864\n\
3865- **bold** and *italic*\n\
3866---\n\
3867| Name | Link |\n\
3868| --- | --- |\n\
3869| Row | [go](rns://abc) |\n";
3870
3871 let out = markdown_to_micron(input);
3872 assert!(out.contains(">Title"));
3873 assert!(out.contains("`_`!`[the docs`https://example.invalid/a_b]`!`_"));
3874 assert!(out.contains("`BT383838`Fdddliteral *code*`f`b"));
3875 assert!(out.contains(" • `!bold`! and `*italic`*"));
3876 assert!(out.contains("\n-\n"));
3877 assert!(out.contains("│ Name"));
3878 assert!(out.contains("`_`!`[go`rns://abc]`!`_"));
3879 }
3880
3881 #[test]
3882 fn markdown_converter_handles_blockquotes_and_heading_inline_markup() {
3883 let out = markdown_to_micron(
3884 "# **Important**\n\
3885> **Quoted** text\n\
3886> with [link](rns://abc)\n\
3887\n\
3888after\n",
3889 );
3890 assert!(out.contains(">`!Important`!"));
3891 assert!(out.contains(" │ `!Quoted`! text with `_`!`[link`rns://abc]`!`_"));
3892 assert!(out.contains("\nafter\n"));
3893 }
3894
3895 #[test]
3896 fn markdown_link_conversion_is_isolated_from_later_substitutions() {
3897 let out = markdown_to_micron("[**bold** `code`](https://example.invalid?q=*x*)");
3898 assert!(out.contains("`_`!`[**bold** code`https://example.invalid?q=*x*]`!`_"));
3899 assert!(!out.contains("`!`!"));
3900 assert!(!out.contains("`*x`*"));
3901 }
3902
3903 #[test]
3904 fn markdown_link_style_can_disable_wrappers_and_apply_color() {
3905 let plain = format_markdown_link(
3906 "label",
3907 "rns://abc",
3908 None,
3909 MarkdownLinkStyle {
3910 bold: false,
3911 underline: false,
3912 color: None,
3913 },
3914 );
3915 assert_eq!(plain, "`[label`rns://abc]");
3916
3917 let short_color = format_markdown_link(
3918 "label",
3919 "rns://abc",
3920 None,
3921 MarkdownLinkStyle {
3922 bold: true,
3923 underline: false,
3924 color: Some("abc"),
3925 },
3926 );
3927 assert_eq!(short_color, "`Fabc`!`[label`rns://abc]`!`f");
3928
3929 let true_color = format_markdown_link(
3930 "label",
3931 "rns://abc",
3932 None,
3933 MarkdownLinkStyle {
3934 bold: false,
3935 underline: true,
3936 color: Some("aabbcc"),
3937 },
3938 );
3939 assert_eq!(true_color, "`FTaabbcc`_`[label`rns://abc]`_`f");
3940 }
3941
3942 #[test]
3943 fn markdown_inline_code_that_looks_like_link_does_not_hide_real_links() {
3944 let out = markdown_to_micron(
3945 "`[literal](https://example.invalid/no)` and [real](https://example.invalid/yes)",
3946 );
3947
3948 assert!(out.contains("`BT383838`Fddd[literal](https://example.invalid/no)`f`b"));
3949 assert!(out.contains("`_`!`[real`https://example.invalid/yes]`!`_"));
3950 assert!(!out.contains("`_`!`[literal`https://example.invalid/no]`!`_"));
3951 }
3952
3953 #[test]
3954 fn markdown_tables_handle_empty_cells_escaped_pipes_alignment_and_visible_width() {
3955 let input = "| Name | Status | Notes |\n\
3956| :--- | ---: | :---: |\n\
3957| `code` | **ok** | a\\|b |\n\
3958| empty | | [go](rns://abc) |\n";
3959
3960 let out = markdown_to_micron(input);
3961 assert!(out.contains("┌"));
3962 assert!(out.contains("├"));
3963 assert!(out.contains("└"));
3964 assert!(out.contains("│ Name"));
3965 assert!(out.contains("`BT383838`Fdddcode`f`b"));
3966 assert!(out.contains("`!ok`!"));
3967 assert!(out.contains("a|b"));
3968 assert!(out.contains("empty"));
3969 assert!(out.contains("`_`!`[go`rns://abc]`!`_"));
3970 assert!(!out.contains("a\\|b"));
3971 }
3972
3973 #[test]
3974 fn markdown_table_width_ignores_generated_micron_tags() {
3975 let input = "| A | B |\n\
3976| --- | --- |\n\
3977| [x](rns://abc) | `y` |\n";
3978
3979 let out = markdown_to_micron(input);
3980 assert!(out.contains("│ `_`!`[x`rns://abc]`!`_ "));
3981 assert!(out.contains("│ `BT383838`Fdddy`f`b "));
3982 assert!(out.contains("┌─────┬─────┐"));
3983 }
3984
3985 #[test]
3986 fn markdown_escaping_does_not_create_or_corrupt_micron_tags() {
3987 let out =
3988 markdown_to_micron("literal `!not bold`! and [bad`label](rns://abc`def) plus `raw`");
3989 assert!(out.contains("literal \\`!not bold\\`!"));
3990 assert!(out.contains("`_`!`[badlabel`rns://abcdef]`!`_"));
3991 assert!(out.contains("`BT383838`Fdddraw`f`b"));
3992 assert!(!out.contains("`!not bold`!"));
3993 }
3994
3995 #[test]
3996 fn markdown_escapes_backslashes_and_expands_tabs_before_inline_formatting() {
3997 let out = markdown_to_micron("plain\\path\t**bold\\path** and `code\\path`\n");
3998
3999 assert!(out.contains("plain\\\\path `!bold\\\\path`!"));
4000 assert!(out.contains("`BT383838`Fdddcode\\\\path`f`b"));
4001 }
4002
4003 #[test]
4004 fn markdown_plain_lines_escape_micron_line_start_controls() {
4005 let out = markdown_to_micron("-not a list\n<not alignment\n- list item\n---\n");
4006 assert!(out.contains("\\-not a list\n"));
4007 assert!(out.contains("\\<not alignment\n"));
4008 assert!(out.contains(" • list item\n"));
4009 assert!(out.contains("-\n"));
4010 }
4011
4012 #[test]
4013 fn markdown_converter_keeps_code_blocks_and_unmatched_markers_literal() {
4014 let out = markdown_to_micron(
4015 "```rust\n\
4016**not bold** and `not inline`\n\
4017```\n\
4018Unmatched * marker\n\
40191. numbered\n",
4020 );
4021 assert!(out.contains("not bold"));
4022 assert!(out.contains("\\`not inline\\`"));
4023 assert!(out.contains("`=\n**not bold** and \\`not inline\\`\n`=\n"));
4024 assert!(!out.contains("`!not bold`!"));
4025 assert!(out.contains("Unmatched * marker"));
4026 assert!(out.contains("1. numbered"));
4027 }
4028
4029 #[test]
4030 fn rawmu_fenced_blocks_pass_through_without_escaping() {
4031 let markdown = markdown_to_micron(
4032 "```rawmu
4033`!raw micron`!
4034>Heading
4035```
4036",
4037 );
4038
4039 assert!(markdown.contains(
4040 "`!raw micron`!
4041>Heading
4042"
4043 ));
4044 assert!(!markdown.contains(r"\`!raw micron"));
4045 assert!(!markdown.contains(
4046 "`=
4047"
4048 ));
4049 }
4050
4051 #[test]
4052 fn unknown_extension_and_fence_language_fall_back_to_plain_literals() {
4053 let blob = crate::highlight::literal_block("value `tick`\n", Some("blob.unknown"), None);
4054 assert!(!blob.contains("`FT"));
4055 assert!(blob.contains("value \\`tick\\`"));
4056
4057 let markdown = markdown_to_micron("```not-a-language\nvalue `tick`\n```\n");
4058 assert!(!markdown.contains("`FT"));
4059 assert!(markdown.contains("value \\`tick\\`"));
4060 }
4061
4062 #[test]
4063 fn binary_and_oversized_blobs_use_plain_fallback_messages() {
4064 let tmp = tempfile::tempdir().unwrap();
4065 let config = cfg(tmp.path());
4066 create_repo_bytes(
4067 config.repositories_dir.join("public/binary"),
4068 "payload.bin",
4069 b"abc\0def",
4070 );
4071 let large = vec![b'x'; 256 * 1024 + 1];
4072 create_repo_bytes(
4073 config.repositories_dir.join("public/large"),
4074 "payload.rs",
4075 &large,
4076 );
4077 let access = access(&config);
4078
4079 let binary = render_page(
4080 PATH_BLOB,
4081 &config,
4082 &access,
4083 &page_request(&[
4084 ("var_g", "public"),
4085 ("var_r", "binary"),
4086 ("var_path", "payload.bin"),
4087 ]),
4088 None,
4089 )
4090 .unwrap();
4091 assert!(binary.contains("Binary"));
4092 assert!(binary.contains("Binary file is not displayed."));
4093 assert!(!binary.contains("`FT"));
4094
4095 let oversized = render_page(
4096 PATH_BLOB,
4097 &config,
4098 &access,
4099 &page_request(&[
4100 ("var_g", "public"),
4101 ("var_r", "large"),
4102 ("var_path", "payload.rs"),
4103 ]),
4104 None,
4105 )
4106 .unwrap();
4107 assert!(oversized.contains("File is too large to display"));
4108 assert!(!oversized.contains("`FT"));
4109 }
4110
4111 #[test]
4112 fn markdown_blob_defaults_to_rendered_view_and_raw_view_escapes_source() {
4113 let tmp = tempfile::tempdir().unwrap();
4114 let config = cfg(tmp.path());
4115 create_repo(
4116 config.repositories_dir.join("public/docs"),
4117 "docs/readme.md",
4118 "# Title\n\nSee [docs](https://example.invalid), [guide](guide.md#intro) and `tick`.\n",
4119 );
4120 let access = access(&config);
4121
4122 let rendered = render_page(
4123 PATH_BLOB,
4124 &config,
4125 &access,
4126 &page_request(&[
4127 ("var_g", "public"),
4128 ("var_r", "docs"),
4129 ("var_path", "docs/readme.md"),
4130 ]),
4131 None,
4132 )
4133 .unwrap();
4134 assert!(rendered.contains("Displaying Rendered"));
4135 assert!(rendered.contains("View raw"));
4136 assert!(rendered.contains(">Title"));
4137 assert!(rendered.contains("`_`!`[docs`https://example.invalid]`!`_"));
4138 assert!(rendered.contains(
4139 "`_`!`[guide`:/page/blob.mu`g=public|r=docs|ref=HEAD|path=docs/guide.md|anchor=intro]`!`_"
4140 ));
4141 assert!(rendered.contains("`BT383838`Fdddtick`f`b"));
4142 assert!(!rendered.contains("# Title"));
4143
4144 let raw = render_page(
4145 PATH_BLOB,
4146 &config,
4147 &access,
4148 &page_request(&[
4149 ("var_g", "public"),
4150 ("var_r", "docs"),
4151 ("var_path", "docs/readme.md"),
4152 ("var_raw", "y"),
4153 ]),
4154 None,
4155 )
4156 .unwrap();
4157 assert!(raw.contains("Displaying Raw"));
4158 assert!(raw.contains("View rendered"));
4159 assert!(raw.contains("Title"));
4160 assert!(raw.contains("\\`"));
4161 assert!(raw.contains("tick"));
4162 assert!(!raw.contains("`_`!`[docs`https://example.invalid]`!`_"));
4163 }
4164
4165 #[test]
4166 fn micron_blob_defaults_to_rendered_passthrough_and_raw_view_is_literal() {
4167 let tmp = tempfile::tempdir().unwrap();
4168 let config = cfg(tmp.path());
4169 create_repo(
4170 config.repositories_dir.join("public/micron"),
4171 "README.mu",
4172 ">Micron\n\n`!Already formatted`!\n",
4173 );
4174 let access = access(&config);
4175
4176 let rendered = render_page(
4177 PATH_BLOB,
4178 &config,
4179 &access,
4180 &page_request(&[
4181 ("var_g", "public"),
4182 ("var_r", "micron"),
4183 ("var_path", "README.mu"),
4184 ]),
4185 None,
4186 )
4187 .unwrap();
4188 assert!(rendered.contains("Displaying Rendered"));
4189 assert!(rendered.contains(">Micron"));
4190 assert!(rendered.contains("`!Already formatted`!"));
4191 assert!(!rendered.contains("\\`!Already formatted\\`!"));
4192
4193 let raw = render_page(
4194 PATH_BLOB,
4195 &config,
4196 &access,
4197 &page_request(&[
4198 ("var_g", "public"),
4199 ("var_r", "micron"),
4200 ("var_path", "README.mu"),
4201 ("var_raw", "y"),
4202 ]),
4203 None,
4204 )
4205 .unwrap();
4206 assert!(raw.contains("Displaying Raw"));
4207 assert!(raw.contains("\\`!Already formatted\\`!"));
4208 }
4209
4210 #[test]
4211 fn explicit_render_parameter_renders_markdown_blob() {
4212 let tmp = tempfile::tempdir().unwrap();
4213 let config = cfg(tmp.path());
4214 create_repo(
4215 config.repositories_dir.join("public/docs"),
4216 "docs/readme.md",
4217 "# Explicit\n",
4218 );
4219 let access = access(&config);
4220
4221 let rendered = render_page(
4222 PATH_BLOB,
4223 &config,
4224 &access,
4225 &page_request(&[
4226 ("var_g", "public"),
4227 ("var_r", "docs"),
4228 ("var_path", "docs/readme.md"),
4229 ("var_raw", "y"),
4230 ("var_render", "y"),
4231 ]),
4232 None,
4233 )
4234 .unwrap();
4235 assert!(rendered.contains("Displaying Raw"));
4236 assert!(rendered.contains("#"));
4237 assert!(rendered.contains("Explicit"));
4238
4239 let rendered = render_page(
4240 PATH_BLOB,
4241 &config,
4242 &access,
4243 &page_request(&[
4244 ("var_g", "public"),
4245 ("var_r", "docs"),
4246 ("var_path", "docs/readme.md"),
4247 ("var_render", "y"),
4248 ]),
4249 None,
4250 )
4251 .unwrap();
4252 assert!(rendered.contains("Displaying Rendered"));
4253 assert!(rendered.contains(">Explicit"));
4254 assert!(!rendered.contains("# Explicit"));
4255 }
4256
4257 #[test]
4258 fn unsupported_blob_extension_ignores_render_parameter_and_uses_raw_literal() {
4259 let tmp = tempfile::tempdir().unwrap();
4260 let config = cfg(tmp.path());
4261 create_repo(
4262 config.repositories_dir.join("public/text"),
4263 "notes.txt",
4264 "# Not a heading\npath\\name\t`literal`\n",
4265 );
4266 let access = access(&config);
4267
4268 let page = render_page(
4269 PATH_BLOB,
4270 &config,
4271 &access,
4272 &page_request(&[
4273 ("var_g", "public"),
4274 ("var_r", "text"),
4275 ("var_path", "notes.txt"),
4276 ("var_render", "y"),
4277 ]),
4278 None,
4279 )
4280 .unwrap();
4281 assert!(!page.contains("Displaying Rendered"));
4282 assert!(!page.contains("View raw"));
4283 assert!(page.contains("Displaying Raw"));
4284 assert!(page.contains("Download"));
4285 assert!(page.contains("Not a heading"));
4286 assert!(page.contains("path\\\\name \\`literal\\`"));
4287 assert!(page.contains("\\`literal\\`"));
4288 }
4289
4290 #[test]
4291 fn renderable_binary_and_oversized_blobs_do_not_render_content() {
4292 let tmp = tempfile::tempdir().unwrap();
4293 let config = cfg(tmp.path());
4294 create_repo_bytes(
4295 config.repositories_dir.join("public/binary-md"),
4296 "payload.md",
4297 b"# Binary\0payload\n",
4298 );
4299 let large = vec![b'#'; 256 * 1024 + 1];
4300 create_repo_bytes(
4301 config.repositories_dir.join("public/large-md"),
4302 "payload.md",
4303 &large,
4304 );
4305 let access = access(&config);
4306
4307 let binary = render_page(
4308 PATH_BLOB,
4309 &config,
4310 &access,
4311 &page_request(&[
4312 ("var_g", "public"),
4313 ("var_r", "binary-md"),
4314 ("var_path", "payload.md"),
4315 ]),
4316 None,
4317 )
4318 .unwrap();
4319 assert!(binary.contains("Binary"));
4320 assert!(binary.contains("Binary file is not displayed."));
4321 assert!(!binary.contains(">Binary"));
4322
4323 let oversized = render_page(
4324 PATH_BLOB,
4325 &config,
4326 &access,
4327 &page_request(&[
4328 ("var_g", "public"),
4329 ("var_r", "large-md"),
4330 ("var_path", "payload.md"),
4331 ("var_render", "y"),
4332 ]),
4333 None,
4334 )
4335 .unwrap();
4336 assert!(oversized.contains("File is too large to display"));
4337 assert!(!oversized.contains("Displaying Rendered\n\n>"));
4338 }
4339
4340 #[test]
4341 fn code_blocks_render_plain_literal_blocks() {
4342 let blob = crate::highlight::literal_block("pub fn main() {}\n", Some("main.rs"), None);
4343 assert!(!blob.contains("`FT"));
4344 assert!(blob.contains("pub fn main() {}"));
4345
4346 let markdown = markdown_to_micron("```rust\npub fn main() {}\n```\n");
4347 assert!(!markdown.contains("`FT"));
4348 assert!(markdown.contains("pub fn main() {}"));
4349 }
4350
4351 #[test]
4352 fn repo_page_renders_markdown_readme_and_passes_micron_readme_through() {
4353 let tmp = tempfile::tempdir().unwrap();
4354 let config = cfg(tmp.path());
4355 create_repo(
4356 config.repositories_dir.join("public/markdown"),
4357 "README.md",
4358 "# Markdown\n\nSee [docs](https://example.invalid) and [local](docs/intro.md).\n",
4359 );
4360 create_repo(
4361 config.repositories_dir.join("public/micron"),
4362 "README.mu",
4363 ">Micron\n\n`!Already formatted`!\n",
4364 );
4365 create_repo(
4366 config.repositories_dir.join("public/microntight"),
4367 "README.mu",
4368 ">Micron without trailing newline",
4369 );
4370 create_repo(
4371 config.repositories_dir.join("public/micronblank"),
4372 "README.mu",
4373 ">Micron with trailing blanks\n\n\n",
4374 );
4375 let access = access(&config);
4376
4377 let markdown = render_page(
4378 PATH_REPO,
4379 &config,
4380 &access,
4381 &page_request(&[("var_g", "public"), ("var_r", "markdown")]),
4382 None,
4383 )
4384 .unwrap();
4385 assert!(markdown.contains(">Markdown"));
4386 assert!(markdown.contains("`_`!`[docs`https://example.invalid]`!`_"));
4387 assert!(markdown.contains(
4388 "`_`!`[local`:/page/blob.mu`g=public|r=markdown|ref=HEAD|path=docs/intro.md]`!`_"
4389 ));
4390
4391 let micron = render_page(
4392 PATH_REPO,
4393 &config,
4394 &access,
4395 &page_request(&[("var_g", "public"), ("var_r", "micron")]),
4396 None,
4397 )
4398 .unwrap();
4399 assert!(micron.contains(">Micron"));
4400 assert!(micron.contains("`!Already formatted`!"));
4401
4402 let micron = render_page(
4403 PATH_REPO,
4404 &config,
4405 &access,
4406 &page_request(&[("var_g", "public"), ("var_r", "microntight")]),
4407 None,
4408 )
4409 .unwrap();
4410 assert!(micron.contains(">Micron without trailing newline\n"));
4411 assert!(!micron.contains(">Micron without trailing newline<"));
4412
4413 let micron = render_page(
4414 PATH_REPO,
4415 &config,
4416 &access,
4417 &page_request(&[("var_g", "public"), ("var_r", "micronblank")]),
4418 None,
4419 )
4420 .unwrap();
4421 assert!(micron.contains(">Micron with trailing blanks\n"));
4422 assert!(!micron.contains(">Micron with trailing blanks\n\n\n"));
4423 }
4424
4425 #[test]
4426 fn work_pages_render_documents_comments_and_repo_links() {
4427 let tmp = tempfile::tempdir().unwrap();
4428 let config = cfg(tmp.path());
4429 let repo_path = create_repo(
4430 config.repositories_dir.join("public/worked"),
4431 "README.md",
4432 "# Worked\n",
4433 );
4434 let work_path = crate::work::work_sidecar_path(&repo_path);
4435 let created = crate::work::create_document(
4436 &work_path,
4437 crate::work::WorkInput {
4438 title: "Active task".into(),
4439 content: "# Active Body\n".into(),
4440 format: "markdown".into(),
4441 signature: None,
4442 identity: None,
4443 author: [0x11; 16],
4444 },
4445 )
4446 .unwrap();
4447 crate::work::add_comment(
4448 &work_path,
4449 crate::work::WorkScope::Active,
4450 created.id,
4451 crate::work::WorkCommentInput {
4452 content: "Progress update".into(),
4453 format: "markdown".into(),
4454 signature: None,
4455 author: [0x22; 16],
4456 },
4457 )
4458 .unwrap();
4459 let completed = crate::work::create_document(
4460 &work_path,
4461 crate::work::WorkInput {
4462 title: "Completed task".into(),
4463 content: "Done".into(),
4464 format: "micron".into(),
4465 signature: None,
4466 identity: None,
4467 author: [0x11; 16],
4468 },
4469 )
4470 .unwrap();
4471 crate::work::complete_document(&work_path, completed.id, &[0x11; 16]).unwrap();
4472 let access = access(&config);
4473
4474 let repo = render_page(
4475 PATH_REPO,
4476 &config,
4477 &access,
4478 &page_request(&[("var_g", "public"), ("var_r", "worked")]),
4479 None,
4480 )
4481 .unwrap();
4482 assert!(repo.contains("Work (2)"));
4483 assert!(repo.contains(PATH_WORK));
4484
4485 let active = render_page(
4486 PATH_WORK,
4487 &config,
4488 &access,
4489 &page_request(&[("var_g", "public"), ("var_r", "worked")]),
4490 None,
4491 )
4492 .unwrap();
4493 assert!(active.contains(">Active Work Documents (1)"));
4494 assert!(active.contains("#1 Active task"));
4495 assert!(active.contains("1 updates"));
4496 assert!(!active.contains("Completed task"));
4497
4498 let completed_page = render_page(
4499 PATH_WORK,
4500 &config,
4501 &access,
4502 &page_request(&[
4503 ("var_g", "public"),
4504 ("var_r", "worked"),
4505 ("var_scope", "completed"),
4506 ]),
4507 None,
4508 )
4509 .unwrap();
4510 assert!(completed_page.contains(">Completed Work Documents (1)"));
4511 assert!(completed_page.contains("Completed task"));
4512
4513 let doc = render_page(
4514 PATH_WORK_DOC,
4515 &config,
4516 &access,
4517 &page_request(&[("var_g", "public"), ("var_r", "worked"), ("var_id", "1")]),
4518 None,
4519 )
4520 .unwrap();
4521 assert!(doc.contains(">Active task"));
4522 assert!(doc.contains(">Active Body"));
4523 assert!(doc.contains(">Updates (1)"));
4524 assert!(doc.contains("Progress update"));
4525 assert!(doc.contains("`[Download`:/file/workdoc`g=public|r=worked|scope=active|id=1]"));
4526
4527 let completed_doc = render_page(
4528 PATH_WORK_DOC,
4529 &config,
4530 &access,
4531 &page_request(&[
4532 ("var_g", "public"),
4533 ("var_r", "worked"),
4534 ("var_id", &completed.id.to_string()),
4535 ]),
4536 None,
4537 )
4538 .unwrap();
4539 assert!(completed_doc.contains(">Completed task"));
4540 assert!(completed_doc.contains("Status: completed"));
4541 assert!(completed_doc.contains("`[work`:/page/work.mu`g=public|r=worked|scope=completed]"));
4542 assert!(completed_doc.contains(&format!(
4543 "`[Download`:/file/workdoc`g=public|r=worked|scope=completed|id={}]",
4544 completed.id
4545 )));
4546
4547 let active_download = download_work_document(
4548 &config,
4549 &access,
4550 &page_request(&[
4551 ("var_g", "public"),
4552 ("var_r", "worked"),
4553 ("var_scope", "active"),
4554 ("var_id", "1"),
4555 ]),
4556 None,
4557 )
4558 .unwrap();
4559 assert_resource_response(active_download, b"# Active Body");
4560
4561 let completed_download = download_work_document(
4562 &config,
4563 &access,
4564 &page_request(&[
4565 ("var_g", "public"),
4566 ("var_r", "worked"),
4567 ("var_id", &completed.id.to_string()),
4568 ]),
4569 None,
4570 )
4571 .unwrap();
4572 assert_resource_response(completed_download, b"Done");
4573 }
4574
4575 #[test]
4576 fn work_pages_respect_read_access() {
4577 let tmp = tempfile::tempdir().unwrap();
4578 let mut config = cfg(tmp.path());
4579 create_repo(
4580 config.repositories_dir.join("public/private-work"),
4581 "README.md",
4582 "# Private\n",
4583 );
4584 config.allow_read = vec!["none".into()];
4585 let access = access(&config);
4586
4587 assert!(render_page(
4588 PATH_WORK,
4589 &config,
4590 &access,
4591 &page_request(&[("var_g", "public"), ("var_r", "private-work")]),
4592 None,
4593 )
4594 .is_err());
4595 }
4596
4597 fn cfg(root: &std::path::Path) -> ServerConfig {
4598 ServerConfig {
4599 dir: root.to_path_buf(),
4600 reticulum_dir: None,
4601 repositories_dir: root.join("repositories"),
4602 identity_path: root.join("repositories_identity"),
4603 client_identity_path: root.join("client_identity"),
4604 node_name: "Test Git Node".into(),
4605 announce_interval_secs: 300,
4606 serve_nomadnet: true,
4607 templates_dir: root.join("templates"),
4608 unicode_icons: false,
4609 record_stats: false,
4610 stats_ignore_identities: Vec::new(),
4611 stats_push_ignore_identities: Vec::new(),
4612 blocked_identities: Vec::new(),
4613 identity_aliases: std::collections::BTreeMap::new(),
4614 allow_read: vec!["all".into()],
4615 allow_write: vec!["none".into()],
4616 allow_create: vec!["none".into()],
4617 allow_stats: vec!["none".into()],
4618 allow_release: vec!["none".into()],
4619 allow_interact: vec!["none".into()],
4620 allow_propose: vec!["none".into()],
4621 allow_admin: vec!["none".into()],
4622 log_level: logging::DEFAULT_LOG_LEVEL,
4623 }
4624 }
4625
4626 fn access(config: &ServerConfig) -> Access {
4627 Access::new(
4628 &config.allow_read,
4629 &config.allow_write,
4630 &config.allow_create,
4631 &config.allow_stats,
4632 &config.allow_release,
4633 &config.allow_interact,
4634 &config.allow_admin,
4635 config.repositories_dir.clone(),
4636 )
4637 .unwrap()
4638 }
4639
4640 fn page_request(fields: &[(&str, &str)]) -> Vec<u8> {
4641 msgpack::pack(&Value::Map(
4642 fields
4643 .iter()
4644 .map(|(k, v)| (Value::Str((*k).into()), Value::Str((*v).into())))
4645 .collect(),
4646 ))
4647 }
4648
4649 fn assert_resource_response(response: RequestResponse, expected: &[u8]) {
4650 match response {
4651 RequestResponse::Resource { data, metadata, .. } => {
4652 assert_eq!(data, expected);
4653 assert_eq!(
4654 metadata.as_deref(),
4655 Some(&protocol::metadata_status(protocol::RES_OK)[..])
4656 );
4657 }
4658 RequestResponse::Bytes(bytes) => panic!("expected resource response, got {bytes:?}"),
4659 }
4660 }
4661
4662 fn create_repo(path: std::path::PathBuf, file: &str, content: &str) -> std::path::PathBuf {
4663 create_repo_bytes(path, file, content.as_bytes())
4664 }
4665
4666 fn create_repo_bytes(
4667 path: std::path::PathBuf,
4668 file: &str,
4669 content: &[u8],
4670 ) -> std::path::PathBuf {
4671 let work = path.with_extension("work");
4672 fs::create_dir_all(&work).unwrap();
4673 run_git(Command::new("git").arg("init").arg(&work));
4674 let file_path = work.join(file);
4675 fs::create_dir_all(file_path.parent().unwrap()).unwrap();
4676 fs::write(&file_path, content).unwrap();
4677 run_git(Command::new("git").arg("-C").arg(&work).arg("add").arg("."));
4678 run_git(
4679 Command::new("git")
4680 .arg("-C")
4681 .arg(&work)
4682 .arg("-c")
4683 .arg("user.name=RNS Page Test")
4684 .arg("-c")
4685 .arg("user.email=rns-page-test@example.invalid")
4686 .arg("commit")
4687 .arg("-m")
4688 .arg("initial"),
4689 );
4690 run_git(
4691 Command::new("git")
4692 .arg("-C")
4693 .arg(&work)
4694 .args(["branch", "-M", "main"]),
4695 );
4696 run_git(
4697 Command::new("git")
4698 .arg("clone")
4699 .arg("--bare")
4700 .arg(&work)
4701 .arg(&path),
4702 );
4703 path
4704 }
4705
4706 fn run_git(cmd: &mut Command) -> String {
4707 let output = cmd.output().unwrap();
4708 assert!(
4709 output.status.success(),
4710 "git command failed: {}",
4711 String::from_utf8_lossy(&output.stderr)
4712 );
4713 String::from_utf8_lossy(&output.stdout).into_owned()
4714 }
4715}