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