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};
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";
37
38const GIT_COMMAND_TIMEOUT: Duration = Duration::from_secs(8);
39
40const PAGE_PATHS: &[&str] = &[
41 PATH_INDEX,
42 PATH_GROUP,
43 PATH_REPO,
44 PATH_TREE,
45 PATH_BLOB,
46 PATH_COMMITS,
47 PATH_COMMIT,
48 PATH_REFS,
49 PATH_STATS,
50 PATH_RELEASES,
51 PATH_RELEASE,
52 PATH_WORK,
53 PATH_WORK_DOC,
54];
55
56pub fn destination_for_identity(identity: &Identity) -> Destination {
57 Destination::single_in(APP_NAME, &[ASPECT_NODE], IdentityHash(*identity.hash()))
58}
59
60pub fn register_nomadnet_destination(
61 node: &RnsNode,
62 config: &ServerConfig,
63 identity: &Identity,
64) -> Result<Destination> {
65 let destination = destination_for_identity(identity);
66 let public_key = identity
67 .get_public_key()
68 .ok_or_else(|| Error::msg("repository identity has no public key"))?;
69 let private_key = identity
70 .get_private_key()
71 .ok_or_else(|| Error::msg("repository identity has no private key"))?;
72 let sig_prv: [u8; 32] = private_key[32..64].try_into().unwrap();
73 let sig_pub: [u8; 32] = public_key[32..64].try_into().unwrap();
74
75 node.register_link_destination(
76 destination.hash.0,
77 sig_prv,
78 sig_pub,
79 ResourceStrategy::AcceptAll as u8,
80 )
81 .map_err(|_| Error::msg("failed to register Nomad Network page destination"))?;
82
83 let access = Access::new(
84 &config.allow_read,
85 &config.allow_write,
86 &config.allow_create,
87 &config.allow_stats,
88 &config.allow_release,
89 &config.allow_interact,
90 &config.allow_admin,
91 config.repositories_dir.clone(),
92 )?;
93 register_page_handlers(node, config.clone(), access)?;
94
95 Ok(destination)
96}
97
98fn register_page_handlers(node: &RnsNode, config: ServerConfig, access: Access) -> Result<()> {
99 for path in PAGE_PATHS {
100 let handler_path = *path;
101 let handler_config = config.clone();
102 let handler_access = access.clone();
103 node.register_request_handler(handler_path, None, move |_link, path, data, remote| {
104 let remote_hash = remote.map(|(hash, _)| hash);
105 Some(
106 match render_page(path, &handler_config, &handler_access, data, remote_hash) {
107 Ok(page) => page.into_bytes(),
108 Err(err) => error_page(&handler_config.node_name, &err.to_string()),
109 },
110 )
111 })
112 .map_err(|_| Error::msg(format!("failed to register page handler {handler_path}")))?;
113 }
114 let download_config = config.clone();
115 let download_access = access.clone();
116 node.register_request_handler_response(
117 PATH_DOWNLOAD,
118 None,
119 move |_link, _path, data, remote| {
120 let remote_hash = remote.map(|(hash, _)| hash);
121 Some(
122 download_file(&download_config, &download_access, data, remote_hash)
123 .unwrap_or_else(|err| RequestResponse::Bytes(error_response_bytes(&err))),
124 )
125 },
126 )
127 .map_err(|_| Error::msg("failed to register file download handler"))?;
128 Ok(())
129}
130
131pub fn render_page(
132 path: &str,
133 config: &ServerConfig,
134 access: &Access,
135 data: &[u8],
136 remote: Option<&[u8; 16]>,
137) -> Result<String> {
138 let vars = parse_page_vars(data)?;
139 let (template, content) = match path {
140 PATH_INDEX => ("front", render_front_page(config, access, remote)?),
141 PATH_GROUP => ("group", render_group_page(config, access, remote, &vars)?),
142 PATH_REPO => ("repo", render_repo_page(config, access, remote, &vars)?),
143 PATH_TREE => ("tree", render_tree_page(config, access, remote, &vars)?),
144 PATH_BLOB => ("blob", render_blob_page(config, access, remote, &vars)?),
145 PATH_COMMITS => (
146 "commits",
147 render_commits_page(config, access, remote, &vars)?,
148 ),
149 PATH_COMMIT => ("commit", render_commit_page(config, access, remote, &vars)?),
150 PATH_REFS => ("refs", render_refs_page(config, access, remote, &vars)?),
151 PATH_STATS => ("stats", render_stats_page(config, access, remote, &vars)?),
152 PATH_RELEASES => (
153 "releases",
154 render_releases_page(config, access, remote, &vars)?,
155 ),
156 PATH_RELEASE => (
157 "release",
158 render_release_page(config, access, remote, &vars)?,
159 ),
160 PATH_WORK => ("work", render_work_page(config, access, remote, &vars)?),
161 PATH_WORK_DOC => (
162 "work_doc",
163 render_work_doc_page(config, access, remote, &vars)?,
164 ),
165 _ => return Err(Error::msg("unknown page path")),
166 };
167 record_page_view(path, config, &vars, remote);
168 Ok(render_template(config, template, &content))
169}
170
171fn record_page_view(
172 path: &str,
173 config: &ServerConfig,
174 vars: &BTreeMap<String, String>,
175 remote: Option<&[u8; 16]>,
176) {
177 match path {
178 PATH_INDEX => crate::stats::record_front_view(config, remote),
179 PATH_GROUP => {
180 if let Some(group) = var(vars, "g") {
181 crate::stats::record_group_view(config, group, remote);
182 }
183 }
184 PATH_REPO | PATH_TREE | PATH_BLOB | PATH_COMMITS | PATH_COMMIT | PATH_REFS
185 | PATH_RELEASES | PATH_RELEASE | PATH_WORK | PATH_WORK_DOC => {
186 if let (Some(group), Some(repo)) = (var(vars, "g"), var(vars, "r")) {
187 crate::stats::record_repository_view(config, group, repo, remote);
188 }
189 }
190 _ => {}
191 }
192}
193
194fn parse_page_vars(data: &[u8]) -> Result<BTreeMap<String, String>> {
195 if data.is_empty() {
196 return Ok(BTreeMap::new());
197 }
198 let value =
199 msgpack::unpack_exact(data).map_err(|e| Error::msg(format!("invalid msgpack: {e}")))?;
200 let map = value
201 .as_map()
202 .ok_or_else(|| Error::msg("page request must be a msgpack map"))?;
203 let mut out = BTreeMap::new();
204 for (key, value) in map {
205 let Some(key) = key.as_str() else {
206 continue;
207 };
208 let value = match value {
209 Value::Str(value) => value.clone(),
210 Value::UInt(value) => value.to_string(),
211 Value::Int(value) => value.to_string(),
212 Value::Bool(value) => {
213 if *value {
214 "true".to_string()
215 } else {
216 "false".to_string()
217 }
218 }
219 _ => continue,
220 };
221 out.insert(key.to_string(), value);
222 }
223 Ok(out)
224}
225
226fn render_template(config: &ServerConfig, template: &str, content: &str) -> String {
227 let page = apply_template(&page_template(config, template), content, config);
228 apply_template(&base_template(config), &page, config)
229}
230
231fn error_page(node_name: &str, message: &str) -> Vec<u8> {
232 default_base_template()
233 .replace("{PAGE_CONTENT}", &format!(">Error\n\n{message}\n"))
234 .replace("{NODE_NAME}", &m_escape(node_name))
235 .replace("{VERSION}", env!("CARGO_PKG_VERSION"))
236 .replace("{NAVIGATION}", "")
237 .replace("{GEN_TIME}", "0ms")
238 .into_bytes()
239}
240
241fn error_response_bytes(err: &Error) -> Vec<u8> {
242 protocol::status_bytes(protocol::RES_INVALID_REQ, err.to_string())
243}
244
245fn base_template(config: &ServerConfig) -> String {
246 load_template(config, "base").unwrap_or_else(|| default_base_template().to_string())
247}
248
249fn page_template(config: &ServerConfig, template: &str) -> String {
250 load_template(config, template).unwrap_or_else(|| "{PAGE_CONTENT}".to_string())
251}
252
253fn load_template(config: &ServerConfig, template: &str) -> Option<String> {
254 if !template
255 .chars()
256 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
257 {
258 return None;
259 }
260 let path = config.templates_dir.join(format!("{template}.mu"));
261 fs::read_to_string(path)
262 .ok()
263 .map(|template| template.trim_end().to_string())
264}
265
266fn apply_template(template: &str, content: &str, config: &ServerConfig) -> String {
267 template
268 .replace("{NODE_NAME}", &m_escape(&config.node_name))
269 .replace("{VERSION}", env!("CARGO_PKG_VERSION"))
270 .replace("{NAVIGATION}", "")
271 .replace("{GEN_TIME}", "0ms")
272 .replace("{PAGE_CONTENT}", content)
273}
274
275fn default_base_template() -> &'static str {
276 "#!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"
277}
278
279fn icon(config: &ServerConfig, name: &str) -> &'static str {
280 if !config.unicode_icons {
281 return "";
282 }
283 match name {
284 "sep" => "•",
285 "folder" => "🗀",
286 "file" => "🗎",
287 "branch" => "⑃",
288 "tag" => "⌆",
289 "commits" => "🖹",
290 "package" => "▣",
291 "stats" => "🗠",
292 "heart" => "♥",
293 _ => "",
294 }
295}
296
297fn icon_label(config: &ServerConfig, name: &str, label: &str) -> String {
298 let icon = icon(config, name);
299 if icon.is_empty() {
300 label.to_string()
301 } else {
302 format!("{icon} {label}")
303 }
304}
305
306fn icon_sep(config: &ServerConfig) -> &'static str {
307 let sep = icon(config, "sep");
308 if sep.is_empty() {
309 "•"
310 } else {
311 sep
312 }
313}
314
315fn m_link(label: &str, path: &str, fields: &[(&str, &str)]) -> String {
316 format!("`!{}`!", m_link_raw(label, path, fields))
317}
318
319fn m_link_raw(label: &str, path: &str, fields: &[(&str, &str)]) -> String {
320 let mut out = String::from("`[");
321 out.push_str(&sanitize_label(label));
322 out.push_str("`:");
323 out.push_str(path);
324 if !fields.is_empty() {
325 out.push('`');
326 for (i, (key, value)) in fields.iter().enumerate() {
327 if i > 0 {
328 out.push('|');
329 }
330 out.push_str(key);
331 out.push('=');
332 out.push_str(&sanitize_field(value));
333 }
334 }
335 out.push(']');
336 out
337}
338
339fn markdown_blob_url_scope(group: &str, repo: &str, reference: &str, current_path: &str) -> String {
340 let directory = current_path
341 .rsplit_once('/')
342 .map(|(directory, _)| format!("{directory}/"))
343 .unwrap_or_default();
344 let mut out = format!(":{PATH_BLOB}`");
345 for (index, (key, value)) in [
346 ("g", group),
347 ("r", repo),
348 ("ref", reference),
349 ("path", directory.as_str()),
350 ]
351 .iter()
352 .enumerate()
353 {
354 if index > 0 {
355 out.push('|');
356 }
357 out.push_str(key);
358 out.push('=');
359 out.push_str(&sanitize_field(value));
360 }
361 out
362}
363
364fn sanitize_label(value: &str) -> String {
365 value
366 .chars()
367 .filter(|ch| *ch != '[' && *ch != ']' && *ch != '`')
368 .collect()
369}
370
371fn sanitize_field(value: &str) -> String {
372 let mut out = String::new();
373 for byte in value.bytes() {
374 match byte {
375 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'/' => {
376 out.push(byte as char)
377 }
378 b' ' => out.push('+'),
379 _ => out.push_str(&format!("%{byte:02X}")),
380 }
381 }
382 out
383}
384
385fn m_escape(value: &str) -> String {
386 value
387 .replace('\\', "\\\\")
388 .replace('`', "\\`")
389 .replace('\t', " ")
390}
391
392fn m_escape_line_start_controls(value: &str) -> String {
393 let escaped = m_escape(value);
394 if value.starts_with('-') || value.starts_with('>') || value.starts_with('<') {
395 format!("\\{escaped}")
396 } else {
397 escaped
398 }
399}
400
401fn join_git_path(parent: &str, child: &str) -> String {
402 if parent.is_empty() {
403 child.to_string()
404 } else {
405 format!("{parent}/{child}")
406 }
407}
408
409fn format_size(size: u64) -> String {
410 const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB"];
411 let mut value = size as f64;
412 let mut unit = 0;
413 while value >= 1024.0 && unit + 1 < UNITS.len() {
414 value /= 1024.0;
415 unit += 1;
416 }
417 if unit == 0 {
418 format!("{size} B")
419 } else {
420 format!("{value:.1} {}", UNITS[unit])
421 }
422}
423
424fn format_unix_time(timestamp: i64) -> String {
425 timestamp.to_string()
426}
427
428fn render_front_page(
429 config: &ServerConfig,
430 access: &Access,
431 remote: Option<&[u8; 16]>,
432) -> Result<String> {
433 let groups = accessible_groups(config, access, remote)?;
434 let mut out = format!(">>\n{} /\n\n>Groups\n\n", m_link("Node", PATH_INDEX, &[]));
435 if groups.is_empty() {
436 out.push_str("No repository groups available.\n");
437 return Ok(out);
438 }
439 for (group, repos) in groups {
440 out.push_str(&format!(
441 "{} ({} repositories)\n",
442 m_link(&group, PATH_GROUP, &[("g", &group)]),
443 repos.len()
444 ));
445 for repo in repos {
446 out.push_str(&format!(
447 " - {}\n",
448 m_link_raw(&repo, PATH_REPO, &[("g", &group), ("r", &repo)])
449 ));
450 }
451 }
452 Ok(out)
453}
454
455fn render_group_page(
456 config: &ServerConfig,
457 access: &Access,
458 remote: Option<&[u8; 16]>,
459 vars: &BTreeMap<String, String>,
460) -> Result<String> {
461 let group = required_var(vars, "g")?;
462 validate_group_name(group)?;
463 let repos = accessible_repositories(config, access, remote, group)?;
464 if repos.is_empty() {
465 return Ok(">Group Not Found\n\nThe requested group was not found.\n".to_string());
466 }
467
468 let mut out = format!(
469 ">>\n{} / {}\n\n>Repositories\n\n",
470 m_link("Node", PATH_INDEX, &[]),
471 m_escape(group)
472 );
473 for repo in repos {
474 let repo_path = config.repositories_dir.join(group).join(&repo);
475 let description = repository_description(&repo_path)?;
476 out.push_str(&format!(
477 "- {}",
478 m_link(&repo, PATH_REPO, &[("g", group), ("r", &repo)])
479 ));
480 if !description.is_empty() {
481 out.push_str(&format!(" - {}", m_escape(&description)));
482 }
483 out.push('\n');
484 }
485 Ok(out)
486}
487
488fn render_repo_page(
489 config: &ServerConfig,
490 access: &Access,
491 remote: Option<&[u8; 16]>,
492 vars: &BTreeMap<String, String>,
493) -> Result<String> {
494 let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
495 let description = repository_description(&repository)?;
496 let refs = git_refs(&repository)?;
497 let readme = readme_content(&repository)?;
498 let repo_url = format!("rns://<repository-destination>/{group}/{repo}");
499 let thanks = repository_thanks(&repository, var(vars, "thanks").is_some())?;
500 let releases_path = crate::release::release_sidecar_path(&repository);
501 let release_count = crate::release::list_releases(&releases_path)?
502 .into_iter()
503 .filter(|release| release.status == "published")
504 .count();
505 let work_path = crate::work::work_sidecar_path(&repository);
506 let work_lists = crate::work::list_documents(&work_path, crate::work::WorkListScope::All)?;
507 let work_count = work_lists.active.len() + work_lists.completed.len();
508
509 let branch_count = refs.heads.len().to_string();
510 let tag_count = refs.tags.len().to_string();
511 let mut out = format!(
512 ">>\n{} / {} / {} `F666{}`f\n\n",
513 m_link("Node", PATH_INDEX, &[]),
514 m_link(&group, PATH_GROUP, &[("g", &group)]),
515 m_escape(&repo),
516 m_escape(&repo_url)
517 );
518 if !description.is_empty() {
519 out.push_str(&format!("{}\n\n", m_escape(&description)));
520 }
521 let mut nav_links = vec![
522 m_link_raw(
523 &icon_label(config, "folder", "Files"),
524 PATH_TREE,
525 &[("g", &group), ("r", &repo), ("ref", "HEAD")],
526 ),
527 m_link_raw(
528 &icon_label(config, "commits", "Commits"),
529 PATH_COMMITS,
530 &[("g", &group), ("r", &repo), ("ref", "HEAD")],
531 ),
532 m_link_raw(
533 &icon_label(config, "branch", &format!("Branches ({branch_count})")),
534 PATH_REFS,
535 &[("g", &group), ("r", &repo), ("type", "heads")],
536 ),
537 m_link_raw(
538 &icon_label(config, "tag", &format!("Tags ({tag_count})")),
539 PATH_REFS,
540 &[("g", &group), ("r", &repo), ("type", "tags")],
541 ),
542 m_link_raw(
543 &icon_label(config, "heart", &format!("Thanks ({thanks})")),
544 PATH_REPO,
545 &[("g", &group), ("r", &repo), ("thanks", "y")],
546 ),
547 ];
548 if release_count > 0 {
549 nav_links.push(m_link_raw(
550 &icon_label(config, "package", &format!("Releases ({release_count})")),
551 PATH_RELEASES,
552 &[("g", &group), ("r", &repo)],
553 ));
554 }
555 if work_count > 0 {
556 nav_links.push(m_link_raw(
557 &icon_label(config, "work", &format!("Work ({work_count})")),
558 PATH_WORK,
559 &[("g", &group), ("r", &repo)],
560 ));
561 }
562 if access.allows(Operation::Stats, &format!("{group}/{repo}"), remote)? {
563 nav_links.push(m_link_raw(
564 &icon_label(config, "stats", "Stats"),
565 PATH_STATS,
566 &[("g", &group), ("r", &repo)],
567 ));
568 }
569 let sep = format!(" {} ", icon_sep(config));
570 out.push_str(&format!("{}\n\n", nav_links.join(&sep)));
571 if let Some(readme) = readme {
572 if !readme.content.trim_start().starts_with('>') {
573 out.push_str("-\n");
574 }
575 if readme.markdown {
576 out.push_str(&markdown_to_micron_scoped(
577 &readme.content,
578 Some(&markdown_blob_url_scope(&group, &repo, "HEAD", "README.md")),
579 ));
580 } else {
581 out.push_str(&readme.content);
582 }
583 if !out.ends_with('\n') {
584 out.push('\n');
585 }
586 } else {
587 out.push_str("No README file found in this repository.\n");
588 }
589 Ok(out)
590}
591
592fn render_tree_page(
593 config: &ServerConfig,
594 access: &Access,
595 remote: Option<&[u8; 16]>,
596 vars: &BTreeMap<String, String>,
597) -> Result<String> {
598 let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
599 let reference = var(vars, "ref").unwrap_or("HEAD");
600 let path = var(vars, "path").unwrap_or("");
601 validate_git_path(path)?;
602 let resolved = resolve_ref(&repository, reference)?;
603 let entries = tree_entries(&repository, &resolved, path)?;
604 let title_path = if path.is_empty() { "/" } else { path };
605
606 let mut out = format!(
607 ">>\n{} / {} / {} / files / {}\n\n>Contents: {} ({})\n\n",
608 m_link("Node", PATH_INDEX, &[]),
609 m_link(&group, PATH_GROUP, &[("g", &group)]),
610 m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
611 m_escape(title_path),
612 m_escape(reference),
613 &resolved[..8]
614 );
615 if entries.is_empty() {
616 out.push_str("Empty directory.\n");
617 } else {
618 for entry in entries {
619 let entry_path = join_git_path(path, &entry.name);
620 if entry.kind == "tree" {
621 let label = icon_label(config, "folder", &format!("{}/", entry.name));
622 out.push_str(&format!(
623 "{}\n",
624 m_link_raw(
625 &label,
626 PATH_TREE,
627 &[
628 ("g", &group),
629 ("r", &repo),
630 ("ref", reference),
631 ("path", &entry_path)
632 ]
633 )
634 ));
635 } else {
636 let label = icon_label(config, "file", &entry.name);
637 out.push_str(&format!(
638 "{} {}\n",
639 m_link_raw(
640 &label,
641 PATH_BLOB,
642 &[
643 ("g", &group),
644 ("r", &repo),
645 ("ref", reference),
646 ("path", &entry_path)
647 ]
648 ),
649 entry.size_label()
650 ));
651 }
652 }
653 }
654 Ok(out)
655}
656
657fn render_blob_page(
658 config: &ServerConfig,
659 access: &Access,
660 remote: Option<&[u8; 16]>,
661 vars: &BTreeMap<String, String>,
662) -> Result<String> {
663 let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
664 let reference = var(vars, "ref").unwrap_or("HEAD");
665 let path = required_var(vars, "path")?;
666 validate_git_path(path)?;
667 let resolved = resolve_ref(&repository, reference)?;
668 let blob = blob_info(&repository, &resolved, path)?;
669 let renderable = renderable_blob(path);
670 let raw_requested = var(vars, "raw").is_some();
671 let render_requested = var(vars, "render").is_some();
672 let render =
673 renderable.is_some() && !raw_requested && (render_requested || render_by_default(path));
674 let download_link = m_link(
675 "Download",
676 PATH_DOWNLOAD,
677 &[
678 ("g", &group),
679 ("r", &repo),
680 ("ref", reference),
681 ("path", path),
682 ],
683 );
684 let controls = if renderable.is_some() {
685 let render_link = m_link(
686 "View rendered",
687 PATH_BLOB,
688 &[
689 ("g", &group),
690 ("r", &repo),
691 ("ref", reference),
692 ("path", path),
693 ("render", "y"),
694 ],
695 );
696 let raw_link = m_link(
697 "View raw",
698 PATH_BLOB,
699 &[
700 ("g", &group),
701 ("r", &repo),
702 ("ref", reference),
703 ("path", path),
704 ("raw", "y"),
705 ],
706 );
707 let sep = icon_sep(config);
708 Some(if render {
709 format!("Displaying Rendered {sep} {raw_link}")
710 } else {
711 format!("Displaying Raw {sep} {render_link} {sep} {download_link}")
712 })
713 } else {
714 Some(format!(
715 "Displaying Raw {} {download_link}",
716 icon_sep(config)
717 ))
718 };
719 let content = if !blob.displayable {
720 crate::highlight::plain_literal_block(&blob.content)
721 } else if render {
722 match renderable {
723 Some(RenderableBlob::Markdown) => markdown_to_micron_scoped(
724 &blob.content,
725 Some(&markdown_blob_url_scope(&group, &repo, reference, path)),
726 ),
727 Some(RenderableBlob::Micron) => {
728 let mut out = blob.content.clone();
729 if !out.ends_with('\n') {
730 out.push('\n');
731 }
732 out
733 }
734 None => crate::highlight::literal_block(&blob.content, Some(path), None),
735 }
736 } else {
737 crate::highlight::literal_block(&blob.content, Some(path), None)
738 };
739 let controls = controls
740 .map(|controls| format!("{controls}\n\n"))
741 .unwrap_or_default();
742 Ok(format!(
743 ">>\n{} / {} / {} / {}\n\n>{} `F666{} ({}, {})`f\n\n{}{}",
744 m_link("Node", PATH_INDEX, &[]),
745 m_link(&group, PATH_GROUP, &[("g", &group)]),
746 m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
747 m_escape(path),
748 m_escape(path),
749 &resolved[..8],
750 if blob.binary { "Binary" } else { "Text" },
751 format_size(blob.size),
752 controls,
753 content
754 ))
755}
756
757#[derive(Debug, Clone, Copy)]
758enum RenderableBlob {
759 Markdown,
760 Micron,
761}
762
763fn renderable_blob(path: &str) -> Option<RenderableBlob> {
764 match Path::new(path)
765 .extension()
766 .and_then(|extension| extension.to_str())
767 .map(str::to_ascii_lowercase)
768 .as_deref()
769 {
770 Some("md") => Some(RenderableBlob::Markdown),
771 Some("mu") => Some(RenderableBlob::Micron),
772 _ => None,
773 }
774}
775
776fn render_by_default(path: &str) -> bool {
777 renderable_blob(path).is_some()
778}
779
780fn render_commits_page(
781 config: &ServerConfig,
782 access: &Access,
783 remote: Option<&[u8; 16]>,
784 vars: &BTreeMap<String, String>,
785) -> Result<String> {
786 let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
787 let reference = var(vars, "ref").unwrap_or("HEAD");
788 let resolved = resolve_ref(&repository, reference)?;
789 let commits = commits(&repository, &resolved, 100)?;
790
791 let mut out = format!(
792 ">>\n{} / {} / {} / commits\n\n>Commits `F666{} ({})`f\n\n",
793 m_link("Node", PATH_INDEX, &[]),
794 m_link(&group, PATH_GROUP, &[("g", &group)]),
795 m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
796 m_escape(reference),
797 &resolved[..8]
798 );
799 if commits.is_empty() {
800 out.push_str("No commits found.\n");
801 } else {
802 for commit in commits {
803 out.push_str(&format!(
804 "{} {} `F666{}`f\n{}\n\n",
805 m_link_raw(
806 &commit.hash[..7],
807 PATH_COMMIT,
808 &[("g", &group), ("r", &repo), ("h", &commit.hash)]
809 ),
810 m_escape(&commit.author),
811 format_unix_time(commit.timestamp),
812 m_escape(&commit.subject)
813 ));
814 }
815 }
816 Ok(out)
817}
818
819fn render_commit_page(
820 config: &ServerConfig,
821 access: &Access,
822 remote: Option<&[u8; 16]>,
823 vars: &BTreeMap<String, String>,
824) -> Result<String> {
825 let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
826 let hash = required_var(vars, "h")?;
827 validate_refish(hash)?;
828 let commit = commit_info(&repository, hash)?;
829 let mut out = format!(
830 ">>\n{} / {} / {} / commit {}\n\n>Commit {}\n\n{}\nAuthor: {} <{}>\nDate: {}\n",
831 m_link("Node", PATH_INDEX, &[]),
832 m_link(&group, PATH_GROUP, &[("g", &group)]),
833 m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
834 &commit.hash[..7],
835 commit.hash,
836 m_escape(&commit.subject),
837 m_escape(&commit.author),
838 m_escape(&commit.email),
839 format_unix_time(commit.timestamp)
840 );
841 if !commit.body.is_empty() {
842 out.push_str(&format!("\n{}\n", m_escape(&commit.body)));
843 }
844 out.push_str("\n>Files changed\n\n");
845 for file in commit.files {
846 out.push_str(&format!("{} {}\n", file.status, m_escape(&file.path)));
847 }
848 Ok(out)
849}
850
851fn render_refs_page(
852 config: &ServerConfig,
853 access: &Access,
854 remote: Option<&[u8; 16]>,
855 vars: &BTreeMap<String, String>,
856) -> Result<String> {
857 let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
858 let refs = git_refs(&repository)?;
859 let mut out = format!(
860 ">>\n{} / {} / {} / refs\n\n>Refs\n\n>Branches\n",
861 m_link("Node", PATH_INDEX, &[]),
862 m_link(&group, PATH_GROUP, &[("g", &group)]),
863 m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)])
864 );
865 for branch in refs.heads {
866 out.push_str(&format!(
867 "- {} `F666{}`f\n",
868 m_link_raw(
869 &branch.name,
870 PATH_TREE,
871 &[("g", &group), ("r", &repo), ("ref", &branch.name)]
872 ),
873 &branch.sha[..8]
874 ));
875 }
876 out.push_str("\n>Tags\n");
877 for tag in refs.tags {
878 out.push_str(&format!(
879 "- {} `F666{}`f\n",
880 m_link_raw(
881 &tag.name,
882 PATH_TREE,
883 &[("g", &group), ("r", &repo), ("ref", &tag.name)]
884 ),
885 &tag.sha[..8]
886 ));
887 }
888 Ok(out)
889}
890
891fn render_stats_page(
892 config: &ServerConfig,
893 access: &Access,
894 remote: Option<&[u8; 16]>,
895 vars: &BTreeMap<String, String>,
896) -> Result<String> {
897 let (group, repo, _repository) = accessible_repository(config, access, remote, vars)?;
898 if !access.allows(Operation::Stats, &format!("{group}/{repo}"), remote)? {
899 return Ok(">>\n>Error\n\nThe requested repository was not found.\n".to_string());
900 }
901 let Some(stats) = crate::stats::repository_stats(config, access, remote, &group, &repo, 90)?
902 else {
903 return Ok(
904 ">>\n>Stats Unavailable\n\nCould not retrieve statistics for this repository.\n"
905 .to_string(),
906 );
907 };
908
909 let mut out = format!(
910 ">>\n{} / {} / {}\n\n>Stats for {}\n\n",
911 m_link("Node", PATH_INDEX, &[]),
912 m_link(&group, PATH_GROUP, &[("g", &group)]),
913 m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
914 m_escape(&stats.repository)
915 );
916 out.push_str(&format!(
917 "`F66dViews`f : {:>5} total `F666(peak: {:>3})`f\n",
918 stats.views.total, stats.views.peak
919 ));
920 out.push_str(&format!(
921 "`F0a0Fetches`f : {:>5} total `F666(peak: {:>3})`f\n",
922 stats.fetches.total, stats.fetches.peak
923 ));
924 out.push_str(&format!(
925 "`Faa0Pushes`f : {:>5} total `F666(peak: {:>3})`f\n",
926 stats.pushes.total, stats.pushes.peak
927 ));
928 out.push_str(&format!(
929 "`F0aaActivity`f : {:>5} points\n\n",
930 stats.activity_score
931 ));
932 out.push_str(&format!(
933 "{}{}`f over the last {} days ({})\n\n",
934 stats.activity_level.color(),
935 stats.activity_level.label(),
936 stats.actual_days,
937 stats.date_range
938 ));
939
940 if stats.views.total > 0 {
941 out.push_str(">Views\n\n");
942 out.push_str(&render_chart(
943 &stats.views.daily,
944 &stats.timeline_labels,
945 "66d",
946 10,
947 ));
948 out.push('\n');
949 }
950 if stats.fetches.total > 0 {
951 out.push_str(">Fetches\n\n");
952 out.push_str(&render_chart(
953 &stats.fetches.daily,
954 &stats.timeline_labels,
955 "0a0",
956 10,
957 ));
958 out.push('\n');
959 }
960 if stats.pushes.total > 0 {
961 out.push_str(">Pushes\n\n");
962 out.push_str(&render_chart(
963 &stats.pushes.daily,
964 &stats.timeline_labels,
965 "aa0",
966 10,
967 ));
968 out.push('\n');
969 }
970 if stats.activity_score > 0 {
971 out.push_str(">Combined Activity\n\n");
972 out.push_str(&render_combined_chart(
973 &stats.views.daily,
974 &stats.fetches.daily,
975 &stats.pushes.daily,
976 &stats.timeline_labels,
977 4,
978 ));
979 } else {
980 out.push_str(
981 "`*\nNo activity recorded for this repository in the selected time period.\n\n`*",
982 );
983 }
984 Ok(out)
985}
986
987fn render_releases_page(
988 config: &ServerConfig,
989 access: &Access,
990 remote: Option<&[u8; 16]>,
991 vars: &BTreeMap<String, String>,
992) -> Result<String> {
993 let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
994 let releases_path = crate::release::release_sidecar_path(&repository);
995 let releases: Vec<_> = crate::release::list_releases(&releases_path)?
996 .into_iter()
997 .filter(|release| release.status == "published")
998 .collect();
999 let mut out = format!(
1000 ">>\n{} / {} / {} / releases\n\n>Releases ({})\n\n",
1001 m_link("Node", PATH_INDEX, &[]),
1002 m_link(&group, PATH_GROUP, &[("g", &group)]),
1003 m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1004 releases.len()
1005 );
1006 if releases.is_empty() {
1007 out.push_str("No releases available for this repository.\n");
1008 return Ok(out);
1009 }
1010 for release in releases {
1011 out.push_str(&format!(
1012 "{} `F666{} artifacts`f\n",
1013 m_link(
1014 &release.tag,
1015 PATH_RELEASE,
1016 &[("g", &group), ("r", &repo), ("tag", &release.tag)]
1017 ),
1018 release.artifacts
1019 ));
1020 if !release.preview.is_empty() {
1021 append_release_preview(&mut out, &release.preview_format, &release.preview);
1022 }
1023 out.push('\n');
1024 }
1025 Ok(out)
1026}
1027
1028fn append_release_preview(out: &mut String, format: &str, preview: &str) {
1029 match format {
1030 "markdown" => out.push_str(&markdown_to_micron(preview)),
1031 "micron" => {
1032 out.push_str(preview);
1033 if !preview.ends_with('\n') {
1034 out.push('\n');
1035 }
1036 }
1037 _ => {
1038 out.push_str(&m_escape_line_start_controls(preview));
1039 out.push('\n');
1040 }
1041 }
1042}
1043
1044fn render_release_page(
1045 config: &ServerConfig,
1046 access: &Access,
1047 remote: Option<&[u8; 16]>,
1048 vars: &BTreeMap<String, String>,
1049) -> Result<String> {
1050 let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1051 let releases_path = crate::release::release_sidecar_path(&repository);
1052 let requested_tag = var(vars, "tag").unwrap_or("latest");
1053 let tag = if requested_tag == "latest" {
1054 let Some(tag) = crate::release::latest_published_tag(&releases_path)? else {
1055 return Ok(">Release Not Found\n\nNo latest release exists.\n".to_string());
1056 };
1057 tag
1058 } else {
1059 requested_tag.to_string()
1060 };
1061 let Some(release) = crate::release::release_data(&releases_path, &tag)? else {
1062 return Ok(">Release Not Found\n\nThe requested release was not found.\n".to_string());
1063 };
1064 if release.status != "published" {
1065 return Ok(">Release Not Found\n\nThe requested release was not found.\n".to_string());
1066 }
1067 let release_dir = releases_path.join(&release.tag);
1068 let thanks = crate::release::release_thanks(&release_dir, var(vars, "thanks").is_some())?;
1069 let mut out = format!(
1070 ">>\n{} / {} / {} / {} / {}\n\n>Release {}\n\n",
1071 m_link("Node", PATH_INDEX, &[]),
1072 m_link(&group, PATH_GROUP, &[("g", &group)]),
1073 m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1074 m_link("releases", PATH_RELEASES, &[("g", &group), ("r", &repo)]),
1075 m_escape(&release.tag),
1076 m_escape(&release.tag)
1077 );
1078 out.push_str(&format!(
1079 "{}\n\n",
1080 m_link(
1081 &icon_label(config, "heart", &format!("Thanks ({thanks})")),
1082 PATH_RELEASE,
1083 &[
1084 ("g", &group),
1085 ("r", &repo),
1086 ("tag", &release.tag),
1087 ("thanks", "y")
1088 ]
1089 )
1090 ));
1091 if !release.notes.is_empty() {
1092 match release.notes_format.as_str() {
1093 "markdown" => out.push_str(&markdown_to_micron(&release.notes)),
1094 "micron" => out.push_str(&release.notes),
1095 _ => out.push_str(&m_escape(&release.notes)),
1096 }
1097 if !out.ends_with('\n') {
1098 out.push('\n');
1099 }
1100 out.push('\n');
1101 }
1102 out.push_str(">Artifacts\n\n");
1103 if release.artifacts.is_empty() {
1104 out.push_str("No artifacts for this release.\n");
1105 } else {
1106 for artifact in release.artifacts {
1107 out.push_str(&format!(
1108 "{} ({})\n",
1109 m_link(
1110 &artifact.name,
1111 PATH_DOWNLOAD,
1112 &[
1113 ("g", &group),
1114 ("r", &repo),
1115 ("tag", &release.tag),
1116 ("artifact", &artifact.name)
1117 ]
1118 ),
1119 format_size(artifact.size)
1120 ));
1121 }
1122 }
1123 Ok(out)
1124}
1125
1126fn render_work_page(
1127 config: &ServerConfig,
1128 access: &Access,
1129 remote: Option<&[u8; 16]>,
1130 vars: &BTreeMap<String, String>,
1131) -> Result<String> {
1132 let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1133 let scope = var(vars, "scope").unwrap_or("active");
1134 let list_scope =
1135 crate::work::WorkListScope::parse(scope).ok_or_else(|| Error::msg("invalid scope"))?;
1136 let lists =
1137 crate::work::list_documents(&crate::work::work_sidecar_path(&repository), list_scope)?;
1138 let mut out = format!(
1139 ">>\n{} / {} / {} / work\n\n>Work Documents\n\n",
1140 m_link("Node", PATH_INDEX, &[]),
1141 m_link(&group, PATH_GROUP, &[("g", &group)]),
1142 m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)])
1143 );
1144 let tabs = [
1145 m_link_raw(
1146 "Active",
1147 PATH_WORK,
1148 &[("g", &group), ("r", &repo), ("scope", "active")],
1149 ),
1150 m_link_raw(
1151 "Completed",
1152 PATH_WORK,
1153 &[("g", &group), ("r", &repo), ("scope", "completed")],
1154 ),
1155 m_link_raw(
1156 "All",
1157 PATH_WORK,
1158 &[("g", &group), ("r", &repo), ("scope", "all")],
1159 ),
1160 ];
1161 out.push_str(&format!(
1162 "{}\n\n",
1163 tabs.join(&format!(" {} ", icon_sep(config)))
1164 ));
1165 if matches!(
1166 list_scope,
1167 crate::work::WorkListScope::Active | crate::work::WorkListScope::All
1168 ) {
1169 append_work_section(
1170 &mut out,
1171 &group,
1172 &repo,
1173 crate::work::WorkScope::Active,
1174 &lists.active,
1175 );
1176 }
1177 if matches!(
1178 list_scope,
1179 crate::work::WorkListScope::Completed | crate::work::WorkListScope::All
1180 ) {
1181 append_work_section(
1182 &mut out,
1183 &group,
1184 &repo,
1185 crate::work::WorkScope::Completed,
1186 &lists.completed,
1187 );
1188 }
1189 Ok(out)
1190}
1191
1192fn append_work_section(
1193 out: &mut String,
1194 group: &str,
1195 repo: &str,
1196 scope: crate::work::WorkScope,
1197 docs: &[crate::work::WorkSummary],
1198) {
1199 let title = match scope {
1200 crate::work::WorkScope::Active => "Active Work Documents",
1201 crate::work::WorkScope::Completed => "Completed Work Documents",
1202 };
1203 out.push_str(&format!(">{title} ({})\n\n", docs.len()));
1204 if docs.is_empty() {
1205 out.push_str("No work documents found.\n\n");
1206 return;
1207 }
1208 for doc in docs {
1209 let doc_id = doc.id.to_string();
1210 out.push_str(&format!(
1211 "{} `F666{} updates`f\n",
1212 m_link(
1213 &format!("#{} {}", doc.id, doc.title),
1214 PATH_WORK_DOC,
1215 &[
1216 ("g", group),
1217 ("r", repo),
1218 ("scope", scope.as_str()),
1219 ("id", &doc_id)
1220 ]
1221 ),
1222 doc.comments
1223 ));
1224 out.push_str(&format!(
1225 "`F666{} by {}`f\n\n",
1226 format_unix_time(doc.created as i64),
1227 m_escape(&doc.author)
1228 ));
1229 }
1230}
1231
1232fn render_work_doc_page(
1233 config: &ServerConfig,
1234 access: &Access,
1235 remote: Option<&[u8; 16]>,
1236 vars: &BTreeMap<String, String>,
1237) -> Result<String> {
1238 let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1239 let scope = var(vars, "scope").unwrap_or("active");
1240 let scope = crate::work::WorkScope::parse(scope).ok_or_else(|| Error::msg("invalid scope"))?;
1241 let id = required_var(vars, "id")?
1242 .parse::<u64>()
1243 .map_err(|_| Error::msg("invalid work document ID"))?;
1244 let Some(document) =
1245 crate::work::view_document(&crate::work::work_sidecar_path(&repository), scope, id)?
1246 else {
1247 return Ok(
1248 ">Work Document Not Found\n\nThe requested work document was not found.\n".into(),
1249 );
1250 };
1251 let mut out = format!(
1252 ">>\n{} / {} / {} / {} / #{}\n\n>{}\n\n",
1253 m_link("Node", PATH_INDEX, &[]),
1254 m_link(&group, PATH_GROUP, &[("g", &group)]),
1255 m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1256 m_link(
1257 "work",
1258 PATH_WORK,
1259 &[("g", &group), ("r", &repo), ("scope", scope.as_str())]
1260 ),
1261 document.id,
1262 m_escape(&document.title)
1263 );
1264 out.push_str(&format!(
1265 "`F666Status: {} | Author: {} | Created: {} | Edited: {}`f\n\n",
1266 scope.as_str(),
1267 m_escape(&document.author),
1268 format_unix_time(document.created as i64),
1269 format_unix_time(document.edited as i64)
1270 ));
1271 append_formatted_content(&mut out, &document.format, &document.content);
1272 out.push_str(&format!("\n>Updates ({})\n\n", document.comments.len()));
1273 if document.comments.is_empty() {
1274 out.push_str("No updates for this work document.\n");
1275 } else {
1276 for comment in document.comments {
1277 out.push_str(&format!(
1278 ">#{} by {} at {}\n\n",
1279 comment.id,
1280 m_escape(&comment.author),
1281 format_unix_time(comment.created as i64)
1282 ));
1283 append_formatted_content(&mut out, &comment.format, &comment.content);
1284 out.push('\n');
1285 }
1286 }
1287 Ok(out)
1288}
1289
1290fn append_formatted_content(out: &mut String, format: &str, content: &str) {
1291 match format {
1292 "micron" => out.push_str(content),
1293 "markdown" => out.push_str(&markdown_to_micron(content)),
1294 _ => out.push_str(&m_escape(content)),
1295 }
1296 if !out.ends_with('\n') {
1297 out.push('\n');
1298 }
1299}
1300
1301pub fn download_file(
1302 config: &ServerConfig,
1303 access: &Access,
1304 data: &[u8],
1305 remote: Option<&[u8; 16]>,
1306) -> Result<RequestResponse> {
1307 let vars = parse_page_vars(data)?;
1308 let (_group, _repo, repository) = match accessible_repository(config, access, remote, &vars) {
1309 Ok(repo) => repo,
1310 Err(_) => {
1311 return Ok(RequestResponse::Bytes(protocol::status_bytes(
1312 protocol::RES_NOT_FOUND,
1313 b"repository not found",
1314 )));
1315 }
1316 };
1317 if let Some(artifact) = var(&vars, "artifact") {
1318 let artifact = decode_url_component(artifact);
1319 let tag = var(&vars, "tag").unwrap_or("latest");
1320 let releases_path = crate::release::release_sidecar_path(&repository);
1321 let Some(path) = crate::release::artifact_path(&releases_path, tag, &artifact)? else {
1322 return Ok(RequestResponse::Bytes(protocol::status_bytes(
1323 protocol::RES_NOT_FOUND,
1324 b"file not found",
1325 )));
1326 };
1327 return Ok(RequestResponse::Resource {
1328 data: fs::read(path)?,
1329 metadata: Some(protocol::metadata_status(protocol::RES_OK)),
1330 auto_compress: true,
1331 });
1332 }
1333
1334 let reference = var(&vars, "ref").unwrap_or("HEAD");
1335 let path = required_var(&vars, "path")?;
1336 validate_git_path(path)?;
1337 let resolved = resolve_ref(&repository, reference)?;
1338 let spec = format!("{resolved}:{path}");
1339 let output = run_git_output(
1340 Command::new("git")
1341 .arg("--git-dir")
1342 .arg(&repository)
1343 .arg("show")
1344 .arg(spec),
1345 GIT_COMMAND_TIMEOUT,
1346 )?;
1347 if !output.status.success() {
1348 return Ok(RequestResponse::Bytes(protocol::status_bytes(
1349 protocol::RES_NOT_FOUND,
1350 b"file not found",
1351 )));
1352 }
1353 Ok(RequestResponse::Resource {
1354 data: output.stdout,
1355 metadata: Some(protocol::metadata_status(protocol::RES_OK)),
1356 auto_compress: true,
1357 })
1358}
1359
1360fn render_chart(data: &[u64], labels: &[String; 2], color: &str, height: u64) -> String {
1361 if data.is_empty() || data.iter().all(|value| *value == 0) {
1362 return "No data available\n".to_string();
1363 }
1364 let max = data.iter().copied().max().unwrap_or(1).max(1);
1365 let mut out = format!("`F{color}Peak: {max}`f\n");
1366 for row in (1..=height).rev() {
1367 let threshold = (row - 1) as f64 / height as f64 * max as f64;
1368 out.push('│');
1369 for value in data {
1370 if *value as f64 > threshold {
1371 let block = if row as f64 >= height as f64 * 0.875 {
1372 '█'
1373 } else if row as f64 >= height as f64 * 0.625 {
1374 '▓'
1375 } else if row as f64 >= height as f64 * 0.375 {
1376 '▒'
1377 } else {
1378 '░'
1379 };
1380 out.push_str(&format!("`F{color}{block}`f"));
1381 } else {
1382 out.push(' ');
1383 }
1384 }
1385 out.push('\n');
1386 }
1387 out.push('└');
1388 for _ in data {
1389 out.push('─');
1390 }
1391 out.push_str("┘\n");
1392 out.push_str(&format!("`F666{:<12}`f", labels[0]));
1393 let chart_width = data.len() + 2;
1394 let spacing = chart_width.saturating_sub(24);
1395 out.push_str(&" ".repeat(spacing));
1396 out.push_str(&format!("`F666{:>12}`f\n", labels[1]));
1397 out
1398}
1399
1400fn render_combined_chart(
1401 views: &[u64],
1402 fetches: &[u64],
1403 pushes: &[u64],
1404 labels: &[String; 2],
1405 height: u64,
1406) -> String {
1407 if views.is_empty() {
1408 return "No data available\n".to_string();
1409 }
1410 let totals: Vec<u64> = views
1411 .iter()
1412 .zip(fetches.iter())
1413 .zip(pushes.iter())
1414 .map(|((view, fetch), push)| view + fetch + push)
1415 .collect();
1416 let max = totals.iter().copied().max().unwrap_or(1).max(1);
1417 let mut out = String::from("`F66d██`f Views `F0a0██`f Fetches `Faa0██`f Pushes\n\n");
1418 for row in (1..=height).rev() {
1419 let threshold = (row - 1) as f64 / height as f64 * max as f64;
1420 out.push('│');
1421 for i in 0..views.len() {
1422 let view = views[i] as f64;
1423 let fetch = fetches.get(i).copied().unwrap_or(0) as f64;
1424 let push = pushes.get(i).copied().unwrap_or(0) as f64;
1425 let total = view + fetch + push;
1426 if total > threshold {
1427 if push > 0.0 && threshold >= view + fetch {
1428 out.push_str("`Faa0█`f");
1429 } else if fetch > 0.0 && threshold >= view {
1430 out.push_str("`F0a0▓`f");
1431 } else if view > 0.0 {
1432 out.push_str("`F66d░`f");
1433 } else {
1434 out.push_str("`F666▒`f");
1435 }
1436 } else {
1437 out.push(' ');
1438 }
1439 }
1440 out.push('\n');
1441 }
1442 out.push('└');
1443 for _ in views {
1444 out.push('─');
1445 }
1446 out.push_str("┘\n");
1447 out.push_str(&format!("`F666{:<12}`f", labels[0]));
1448 let chart_width = views.len() + 2;
1449 let spacing = chart_width.saturating_sub(24);
1450 out.push_str(&" ".repeat(spacing));
1451 out.push_str(&format!("`F666{:>12}`f\n", labels[1]));
1452 out
1453}
1454
1455fn accessible_groups(
1456 config: &ServerConfig,
1457 access: &Access,
1458 remote: Option<&[u8; 16]>,
1459) -> Result<BTreeMap<String, Vec<String>>> {
1460 let mut groups = BTreeMap::new();
1461 if !config.repositories_dir.exists() {
1462 return Ok(groups);
1463 }
1464 for group_entry in fs::read_dir(&config.repositories_dir)? {
1465 let group_entry = group_entry?;
1466 if !group_entry.file_type()?.is_dir() {
1467 continue;
1468 }
1469 let group = group_entry.file_name().to_string_lossy().to_string();
1470 if validate_group_name(&group).is_err() {
1471 continue;
1472 }
1473 let repos = accessible_repositories(config, access, remote, &group)?;
1474 if !repos.is_empty() {
1475 groups.insert(group, repos);
1476 }
1477 }
1478 Ok(groups)
1479}
1480
1481fn accessible_repositories(
1482 config: &ServerConfig,
1483 access: &Access,
1484 remote: Option<&[u8; 16]>,
1485 group: &str,
1486) -> Result<Vec<String>> {
1487 validate_group_name(group)?;
1488 let group_dir = config.repositories_dir.join(group);
1489 let mut repos = Vec::new();
1490 if !group_dir.exists() {
1491 return Ok(repos);
1492 }
1493 for repo_entry in fs::read_dir(group_dir)? {
1494 let repo_entry = repo_entry?;
1495 if !repo_entry.file_type()?.is_dir() {
1496 continue;
1497 }
1498 let repo = repo_entry.file_name().to_string_lossy().to_string();
1499 let Ok(name) = repository_name(group, &repo) else {
1500 continue;
1501 };
1502 if crate::git::is_bare_repository(&config.repositories_dir.join(&name))
1503 && access.allows(Operation::Read, &name, remote)?
1504 {
1505 repos.push(repo);
1506 }
1507 }
1508 repos.sort();
1509 Ok(repos)
1510}
1511
1512fn accessible_repository(
1513 config: &ServerConfig,
1514 access: &Access,
1515 remote: Option<&[u8; 16]>,
1516 vars: &BTreeMap<String, String>,
1517) -> Result<(String, String, PathBuf)> {
1518 let group = required_var(vars, "g")?;
1519 let repo = required_var(vars, "r")?;
1520 let name = repository_name(group, repo)?;
1521 let path = config.repositories_dir.join(&name);
1522 if !crate::git::is_bare_repository(&path) || !access.allows(Operation::Read, &name, remote)? {
1523 return Err(Error::msg("repository not found"));
1524 }
1525 Ok((group.to_string(), repo.to_string(), path))
1526}
1527
1528fn required_var<'a>(vars: &'a BTreeMap<String, String>, key: &str) -> Result<&'a str> {
1529 var(vars, key).ok_or_else(|| Error::msg(format!("missing page variable {key}")))
1530}
1531
1532fn decode_url_component(value: &str) -> String {
1533 let bytes = value.as_bytes();
1534 let mut out = Vec::with_capacity(bytes.len());
1535 let mut i = 0;
1536 while i < bytes.len() {
1537 match bytes[i] {
1538 b'+' => {
1539 out.push(b' ');
1540 i += 1;
1541 }
1542 b'%' if i + 2 < bytes.len() => {
1543 if let (Some(high), Some(low)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2]))
1544 {
1545 out.push((high << 4) | low);
1546 i += 3;
1547 } else {
1548 out.push(bytes[i]);
1549 i += 1;
1550 }
1551 }
1552 byte => {
1553 out.push(byte);
1554 i += 1;
1555 }
1556 }
1557 }
1558 String::from_utf8_lossy(&out).into_owned()
1559}
1560
1561fn hex_value(byte: u8) -> Option<u8> {
1562 match byte {
1563 b'0'..=b'9' => Some(byte - b'0'),
1564 b'a'..=b'f' => Some(byte - b'a' + 10),
1565 b'A'..=b'F' => Some(byte - b'A' + 10),
1566 _ => None,
1567 }
1568}
1569
1570fn var<'a>(vars: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {
1571 vars.get(&format!("var_{key}"))
1572 .or_else(|| vars.get(key))
1573 .map(String::as_str)
1574}
1575
1576fn repository_name(group: &str, repo: &str) -> Result<String> {
1577 validate_group_name(group)?;
1578 validate_repo_component(repo)?;
1579 let name = format!("{group}/{repo}");
1580 validate_repo_name(&name)?;
1581 Ok(name)
1582}
1583
1584fn validate_group_name(group: &str) -> Result<()> {
1585 validate_repo_component(group)
1586}
1587
1588fn validate_repo_component(component: &str) -> Result<()> {
1589 if component.is_empty()
1590 || component == "."
1591 || component == ".."
1592 || component.contains('/')
1593 || component.contains('\\')
1594 || component.len() > 256
1595 {
1596 Err(Error::msg("invalid repository name"))
1597 } else {
1598 Ok(())
1599 }
1600}
1601
1602fn validate_git_path(path: &str) -> Result<()> {
1603 if path.starts_with('/') || path.contains('\\') {
1604 return Err(Error::msg("invalid repository path"));
1605 }
1606 for component in path.split('/') {
1607 if component == "." || component == ".." {
1608 return Err(Error::msg("invalid repository path"));
1609 }
1610 }
1611 Ok(())
1612}
1613
1614fn validate_refish(value: &str) -> Result<()> {
1615 if value.is_empty()
1616 || value.starts_with('-')
1617 || value.contains("..")
1618 || value.contains('\\')
1619 || value.contains(' ')
1620 || value.contains('~')
1621 || value.contains('^')
1622 || value.contains(':')
1623 {
1624 Err(Error::msg("invalid git ref"))
1625 } else {
1626 Ok(())
1627 }
1628}
1629
1630fn repository_description(repo: &Path) -> Result<String> {
1631 let path = repo.join("description");
1632 if !path.exists() {
1633 return Ok(String::new());
1634 }
1635 Ok(fs::read_to_string(path)?.trim().to_string())
1636}
1637
1638fn repository_thanks(repo: &Path, add: bool) -> Result<u64> {
1639 let path = repo.with_extension("thanks");
1640 let mut count = if path.exists() {
1641 fs::read_to_string(&path)?
1642 .trim()
1643 .parse::<u64>()
1644 .unwrap_or(0)
1645 } else {
1646 0
1647 };
1648 if add {
1649 count = count.saturating_add(1);
1650 fs::write(path, format!("{count}\n"))?;
1651 }
1652 Ok(count)
1653}
1654
1655#[derive(Debug)]
1656struct ReadmeContent {
1657 content: String,
1658 markdown: bool,
1659}
1660
1661fn readme_content(repo: &Path) -> Result<Option<ReadmeContent>> {
1662 const NAMES: &[&str] = &[
1663 "README.mu",
1664 "Readme.mu",
1665 "readme.mu",
1666 "README",
1667 "readme",
1668 "README.md",
1669 "readme.md",
1670 "README.rst",
1671 "README.txt",
1672 "readme.rst",
1673 "readme.txt",
1674 ];
1675 for name in NAMES {
1676 match run_git_output(
1677 Command::new("git")
1678 .arg("--git-dir")
1679 .arg(repo)
1680 .arg("show")
1681 .arg(format!("HEAD:{name}")),
1682 GIT_COMMAND_TIMEOUT,
1683 ) {
1684 Ok(output) if output.status.success() => {
1685 let content = String::from_utf8_lossy(&output.stdout).into_owned();
1686 let markdown = name.ends_with(".md");
1687 let content = if markdown || name.ends_with(".mu") {
1688 content
1689 } else {
1690 m_escape(&content)
1691 };
1692 return Ok(Some(ReadmeContent { content, markdown }));
1693 }
1694 Ok(_) => {}
1695 Err(err) => {
1696 log::warn!("Git command execution failed while reading README: {err}");
1697 }
1698 }
1699 }
1700 Ok(None)
1701}
1702
1703fn markdown_to_micron(input: &str) -> String {
1704 markdown_to_micron_scoped(input, None)
1705}
1706
1707fn markdown_to_micron_scoped(input: &str, url_scope: Option<&str>) -> String {
1708 let lines: Vec<&str> = input.lines().collect();
1709 let mut out = String::new();
1710 let mut code_block: Option<(Option<String>, String)> = None;
1711 let mut index = 0;
1712
1713 while index < lines.len() {
1714 let line = lines[index];
1715 let trimmed = line.trim();
1716 if trimmed.starts_with("```") {
1717 if let Some((language, content)) = code_block.take() {
1718 out.push_str(&crate::highlight::literal_block(
1719 &content,
1720 None,
1721 language.as_deref(),
1722 ));
1723 } else {
1724 code_block = Some((markdown_fence_language(trimmed), String::new()));
1725 }
1726 index += 1;
1727 continue;
1728 }
1729 if let Some((_, content)) = code_block.as_mut() {
1730 content.push_str(line);
1731 content.push('\n');
1732 index += 1;
1733 continue;
1734 }
1735 if let Some(quote) = markdown_quote(line) {
1736 let mut parts = vec![quote.to_string()];
1737 index += 1;
1738 while index < lines.len() {
1739 if let Some(quote) = markdown_quote(lines[index]) {
1740 parts.push(quote.to_string());
1741 index += 1;
1742 } else {
1743 break;
1744 }
1745 }
1746 let formatted = format_markdown_inline_scoped(&parts.join(" "), url_scope);
1747 for wrapped in markdown_wrap_text(&formatted, 77) {
1748 out.push_str(" │ ");
1749 out.push_str(&wrapped);
1750 out.push('\n');
1751 }
1752 continue;
1753 }
1754 if is_table_start(&lines, index) {
1755 let mut table = Vec::new();
1756 while index < lines.len() && lines[index].contains('|') {
1757 table.push(lines[index]);
1758 index += 1;
1759 }
1760 out.push_str(&format_markdown_table(&table, url_scope));
1761 continue;
1762 }
1763 if let Some((level, text)) = markdown_heading(line) {
1764 out.push_str(&">".repeat(level));
1765 out.push_str(&format_markdown_inline_scoped(text.trim(), url_scope));
1766 out.push('\n');
1767 } else if is_markdown_rule(trimmed) {
1768 out.push_str("-\n");
1769 } else if let Some((indent, text)) = unordered_list_item(line) {
1770 out.push_str(indent);
1771 out.push_str(" • ");
1772 out.push_str(&format_markdown_inline_scoped(text, url_scope));
1773 out.push('\n');
1774 } else if let Some((indent, number, text)) = ordered_list_item(line) {
1775 out.push_str(indent);
1776 out.push_str(number);
1777 out.push_str(". ");
1778 out.push_str(&format_markdown_inline_scoped(text, url_scope));
1779 out.push('\n');
1780 } else {
1781 out.push_str(&format_markdown_line(line, url_scope));
1782 out.push('\n');
1783 }
1784 index += 1;
1785 }
1786 if let Some((language, content)) = code_block {
1787 out.push_str(&crate::highlight::literal_block(
1788 &content,
1789 None,
1790 language.as_deref(),
1791 ));
1792 }
1793
1794 out
1795}
1796
1797fn markdown_fence_language(trimmed: &str) -> Option<String> {
1798 trimmed
1799 .strip_prefix("```")
1800 .and_then(|info| info.split_whitespace().next())
1801 .filter(|language| !language.is_empty())
1802 .map(str::to_string)
1803}
1804
1805fn markdown_quote(line: &str) -> Option<&str> {
1806 line.strip_prefix('>')
1807 .map(|quote| quote.strip_prefix(' ').unwrap_or(quote))
1808}
1809
1810fn markdown_heading(line: &str) -> Option<(usize, &str)> {
1811 let level = line.chars().take_while(|ch| *ch == '#').count();
1812 if (1..=6).contains(&level) && line.as_bytes().get(level) == Some(&b' ') {
1813 Some((level, &line[level + 1..]))
1814 } else {
1815 None
1816 }
1817}
1818
1819fn is_markdown_rule(trimmed: &str) -> bool {
1820 trimmed.len() >= 3
1821 && ["-", "*", "_", "="]
1822 .iter()
1823 .any(|marker| trimmed.chars().all(|ch| ch.to_string() == *marker))
1824}
1825
1826fn unordered_list_item(line: &str) -> Option<(&str, &str)> {
1827 let trimmed = line.trim_start();
1828 let indent = &line[..line.len() - trimmed.len()];
1829 for marker in ["- ", "* ", "+ "] {
1830 if let Some(text) = trimmed.strip_prefix(marker) {
1831 return Some((indent, text));
1832 }
1833 }
1834 None
1835}
1836
1837fn ordered_list_item(line: &str) -> Option<(&str, &str, &str)> {
1838 let trimmed = line.trim_start();
1839 let indent = &line[..line.len() - trimmed.len()];
1840 let Some((number, text)) = trimmed.split_once(". ") else {
1841 return None;
1842 };
1843 if !number.is_empty() && number.chars().all(|ch| ch.is_ascii_digit()) {
1844 Some((indent, number, text))
1845 } else {
1846 None
1847 }
1848}
1849
1850fn format_markdown_line(line: &str, url_scope: Option<&str>) -> String {
1851 let formatted = format_markdown_inline_scoped(line, url_scope);
1852 if (line.starts_with('-') && !line.starts_with("---") && !line.starts_with("- "))
1853 || line.starts_with('<')
1854 {
1855 format!("\\{formatted}")
1856 } else {
1857 formatted
1858 }
1859}
1860
1861fn is_table_start(lines: &[&str], index: usize) -> bool {
1862 index + 1 < lines.len()
1863 && lines[index].contains('|')
1864 && lines[index + 1].contains('|')
1865 && is_table_separator(lines[index + 1])
1866}
1867
1868fn is_table_separator(line: &str) -> bool {
1869 parse_table_row(line)
1870 .iter()
1871 .all(|cell| !cell.is_empty() && cell.chars().all(|ch| matches!(ch, '-' | ':' | ' ')))
1872}
1873
1874#[derive(Clone, Copy)]
1875enum TableAlign {
1876 Left,
1877 Right,
1878 Center,
1879}
1880
1881fn format_markdown_table(lines: &[&str], url_scope: Option<&str>) -> String {
1882 if lines.len() < 2 {
1883 return String::new();
1884 }
1885 let header: Vec<String> = parse_table_row(lines[0])
1886 .into_iter()
1887 .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
1888 .collect();
1889 if header.is_empty() {
1890 return String::new();
1891 }
1892 let columns = header.len();
1893 let mut alignments = parse_table_alignments(lines[1]);
1894 while alignments.len() < columns {
1895 alignments.push(TableAlign::Left);
1896 }
1897 alignments.truncate(columns);
1898
1899 let rows: Vec<Vec<String>> = lines
1900 .iter()
1901 .skip(2)
1902 .map(|line| {
1903 let mut row: Vec<String> = parse_table_row(line)
1904 .into_iter()
1905 .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
1906 .collect();
1907 while row.len() < columns {
1908 row.push(String::new());
1909 }
1910 row.truncate(columns);
1911 row
1912 })
1913 .collect();
1914
1915 let mut widths = vec![3; columns];
1916 for (index, cell) in header.iter().enumerate() {
1917 widths[index] = widths[index].max(markdown_visible_width(cell));
1918 }
1919 for row in &rows {
1920 for (index, cell) in row.iter().enumerate().take(columns) {
1921 widths[index] = widths[index].max(markdown_visible_width(cell));
1922 }
1923 }
1924
1925 let mut out = String::new();
1926 out.push_str(&table_border('┌', '┬', '┐', &widths));
1927 out.push_str(&table_row(
1928 &header,
1929 &widths,
1930 &vec![TableAlign::Left; columns],
1931 ));
1932 out.push_str(&table_border('├', '┼', '┤', &widths));
1933 for row in rows {
1934 out.push_str(&table_row(&row, &widths, &alignments));
1935 }
1936 out.push_str(&table_border('└', '┴', '┘', &widths));
1937 out
1938}
1939
1940fn parse_table_row(line: &str) -> Vec<String> {
1941 let mut trimmed = line.trim();
1942 if let Some(rest) = trimmed.strip_prefix('|') {
1943 trimmed = rest;
1944 }
1945 if let Some(rest) = trimmed.strip_suffix('|') {
1946 trimmed = rest;
1947 }
1948 let mut cells = Vec::new();
1949 let mut current = String::new();
1950 let mut escaped = false;
1951 for ch in trimmed.chars() {
1952 if escaped {
1953 current.push(ch);
1954 escaped = false;
1955 } else if ch == '\\' {
1956 escaped = true;
1957 } else if ch == '|' {
1958 cells.push(current.trim().to_string());
1959 current.clear();
1960 } else {
1961 current.push(ch);
1962 }
1963 }
1964 if escaped {
1965 current.push('\\');
1966 }
1967 cells.push(current.trim().to_string());
1968 cells
1969}
1970
1971fn parse_table_alignments(line: &str) -> Vec<TableAlign> {
1972 parse_table_row(line)
1973 .into_iter()
1974 .map(|cell| {
1975 let cell = cell.trim();
1976 if cell.starts_with(':') && cell.ends_with(':') {
1977 TableAlign::Center
1978 } else if cell.ends_with(':') {
1979 TableAlign::Right
1980 } else {
1981 TableAlign::Left
1982 }
1983 })
1984 .collect()
1985}
1986
1987fn table_border(left: char, middle: char, right: char, widths: &[usize]) -> String {
1988 let mut out = String::new();
1989 out.push(left);
1990 for (index, width) in widths.iter().enumerate() {
1991 out.push_str(&"─".repeat(width + 2));
1992 if index + 1 == widths.len() {
1993 out.push(right);
1994 } else {
1995 out.push(middle);
1996 }
1997 }
1998 out.push('\n');
1999 out
2000}
2001
2002fn table_row(cells: &[String], widths: &[usize], alignments: &[TableAlign]) -> String {
2003 let mut out = String::from("│");
2004 for (index, width) in widths.iter().enumerate() {
2005 let cell = cells.get(index).map(String::as_str).unwrap_or("");
2006 out.push(' ');
2007 out.push_str(&pad_table_cell(
2008 cell,
2009 *width,
2010 alignments.get(index).copied().unwrap_or(TableAlign::Left),
2011 ));
2012 out.push(' ');
2013 out.push('│');
2014 }
2015 out.push('\n');
2016 out
2017}
2018
2019fn pad_table_cell(cell: &str, width: usize, alignment: TableAlign) -> String {
2020 let visible = markdown_visible_width(cell);
2021 let padding = width.saturating_sub(visible);
2022 match alignment {
2023 TableAlign::Left => format!("{cell}{}", " ".repeat(padding)),
2024 TableAlign::Right => format!("{}{cell}", " ".repeat(padding)),
2025 TableAlign::Center => {
2026 let left = padding / 2;
2027 let right = padding - left;
2028 format!("{}{cell}{}", " ".repeat(left), " ".repeat(right))
2029 }
2030 }
2031}
2032
2033fn markdown_visible_width(value: &str) -> usize {
2034 let mut width = 0;
2035 let mut chars = value.chars().peekable();
2036 while let Some(ch) = chars.next() {
2037 if ch == '`' {
2038 if matches!(chars.peek(), Some('F') | Some('B')) {
2039 chars.next();
2040 if matches!(chars.peek(), Some('T')) {
2041 chars.next();
2042 for _ in 0..6 {
2043 chars.next();
2044 }
2045 } else {
2046 for _ in 0..3 {
2047 chars.next();
2048 }
2049 }
2050 continue;
2051 }
2052 if matches!(chars.peek(), Some('!') | Some('*') | Some('_') | Some('=')) {
2053 chars.next();
2054 continue;
2055 }
2056 if matches!(chars.peek(), Some('f') | Some('b') | Some('a')) {
2057 chars.next();
2058 continue;
2059 }
2060 if matches!(chars.peek(), Some('[')) {
2061 let mut label_width = 0;
2062 for link_ch in chars.by_ref() {
2063 if link_ch == '`' {
2064 for target_ch in chars.by_ref() {
2065 if target_ch == ']' {
2066 break;
2067 }
2068 }
2069 break;
2070 }
2071 label_width += 1;
2072 }
2073 width += label_width;
2074 continue;
2075 }
2076 }
2077 width += 1;
2078 }
2079 width
2080}
2081
2082fn markdown_wrap_text(text: &str, width: usize) -> Vec<String> {
2083 if text.is_empty() {
2084 return vec![String::new()];
2085 }
2086 let mut lines = Vec::new();
2087 let mut current = String::new();
2088 let mut current_width = 0;
2089 for word in text.split_whitespace() {
2090 let word_width = markdown_visible_width(word);
2091 if !current.is_empty() && current_width + 1 + word_width > width {
2092 lines.push(current);
2093 current = String::new();
2094 current_width = 0;
2095 }
2096 if !current.is_empty() {
2097 current.push(' ');
2098 current_width += 1;
2099 }
2100 current.push_str(word);
2101 current_width += word_width;
2102 }
2103 if !current.is_empty() {
2104 lines.push(current);
2105 }
2106 if lines.is_empty() {
2107 lines.push(String::new());
2108 }
2109 lines
2110}
2111
2112#[derive(Debug)]
2113enum InlineToken {
2114 Link { label: String, target: String },
2115 Code(String),
2116}
2117
2118fn format_markdown_inline_scoped(input: &str, url_scope: Option<&str>) -> String {
2119 let (with_placeholders, tokens) = extract_markdown_inline_tokens(input);
2120 let escaped = m_escape(&with_placeholders);
2121 let styled = apply_markdown_style(&escaped, "**", "`!", "`!");
2122 let styled = apply_markdown_style(&styled, "__", "`!", "`!");
2123 let styled = apply_markdown_style(&styled, "*", "`*", "`*");
2124 let styled = apply_markdown_style(&styled, "_", "`*", "`*");
2125 restore_markdown_inline_tokens(&styled, &tokens, url_scope)
2126}
2127
2128fn extract_markdown_inline_tokens(input: &str) -> (String, Vec<InlineToken>) {
2129 let mut out = String::new();
2130 let mut tokens = Vec::new();
2131 let mut index = 0;
2132
2133 while index < input.len() {
2134 let rest = &input[index..];
2135 if rest.starts_with('`') {
2136 if let Some(end) = rest[1..].find('`') {
2137 let content = &rest[1..1 + end];
2138 if is_markdown_inline_code(content) {
2139 tokens.push(InlineToken::Code(content.to_string()));
2140 out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2141 index += end + 2;
2142 continue;
2143 }
2144 }
2145 }
2146 if rest.starts_with('[') {
2147 if let Some((consumed, label, target)) = parse_markdown_link(rest) {
2148 tokens.push(InlineToken::Link { label, target });
2149 out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2150 index += consumed;
2151 continue;
2152 }
2153 }
2154 let Some(ch) = rest.chars().next() else {
2155 break;
2156 };
2157 out.push(ch);
2158 index += ch.len_utf8();
2159 }
2160
2161 (out, tokens)
2162}
2163
2164fn is_markdown_inline_code(content: &str) -> bool {
2165 !content.is_empty()
2166 && !content
2167 .chars()
2168 .next()
2169 .is_some_and(|ch| matches!(ch, '!' | '*' | '_' | '=' | 'F' | 'B' | 'f' | 'b' | 'a'))
2170}
2171
2172fn parse_markdown_link(input: &str) -> Option<(usize, String, String)> {
2173 let label_end = input[1..].find(']')? + 1;
2174 if !input[label_end + 1..].starts_with('(') {
2175 return None;
2176 }
2177 let target_start = label_end + 2;
2178 let target_end = input[target_start..].find(')')? + target_start;
2179 Some((
2180 target_end + 1,
2181 input[1..label_end].replace('`', ""),
2182 input[target_start..target_end].to_string(),
2183 ))
2184}
2185
2186fn markdown_token_placeholder(index: usize) -> String {
2187 format!("\u{1f}{index}\u{1f}")
2188}
2189
2190fn restore_markdown_inline_tokens(
2191 input: &str,
2192 tokens: &[InlineToken],
2193 url_scope: Option<&str>,
2194) -> String {
2195 let mut out = input.to_string();
2196 for (index, token) in tokens.iter().enumerate() {
2197 let replacement = match token {
2198 InlineToken::Link { label, target } => format!(
2199 "`!`[{}{}{}]`!",
2200 sanitize_label(label).trim(),
2201 "`",
2202 markdown_link_target(target, url_scope)
2203 ),
2204 InlineToken::Code(value) => format!("`BT383838`Fddd{}`f`b", m_escape(value)),
2205 };
2206 out = out.replace(&markdown_token_placeholder(index), &replacement);
2207 }
2208 out
2209}
2210
2211fn markdown_link_target(value: &str, url_scope: Option<&str>) -> String {
2212 if let Some(scope) = url_scope {
2213 if !value.contains(":/") {
2214 let (path, anchor) = value
2215 .split_once('#')
2216 .map(|(path, anchor)| (path, Some(anchor)))
2217 .unwrap_or((value, None));
2218 let mut out = format!("{scope}{}", sanitize_field(path));
2219 if let Some(anchor) = anchor.filter(|anchor| !anchor.is_empty()) {
2220 out.push_str("|anchor=");
2221 out.push_str(&sanitize_field(anchor));
2222 }
2223 return out;
2224 }
2225 }
2226 sanitize_markdown_link_target(value)
2227}
2228
2229fn sanitize_markdown_link_target(value: &str) -> String {
2230 value
2231 .chars()
2232 .filter(|ch| *ch != '[' && *ch != ']' && *ch != '`')
2233 .collect()
2234}
2235
2236fn apply_markdown_style(input: &str, marker: &str, open: &str, close: &str) -> String {
2237 let mut out = String::new();
2238 let mut rest = input;
2239
2240 while let Some(start) = rest.find(marker) {
2241 let content_start = start + marker.len();
2242 let Some(end) = rest[content_start..].find(marker) else {
2243 out.push_str(rest);
2244 return out;
2245 };
2246 let content_end = content_start + end;
2247 out.push_str(&rest[..start]);
2248 out.push_str(open);
2249 out.push_str(&rest[content_start..content_end]);
2250 out.push_str(close);
2251 rest = &rest[content_end + marker.len()..];
2252 }
2253 out.push_str(rest);
2254 out
2255}
2256
2257#[derive(Debug)]
2258struct Refs {
2259 heads: Vec<RefInfo>,
2260 tags: Vec<RefInfo>,
2261}
2262
2263#[derive(Debug)]
2264struct RefInfo {
2265 sha: String,
2266 name: String,
2267}
2268
2269fn git_refs(repo: &Path) -> Result<Refs> {
2270 let output = run_git(
2271 Command::new("git")
2272 .arg("--git-dir")
2273 .arg(repo)
2274 .args(["for-each-ref", "--format=%(objectname) %(refname)"]),
2275 )?;
2276 let mut refs = Refs {
2277 heads: Vec::new(),
2278 tags: Vec::new(),
2279 };
2280 for line in output.lines() {
2281 let Some((sha, name)) = line.split_once(' ') else {
2282 continue;
2283 };
2284 if let Some(short) = name.strip_prefix("refs/heads/") {
2285 refs.heads.push(RefInfo {
2286 sha: sha.to_string(),
2287 name: short.to_string(),
2288 });
2289 } else if let Some(short) = name.strip_prefix("refs/tags/") {
2290 refs.tags.push(RefInfo {
2291 sha: sha.to_string(),
2292 name: short.to_string(),
2293 });
2294 }
2295 }
2296 Ok(refs)
2297}
2298
2299fn resolve_ref(repo: &Path, reference: &str) -> Result<String> {
2300 validate_refish(reference)?;
2301 let output = run_git(
2302 Command::new("git")
2303 .arg("--git-dir")
2304 .arg(repo)
2305 .arg("rev-parse")
2306 .arg("--verify")
2307 .arg(format!("{reference}^{{commit}}")),
2308 )?;
2309 Ok(output.trim().to_string())
2310}
2311
2312#[derive(Debug)]
2313struct TreeEntry {
2314 kind: String,
2315 name: String,
2316 size: Option<u64>,
2317}
2318
2319impl TreeEntry {
2320 fn size_label(&self) -> String {
2321 self.size
2322 .map(|size| format!(" ({size} bytes)"))
2323 .unwrap_or_default()
2324 }
2325}
2326
2327fn tree_entries(repo: &Path, reference: &str, path: &str) -> Result<Vec<TreeEntry>> {
2328 validate_git_path(path)?;
2329 let spec = if path.is_empty() {
2330 reference.to_string()
2331 } else {
2332 format!("{reference}:{path}")
2333 };
2334 let output = run_git(
2335 Command::new("git")
2336 .arg("--git-dir")
2337 .arg(repo)
2338 .arg("ls-tree")
2339 .arg("-l")
2340 .arg(spec),
2341 )?;
2342 let mut entries = Vec::new();
2343 for line in output.lines() {
2344 let Some((meta, name)) = line.split_once('\t') else {
2345 continue;
2346 };
2347 let parts: Vec<&str> = meta.split_whitespace().collect();
2348 if parts.len() < 4 {
2349 continue;
2350 }
2351 let size = parts[3].parse::<u64>().ok();
2352 entries.push(TreeEntry {
2353 kind: parts[1].to_string(),
2354 name: name.to_string(),
2355 size,
2356 });
2357 }
2358 entries.sort_by_key(|entry| (entry.kind != "tree", entry.name.to_ascii_lowercase()));
2359 Ok(entries)
2360}
2361
2362struct BlobInfo {
2363 content: String,
2364 size: u64,
2365 binary: bool,
2366 displayable: bool,
2367}
2368
2369fn blob_info(repo: &Path, reference: &str, path: &str) -> Result<BlobInfo> {
2370 validate_git_path(path)?;
2371 let spec = format!("{reference}:{path}");
2372 let size = run_git(
2373 Command::new("git")
2374 .arg("--git-dir")
2375 .arg(repo)
2376 .arg("cat-file")
2377 .arg("-s")
2378 .arg(&spec),
2379 )?
2380 .trim()
2381 .parse::<u64>()
2382 .unwrap_or(0);
2383 if size > 256 * 1024 {
2384 return Ok(BlobInfo {
2385 content: format!("File is too large to display ({size} bytes)."),
2386 size,
2387 binary: false,
2388 displayable: false,
2389 });
2390 }
2391 let output = run_git_output(
2392 Command::new("git")
2393 .arg("--git-dir")
2394 .arg(repo)
2395 .arg("show")
2396 .arg(spec),
2397 GIT_COMMAND_TIMEOUT,
2398 )?;
2399 if !output.status.success() {
2400 return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
2401 }
2402 let binary = output.stdout.contains(&0);
2403 Ok(BlobInfo {
2404 content: if binary {
2405 "Binary file is not displayed.".to_string()
2406 } else {
2407 String::from_utf8_lossy(&output.stdout).into_owned()
2408 },
2409 size,
2410 binary,
2411 displayable: !binary,
2412 })
2413}
2414
2415#[derive(Debug)]
2416struct CommitSummary {
2417 hash: String,
2418 subject: String,
2419 author: String,
2420 timestamp: i64,
2421}
2422
2423fn commits(repo: &Path, reference: &str, limit: usize) -> Result<Vec<CommitSummary>> {
2424 let format = "%H%x1f%s%x1f%an%x1f%at";
2425 let output = run_git(
2426 Command::new("git")
2427 .arg("--git-dir")
2428 .arg(repo)
2429 .arg("log")
2430 .arg(format!("--format={format}"))
2431 .arg("-n")
2432 .arg(limit.to_string())
2433 .arg(reference),
2434 )?;
2435 let mut commits = Vec::new();
2436 for line in output.lines() {
2437 let parts: Vec<&str> = line.split('\x1f').collect();
2438 if parts.len() == 4 {
2439 commits.push(CommitSummary {
2440 hash: parts[0].to_string(),
2441 subject: parts[1].to_string(),
2442 author: parts[2].to_string(),
2443 timestamp: parts[3].parse().unwrap_or_default(),
2444 });
2445 }
2446 }
2447 Ok(commits)
2448}
2449
2450#[derive(Debug)]
2451struct CommitInfo {
2452 hash: String,
2453 subject: String,
2454 body: String,
2455 author: String,
2456 email: String,
2457 timestamp: i64,
2458 files: Vec<CommitFile>,
2459}
2460
2461#[derive(Debug)]
2462struct CommitFile {
2463 status: String,
2464 path: String,
2465}
2466
2467fn commit_info(repo: &Path, hash: &str) -> Result<CommitInfo> {
2468 validate_refish(hash)?;
2469 let output = run_git(
2470 Command::new("git")
2471 .arg("--git-dir")
2472 .arg(repo)
2473 .arg("show")
2474 .arg("--no-patch")
2475 .arg("--format=%H%x1f%s%x1f%an%x1f%ae%x1f%at%x1f%B")
2476 .arg(hash),
2477 )?;
2478 let parts: Vec<&str> = output.splitn(6, '\x1f').collect();
2479 if parts.len() != 6 {
2480 return Err(Error::msg("invalid commit output"));
2481 }
2482 let files_output = run_git(
2483 Command::new("git")
2484 .arg("--git-dir")
2485 .arg(repo)
2486 .arg("diff-tree")
2487 .arg("--root")
2488 .arg("--no-commit-id")
2489 .arg("--name-status")
2490 .arg("-r")
2491 .arg(hash),
2492 )?;
2493 let mut files = Vec::new();
2494 for line in files_output.lines() {
2495 let Some((status, path)) = line.split_once('\t') else {
2496 continue;
2497 };
2498 files.push(CommitFile {
2499 status: status.to_string(),
2500 path: path.to_string(),
2501 });
2502 }
2503 Ok(CommitInfo {
2504 hash: parts[0].trim().to_string(),
2505 subject: parts[1].trim().to_string(),
2506 author: parts[2].trim().to_string(),
2507 email: parts[3].trim().to_string(),
2508 timestamp: parts[4].trim().parse().unwrap_or_default(),
2509 body: parts[5].trim().to_string(),
2510 files,
2511 })
2512}
2513
2514fn run_git(cmd: &mut Command) -> Result<String> {
2515 let output = run_git_output(cmd, GIT_COMMAND_TIMEOUT)?;
2516 if !output.status.success() {
2517 return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
2518 }
2519 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
2520}
2521
2522fn run_git_output(cmd: &mut Command, timeout: Duration) -> Result<Output> {
2523 let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
2524 let deadline = Instant::now() + timeout;
2525
2526 loop {
2527 if child.try_wait()?.is_some() {
2528 return child.wait_with_output().map_err(Error::from);
2529 }
2530
2531 if Instant::now() >= deadline {
2532 let _ = child.kill();
2533 let _ = child.wait();
2534 log::warn!("Git command execution timed out");
2535 return Err(Error::msg("Git command execution timed out"));
2536 }
2537
2538 let sleep_for = deadline
2539 .saturating_duration_since(Instant::now())
2540 .min(Duration::from_millis(10));
2541 if !sleep_for.is_zero() {
2542 thread::sleep(sleep_for);
2543 }
2544 }
2545}
2546
2547#[cfg(test)]
2548mod tests {
2549 use super::*;
2550 use crate::acl::Access;
2551 use crate::config::ServerConfig;
2552 use crate::logging;
2553 use rns_core::msgpack::{self, Value};
2554 use rns_crypto::OsRng;
2555 use std::fs;
2556 use std::process::Command;
2557
2558 #[test]
2559 fn nomadnet_destination_uses_upstream_name() {
2560 let identity = Identity::new(&mut OsRng);
2561 let destination = destination_for_identity(&identity);
2562 let expected =
2563 Destination::single_in("nomadnetwork", &["node"], IdentityHash(*identity.hash()));
2564 assert_eq!(destination.hash, expected.hash);
2565 }
2566
2567 #[test]
2568 fn git_command_timeout_kills_slow_process() {
2569 let start = Instant::now();
2570
2571 let err =
2572 run_git_output(Command::new("sleep").arg("5"), Duration::from_millis(30)).unwrap_err();
2573
2574 assert!(start.elapsed() < Duration::from_secs(1));
2575 assert!(err.to_string().contains("timed out"));
2576 }
2577
2578 #[test]
2579 fn front_page_lists_only_readable_groups() {
2580 let tmp = tempfile::tempdir().unwrap();
2581 let mut config = cfg(tmp.path());
2582 config.allow_read = vec!["none".into()];
2583 create_repo(
2584 config.repositories_dir.join("public/alpha"),
2585 "README.md",
2586 "alpha readme\n",
2587 );
2588 create_repo(
2589 config.repositories_dir.join("private/secret"),
2590 "README.md",
2591 "secret\n",
2592 );
2593 fs::write(
2594 config.repositories_dir.join("public/group.allowed"),
2595 "read = all\n",
2596 )
2597 .unwrap();
2598 fs::write(
2599 config.repositories_dir.join("private/group.allowed"),
2600 "read = none\n",
2601 )
2602 .unwrap();
2603 let access = access(&config);
2604
2605 let page = render_page(PATH_INDEX, &config, &access, &page_request(&[]), None).unwrap();
2606 assert!(page.contains("public"));
2607 assert!(page.contains("alpha"));
2608 assert!(!page.contains("private"));
2609 assert!(!page.contains("secret"));
2610 }
2611
2612 #[test]
2613 fn group_and_repo_pages_use_repository_metadata() {
2614 let tmp = tempfile::tempdir().unwrap();
2615 let config = cfg(tmp.path());
2616 let repo = create_repo(
2617 config.repositories_dir.join("public/alpha"),
2618 "README.md",
2619 "# Alpha\n",
2620 );
2621 fs::write(repo.join("description"), "Alpha repository\n").unwrap();
2622 let access = access(&config);
2623
2624 let group = render_page(
2625 PATH_GROUP,
2626 &config,
2627 &access,
2628 &page_request(&[("var_g", "public")]),
2629 None,
2630 )
2631 .unwrap();
2632 assert!(group.contains("Alpha repository"));
2633 assert!(group.contains("alpha"));
2634
2635 let repo = render_page(
2636 PATH_REPO,
2637 &config,
2638 &access,
2639 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2640 None,
2641 )
2642 .unwrap();
2643 assert!(repo.contains("rns://"));
2644 assert!(repo.contains("Alpha repository"));
2645 assert!(repo.contains(">Alpha"));
2646 }
2647
2648 #[test]
2649 fn tree_blob_commits_commit_and_refs_pages_are_git_backed() {
2650 let tmp = tempfile::tempdir().unwrap();
2651 let config = cfg(tmp.path());
2652 let repo = create_repo(
2653 config.repositories_dir.join("public/alpha"),
2654 "src/lib.rs",
2655 "pub fn answer() -> u8 { 42 }\n",
2656 );
2657 run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
2658 "tag",
2659 "v1",
2660 "refs/heads/main",
2661 ]));
2662 let access = access(&config);
2663
2664 let tree = render_page(
2665 PATH_TREE,
2666 &config,
2667 &access,
2668 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2669 None,
2670 )
2671 .unwrap();
2672 assert!(tree.contains("src/"));
2673
2674 let blob = render_page(
2675 PATH_BLOB,
2676 &config,
2677 &access,
2678 &page_request(&[
2679 ("var_g", "public"),
2680 ("var_r", "alpha"),
2681 ("var_path", "src/lib.rs"),
2682 ]),
2683 None,
2684 )
2685 .unwrap();
2686 assert!(blob.contains("answer"));
2687 #[cfg(feature = "syntax-highlighting")]
2688 assert!(blob.contains("`FT"));
2689 #[cfg(not(feature = "syntax-highlighting"))]
2690 assert!(blob.contains("pub fn answer()"));
2691
2692 let commits = render_page(
2693 PATH_COMMITS,
2694 &config,
2695 &access,
2696 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2697 None,
2698 )
2699 .unwrap();
2700 assert!(commits.contains("initial"));
2701
2702 let commit_hash = run_git(
2703 Command::new("git")
2704 .arg("--git-dir")
2705 .arg(&repo)
2706 .args(["rev-parse", "refs/heads/main"]),
2707 );
2708 let commit = render_page(
2709 PATH_COMMIT,
2710 &config,
2711 &access,
2712 &page_request(&[
2713 ("var_g", "public"),
2714 ("var_r", "alpha"),
2715 ("var_h", commit_hash.trim()),
2716 ]),
2717 None,
2718 )
2719 .unwrap();
2720 assert!(commit.contains("initial"));
2721 assert!(commit.contains("src/lib.rs"));
2722
2723 let refs = render_page(
2724 PATH_REFS,
2725 &config,
2726 &access,
2727 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2728 None,
2729 )
2730 .unwrap();
2731 assert!(refs.contains("main"));
2732 assert!(refs.contains("v1"));
2733 }
2734
2735 #[test]
2736 fn page_requests_reject_invalid_repository_names() {
2737 let tmp = tempfile::tempdir().unwrap();
2738 let config = cfg(tmp.path());
2739 let access = access(&config);
2740 let err = render_page(
2741 PATH_REPO,
2742 &config,
2743 &access,
2744 &page_request(&[("var_g", ".."), ("var_r", "repo")]),
2745 None,
2746 )
2747 .unwrap_err();
2748 assert!(err.to_string().contains("invalid repository name"));
2749 }
2750
2751 #[test]
2752 fn page_ref_resolution_rejects_invalid_refs_before_git_lookup() {
2753 let tmp = tempfile::tempdir().unwrap();
2754 let config = cfg(tmp.path());
2755 create_repo(
2756 config.repositories_dir.join("public/alpha"),
2757 "README.md",
2758 "# Alpha\n",
2759 );
2760 let access = access(&config);
2761
2762 for (path, vars) in [
2763 (
2764 PATH_TREE,
2765 vec![
2766 ("var_g", "public"),
2767 ("var_r", "alpha"),
2768 ("var_ref", "--upload-pack=/tmp/x"),
2769 ],
2770 ),
2771 (
2772 PATH_BLOB,
2773 vec![
2774 ("var_g", "public"),
2775 ("var_r", "alpha"),
2776 ("var_path", "README.md"),
2777 ("var_ref", "refs/heads/main:README.md"),
2778 ],
2779 ),
2780 (
2781 PATH_COMMITS,
2782 vec![
2783 ("var_g", "public"),
2784 ("var_r", "alpha"),
2785 ("var_ref", "refs/heads/main..refs/heads/other"),
2786 ],
2787 ),
2788 ] {
2789 let err = render_page(path, &config, &access, &page_request(&vars), None).unwrap_err();
2790 assert_eq!(err.to_string(), "invalid git ref");
2791 }
2792 }
2793
2794 #[test]
2795 fn pages_use_base_template_breadcrumbs_and_micron_links() {
2796 let tmp = tempfile::tempdir().unwrap();
2797 let config = cfg(tmp.path());
2798 create_repo(
2799 config.repositories_dir.join("public/alpha"),
2800 "README.md",
2801 "# Alpha\n",
2802 );
2803 let access = access(&config);
2804
2805 let repo = render_page(
2806 PATH_REPO,
2807 &config,
2808 &access,
2809 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2810 None,
2811 )
2812 .unwrap();
2813
2814 assert!(repo.starts_with("#!c=0\n>Test Git Node\n\n>>\n"));
2815 assert!(repo.contains("`!`[Node`:/page/index.mu]`!"));
2816 assert!(repo.contains("`!`[public`:/page/group.mu`g=public]`!"));
2817 assert!(repo.contains("`[Files`:/page/tree.mu`g=public|r=alpha|ref=HEAD]"));
2818 assert!(repo.contains("`[Commits"));
2819 assert!(repo.contains("`[Branches (1)`:/page/refs.mu`g=public|r=alpha|type=heads]"));
2820 assert!(repo.contains("`[Tags (0)`:/page/refs.mu`g=public|r=alpha|type=tags]"));
2821 assert!(repo.contains("<\n-\n`a`F666`[Served by rngit`:/page/index.mu]"));
2822 }
2823
2824 #[test]
2825 fn custom_templates_wrap_page_content_and_replace_variables() {
2826 let tmp = tempfile::tempdir().unwrap();
2827 let config = cfg(tmp.path());
2828 fs::create_dir_all(&config.templates_dir).unwrap();
2829 fs::write(
2830 config.templates_dir.join("base.mu"),
2831 "BASE {NODE_NAME} {VERSION} {GEN_TIME}\n{PAGE_CONTENT}\n{NAVIGATION} \n",
2832 )
2833 .unwrap();
2834 fs::write(
2835 config.templates_dir.join("repo.mu"),
2836 "REPO-BEGIN\n{PAGE_CONTENT}\nREPO-END \n",
2837 )
2838 .unwrap();
2839 create_repo(
2840 config.repositories_dir.join("public/alpha"),
2841 "README.md",
2842 "# Alpha\n",
2843 );
2844 let access = access(&config);
2845
2846 let repo = render_page(
2847 PATH_REPO,
2848 &config,
2849 &access,
2850 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2851 None,
2852 )
2853 .unwrap();
2854
2855 assert!(repo.starts_with("BASE Test Git Node "));
2856 assert!(repo.contains("\nREPO-BEGIN\n"));
2857 assert!(repo.contains(">Alpha"));
2858 assert!(repo.contains("\nREPO-END\n"));
2859 assert!(repo.contains(env!("CARGO_PKG_VERSION")));
2860 assert!(repo.contains("0ms"));
2861 assert!(!repo.ends_with(" \n"));
2862 }
2863
2864 #[test]
2865 fn template_substitution_does_not_rewrite_page_content_placeholders() {
2866 let tmp = tempfile::tempdir().unwrap();
2867 let config = cfg(tmp.path());
2868
2869 let out = apply_template(
2870 "Template {NODE_NAME}\n{PAGE_CONTENT}",
2871 "Literal {NODE_NAME}",
2872 &config,
2873 );
2874
2875 assert!(out.starts_with("Template Test Git Node\n"));
2876 assert!(out.ends_with("Literal {NODE_NAME}"));
2877 }
2878
2879 #[test]
2880 fn unicode_icon_config_changes_repository_navigation_labels() {
2881 let tmp = tempfile::tempdir().unwrap();
2882 let mut config = cfg(tmp.path());
2883 config.unicode_icons = true;
2884 create_repo(
2885 config.repositories_dir.join("public/alpha"),
2886 "README.md",
2887 "# Alpha\n",
2888 );
2889 let access = access(&config);
2890
2891 let repo = render_page(
2892 PATH_REPO,
2893 &config,
2894 &access,
2895 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2896 None,
2897 )
2898 .unwrap();
2899
2900 assert!(repo.contains("🗀 Files"));
2901 assert!(repo.contains("🖹 Commits"));
2902 assert!(repo.contains("⑃ Branches"));
2903 assert!(repo.contains("⌆ Tags"));
2904 }
2905
2906 #[test]
2907 fn repo_page_renders_and_persists_thanks_count() {
2908 let tmp = tempfile::tempdir().unwrap();
2909 let config = cfg(tmp.path());
2910 let repo_path = create_repo(
2911 config.repositories_dir.join("public/alpha"),
2912 "README.md",
2913 "# Alpha\n",
2914 );
2915 let access = access(&config);
2916
2917 let first = render_page(
2918 PATH_REPO,
2919 &config,
2920 &access,
2921 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2922 None,
2923 )
2924 .unwrap();
2925 assert!(first.contains("Thanks (0)"));
2926 assert!(!repo_path.with_extension("thanks").exists());
2927
2928 let thanked = render_page(
2929 PATH_REPO,
2930 &config,
2931 &access,
2932 &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
2933 None,
2934 )
2935 .unwrap();
2936 assert!(thanked.contains("Thanks (1)"));
2937 assert_eq!(
2938 fs::read_to_string(repo_path.with_extension("thanks")).unwrap(),
2939 "1\n"
2940 );
2941
2942 let incremented = render_page(
2943 PATH_REPO,
2944 &config,
2945 &access,
2946 &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
2947 None,
2948 )
2949 .unwrap();
2950 assert!(incremented.contains("Thanks (2)"));
2951
2952 let viewed = render_page(
2953 PATH_REPO,
2954 &config,
2955 &access,
2956 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2957 None,
2958 )
2959 .unwrap();
2960 assert!(viewed.contains("Thanks (2)"));
2961 }
2962
2963 #[test]
2964 fn repo_page_uses_unicode_heart_for_thanks_when_enabled() {
2965 let tmp = tempfile::tempdir().unwrap();
2966 let mut config = cfg(tmp.path());
2967 config.unicode_icons = true;
2968 create_repo(
2969 config.repositories_dir.join("public/alpha"),
2970 "README.md",
2971 "# Alpha\n",
2972 );
2973 let access = access(&config);
2974
2975 let page = render_page(
2976 PATH_REPO,
2977 &config,
2978 &access,
2979 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2980 None,
2981 )
2982 .unwrap();
2983
2984 assert!(page.contains("♥ Thanks (0)"));
2985 assert!(page.contains("thanks=y"));
2986 }
2987
2988 #[test]
2989 fn empty_page_request_data_is_deterministic() {
2990 let tmp = tempfile::tempdir().unwrap();
2991 let config = cfg(tmp.path());
2992 let access = access(&config);
2993
2994 let index = render_page(PATH_INDEX, &config, &access, &[], None).unwrap();
2995 assert!(index.contains(">Groups"));
2996
2997 let err = render_page(PATH_REPO, &config, &access, &[], None).unwrap_err();
2998 assert!(err.to_string().contains("missing page variable g"));
2999 }
3000
3001 #[test]
3002 fn tree_blob_and_commit_pages_escape_content_and_format_metadata() {
3003 let tmp = tempfile::tempdir().unwrap();
3004 let config = cfg(tmp.path());
3005 let repo = create_repo(
3006 config.repositories_dir.join("public/alpha"),
3007 "src/lib.rs",
3008 "pub fn `answer`() -> u8 { 42 }\n",
3009 );
3010 let access = access(&config);
3011
3012 let tree = render_page(
3013 PATH_TREE,
3014 &config,
3015 &access,
3016 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3017 None,
3018 )
3019 .unwrap();
3020 assert!(tree.contains("`[src/`:/page/tree.mu`g=public|r=alpha|ref=HEAD|path=src]"));
3021
3022 let blob = render_page(
3023 PATH_BLOB,
3024 &config,
3025 &access,
3026 &page_request(&[
3027 ("var_g", "public"),
3028 ("var_r", "alpha"),
3029 ("var_path", "src/lib.rs"),
3030 ]),
3031 None,
3032 )
3033 .unwrap();
3034 assert!(blob.contains("Text, "));
3035 assert!(!blob.contains("View raw"));
3036 assert!(blob.contains("answer"));
3037 assert!(blob.contains("\\`"));
3038
3039 let commit_hash = run_git(
3040 Command::new("git")
3041 .arg("--git-dir")
3042 .arg(&repo)
3043 .args(["rev-parse", "refs/heads/main"]),
3044 );
3045 let commit = render_page(
3046 PATH_COMMIT,
3047 &config,
3048 &access,
3049 &page_request(&[
3050 ("var_g", "public"),
3051 ("var_r", "alpha"),
3052 ("var_h", commit_hash.trim()),
3053 ]),
3054 None,
3055 )
3056 .unwrap();
3057 assert!(commit.contains("Author: RNS Page Test"));
3058 assert!(commit.contains("Files changed"));
3059 assert!(commit.contains("A src/lib.rs"));
3060 }
3061
3062 #[test]
3063 fn markdown_converter_handles_links_code_lists_rules_and_tables() {
3064 let input = "# Title\n\
3065\n\
3066See [the docs](https://example.invalid/a_b) and `literal *code*`.\n\
3067\n\
3068- **bold** and *italic*\n\
3069---\n\
3070| Name | Link |\n\
3071| --- | --- |\n\
3072| Row | [go](rns://abc) |\n";
3073
3074 let out = markdown_to_micron(input);
3075 assert!(out.contains(">Title"));
3076 assert!(out.contains("`!`[the docs`https://example.invalid/a_b]`!"));
3077 assert!(out.contains("`BT383838`Fdddliteral *code*`f`b"));
3078 assert!(out.contains(" • `!bold`! and `*italic`*"));
3079 assert!(out.contains("\n-\n"));
3080 assert!(out.contains("│ Name"));
3081 assert!(out.contains("`!`[go`rns://abc]`!"));
3082 }
3083
3084 #[test]
3085 fn markdown_converter_handles_blockquotes_and_heading_inline_markup() {
3086 let out = markdown_to_micron(
3087 "# **Important**\n\
3088> **Quoted** text\n\
3089> with [link](rns://abc)\n\
3090\n\
3091after\n",
3092 );
3093 assert!(out.contains(">`!Important`!"));
3094 assert!(out.contains(" │ `!Quoted`! text with `!`[link`rns://abc]`!"));
3095 assert!(out.contains("\nafter\n"));
3096 }
3097
3098 #[test]
3099 fn markdown_link_conversion_is_isolated_from_later_substitutions() {
3100 let out = markdown_to_micron("[**bold** `code`](https://example.invalid?q=*x*)");
3101 assert!(out.contains("`!`[**bold** code`https://example.invalid?q=*x*]`!"));
3102 assert!(!out.contains("`!`!"));
3103 assert!(!out.contains("`*x`*"));
3104 }
3105
3106 #[test]
3107 fn markdown_inline_code_that_looks_like_link_does_not_hide_real_links() {
3108 let out = markdown_to_micron(
3109 "`[literal](https://example.invalid/no)` and [real](https://example.invalid/yes)",
3110 );
3111
3112 assert!(out.contains("`BT383838`Fddd[literal](https://example.invalid/no)`f`b"));
3113 assert!(out.contains("`!`[real`https://example.invalid/yes]`!"));
3114 assert!(!out.contains("`!`[literal`https://example.invalid/no]`!"));
3115 }
3116
3117 #[test]
3118 fn markdown_tables_handle_empty_cells_escaped_pipes_alignment_and_visible_width() {
3119 let input = "| Name | Status | Notes |\n\
3120| :--- | ---: | :---: |\n\
3121| `code` | **ok** | a\\|b |\n\
3122| empty | | [go](rns://abc) |\n";
3123
3124 let out = markdown_to_micron(input);
3125 assert!(out.contains("┌"));
3126 assert!(out.contains("├"));
3127 assert!(out.contains("└"));
3128 assert!(out.contains("│ Name"));
3129 assert!(out.contains("`BT383838`Fdddcode`f`b"));
3130 assert!(out.contains("`!ok`!"));
3131 assert!(out.contains("a|b"));
3132 assert!(out.contains("empty"));
3133 assert!(out.contains("`!`[go`rns://abc]`!"));
3134 assert!(!out.contains("a\\|b"));
3135 }
3136
3137 #[test]
3138 fn markdown_table_width_ignores_generated_micron_tags() {
3139 let input = "| A | B |\n\
3140| --- | --- |\n\
3141| [x](rns://abc) | `y` |\n";
3142
3143 let out = markdown_to_micron(input);
3144 assert!(out.contains("│ `!`[x`rns://abc]`! "));
3145 assert!(out.contains("│ `BT383838`Fdddy`f`b "));
3146 assert!(out.contains("┌─────┬─────┐"));
3147 }
3148
3149 #[test]
3150 fn markdown_escaping_does_not_create_or_corrupt_micron_tags() {
3151 let out =
3152 markdown_to_micron("literal `!not bold`! and [bad`label](rns://abc`def) plus `raw`");
3153 assert!(out.contains("literal \\`!not bold\\`!"));
3154 assert!(out.contains("`!`[badlabel`rns://abcdef]`!"));
3155 assert!(out.contains("`BT383838`Fdddraw`f`b"));
3156 assert!(!out.contains("`!not bold`!"));
3157 }
3158
3159 #[test]
3160 fn markdown_escapes_backslashes_and_expands_tabs_before_inline_formatting() {
3161 let out = markdown_to_micron("plain\\path\t**bold\\path** and `code\\path`\n");
3162
3163 assert!(out.contains("plain\\\\path `!bold\\\\path`!"));
3164 assert!(out.contains("`BT383838`Fdddcode\\\\path`f`b"));
3165 }
3166
3167 #[test]
3168 fn markdown_plain_lines_escape_micron_line_start_controls() {
3169 let out = markdown_to_micron("-not a list\n<not alignment\n- list item\n---\n");
3170 assert!(out.contains("\\-not a list\n"));
3171 assert!(out.contains("\\<not alignment\n"));
3172 assert!(out.contains(" • list item\n"));
3173 assert!(out.contains("-\n"));
3174 }
3175
3176 #[test]
3177 fn markdown_converter_keeps_code_blocks_and_unmatched_markers_literal() {
3178 let out = markdown_to_micron(
3179 "```rust\n\
3180**not bold** and `not inline`\n\
3181```\n\
3182Unmatched * marker\n\
31831. numbered\n",
3184 );
3185 assert!(out.contains("not bold"));
3186 assert!(out.contains("\\`not inline\\`"));
3187 #[cfg(feature = "syntax-highlighting")]
3188 assert!(out.contains("`FT"));
3189 #[cfg(not(feature = "syntax-highlighting"))]
3190 assert!(out.contains("`=\n**not bold** and \\`not inline\\`\n`=\n"));
3191 assert!(!out.contains("`!not bold`!"));
3192 assert!(out.contains("Unmatched * marker"));
3193 assert!(out.contains("1. numbered"));
3194 }
3195
3196 #[cfg(feature = "syntax-highlighting")]
3197 #[test]
3198 fn rust_blob_page_uses_syntax_highlighting_and_escapes_source() {
3199 let tmp = tempfile::tempdir().unwrap();
3200 let config = cfg(tmp.path());
3201 create_repo(
3202 config.repositories_dir.join("public/highlighted"),
3203 "src/lib.rs",
3204 "pub fn `answer`() -> u8 { 42 }\n",
3205 );
3206 let access = access(&config);
3207
3208 let blob = render_page(
3209 PATH_BLOB,
3210 &config,
3211 &access,
3212 &page_request(&[
3213 ("var_g", "public"),
3214 ("var_r", "highlighted"),
3215 ("var_path", "src/lib.rs"),
3216 ]),
3217 None,
3218 )
3219 .unwrap();
3220
3221 assert!(blob.contains("`FT"));
3222 assert!(blob.contains("\\`"));
3223 assert!(blob.contains("answer"));
3224 }
3225
3226 #[cfg(feature = "syntax-highlighting")]
3227 #[test]
3228 fn markdown_readme_rust_fence_uses_syntax_highlighting() {
3229 let tmp = tempfile::tempdir().unwrap();
3230 let config = cfg(tmp.path());
3231 create_repo(
3232 config.repositories_dir.join("public/readme-code"),
3233 "README.md",
3234 "# Example\n\n```rust\npub fn `answer`() -> u8 { 42 }\n```\n",
3235 );
3236 let access = access(&config);
3237
3238 let page = render_page(
3239 PATH_REPO,
3240 &config,
3241 &access,
3242 &page_request(&[("var_g", "public"), ("var_r", "readme-code")]),
3243 None,
3244 )
3245 .unwrap();
3246
3247 assert!(page.contains("`FT"));
3248 assert!(page.contains("\\`"));
3249 assert!(page.contains("answer"));
3250 }
3251
3252 #[test]
3253 fn unknown_extension_and_fence_language_fall_back_to_plain_literals() {
3254 let blob = crate::highlight::literal_block("value `tick`\n", Some("blob.unknown"), None);
3255 assert!(!blob.contains("`FT"));
3256 assert!(blob.contains("value \\`tick\\`"));
3257
3258 let markdown = markdown_to_micron("```not-a-language\nvalue `tick`\n```\n");
3259 assert!(!markdown.contains("`FT"));
3260 assert!(markdown.contains("value \\`tick\\`"));
3261 }
3262
3263 #[test]
3264 fn binary_and_oversized_blobs_use_plain_fallback_messages() {
3265 let tmp = tempfile::tempdir().unwrap();
3266 let config = cfg(tmp.path());
3267 create_repo_bytes(
3268 config.repositories_dir.join("public/binary"),
3269 "payload.bin",
3270 b"abc\0def",
3271 );
3272 let large = vec![b'x'; 256 * 1024 + 1];
3273 create_repo_bytes(
3274 config.repositories_dir.join("public/large"),
3275 "payload.rs",
3276 &large,
3277 );
3278 let access = access(&config);
3279
3280 let binary = render_page(
3281 PATH_BLOB,
3282 &config,
3283 &access,
3284 &page_request(&[
3285 ("var_g", "public"),
3286 ("var_r", "binary"),
3287 ("var_path", "payload.bin"),
3288 ]),
3289 None,
3290 )
3291 .unwrap();
3292 assert!(binary.contains("Binary"));
3293 assert!(binary.contains("Binary file is not displayed."));
3294 assert!(!binary.contains("`FT"));
3295
3296 let oversized = render_page(
3297 PATH_BLOB,
3298 &config,
3299 &access,
3300 &page_request(&[
3301 ("var_g", "public"),
3302 ("var_r", "large"),
3303 ("var_path", "payload.rs"),
3304 ]),
3305 None,
3306 )
3307 .unwrap();
3308 assert!(oversized.contains("File is too large to display"));
3309 assert!(!oversized.contains("`FT"));
3310 }
3311
3312 #[test]
3313 fn markdown_blob_defaults_to_rendered_view_and_raw_view_escapes_source() {
3314 let tmp = tempfile::tempdir().unwrap();
3315 let config = cfg(tmp.path());
3316 create_repo(
3317 config.repositories_dir.join("public/docs"),
3318 "docs/readme.md",
3319 "# Title\n\nSee [docs](https://example.invalid), [guide](guide.md#intro) and `tick`.\n",
3320 );
3321 let access = access(&config);
3322
3323 let rendered = render_page(
3324 PATH_BLOB,
3325 &config,
3326 &access,
3327 &page_request(&[
3328 ("var_g", "public"),
3329 ("var_r", "docs"),
3330 ("var_path", "docs/readme.md"),
3331 ]),
3332 None,
3333 )
3334 .unwrap();
3335 assert!(rendered.contains("Displaying Rendered"));
3336 assert!(rendered.contains("View raw"));
3337 assert!(rendered.contains(">Title"));
3338 assert!(rendered.contains("`!`[docs`https://example.invalid]`!"));
3339 assert!(rendered.contains(
3340 "`!`[guide`:/page/blob.mu`g=public|r=docs|ref=HEAD|path=docs/guide.md|anchor=intro]`!"
3341 ));
3342 assert!(rendered.contains("`BT383838`Fdddtick`f`b"));
3343 assert!(!rendered.contains("# Title"));
3344
3345 let raw = render_page(
3346 PATH_BLOB,
3347 &config,
3348 &access,
3349 &page_request(&[
3350 ("var_g", "public"),
3351 ("var_r", "docs"),
3352 ("var_path", "docs/readme.md"),
3353 ("var_raw", "y"),
3354 ]),
3355 None,
3356 )
3357 .unwrap();
3358 assert!(raw.contains("Displaying Raw"));
3359 assert!(raw.contains("View rendered"));
3360 assert!(raw.contains("Title"));
3361 assert!(raw.contains("\\`"));
3362 assert!(raw.contains("tick"));
3363 assert!(!raw.contains("`!`[docs`https://example.invalid]`!"));
3364 }
3365
3366 #[test]
3367 fn micron_blob_defaults_to_rendered_passthrough_and_raw_view_is_literal() {
3368 let tmp = tempfile::tempdir().unwrap();
3369 let config = cfg(tmp.path());
3370 create_repo(
3371 config.repositories_dir.join("public/micron"),
3372 "README.mu",
3373 ">Micron\n\n`!Already formatted`!\n",
3374 );
3375 let access = access(&config);
3376
3377 let rendered = render_page(
3378 PATH_BLOB,
3379 &config,
3380 &access,
3381 &page_request(&[
3382 ("var_g", "public"),
3383 ("var_r", "micron"),
3384 ("var_path", "README.mu"),
3385 ]),
3386 None,
3387 )
3388 .unwrap();
3389 assert!(rendered.contains("Displaying Rendered"));
3390 assert!(rendered.contains(">Micron"));
3391 assert!(rendered.contains("`!Already formatted`!"));
3392 assert!(!rendered.contains("\\`!Already formatted\\`!"));
3393
3394 let raw = render_page(
3395 PATH_BLOB,
3396 &config,
3397 &access,
3398 &page_request(&[
3399 ("var_g", "public"),
3400 ("var_r", "micron"),
3401 ("var_path", "README.mu"),
3402 ("var_raw", "y"),
3403 ]),
3404 None,
3405 )
3406 .unwrap();
3407 assert!(raw.contains("Displaying Raw"));
3408 assert!(raw.contains("\\`!Already formatted\\`!"));
3409 }
3410
3411 #[test]
3412 fn explicit_render_parameter_renders_markdown_blob() {
3413 let tmp = tempfile::tempdir().unwrap();
3414 let config = cfg(tmp.path());
3415 create_repo(
3416 config.repositories_dir.join("public/docs"),
3417 "docs/readme.md",
3418 "# Explicit\n",
3419 );
3420 let access = access(&config);
3421
3422 let rendered = render_page(
3423 PATH_BLOB,
3424 &config,
3425 &access,
3426 &page_request(&[
3427 ("var_g", "public"),
3428 ("var_r", "docs"),
3429 ("var_path", "docs/readme.md"),
3430 ("var_raw", "y"),
3431 ("var_render", "y"),
3432 ]),
3433 None,
3434 )
3435 .unwrap();
3436 assert!(rendered.contains("Displaying Raw"));
3437 assert!(rendered.contains("#"));
3438 assert!(rendered.contains("Explicit"));
3439
3440 let rendered = render_page(
3441 PATH_BLOB,
3442 &config,
3443 &access,
3444 &page_request(&[
3445 ("var_g", "public"),
3446 ("var_r", "docs"),
3447 ("var_path", "docs/readme.md"),
3448 ("var_render", "y"),
3449 ]),
3450 None,
3451 )
3452 .unwrap();
3453 assert!(rendered.contains("Displaying Rendered"));
3454 assert!(rendered.contains(">Explicit"));
3455 assert!(!rendered.contains("# Explicit"));
3456 }
3457
3458 #[test]
3459 fn unsupported_blob_extension_ignores_render_parameter_and_uses_raw_literal() {
3460 let tmp = tempfile::tempdir().unwrap();
3461 let config = cfg(tmp.path());
3462 create_repo(
3463 config.repositories_dir.join("public/text"),
3464 "notes.txt",
3465 "# Not a heading\npath\\name\t`literal`\n",
3466 );
3467 let access = access(&config);
3468
3469 let page = render_page(
3470 PATH_BLOB,
3471 &config,
3472 &access,
3473 &page_request(&[
3474 ("var_g", "public"),
3475 ("var_r", "text"),
3476 ("var_path", "notes.txt"),
3477 ("var_render", "y"),
3478 ]),
3479 None,
3480 )
3481 .unwrap();
3482 assert!(!page.contains("Displaying Rendered"));
3483 assert!(!page.contains("View raw"));
3484 assert!(page.contains("Displaying Raw"));
3485 assert!(page.contains("Download"));
3486 assert!(page.contains("Not a heading"));
3487 assert!(page.contains("path\\\\name \\`literal\\`"));
3488 assert!(page.contains("\\`literal\\`"));
3489 }
3490
3491 #[test]
3492 fn renderable_binary_and_oversized_blobs_do_not_render_content() {
3493 let tmp = tempfile::tempdir().unwrap();
3494 let config = cfg(tmp.path());
3495 create_repo_bytes(
3496 config.repositories_dir.join("public/binary-md"),
3497 "payload.md",
3498 b"# Binary\0payload\n",
3499 );
3500 let large = vec![b'#'; 256 * 1024 + 1];
3501 create_repo_bytes(
3502 config.repositories_dir.join("public/large-md"),
3503 "payload.md",
3504 &large,
3505 );
3506 let access = access(&config);
3507
3508 let binary = render_page(
3509 PATH_BLOB,
3510 &config,
3511 &access,
3512 &page_request(&[
3513 ("var_g", "public"),
3514 ("var_r", "binary-md"),
3515 ("var_path", "payload.md"),
3516 ]),
3517 None,
3518 )
3519 .unwrap();
3520 assert!(binary.contains("Binary"));
3521 assert!(binary.contains("Binary file is not displayed."));
3522 assert!(!binary.contains(">Binary"));
3523
3524 let oversized = render_page(
3525 PATH_BLOB,
3526 &config,
3527 &access,
3528 &page_request(&[
3529 ("var_g", "public"),
3530 ("var_r", "large-md"),
3531 ("var_path", "payload.md"),
3532 ("var_render", "y"),
3533 ]),
3534 None,
3535 )
3536 .unwrap();
3537 assert!(oversized.contains("File is too large to display"));
3538 assert!(!oversized.contains("Displaying Rendered\n\n>"));
3539 }
3540
3541 #[cfg(not(feature = "syntax-highlighting"))]
3542 #[test]
3543 fn feature_off_renders_plain_literal_blocks() {
3544 let blob = crate::highlight::literal_block("pub fn main() {}\n", Some("main.rs"), None);
3545 assert!(!blob.contains("`FT"));
3546 assert!(blob.contains("pub fn main() {}"));
3547
3548 let markdown = markdown_to_micron("```rust\npub fn main() {}\n```\n");
3549 assert!(!markdown.contains("`FT"));
3550 assert!(markdown.contains("pub fn main() {}"));
3551 }
3552
3553 #[test]
3554 fn repo_page_renders_markdown_readme_and_passes_micron_readme_through() {
3555 let tmp = tempfile::tempdir().unwrap();
3556 let config = cfg(tmp.path());
3557 create_repo(
3558 config.repositories_dir.join("public/markdown"),
3559 "README.md",
3560 "# Markdown\n\nSee [docs](https://example.invalid) and [local](docs/intro.md).\n",
3561 );
3562 create_repo(
3563 config.repositories_dir.join("public/micron"),
3564 "README.mu",
3565 ">Micron\n\n`!Already formatted`!\n",
3566 );
3567 let access = access(&config);
3568
3569 let markdown = render_page(
3570 PATH_REPO,
3571 &config,
3572 &access,
3573 &page_request(&[("var_g", "public"), ("var_r", "markdown")]),
3574 None,
3575 )
3576 .unwrap();
3577 assert!(markdown.contains(">Markdown"));
3578 assert!(markdown.contains("`!`[docs`https://example.invalid]`!"));
3579 assert!(markdown.contains(
3580 "`!`[local`:/page/blob.mu`g=public|r=markdown|ref=HEAD|path=docs/intro.md]`!"
3581 ));
3582
3583 let micron = render_page(
3584 PATH_REPO,
3585 &config,
3586 &access,
3587 &page_request(&[("var_g", "public"), ("var_r", "micron")]),
3588 None,
3589 )
3590 .unwrap();
3591 assert!(micron.contains(">Micron"));
3592 assert!(micron.contains("`!Already formatted`!"));
3593 }
3594
3595 #[test]
3596 fn work_pages_render_documents_comments_and_repo_links() {
3597 let tmp = tempfile::tempdir().unwrap();
3598 let config = cfg(tmp.path());
3599 let repo_path = create_repo(
3600 config.repositories_dir.join("public/worked"),
3601 "README.md",
3602 "# Worked\n",
3603 );
3604 let work_path = crate::work::work_sidecar_path(&repo_path);
3605 let created = crate::work::create_document(
3606 &work_path,
3607 crate::work::WorkInput {
3608 title: "Active task".into(),
3609 content: "# Active Body\n".into(),
3610 format: "markdown".into(),
3611 signature: None,
3612 author: [0x11; 16],
3613 },
3614 )
3615 .unwrap();
3616 crate::work::add_comment(
3617 &work_path,
3618 crate::work::WorkScope::Active,
3619 created.id,
3620 crate::work::WorkCommentInput {
3621 content: "Progress update".into(),
3622 format: "markdown".into(),
3623 signature: None,
3624 author: [0x22; 16],
3625 },
3626 )
3627 .unwrap();
3628 let completed = crate::work::create_document(
3629 &work_path,
3630 crate::work::WorkInput {
3631 title: "Completed task".into(),
3632 content: "Done".into(),
3633 format: "micron".into(),
3634 signature: None,
3635 author: [0x11; 16],
3636 },
3637 )
3638 .unwrap();
3639 crate::work::complete_document(&work_path, completed.id, &[0x11; 16]).unwrap();
3640 let access = access(&config);
3641
3642 let repo = render_page(
3643 PATH_REPO,
3644 &config,
3645 &access,
3646 &page_request(&[("var_g", "public"), ("var_r", "worked")]),
3647 None,
3648 )
3649 .unwrap();
3650 assert!(repo.contains("Work (2)"));
3651 assert!(repo.contains(PATH_WORK));
3652
3653 let active = render_page(
3654 PATH_WORK,
3655 &config,
3656 &access,
3657 &page_request(&[("var_g", "public"), ("var_r", "worked")]),
3658 None,
3659 )
3660 .unwrap();
3661 assert!(active.contains(">Active Work Documents (1)"));
3662 assert!(active.contains("#1 Active task"));
3663 assert!(active.contains("1 updates"));
3664 assert!(!active.contains("Completed task"));
3665
3666 let completed_page = render_page(
3667 PATH_WORK,
3668 &config,
3669 &access,
3670 &page_request(&[
3671 ("var_g", "public"),
3672 ("var_r", "worked"),
3673 ("var_scope", "completed"),
3674 ]),
3675 None,
3676 )
3677 .unwrap();
3678 assert!(completed_page.contains(">Completed Work Documents (1)"));
3679 assert!(completed_page.contains("Completed task"));
3680
3681 let doc = render_page(
3682 PATH_WORK_DOC,
3683 &config,
3684 &access,
3685 &page_request(&[("var_g", "public"), ("var_r", "worked"), ("var_id", "1")]),
3686 None,
3687 )
3688 .unwrap();
3689 assert!(doc.contains(">Active task"));
3690 assert!(doc.contains(">Active Body"));
3691 assert!(doc.contains(">Updates (1)"));
3692 assert!(doc.contains("Progress update"));
3693 }
3694
3695 #[test]
3696 fn work_pages_respect_read_access() {
3697 let tmp = tempfile::tempdir().unwrap();
3698 let mut config = cfg(tmp.path());
3699 create_repo(
3700 config.repositories_dir.join("public/private-work"),
3701 "README.md",
3702 "# Private\n",
3703 );
3704 config.allow_read = vec!["none".into()];
3705 let access = access(&config);
3706
3707 assert!(render_page(
3708 PATH_WORK,
3709 &config,
3710 &access,
3711 &page_request(&[("var_g", "public"), ("var_r", "private-work")]),
3712 None,
3713 )
3714 .is_err());
3715 }
3716
3717 fn cfg(root: &std::path::Path) -> ServerConfig {
3718 ServerConfig {
3719 dir: root.to_path_buf(),
3720 reticulum_dir: None,
3721 repositories_dir: root.join("repositories"),
3722 identity_path: root.join("repositories_identity"),
3723 client_identity_path: root.join("client_identity"),
3724 node_name: "Test Git Node".into(),
3725 announce_interval_secs: 300,
3726 serve_nomadnet: true,
3727 templates_dir: root.join("templates"),
3728 unicode_icons: false,
3729 record_stats: false,
3730 stats_ignore_identities: Vec::new(),
3731 allow_read: vec!["all".into()],
3732 allow_write: vec!["none".into()],
3733 allow_create: vec!["none".into()],
3734 allow_stats: vec!["none".into()],
3735 allow_release: vec!["none".into()],
3736 allow_interact: vec!["none".into()],
3737 allow_admin: vec!["none".into()],
3738 log_level: logging::DEFAULT_LOG_LEVEL,
3739 }
3740 }
3741
3742 fn access(config: &ServerConfig) -> Access {
3743 Access::new(
3744 &config.allow_read,
3745 &config.allow_write,
3746 &config.allow_create,
3747 &config.allow_stats,
3748 &config.allow_release,
3749 &config.allow_interact,
3750 &config.allow_admin,
3751 config.repositories_dir.clone(),
3752 )
3753 .unwrap()
3754 }
3755
3756 fn page_request(fields: &[(&str, &str)]) -> Vec<u8> {
3757 msgpack::pack(&Value::Map(
3758 fields
3759 .iter()
3760 .map(|(k, v)| (Value::Str((*k).into()), Value::Str((*v).into())))
3761 .collect(),
3762 ))
3763 }
3764
3765 fn create_repo(path: std::path::PathBuf, file: &str, content: &str) -> std::path::PathBuf {
3766 create_repo_bytes(path, file, content.as_bytes())
3767 }
3768
3769 fn create_repo_bytes(
3770 path: std::path::PathBuf,
3771 file: &str,
3772 content: &[u8],
3773 ) -> std::path::PathBuf {
3774 let work = path.with_extension("work");
3775 fs::create_dir_all(&work).unwrap();
3776 run_git(Command::new("git").arg("init").arg(&work));
3777 let file_path = work.join(file);
3778 fs::create_dir_all(file_path.parent().unwrap()).unwrap();
3779 fs::write(&file_path, content).unwrap();
3780 run_git(Command::new("git").arg("-C").arg(&work).arg("add").arg("."));
3781 run_git(
3782 Command::new("git")
3783 .arg("-C")
3784 .arg(&work)
3785 .arg("-c")
3786 .arg("user.name=RNS Page Test")
3787 .arg("-c")
3788 .arg("user.email=rns-page-test@example.invalid")
3789 .arg("commit")
3790 .arg("-m")
3791 .arg("initial"),
3792 );
3793 run_git(
3794 Command::new("git")
3795 .arg("-C")
3796 .arg(&work)
3797 .args(["branch", "-M", "main"]),
3798 );
3799 run_git(
3800 Command::new("git")
3801 .arg("clone")
3802 .arg("--bare")
3803 .arg(&work)
3804 .arg(&path),
3805 );
3806 path
3807 }
3808
3809 fn run_git(cmd: &mut Command) -> String {
3810 let output = cmd.output().unwrap();
3811 assert!(
3812 output.status.success(),
3813 "git command failed: {}",
3814 String::from_utf8_lossy(&output.stderr)
3815 );
3816 String::from_utf8_lossy(&output.stdout).into_owned()
3817 }
3818}