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 Ok(identity) = Identity::from_public_key(&public_key) else {
1561 return "Not valid";
1562 };
1563 if identity.verify(signature, document.content.as_bytes()) {
1564 "Valid"
1565 } else {
1566 "Not valid"
1567 }
1568}
1569
1570fn resolve_work_document(
1571 work_path: &Path,
1572 requested_scope: &str,
1573 id: u64,
1574) -> Result<(crate::work::WorkScope, Option<crate::work::WorkDocument>)> {
1575 let requested_scope = if matches!(requested_scope, "active" | "completed" | "proposed" | "all")
1576 {
1577 requested_scope
1578 } else {
1579 "active"
1580 };
1581 match requested_scope {
1582 "active" | "completed" | "proposed" => {
1583 let scope = crate::work::WorkScope::parse(requested_scope).unwrap();
1584 Ok((scope, crate::work::view_document(work_path, scope, id)?))
1585 }
1586 "all" => {
1587 if let Some(document) =
1588 crate::work::view_document(work_path, crate::work::WorkScope::Active, id)?
1589 {
1590 Ok((crate::work::WorkScope::Active, Some(document)))
1591 } else if let Some(document) =
1592 crate::work::view_document(work_path, crate::work::WorkScope::Completed, id)?
1593 {
1594 Ok((crate::work::WorkScope::Completed, Some(document)))
1595 } else {
1596 Ok((
1597 crate::work::WorkScope::Proposed,
1598 crate::work::view_document(work_path, crate::work::WorkScope::Proposed, id)?,
1599 ))
1600 }
1601 }
1602 _ => unreachable!(),
1603 }
1604}
1605
1606fn append_formatted_content(out: &mut String, format: &str, content: &str) {
1607 match format {
1608 "micron" => out.push_str(content),
1609 "markdown" => out.push_str(&markdown_to_micron(content)),
1610 _ => out.push_str(&m_escape(content)),
1611 }
1612 if !out.ends_with('\n') {
1613 out.push('\n');
1614 }
1615}
1616
1617pub fn download_work_document(
1618 config: &ServerConfig,
1619 access: &Access,
1620 data: &[u8],
1621 remote: Option<&[u8; 16]>,
1622) -> Result<RequestResponse> {
1623 let vars = parse_page_vars(data)?;
1624 let (group, repo, repository) = match accessible_repository(config, access, remote, &vars) {
1625 Ok(repo) => repo,
1626 Err(_) => {
1627 return Ok(RequestResponse::Bytes(protocol::status_bytes(
1628 protocol::RES_NOT_FOUND,
1629 b"repository not found",
1630 )));
1631 }
1632 };
1633 let id = required_var(&vars, "id")?
1634 .parse::<u64>()
1635 .map_err(|_| Error::msg("invalid work document ID"))?;
1636 let work_path = crate::work::work_sidecar_path(&repository);
1637 let Some(document) =
1638 resolve_work_document(&work_path, var(&vars, "scope").unwrap_or("all"), id)?.1
1639 else {
1640 return Ok(RequestResponse::Bytes(protocol::status_bytes(
1641 protocol::RES_NOT_FOUND,
1642 b"work document not found",
1643 )));
1644 };
1645 if crate::work::document_permission_denies(&work_path, id, Operation::Read)? {
1646 return Ok(RequestResponse::Bytes(protocol::status_bytes(
1647 protocol::RES_NOT_FOUND,
1648 b"work document not found",
1649 )));
1650 }
1651 crate::stats::record_download(config, &format!("{group}/{repo}"), remote);
1652 Ok(RequestResponse::Resource {
1653 data: document.content.into_bytes(),
1654 metadata: Some(protocol::metadata_status(protocol::RES_OK)),
1655 auto_compress: true,
1656 })
1657}
1658
1659fn is_image_path(path: &str) -> bool {
1660 Path::new(path)
1661 .extension()
1662 .and_then(|ext| ext.to_str())
1663 .is_some_and(|ext| {
1664 matches!(
1665 ext.to_ascii_lowercase().as_str(),
1666 "webp" | "png" | "jpg" | "jpeg" | "gif" | "tiff" | "tif" | "bmp"
1667 )
1668 })
1669}
1670
1671fn quote_media_path(path: &str) -> String {
1672 let mut out = String::new();
1673 for byte in path.bytes() {
1674 match byte {
1675 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'.' | b'-' | b'~' => {
1676 out.push(byte as char)
1677 }
1678 b' ' => out.push('+'),
1679 _ => out.push_str(&format!("%{byte:02X}")),
1680 }
1681 }
1682 out
1683}
1684
1685pub fn serve_media(
1688 config: &ServerConfig,
1689 access: &Access,
1690 data: &[u8],
1691 remote: Option<&[u8; 16]>,
1692) -> Result<RequestResponse> {
1693 let rejected = || RequestResponse::Bytes(msgpack::pack(&Value::Bool(false)));
1694 let Ok(Value::Map(fields)) = msgpack::unpack_exact(data) else {
1695 return Ok(rejected());
1696 };
1697 if !fields.iter().any(|(key, _)| key.as_str() == Some("key")) {
1698 return Ok(rejected());
1699 }
1700 let Some(path) = fields
1701 .iter()
1702 .find(|(key, _)| key.as_str() == Some("path"))
1703 .and_then(|(_, value)| value.as_str())
1704 else {
1705 return Ok(rejected());
1706 };
1707 let components: Vec<_> = path
1708 .strip_prefix("/media")
1709 .unwrap_or(path)
1710 .trim_start_matches('/')
1711 .splitn(4, '/')
1712 .collect();
1713 if components.len() != 4 {
1714 return Ok(rejected());
1715 }
1716 let vars = BTreeMap::from([
1717 ("g".into(), components[0].into()),
1718 ("r".into(), components[1].into()),
1719 ]);
1720 let Ok((_, _, repository)) = accessible_repository(config, access, remote, &vars) else {
1721 return Ok(rejected());
1722 };
1723 let path = decode_url_component(components[3]);
1724 if path.is_empty() || validate_git_path(&path).is_err() {
1725 return Ok(rejected());
1726 }
1727 let Ok(resolved) = resolve_ref(&repository, components[2]) else {
1728 return Ok(rejected());
1729 };
1730 if !matches!(
1731 git_object_kind(&repository, &resolved, &path).as_deref(),
1732 Ok("blob")
1733 ) {
1734 return Ok(rejected());
1735 }
1736 let output = run_git_output(
1737 Command::new("git")
1738 .arg("--git-dir")
1739 .arg(&repository)
1740 .arg("show")
1741 .arg(format!("{resolved}:{path}")),
1742 GIT_COMMAND_TIMEOUT,
1743 )?;
1744 if !output.status.success() {
1745 return Ok(rejected());
1746 }
1747 let name = path.rsplit('/').next().unwrap_or(&path);
1748 let (data, name) = media_response_data(config.media_conversion, &path, name, output.stdout);
1749 Ok(RequestResponse::File {
1750 data,
1751 metadata: msgpack::pack(&Value::Map(vec![(
1752 Value::Str("name".into()),
1753 Value::Bin(name.as_bytes().to_vec()),
1754 )])),
1755 auto_compress: false,
1756 })
1757}
1758
1759fn media_response_data(
1760 enabled: bool,
1761 path: &str,
1762 name: &str,
1763 original: Vec<u8>,
1764) -> (Vec<u8>, String) {
1765 if enabled && is_image_path(path) && !path.to_ascii_lowercase().ends_with(".webp") {
1766 if let Some(converted) = crate::media::convert_to_webp(&original) {
1767 let stem = name.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(name);
1768 return (converted, format!("{stem}.webp"));
1769 }
1770 }
1771 (original, name.to_string())
1772}
1773
1774pub fn download_file(
1775 config: &ServerConfig,
1776 access: &Access,
1777 data: &[u8],
1778 remote: Option<&[u8; 16]>,
1779) -> Result<RequestResponse> {
1780 let vars = parse_page_vars(data)?;
1781 let (group, repo, repository) = match accessible_repository(config, access, remote, &vars) {
1782 Ok(repo) => repo,
1783 Err(_) => {
1784 return Ok(RequestResponse::Bytes(protocol::status_bytes(
1785 protocol::RES_NOT_FOUND,
1786 b"repository not found",
1787 )));
1788 }
1789 };
1790 if let Some(artifact) = var(&vars, "artifact") {
1791 let artifact = decode_url_component(artifact);
1792 let tag = var(&vars, "tag").unwrap_or("latest");
1793 let releases_path = crate::release::release_sidecar_path(&repository);
1794 let Some(path) = crate::release::artifact_path(&releases_path, tag, &artifact)? else {
1795 return Ok(RequestResponse::Bytes(protocol::status_bytes(
1796 protocol::RES_NOT_FOUND,
1797 b"file not found",
1798 )));
1799 };
1800 let data = fs::read(path)?;
1801 crate::stats::record_release_download(config, &format!("{group}/{repo}"), remote);
1802 return Ok(RequestResponse::Resource {
1803 data,
1804 metadata: Some(protocol::metadata_status(protocol::RES_OK)),
1805 auto_compress: true,
1806 });
1807 }
1808
1809 let reference = var(&vars, "ref").unwrap_or("HEAD");
1810 let path = required_var(&vars, "path")?;
1811 let format = var(&vars, "fmt");
1812 if format.is_some() && (format != Some("mu") || !path.to_ascii_lowercase().ends_with(".md")) {
1813 return Ok(RequestResponse::Bytes(protocol::status_bytes(
1814 protocol::RES_NOT_FOUND,
1815 b"unsupported conversion",
1816 )));
1817 }
1818 validate_git_path(path)?;
1819 let resolved = resolve_ref(&repository, reference)?;
1820 let spec = format!("{resolved}:{path}");
1821 let output = run_git_output(
1822 Command::new("git")
1823 .arg("--git-dir")
1824 .arg(&repository)
1825 .arg("show")
1826 .arg(spec),
1827 GIT_COMMAND_TIMEOUT,
1828 )?;
1829 if !output.status.success() {
1830 return Ok(RequestResponse::Bytes(protocol::status_bytes(
1831 protocol::RES_NOT_FOUND,
1832 b"file not found",
1833 )));
1834 }
1835 let (data, metadata) = if format == Some("mu") {
1836 let markdown = String::from_utf8(output.stdout)
1837 .map_err(|_| Error::msg("markdown download is not UTF-8"))?;
1838 let converted = markdown_to_micron_scoped(
1839 &markdown,
1840 Some(&markdown_blob_url_scope(&group, &repo, reference, path)),
1841 );
1842 let filename = path.rsplit('/').next().unwrap_or(path);
1843 let stem = filename
1844 .rsplit_once('.')
1845 .map(|(stem, _)| stem)
1846 .unwrap_or(filename);
1847 (
1848 converted.trim_end().as_bytes().to_vec(),
1849 msgpack::pack(&Value::Map(vec![
1850 (
1851 Value::UInt(protocol::IDX_RESULT_CODE),
1852 Value::UInt(protocol::RES_OK as u64),
1853 ),
1854 (
1855 Value::Str("name".into()),
1856 Value::Bin(format!("{stem}.mu").into_bytes()),
1857 ),
1858 ])),
1859 )
1860 } else {
1861 (output.stdout, protocol::metadata_status(protocol::RES_OK))
1862 };
1863 crate::stats::record_download(config, &format!("{group}/{repo}"), remote);
1864 Ok(RequestResponse::Resource {
1865 data,
1866 metadata: Some(metadata),
1867 auto_compress: true,
1868 })
1869}
1870
1871fn render_chart(
1872 data: &[u64],
1873 labels: &[String; 2],
1874 color: &str,
1875 secondary_color: Option<&str>,
1876 gradient_factor: f64,
1877 height: u64,
1878) -> String {
1879 if data.is_empty() || data.iter().all(|value| *value == 0) {
1880 return "No data available\n".to_string();
1881 }
1882 let max = data.iter().copied().max().unwrap_or(1).max(1) as f64;
1883 let primary = expand_chart_color(color);
1884 let secondary = secondary_color
1885 .map(expand_chart_color)
1886 .unwrap_or_else(|| dim_chart_color(&primary));
1887 let mut out = format!("`FT{primary}Peak: {}`f\n", max as u64);
1888 for row in (1..=height).rev() {
1889 let row_top = row as f64 / height as f64 * max;
1890 let row_mid = (row as f64 - 0.5) / height as f64 * max;
1891 let grad_top = row as f64 / height as f64;
1892 let grad_mid = (row as f64 - 0.5) / height as f64;
1893 let top_color = gradient_chart_color(&primary, &secondary, grad_top, gradient_factor);
1894 let mid_color = gradient_chart_color(&primary, &secondary, grad_mid, gradient_factor);
1895 out.push('│');
1896 for value in data {
1897 let value = *value as f64;
1898 if value >= row_top {
1899 out.push_str(&format!("`FT{top_color}`BT{mid_color}▀`f`b"));
1900 } else if value >= row_mid || (row == 1 && value > 0.0) {
1901 out.push_str(&format!("`FT{mid_color}▄`f"));
1902 } else {
1903 out.push(' ');
1904 }
1905 }
1906 out.push('\n');
1907 }
1908 out.push('└');
1909 for _ in data {
1910 out.push('─');
1911 }
1912 out.push_str("┘\n");
1913 out.push_str(&format!("`F666{:<12}`f", labels[0]));
1914 let chart_width = data.len() + 2;
1915 let spacing = chart_width.saturating_sub(24);
1916 out.push_str(&" ".repeat(spacing));
1917 out.push_str(&format!("`F666{:>12}`f\n", labels[1]));
1918 out
1919}
1920
1921fn expand_chart_color(color: &str) -> String {
1922 let color = color.trim_start_matches('#');
1923 if color.len() == 3 {
1924 color.chars().flat_map(|ch| [ch, ch]).collect()
1925 } else {
1926 color.chars().take(6).collect()
1927 }
1928}
1929
1930fn dim_chart_color(primary: &str) -> String {
1931 let rgb = chart_rgb(primary);
1932 format!(
1933 "{:02x}{:02x}{:02x}",
1934 (rgb[0] as f64 * 0.42) as u8,
1935 (rgb[1] as f64 * 0.42) as u8,
1936 (rgb[2] as f64 * 0.42) as u8
1937 )
1938}
1939
1940fn gradient_chart_color(primary: &str, secondary: &str, t: f64, factor: f64) -> String {
1941 let primary = chart_rgb(primary);
1942 let secondary = chart_rgb(secondary);
1943 let t = (t * factor).min(1.0);
1944 format!(
1945 "{:02x}{:02x}{:02x}",
1946 (secondary[0] as f64 + (primary[0] as f64 - secondary[0] as f64) * t) as u8,
1947 (secondary[1] as f64 + (primary[1] as f64 - secondary[1] as f64) * t) as u8,
1948 (secondary[2] as f64 + (primary[2] as f64 - secondary[2] as f64) * t) as u8
1949 )
1950}
1951
1952fn chart_rgb(color: &str) -> [u8; 3] {
1953 let color = expand_chart_color(color);
1954 [
1955 u8::from_str_radix(color.get(0..2).unwrap_or("00"), 16).unwrap_or(0),
1956 u8::from_str_radix(color.get(2..4).unwrap_or("00"), 16).unwrap_or(0),
1957 u8::from_str_radix(color.get(4..6).unwrap_or("00"), 16).unwrap_or(0),
1958 ]
1959}
1960
1961fn render_combined_chart(
1962 views: &[u64],
1963 fetches: &[u64],
1964 pushes: &[u64],
1965 downloads: &[u64],
1966 labels: &[String; 2],
1967 height: u64,
1968) -> String {
1969 if views.is_empty() || fetches.is_empty() || pushes.is_empty() || downloads.is_empty() {
1970 return "No data available\n".to_string();
1971 }
1972
1973 let categories = [
1974 ("Pushes", dim_to_black(RCLR_PUSH, 0.87), pushes),
1975 ("Fetches", dim_to_black(RCLR_FETCH, 0.87), fetches),
1976 ("Views", dim_to_black(RCLR_VIEW, 0.87), views),
1977 ("Downloads", dim_to_black(RCLR_DOWNLOAD, 0.87), downloads),
1978 ];
1979 let mut out = categories
1980 .iter()
1981 .map(|(label, color, _)| format!("`FT{color}`BT{color}██`f`b {label}"))
1982 .collect::<Vec<_>>()
1983 .join(" ");
1984 out.push_str("\n\n");
1985 let num_points = views.len();
1986
1987 for row in (1..=height).rev() {
1988 let lower_min = (row as f64 - 1.0) / height as f64;
1989 let lower_max = (row as f64 - 0.5) / height as f64;
1990 let upper_min = (row as f64 - 0.5) / height as f64;
1991 let upper_max = row as f64 / height as f64;
1992 out.push('│');
1993 for index in 0..num_points {
1994 let total: u64 = categories
1995 .iter()
1996 .map(|(_, _, data)| data.get(index).copied().unwrap_or(0))
1997 .sum();
1998 if total == 0 {
1999 out.push(' ');
2000 continue;
2001 }
2002
2003 let upper_cat = stacked_pixel_category(&categories, index, total, upper_min, upper_max);
2004 let lower_cat = stacked_pixel_category(&categories, index, total, lower_min, lower_max);
2005 match (upper_cat, lower_cat) {
2006 (Some(upper), Some(lower)) if upper == lower => {
2007 let color = &categories[upper].1;
2008 out.push_str(&format!("`FT{color}`BT{color}█`f`b"));
2009 }
2010 (Some(upper), Some(lower)) => {
2011 let upper_color = &categories[upper].1;
2012 let lower_color = &categories[lower].1;
2013 out.push_str(&format!("`FT{upper_color}`BT{lower_color}▀`f`b"));
2014 }
2015 (Some(upper), None) => {
2016 let color = &categories[upper].1;
2017 out.push_str(&format!("`FT{color}▀`f"));
2018 }
2019 (None, Some(lower)) => {
2020 let color = &categories[lower].1;
2021 out.push_str(&format!("`FT{color}▄`f"));
2022 }
2023 (None, None) => out.push(' '),
2024 }
2025 }
2026 out.push('\n');
2027 }
2028 out.push('└');
2029 for _ in 0..num_points {
2030 out.push('─');
2031 }
2032 out.push_str("┘\n");
2033 out.push_str(&format!(
2034 "`F666{:<12}`f",
2035 labels[0].chars().take(12).collect::<String>()
2036 ));
2037 let chart_width = num_points + 2;
2038 let spacing = chart_width.saturating_sub(24);
2039 out.push_str(&" ".repeat(spacing));
2040 out.push_str(&format!(
2041 "`F666{:>12}`f\n",
2042 labels[1].chars().take(12).collect::<String>()
2043 ));
2044 out
2045}
2046
2047fn stacked_pixel_category(
2048 categories: &[(&str, String, &[u64])],
2049 index: usize,
2050 total: u64,
2051 pixel_min: f64,
2052 pixel_max: f64,
2053) -> Option<usize> {
2054 let mut cumulative = 0u64;
2055 for (category_index, (_, _, data)) in categories.iter().enumerate() {
2056 let value = data.get(index).copied().unwrap_or(0);
2057 if value == 0 {
2058 continue;
2059 }
2060 let start = cumulative as f64 / total as f64;
2061 cumulative += value;
2062 let end = cumulative as f64 / total as f64;
2063 if pixel_min < end && pixel_max > start {
2064 return Some(category_index);
2065 }
2066 }
2067 None
2068}
2069
2070fn dim_to_black(color: &str, dim: f64) -> String {
2071 gradient_chart_color(&expand_chart_color(color), "000000", dim, 1.0)
2072}
2073
2074fn accessible_groups(
2075 config: &ServerConfig,
2076 access: &Access,
2077 remote: Option<&[u8; 16]>,
2078) -> Result<BTreeMap<String, Vec<String>>> {
2079 let mut groups = BTreeMap::new();
2080 if !config.repositories_dir.exists() {
2081 return Ok(groups);
2082 }
2083 for group_entry in fs::read_dir(&config.repositories_dir)? {
2084 let group_entry = group_entry?;
2085 if !group_entry.file_type()?.is_dir() {
2086 continue;
2087 }
2088 let group = group_entry.file_name().to_string_lossy().to_string();
2089 if validate_group_name(&group).is_err() {
2090 continue;
2091 }
2092 let repos = accessible_repositories(config, access, remote, &group)?;
2093 if !repos.is_empty() {
2094 groups.insert(group, repos);
2095 }
2096 }
2097 Ok(groups)
2098}
2099
2100fn accessible_repositories(
2101 config: &ServerConfig,
2102 access: &Access,
2103 remote: Option<&[u8; 16]>,
2104 group: &str,
2105) -> Result<Vec<String>> {
2106 validate_group_name(group)?;
2107 let group_dir = config.repositories_dir.join(group);
2108 let mut repos = Vec::new();
2109 if !group_dir.exists() {
2110 return Ok(repos);
2111 }
2112 for repo_entry in fs::read_dir(group_dir)? {
2113 let repo_entry = repo_entry?;
2114 if !repo_entry.file_type()?.is_dir() {
2115 continue;
2116 }
2117 let repo = repo_entry.file_name().to_string_lossy().to_string();
2118 let Ok(name) = repository_name(group, &repo) else {
2119 continue;
2120 };
2121 if crate::git::is_bare_repository(&config.repositories_dir.join(&name))
2122 && access.allows(Operation::Read, &name, remote)?
2123 {
2124 repos.push(repo);
2125 }
2126 }
2127 repos.sort();
2128 Ok(repos)
2129}
2130
2131fn accessible_repository(
2132 config: &ServerConfig,
2133 access: &Access,
2134 remote: Option<&[u8; 16]>,
2135 vars: &BTreeMap<String, String>,
2136) -> Result<(String, String, PathBuf)> {
2137 let group = required_var(vars, "g")?;
2138 let repo = required_var(vars, "r")?;
2139 let name = repository_name(group, repo)?;
2140 let path = config.repositories_dir.join(&name);
2141 if !crate::git::is_bare_repository(&path) || !access.allows(Operation::Read, &name, remote)? {
2142 return Err(Error::msg("repository not found"));
2143 }
2144 Ok((group.to_string(), repo.to_string(), path))
2145}
2146
2147fn required_var<'a>(vars: &'a BTreeMap<String, String>, key: &str) -> Result<&'a str> {
2148 var(vars, key).ok_or_else(|| Error::msg(format!("missing page variable {key}")))
2149}
2150
2151fn decode_url_component(value: &str) -> String {
2152 let bytes = value.as_bytes();
2153 let mut out = Vec::with_capacity(bytes.len());
2154 let mut i = 0;
2155 while i < bytes.len() {
2156 match bytes[i] {
2157 b'+' => {
2158 out.push(b' ');
2159 i += 1;
2160 }
2161 b'%' if i + 2 < bytes.len() => {
2162 if let (Some(high), Some(low)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2]))
2163 {
2164 out.push((high << 4) | low);
2165 i += 3;
2166 } else {
2167 out.push(bytes[i]);
2168 i += 1;
2169 }
2170 }
2171 byte => {
2172 out.push(byte);
2173 i += 1;
2174 }
2175 }
2176 }
2177 String::from_utf8_lossy(&out).into_owned()
2178}
2179
2180fn hex_value(byte: u8) -> Option<u8> {
2181 match byte {
2182 b'0'..=b'9' => Some(byte - b'0'),
2183 b'a'..=b'f' => Some(byte - b'a' + 10),
2184 b'A'..=b'F' => Some(byte - b'A' + 10),
2185 _ => None,
2186 }
2187}
2188
2189fn var<'a>(vars: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {
2190 vars.get(&format!("var_{key}"))
2191 .or_else(|| vars.get(key))
2192 .map(String::as_str)
2193}
2194
2195fn repository_name(group: &str, repo: &str) -> Result<String> {
2196 validate_group_name(group)?;
2197 validate_repo_component(repo)?;
2198 let name = format!("{group}/{repo}");
2199 validate_repo_name(&name)?;
2200 Ok(name)
2201}
2202
2203fn validate_group_name(group: &str) -> Result<()> {
2204 validate_repo_component(group)
2205}
2206
2207fn validate_repo_component(component: &str) -> Result<()> {
2208 if component.is_empty()
2209 || component == "."
2210 || component == ".."
2211 || component.contains('/')
2212 || component.contains('\\')
2213 || component.len() > 256
2214 {
2215 Err(Error::msg("invalid repository name"))
2216 } else {
2217 Ok(())
2218 }
2219}
2220
2221fn validate_git_path(path: &str) -> Result<()> {
2222 if path.starts_with('/') || path.contains('\\') {
2223 return Err(Error::msg("invalid repository path"));
2224 }
2225 for component in path.split('/') {
2226 if component == "." || component == ".." {
2227 return Err(Error::msg("invalid repository path"));
2228 }
2229 }
2230 Ok(())
2231}
2232
2233fn normalize_blob_path(path: &str) -> String {
2234 path.strip_prefix("./").unwrap_or(path).replace("/./", "/")
2235}
2236
2237fn validate_refish(value: &str) -> Result<()> {
2238 if value.is_empty()
2239 || value.starts_with('-')
2240 || value.contains("..")
2241 || value.contains('\\')
2242 || value.contains(' ')
2243 || value.contains('~')
2244 || value.contains('^')
2245 || value.contains(':')
2246 {
2247 Err(Error::msg("invalid git ref"))
2248 } else {
2249 Ok(())
2250 }
2251}
2252
2253fn repository_description(repo: &Path) -> Result<String> {
2254 let path = repo.join("description");
2255 if !path.exists() {
2256 return Ok(String::new());
2257 }
2258 Ok(fs::read_to_string(path)?.trim().to_string())
2259}
2260
2261#[derive(Debug, Clone, PartialEq, Eq)]
2262struct RepositoryOrigin {
2263 kind: RepositoryOriginKind,
2264 source: String,
2265 synced_at: Option<u64>,
2266}
2267
2268#[derive(Debug, Clone, PartialEq, Eq)]
2269enum RepositoryOriginKind {
2270 Fork,
2271 Mirror,
2272}
2273
2274impl RepositoryOrigin {
2275 fn label(&self) -> &'static str {
2276 match self.kind {
2277 RepositoryOriginKind::Fork => "Forked",
2278 RepositoryOriginKind::Mirror => "Mirrored",
2279 }
2280 }
2281}
2282
2283fn repository_origin(repo: &Path) -> Result<Option<RepositoryOrigin>> {
2284 let kind_output = run_git_output(
2285 Command::new("git")
2286 .arg("--git-dir")
2287 .arg(repo)
2288 .arg("config")
2289 .arg("--get")
2290 .arg("repository.rngit.type"),
2291 GIT_COMMAND_TIMEOUT,
2292 )?;
2293 if !kind_output.status.success() {
2294 return Ok(None);
2295 }
2296 let kind = match String::from_utf8_lossy(&kind_output.stdout).trim() {
2297 "fork" => RepositoryOriginKind::Fork,
2298 "mirror" => RepositoryOriginKind::Mirror,
2299 _ => return Ok(None),
2300 };
2301 let source_output = run_git_output(
2302 Command::new("git")
2303 .arg("--git-dir")
2304 .arg(repo)
2305 .arg("config")
2306 .arg("--get")
2307 .arg("repository.rngit.upstream.source"),
2308 GIT_COMMAND_TIMEOUT,
2309 )?;
2310 if !source_output.status.success() {
2311 return Ok(None);
2312 }
2313 let source = String::from_utf8_lossy(&source_output.stdout)
2314 .trim()
2315 .to_string();
2316 if source.is_empty() {
2317 return Ok(None);
2318 }
2319 let synced_at = run_git_output(
2320 Command::new("git")
2321 .arg("--git-dir")
2322 .arg(repo)
2323 .arg("config")
2324 .arg("--get")
2325 .arg("repository.rngit.upstream.sync"),
2326 GIT_COMMAND_TIMEOUT,
2327 )
2328 .ok()
2329 .and_then(|output| output.status.success().then_some(output))
2330 .and_then(|output| {
2331 String::from_utf8_lossy(&output.stdout)
2332 .trim()
2333 .parse::<u64>()
2334 .ok()
2335 });
2336
2337 Ok(Some(RepositoryOrigin {
2338 kind,
2339 source,
2340 synced_at,
2341 }))
2342}
2343
2344fn format_sync_age(synced_at: u64) -> String {
2345 let now = SystemTime::now()
2346 .duration_since(UNIX_EPOCH)
2347 .unwrap_or_default()
2348 .as_secs();
2349 let seconds = now.saturating_sub(synced_at);
2350 if seconds < 60 {
2351 format!("{seconds}s")
2352 } else if seconds < 3_600 {
2353 format!("{}m", seconds / 60)
2354 } else if seconds < 86_400 {
2355 format!("{}h", seconds / 3_600)
2356 } else {
2357 format!("{}d", seconds / 86_400)
2358 }
2359}
2360
2361fn repository_thanks(repo: &Path, add: bool) -> Result<u64> {
2362 let path = repo.with_extension("thanks");
2363 let mut count = if path.exists() {
2364 fs::read_to_string(&path)?
2365 .trim()
2366 .parse::<u64>()
2367 .unwrap_or(0)
2368 } else {
2369 0
2370 };
2371 if add {
2372 count = count.saturating_add(1);
2373 fs::write(path, format!("{count}\n"))?;
2374 }
2375 Ok(count)
2376}
2377
2378#[derive(Debug)]
2379struct ReadmeContent {
2380 content: String,
2381 markdown: bool,
2382}
2383
2384fn readme_content(repo: &Path) -> Result<Option<ReadmeContent>> {
2385 const NAMES: &[&str] = &[
2386 "README.mu",
2387 "Readme.mu",
2388 "readme.mu",
2389 "README",
2390 "readme",
2391 "README.md",
2392 "readme.md",
2393 "README.rst",
2394 "README.txt",
2395 "readme.rst",
2396 "readme.txt",
2397 ];
2398 for name in NAMES {
2399 match run_git_output(
2400 Command::new("git")
2401 .arg("--git-dir")
2402 .arg(repo)
2403 .arg("show")
2404 .arg(format!("HEAD:{name}")),
2405 GIT_COMMAND_TIMEOUT,
2406 ) {
2407 Ok(output) if output.status.success() => {
2408 let content = String::from_utf8_lossy(&output.stdout).into_owned();
2409 let markdown = name.ends_with(".md");
2410 let content = if markdown || name.ends_with(".mu") {
2411 content
2412 } else {
2413 m_escape(&content)
2414 };
2415 return Ok(Some(ReadmeContent { content, markdown }));
2416 }
2417 Ok(_) => {}
2418 Err(err) => {
2419 log::warn!("Git command execution failed while reading README: {err}");
2420 }
2421 }
2422 }
2423 Ok(None)
2424}
2425
2426fn markdown_to_micron(input: &str) -> String {
2427 markdown_to_micron_scoped(input, None)
2428}
2429
2430fn markdown_to_micron_scoped(input: &str, url_scope: Option<&str>) -> String {
2431 let lines: Vec<&str> = input.lines().collect();
2432 let mut out = String::new();
2433 let mut code_block: Option<(Option<String>, String)> = None;
2434 let mut index = 0;
2435
2436 while index < lines.len() {
2437 let line = lines[index];
2438 let trimmed = line.trim();
2439 if trimmed.starts_with("```") {
2440 if let Some((language, content)) = code_block.take() {
2441 out.push_str(&markdown_code_block_to_micron(
2442 language.as_deref(),
2443 &content,
2444 ));
2445 } else {
2446 code_block = Some((markdown_fence_language(trimmed), String::new()));
2447 }
2448 index += 1;
2449 continue;
2450 }
2451 if let Some((_, content)) = code_block.as_mut() {
2452 content.push_str(line);
2453 content.push('\n');
2454 index += 1;
2455 continue;
2456 }
2457 if let Some(quote) = markdown_quote(line) {
2458 let mut parts = vec![quote.to_string()];
2459 index += 1;
2460 while index < lines.len() {
2461 if let Some(quote) = markdown_quote(lines[index]) {
2462 parts.push(quote.to_string());
2463 index += 1;
2464 } else {
2465 break;
2466 }
2467 }
2468 let formatted = format_markdown_inline_scoped(&parts.join(" "), url_scope);
2469 for wrapped in markdown_wrap_text(&formatted, 77) {
2470 out.push_str(" │ ");
2471 out.push_str(&wrapped);
2472 out.push('\n');
2473 }
2474 continue;
2475 }
2476 if is_table_start(&lines, index) {
2477 let mut table = Vec::new();
2478 while index < lines.len() && lines[index].contains('|') {
2479 table.push(lines[index]);
2480 index += 1;
2481 }
2482 out.push_str(&format_markdown_table(&table, url_scope));
2483 continue;
2484 }
2485 if let Some((level, text)) = markdown_heading(line) {
2486 out.push_str(&">".repeat(level));
2487 out.push_str(&format_markdown_inline_scoped(text.trim(), url_scope));
2488 out.push('\n');
2489 } else if is_markdown_rule(trimmed) {
2490 out.push_str("-\n");
2491 } else if let Some((indent, text)) = unordered_list_item(line) {
2492 out.push_str(indent);
2493 out.push_str(" • ");
2494 out.push_str(&format_markdown_inline_scoped(text, url_scope));
2495 out.push('\n');
2496 } else if let Some((indent, number, text)) = ordered_list_item(line) {
2497 out.push_str(indent);
2498 out.push_str(number);
2499 out.push_str(". ");
2500 out.push_str(&format_markdown_inline_scoped(text, url_scope));
2501 out.push('\n');
2502 } else {
2503 out.push_str(&format_markdown_line(line, url_scope));
2504 out.push('\n');
2505 }
2506 index += 1;
2507 }
2508 if let Some((language, content)) = code_block {
2509 out.push_str(&markdown_code_block_to_micron(
2510 language.as_deref(),
2511 &content,
2512 ));
2513 }
2514
2515 out
2516}
2517
2518fn markdown_code_block_to_micron(language: Option<&str>, content: &str) -> String {
2519 if language.is_some_and(|language| language.eq_ignore_ascii_case("rawmu")) {
2520 return content.to_string();
2521 }
2522 crate::highlight::literal_block(content, None, language)
2523}
2524
2525fn markdown_fence_language(trimmed: &str) -> Option<String> {
2526 trimmed
2527 .strip_prefix("```")
2528 .and_then(|info| info.split_whitespace().next())
2529 .filter(|language| !language.is_empty())
2530 .map(str::to_string)
2531}
2532
2533fn markdown_quote(line: &str) -> Option<&str> {
2534 line.strip_prefix('>')
2535 .map(|quote| quote.strip_prefix(' ').unwrap_or(quote))
2536}
2537
2538fn markdown_heading(line: &str) -> Option<(usize, &str)> {
2539 let level = line.chars().take_while(|ch| *ch == '#').count();
2540 if (1..=6).contains(&level) && line.as_bytes().get(level) == Some(&b' ') {
2541 Some((level, &line[level + 1..]))
2542 } else {
2543 None
2544 }
2545}
2546
2547fn is_markdown_rule(trimmed: &str) -> bool {
2548 trimmed.len() >= 3
2549 && ["-", "*", "_", "="]
2550 .iter()
2551 .any(|marker| trimmed.chars().all(|ch| ch.to_string() == *marker))
2552}
2553
2554fn unordered_list_item(line: &str) -> Option<(&str, &str)> {
2555 let trimmed = line.trim_start();
2556 let indent = &line[..line.len() - trimmed.len()];
2557 for marker in ["- ", "* ", "+ "] {
2558 if let Some(text) = trimmed.strip_prefix(marker) {
2559 return Some((indent, text));
2560 }
2561 }
2562 None
2563}
2564
2565fn ordered_list_item(line: &str) -> Option<(&str, &str, &str)> {
2566 let trimmed = line.trim_start();
2567 let indent = &line[..line.len() - trimmed.len()];
2568 let (number, text) = trimmed.split_once(". ")?;
2569 if !number.is_empty() && number.chars().all(|ch| ch.is_ascii_digit()) {
2570 Some((indent, number, text))
2571 } else {
2572 None
2573 }
2574}
2575
2576fn format_markdown_line(line: &str, url_scope: Option<&str>) -> String {
2577 let formatted = format_markdown_inline_scoped(line, url_scope);
2578 if (line.starts_with('-') && !line.starts_with("---") && !line.starts_with("- "))
2579 || line.starts_with('<')
2580 {
2581 format!("\\{formatted}")
2582 } else {
2583 formatted
2584 }
2585}
2586
2587fn is_table_start(lines: &[&str], index: usize) -> bool {
2588 index + 1 < lines.len()
2589 && lines[index].contains('|')
2590 && lines[index + 1].contains('|')
2591 && is_table_separator(lines[index + 1])
2592}
2593
2594fn is_table_separator(line: &str) -> bool {
2595 parse_table_row(line)
2596 .iter()
2597 .all(|cell| !cell.is_empty() && cell.chars().all(|ch| matches!(ch, '-' | ':' | ' ')))
2598}
2599
2600#[derive(Clone, Copy)]
2601enum TableAlign {
2602 Left,
2603 Right,
2604 Center,
2605}
2606
2607fn format_markdown_table(lines: &[&str], url_scope: Option<&str>) -> String {
2608 if lines.len() < 2 {
2609 return String::new();
2610 }
2611 let header: Vec<String> = parse_table_row(lines[0])
2612 .into_iter()
2613 .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
2614 .collect();
2615 if header.is_empty() {
2616 return String::new();
2617 }
2618 let columns = header.len();
2619 let mut alignments = parse_table_alignments(lines[1]);
2620 while alignments.len() < columns {
2621 alignments.push(TableAlign::Left);
2622 }
2623 alignments.truncate(columns);
2624
2625 let rows: Vec<Vec<String>> = lines
2626 .iter()
2627 .skip(2)
2628 .map(|line| {
2629 let mut row: Vec<String> = parse_table_row(line)
2630 .into_iter()
2631 .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
2632 .collect();
2633 while row.len() < columns {
2634 row.push(String::new());
2635 }
2636 row.truncate(columns);
2637 row
2638 })
2639 .collect();
2640
2641 let mut widths = vec![3; columns];
2642 for (index, cell) in header.iter().enumerate() {
2643 widths[index] = widths[index].max(markdown_visible_width(cell));
2644 }
2645 for row in &rows {
2646 for (index, cell) in row.iter().enumerate().take(columns) {
2647 widths[index] = widths[index].max(markdown_visible_width(cell));
2648 }
2649 }
2650
2651 let mut out = String::new();
2652 out.push_str(&table_border('┌', '┬', '┐', &widths));
2653 out.push_str(&table_row(
2654 &header,
2655 &widths,
2656 &vec![TableAlign::Left; columns],
2657 ));
2658 out.push_str(&table_border('├', '┼', '┤', &widths));
2659 for row in rows {
2660 out.push_str(&table_row(&row, &widths, &alignments));
2661 }
2662 out.push_str(&table_border('└', '┴', '┘', &widths));
2663 out
2664}
2665
2666fn parse_table_row(line: &str) -> Vec<String> {
2667 let mut trimmed = line.trim();
2668 if let Some(rest) = trimmed.strip_prefix('|') {
2669 trimmed = rest;
2670 }
2671 if let Some(rest) = trimmed.strip_suffix('|') {
2672 trimmed = rest;
2673 }
2674 let mut cells = Vec::new();
2675 let mut current = String::new();
2676 let mut escaped = false;
2677 for ch in trimmed.chars() {
2678 if escaped {
2679 current.push(ch);
2680 escaped = false;
2681 } else if ch == '\\' {
2682 escaped = true;
2683 } else if ch == '|' {
2684 cells.push(current.trim().to_string());
2685 current.clear();
2686 } else {
2687 current.push(ch);
2688 }
2689 }
2690 if escaped {
2691 current.push('\\');
2692 }
2693 cells.push(current.trim().to_string());
2694 cells
2695}
2696
2697fn parse_table_alignments(line: &str) -> Vec<TableAlign> {
2698 parse_table_row(line)
2699 .into_iter()
2700 .map(|cell| {
2701 let cell = cell.trim();
2702 if cell.starts_with(':') && cell.ends_with(':') {
2703 TableAlign::Center
2704 } else if cell.ends_with(':') {
2705 TableAlign::Right
2706 } else {
2707 TableAlign::Left
2708 }
2709 })
2710 .collect()
2711}
2712
2713fn table_border(left: char, middle: char, right: char, widths: &[usize]) -> String {
2714 let mut out = String::new();
2715 out.push(left);
2716 for (index, width) in widths.iter().enumerate() {
2717 out.push_str(&"─".repeat(width + 2));
2718 if index + 1 == widths.len() {
2719 out.push(right);
2720 } else {
2721 out.push(middle);
2722 }
2723 }
2724 out.push('\n');
2725 out
2726}
2727
2728fn table_row(cells: &[String], widths: &[usize], alignments: &[TableAlign]) -> String {
2729 let mut out = String::from("│");
2730 for (index, width) in widths.iter().enumerate() {
2731 let cell = cells.get(index).map(String::as_str).unwrap_or("");
2732 out.push(' ');
2733 out.push_str(&pad_table_cell(
2734 cell,
2735 *width,
2736 alignments.get(index).copied().unwrap_or(TableAlign::Left),
2737 ));
2738 out.push(' ');
2739 out.push('│');
2740 }
2741 out.push('\n');
2742 out
2743}
2744
2745fn pad_table_cell(cell: &str, width: usize, alignment: TableAlign) -> String {
2746 let visible = markdown_visible_width(cell);
2747 let padding = width.saturating_sub(visible);
2748 match alignment {
2749 TableAlign::Left => format!("{cell}{}", " ".repeat(padding)),
2750 TableAlign::Right => format!("{}{cell}", " ".repeat(padding)),
2751 TableAlign::Center => {
2752 let left = padding / 2;
2753 let right = padding - left;
2754 format!("{}{cell}{}", " ".repeat(left), " ".repeat(right))
2755 }
2756 }
2757}
2758
2759fn markdown_visible_width(value: &str) -> usize {
2760 let mut width = 0;
2761 let mut chars = value.chars().peekable();
2762 while let Some(ch) = chars.next() {
2763 if ch == '`' {
2764 if matches!(chars.peek(), Some('F') | Some('B')) {
2765 chars.next();
2766 if matches!(chars.peek(), Some('T')) {
2767 chars.next();
2768 for _ in 0..6 {
2769 chars.next();
2770 }
2771 } else {
2772 for _ in 0..3 {
2773 chars.next();
2774 }
2775 }
2776 continue;
2777 }
2778 if matches!(chars.peek(), Some('!') | Some('*') | Some('_') | Some('=')) {
2779 chars.next();
2780 continue;
2781 }
2782 if matches!(chars.peek(), Some('f') | Some('b') | Some('a')) {
2783 chars.next();
2784 continue;
2785 }
2786 if matches!(chars.peek(), Some('[')) {
2787 let mut label_width = 0;
2788 for link_ch in chars.by_ref() {
2789 if link_ch == '`' {
2790 for target_ch in chars.by_ref() {
2791 if target_ch == ']' {
2792 break;
2793 }
2794 }
2795 break;
2796 }
2797 label_width += 1;
2798 }
2799 width += label_width;
2800 continue;
2801 }
2802 }
2803 width += 1;
2804 }
2805 width
2806}
2807
2808fn markdown_wrap_text(text: &str, width: usize) -> Vec<String> {
2809 if text.is_empty() {
2810 return vec![String::new()];
2811 }
2812 let mut lines = Vec::new();
2813 let mut current = String::new();
2814 let mut current_width = 0;
2815 for word in text.split_whitespace() {
2816 let word_width = markdown_visible_width(word);
2817 if !current.is_empty() && current_width + 1 + word_width > width {
2818 lines.push(current);
2819 current = String::new();
2820 current_width = 0;
2821 }
2822 if !current.is_empty() {
2823 current.push(' ');
2824 current_width += 1;
2825 }
2826 current.push_str(word);
2827 current_width += word_width;
2828 }
2829 if !current.is_empty() {
2830 lines.push(current);
2831 }
2832 if lines.is_empty() {
2833 lines.push(String::new());
2834 }
2835 lines
2836}
2837
2838#[derive(Debug)]
2839enum InlineToken {
2840 Link { label: String, target: String },
2841 Code(String),
2842}
2843
2844#[derive(Debug, Clone, Copy)]
2845struct MarkdownLinkStyle<'a> {
2846 bold: bool,
2847 underline: bool,
2848 color: Option<&'a str>,
2849}
2850
2851impl Default for MarkdownLinkStyle<'_> {
2852 fn default() -> Self {
2853 Self {
2854 bold: true,
2855 underline: true,
2856 color: None,
2857 }
2858 }
2859}
2860
2861fn format_markdown_inline_scoped(input: &str, url_scope: Option<&str>) -> String {
2862 let (with_placeholders, tokens) = extract_markdown_inline_tokens(input);
2863 let escaped = m_escape(&with_placeholders);
2864 let styled = apply_markdown_style(&escaped, "**", "`!", "`!");
2865 let styled = apply_markdown_style(&styled, "__", "`!", "`!");
2866 let styled = apply_markdown_style(&styled, "*", "`*", "`*");
2867 let styled = apply_markdown_style(&styled, "_", "`*", "`*");
2868 restore_markdown_inline_tokens(&styled, &tokens, url_scope)
2869}
2870
2871fn extract_markdown_inline_tokens(input: &str) -> (String, Vec<InlineToken>) {
2872 let mut out = String::new();
2873 let mut tokens = Vec::new();
2874 let mut index = 0;
2875
2876 while index < input.len() {
2877 let rest = &input[index..];
2878 if let Some(stripped) = rest.strip_prefix('`') {
2879 if let Some(end) = stripped.find('`') {
2880 let content = &stripped[..end];
2881 if is_markdown_inline_code(content) {
2882 tokens.push(InlineToken::Code(content.to_string()));
2883 out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2884 index += end + 2;
2885 continue;
2886 }
2887 }
2888 }
2889 if rest.starts_with('[') {
2890 if let Some((consumed, label, target)) = parse_markdown_link(rest) {
2891 tokens.push(InlineToken::Link { label, target });
2892 out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2893 index += consumed;
2894 continue;
2895 }
2896 }
2897 let Some(ch) = rest.chars().next() else {
2898 break;
2899 };
2900 out.push(ch);
2901 index += ch.len_utf8();
2902 }
2903
2904 (out, tokens)
2905}
2906
2907fn is_markdown_inline_code(content: &str) -> bool {
2908 !content.is_empty()
2909 && !content
2910 .chars()
2911 .next()
2912 .is_some_and(|ch| matches!(ch, '!' | '*' | '_' | '=' | 'F' | 'B' | 'f' | 'b' | 'a'))
2913}
2914
2915fn parse_markdown_link(input: &str) -> Option<(usize, String, String)> {
2916 let label_end = input[1..].find(']')? + 1;
2917 if !input[label_end + 1..].starts_with('(') {
2918 return None;
2919 }
2920 let target_start = label_end + 2;
2921 let target_end = input[target_start..].find(')')? + target_start;
2922 Some((
2923 target_end + 1,
2924 input[1..label_end].replace('`', ""),
2925 input[target_start..target_end].to_string(),
2926 ))
2927}
2928
2929fn markdown_token_placeholder(index: usize) -> String {
2930 format!("\u{1f}{index}\u{1f}")
2931}
2932
2933fn restore_markdown_inline_tokens(
2934 input: &str,
2935 tokens: &[InlineToken],
2936 url_scope: Option<&str>,
2937) -> String {
2938 let mut out = input.to_string();
2939 for (index, token) in tokens.iter().enumerate() {
2940 let replacement = match token {
2941 InlineToken::Link { label, target } => {
2942 format_markdown_link(label, target, url_scope, MarkdownLinkStyle::default())
2943 }
2944 InlineToken::Code(value) => format!("`BT383838`Fddd{}`f`b", m_escape(value)),
2945 };
2946 out = out.replace(&markdown_token_placeholder(index), &replacement);
2947 }
2948 out
2949}
2950
2951fn format_markdown_link(
2952 label: &str,
2953 target: &str,
2954 url_scope: Option<&str>,
2955 style: MarkdownLinkStyle<'_>,
2956) -> String {
2957 let mut link = String::new();
2958 if style.underline {
2959 link.push_str("`_");
2960 }
2961 if style.bold {
2962 link.push_str("`!");
2963 }
2964 link.push_str("`[");
2965 link.push_str(sanitize_label(label).trim());
2966 link.push('`');
2967 link.push_str(&markdown_link_target(target, url_scope));
2968 link.push(']');
2969 if style.bold {
2970 link.push_str("`!");
2971 }
2972 if style.underline {
2973 link.push_str("`_");
2974 }
2975
2976 match style.color {
2977 Some(color) if color.len() == 3 => format!("`F{color}{link}`f"),
2978 Some(color) if color.len() == 6 => format!("`FT{color}{link}`f"),
2979 _ => link,
2980 }
2981}
2982
2983fn markdown_link_target(value: &str, url_scope: Option<&str>) -> String {
2984 if let Some(scope) = url_scope {
2985 if !value.contains(":/") {
2986 let (path, anchor) = value
2987 .split_once('#')
2988 .map(|(path, anchor)| (path, Some(anchor)))
2989 .unwrap_or((value, None));
2990 let mut out = format!("{scope}{}", sanitize_field(path));
2991 if let Some(anchor) = anchor.filter(|anchor| !anchor.is_empty()) {
2992 out.push_str("|anchor=");
2993 out.push_str(&sanitize_field(anchor));
2994 }
2995 return out;
2996 }
2997 }
2998 sanitize_markdown_link_target(value)
2999}
3000
3001fn sanitize_markdown_link_target(value: &str) -> String {
3002 value
3003 .chars()
3004 .filter(|ch| *ch != '[' && *ch != ']' && *ch != '`')
3005 .collect()
3006}
3007
3008fn apply_markdown_style(input: &str, marker: &str, open: &str, close: &str) -> String {
3009 let mut out = String::new();
3010 let mut rest = input;
3011
3012 while let Some(start) = rest.find(marker) {
3013 let content_start = start + marker.len();
3014 let Some(end) = rest[content_start..].find(marker) else {
3015 out.push_str(rest);
3016 return out;
3017 };
3018 let content_end = content_start + end;
3019 out.push_str(&rest[..start]);
3020 out.push_str(open);
3021 out.push_str(&rest[content_start..content_end]);
3022 out.push_str(close);
3023 rest = &rest[content_end + marker.len()..];
3024 }
3025 out.push_str(rest);
3026 out
3027}
3028
3029#[derive(Debug)]
3030struct Refs {
3031 heads: Vec<RefInfo>,
3032 tags: Vec<RefInfo>,
3033}
3034
3035#[derive(Debug)]
3036struct RefInfo {
3037 sha: String,
3038 name: String,
3039}
3040
3041fn git_refs(repo: &Path) -> Result<Refs> {
3042 let output = run_git(
3043 Command::new("git")
3044 .arg("--git-dir")
3045 .arg(repo)
3046 .args(["for-each-ref", "--format=%(objectname) %(refname)"]),
3047 )?;
3048 let mut refs = Refs {
3049 heads: Vec::new(),
3050 tags: Vec::new(),
3051 };
3052 for line in output.lines() {
3053 let Some((sha, name)) = line.split_once(' ') else {
3054 continue;
3055 };
3056 if let Some(short) = name.strip_prefix("refs/heads/") {
3057 refs.heads.push(RefInfo {
3058 sha: sha.to_string(),
3059 name: short.to_string(),
3060 });
3061 } else if let Some(short) = name.strip_prefix("refs/tags/") {
3062 refs.tags.push(RefInfo {
3063 sha: sha.to_string(),
3064 name: short.to_string(),
3065 });
3066 }
3067 }
3068 Ok(refs)
3069}
3070
3071fn resolve_ref(repo: &Path, reference: &str) -> Result<String> {
3072 validate_refish(reference)?;
3073 let output = run_git(
3074 Command::new("git")
3075 .arg("--git-dir")
3076 .arg(repo)
3077 .arg("rev-parse")
3078 .arg("--verify")
3079 .arg(format!("{reference}^{{commit}}")),
3080 )?;
3081 Ok(output.trim().to_string())
3082}
3083
3084#[derive(Debug)]
3085struct TreeEntry {
3086 kind: String,
3087 name: String,
3088 size: Option<u64>,
3089}
3090
3091impl TreeEntry {
3092 fn size_label(&self) -> String {
3093 self.size
3094 .map(|size| format!(" ({size} bytes)"))
3095 .unwrap_or_default()
3096 }
3097}
3098
3099fn tree_entries(repo: &Path, reference: &str, path: &str) -> Result<Vec<TreeEntry>> {
3100 validate_git_path(path)?;
3101 let spec = if path.is_empty() {
3102 reference.to_string()
3103 } else {
3104 format!("{reference}:{path}")
3105 };
3106 let output = run_git(
3107 Command::new("git")
3108 .arg("--git-dir")
3109 .arg(repo)
3110 .arg("ls-tree")
3111 .arg("-l")
3112 .arg(spec),
3113 )?;
3114 let mut entries = Vec::new();
3115 for line in output.lines() {
3116 let Some((meta, name)) = line.split_once('\t') else {
3117 continue;
3118 };
3119 let parts: Vec<&str> = meta.split_whitespace().collect();
3120 if parts.len() < 4 {
3121 continue;
3122 }
3123 let size = parts[3].parse::<u64>().ok();
3124 entries.push(TreeEntry {
3125 kind: parts[1].to_string(),
3126 name: name.to_string(),
3127 size,
3128 });
3129 }
3130 entries.sort_by_key(|entry| (entry.kind != "tree", entry.name.to_ascii_lowercase()));
3131 Ok(entries)
3132}
3133
3134fn git_object_kind(repo: &Path, reference: &str, path: &str) -> Result<String> {
3135 validate_git_path(path)?;
3136 let spec = if path.is_empty() {
3137 reference.to_string()
3138 } else {
3139 format!("{reference}:{path}")
3140 };
3141 let output = run_git(
3142 Command::new("git")
3143 .arg("--git-dir")
3144 .arg(repo)
3145 .arg("cat-file")
3146 .arg("-t")
3147 .arg(spec),
3148 )?;
3149 Ok(output.trim().to_string())
3150}
3151
3152struct BlobInfo {
3153 content: String,
3154 size: u64,
3155 binary: bool,
3156 displayable: bool,
3157}
3158
3159fn blob_info(repo: &Path, reference: &str, path: &str) -> Result<BlobInfo> {
3160 validate_git_path(path)?;
3161 let spec = format!("{reference}:{path}");
3162 let size = run_git(
3163 Command::new("git")
3164 .arg("--git-dir")
3165 .arg(repo)
3166 .arg("cat-file")
3167 .arg("-s")
3168 .arg(&spec),
3169 )?
3170 .trim()
3171 .parse::<u64>()
3172 .unwrap_or(0);
3173 if size > 256 * 1024 && !is_image_path(path) {
3174 return Ok(BlobInfo {
3175 content: format!("File is too large to display ({size} bytes)."),
3176 size,
3177 binary: false,
3178 displayable: false,
3179 });
3180 }
3181 let output = run_git_output(
3182 Command::new("git")
3183 .arg("--git-dir")
3184 .arg(repo)
3185 .arg("show")
3186 .arg(spec),
3187 GIT_COMMAND_TIMEOUT,
3188 )?;
3189 if !output.status.success() {
3190 return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
3191 }
3192 let binary = output.stdout.contains(&0);
3193 if size > 256 * 1024 && !binary {
3194 return Ok(BlobInfo {
3195 content: format!("File is too large to display ({size} bytes)."),
3196 size,
3197 binary: false,
3198 displayable: false,
3199 });
3200 }
3201 Ok(BlobInfo {
3202 content: if binary {
3203 "Binary file is not displayed.".to_string()
3204 } else {
3205 String::from_utf8_lossy(&output.stdout).into_owned()
3206 },
3207 size,
3208 binary,
3209 displayable: !binary,
3210 })
3211}
3212
3213#[derive(Debug)]
3214struct CommitSummary {
3215 hash: String,
3216 subject: String,
3217 author: String,
3218 timestamp: i64,
3219}
3220
3221fn commits(repo: &Path, reference: &str, limit: usize) -> Result<Vec<CommitSummary>> {
3222 let format = "%H%x1f%s%x1f%an%x1f%at";
3223 let output = run_git(
3224 Command::new("git")
3225 .arg("--git-dir")
3226 .arg(repo)
3227 .arg("log")
3228 .arg(format!("--format={format}"))
3229 .arg("-n")
3230 .arg(limit.to_string())
3231 .arg(reference),
3232 )?;
3233 let mut commits = Vec::new();
3234 for line in output.lines() {
3235 let parts: Vec<&str> = line.split('\x1f').collect();
3236 if parts.len() == 4 {
3237 commits.push(CommitSummary {
3238 hash: parts[0].to_string(),
3239 subject: parts[1].to_string(),
3240 author: parts[2].to_string(),
3241 timestamp: parts[3].parse().unwrap_or_default(),
3242 });
3243 }
3244 }
3245 Ok(commits)
3246}
3247
3248#[derive(Debug)]
3249struct CommitInfo {
3250 hash: String,
3251 subject: String,
3252 body: String,
3253 author: String,
3254 email: String,
3255 timestamp: i64,
3256 files: Vec<CommitFile>,
3257}
3258
3259#[derive(Debug)]
3260struct CommitFile {
3261 status: String,
3262 path: String,
3263}
3264
3265fn commit_info(repo: &Path, hash: &str) -> Result<CommitInfo> {
3266 validate_refish(hash)?;
3267 let output = run_git(
3268 Command::new("git")
3269 .arg("--git-dir")
3270 .arg(repo)
3271 .arg("show")
3272 .arg("--no-patch")
3273 .arg("--format=%H%x1f%s%x1f%an%x1f%ae%x1f%at%x1f%B")
3274 .arg(hash),
3275 )?;
3276 let parts: Vec<&str> = output.splitn(6, '\x1f').collect();
3277 if parts.len() != 6 {
3278 return Err(Error::msg("invalid commit output"));
3279 }
3280 let files_output = run_git(
3281 Command::new("git")
3282 .arg("--git-dir")
3283 .arg(repo)
3284 .arg("diff-tree")
3285 .arg("--root")
3286 .arg("--no-commit-id")
3287 .arg("--name-status")
3288 .arg("-r")
3289 .arg(hash),
3290 )?;
3291 let mut files = Vec::new();
3292 for line in files_output.lines() {
3293 let Some((status, path)) = line.split_once('\t') else {
3294 continue;
3295 };
3296 files.push(CommitFile {
3297 status: status.to_string(),
3298 path: path.to_string(),
3299 });
3300 }
3301 Ok(CommitInfo {
3302 hash: parts[0].trim().to_string(),
3303 subject: parts[1].trim().to_string(),
3304 author: parts[2].trim().to_string(),
3305 email: parts[3].trim().to_string(),
3306 timestamp: parts[4].trim().parse().unwrap_or_default(),
3307 body: parts[5].trim().to_string(),
3308 files,
3309 })
3310}
3311
3312fn raw_commit_object(repo: &Path, hash: &str) -> Result<Vec<u8>> {
3313 validate_refish(hash)?;
3314 let output = run_git_output(
3315 Command::new("git")
3316 .arg("--git-dir")
3317 .arg(repo)
3318 .arg("cat-file")
3319 .arg("-p")
3320 .arg(hash),
3321 GIT_COMMAND_TIMEOUT,
3322 )?;
3323 if !output.status.success() {
3324 return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
3325 }
3326 Ok(output.stdout)
3327}
3328
3329fn run_git(cmd: &mut Command) -> Result<String> {
3330 let output = run_git_output(cmd, GIT_COMMAND_TIMEOUT)?;
3331 if !output.status.success() {
3332 return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
3333 }
3334 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
3335}
3336
3337fn run_git_output(cmd: &mut Command, timeout: Duration) -> Result<Output> {
3338 let mut stdout = tempfile::tempfile()?;
3342 let mut stderr = tempfile::tempfile()?;
3343 let mut child = cmd
3344 .stdout(Stdio::from(stdout.try_clone()?))
3345 .stderr(Stdio::from(stderr.try_clone()?))
3346 .spawn()?;
3347 let deadline = Instant::now() + timeout;
3348
3349 loop {
3350 if let Some(status) = child.try_wait()? {
3351 stdout.seek(SeekFrom::Start(0))?;
3352 stderr.seek(SeekFrom::Start(0))?;
3353 let mut output = Output {
3354 status,
3355 stdout: Vec::new(),
3356 stderr: Vec::new(),
3357 };
3358 stdout.read_to_end(&mut output.stdout)?;
3359 stderr.read_to_end(&mut output.stderr)?;
3360 return Ok(output);
3361 }
3362
3363 if Instant::now() >= deadline {
3364 let _ = child.kill();
3365 let _ = child.wait();
3366 log::warn!("Git command execution timed out");
3367 return Err(Error::msg("Git command execution timed out"));
3368 }
3369
3370 let sleep_for = deadline
3371 .saturating_duration_since(Instant::now())
3372 .min(Duration::from_millis(10));
3373 if !sleep_for.is_zero() {
3374 thread::sleep(sleep_for);
3375 }
3376 }
3377}
3378
3379#[cfg(test)]
3380mod tests {
3381 use super::*;
3382 use crate::acl::Access;
3383 use crate::config::ServerConfig;
3384
3385 #[test]
3386 fn media_preserves_original_for_disabled_webp_nonimage_and_failed_conversion() {
3387 for (enabled, name) in [
3388 (false, "test.png"),
3389 (true, "test.WEBP"),
3390 (true, "test.txt"),
3391 (true, "bad.png"),
3392 ] {
3393 let original = b"not an image".to_vec();
3394 let (bytes, response_name) = media_response_data(enabled, name, name, original.clone());
3395 assert_eq!(bytes, original);
3396 assert_eq!(response_name, name);
3397 }
3398 }
3399
3400 #[test]
3401 fn media_serves_decoded_blob_with_filename_and_checks_access() {
3402 let tmp = tempfile::tempdir().unwrap();
3403 let mut config = cfg(tmp.path());
3404 let payload = b"RIFF\0image bytes";
3405 create_repo_bytes(
3406 config.repositories_dir.join("public/images"),
3407 "docs/a + b.webp",
3408 payload,
3409 );
3410 let request = msgpack::pack(&Value::Map(vec![
3411 (Value::Str("key".into()), Value::Bin(vec![1, 2, 3])),
3412 (
3413 Value::Str("path".into()),
3414 Value::Str("/media/public/images/HEAD/docs%2Fa+%2B+b.webp".into()),
3415 ),
3416 ]));
3417 match serve_media(&config, &access(&config), &request, None).unwrap() {
3418 RequestResponse::File {
3419 data,
3420 metadata,
3421 auto_compress,
3422 } => {
3423 assert!(!auto_compress, "media responses must not auto-compress");
3424 assert_eq!(data, payload);
3425 assert_eq!(
3426 msgpack::unpack_exact(&metadata).unwrap(),
3427 Value::Map(vec![(
3428 Value::Str("name".into()),
3429 Value::Bin(b"a + b.webp".to_vec())
3430 ),])
3431 );
3432 }
3433 _ => panic!("expected media resource"),
3434 }
3435 config.allow_read = vec!["none".into()];
3436 assert!(
3437 matches!(serve_media(&config, &access(&config), &request, None).unwrap(), RequestResponse::Bytes(data) if data == msgpack::pack(&Value::Bool(false)))
3438 );
3439 }
3440
3441 #[test]
3442 fn media_rejects_missing_key_bad_paths_and_unknown_objects() {
3443 let tmp = tempfile::tempdir().unwrap();
3444 let config = cfg(tmp.path());
3445 create_repo_bytes(
3446 config.repositories_dir.join("public/images"),
3447 "image.webp",
3448 b"RIFF\0data",
3449 );
3450 let access = access(&config);
3451 let mut requests = vec![
3452 page_request(&[("path", "/media/public/images/HEAD/image.webp")]),
3453 msgpack::pack(&Value::Bool(true)),
3454 ];
3455 for path in [
3456 "/media/public/images",
3457 "/media/public/images/HEAD/../secret",
3458 "/media/public/images/missing/image.webp",
3459 "/media/public/images/HEAD/missing.webp",
3460 "/media/../images/HEAD/image.webp",
3461 ] {
3462 requests.push(page_request(&[("key", "k"), ("path", path)]));
3463 }
3464 for request in requests {
3465 assert!(
3466 matches!(serve_media(&config, &access, &request, None).unwrap(), RequestResponse::Bytes(data) if data == msgpack::pack(&Value::Bool(false)))
3467 );
3468 }
3469 }
3470
3471 #[test]
3472 fn binary_image_blob_renders_micron_media_reference() {
3473 let tmp = tempfile::tempdir().unwrap();
3474 let config = cfg(tmp.path());
3475 create_repo_bytes(
3476 config.repositories_dir.join("public/images"),
3477 "docs/a + b.PNG",
3478 &vec![0; 256 * 1024 + 1],
3479 );
3480 let page = render_page(
3481 PATH_BLOB,
3482 &config,
3483 &access(&config),
3484 &page_request(&[
3485 ("var_g", "public"),
3486 ("var_r", "images"),
3487 ("var_path", "docs/a + b.PNG"),
3488 ]),
3489 None,
3490 )
3491 .unwrap();
3492 assert!(
3493 page.contains("`(Image file`w=n`a=c`:/media/public/images/HEAD/docs%2Fa+%2B+b.PNG)")
3494 );
3495 assert!(!page.contains("Binary file is not displayed"));
3496 }
3497
3498 #[test]
3499 fn blob_path_normalization_removes_only_the_explicit_relative_prefix() {
3500 assert_eq!(normalize_blob_path("./docs/guide.md"), "docs/guide.md");
3501 assert_eq!(normalize_blob_path("docs/./guide.md"), "docs/guide.md");
3502 assert_eq!(normalize_blob_path(".hidden/config"), ".hidden/config");
3503 assert_eq!(normalize_blob_path(".../guide.md"), ".../guide.md");
3504 assert_eq!(normalize_blob_path("/absolute.md"), "/absolute.md");
3505 assert!(validate_git_path(&normalize_blob_path("/absolute.md")).is_err());
3506 }
3507 use crate::logging;
3508 use rns_core::msgpack::{self, Value};
3509 use rns_crypto::OsRng;
3510 use std::fs;
3511 use std::process::Command;
3512
3513 #[test]
3514 fn nomadnet_destination_uses_upstream_name() {
3515 let identity = Identity::new(&mut OsRng);
3516 let destination = destination_for_identity(&identity);
3517 let expected =
3518 Destination::single_in("nomadnetwork", &["node"], IdentityHash(*identity.hash()));
3519 assert_eq!(destination.hash, expected.hash);
3520 }
3521
3522 #[test]
3523 fn chart_renders_positive_values_on_bottom_row() {
3524 let labels = ["start".to_string(), "end".to_string()];
3525 let chart = render_chart(&[1, 100], &labels, "57c", None, 1.3, 10);
3526 let bottom_row = chart.lines().nth(10).unwrap();
3527
3528 assert!(bottom_row.starts_with("│`FT"));
3529 assert!(bottom_row.contains('▄'));
3530 }
3531
3532 #[test]
3533 fn git_command_timeout_kills_slow_process() {
3534 let start = Instant::now();
3535
3536 let err =
3537 run_git_output(Command::new("sleep").arg("5"), Duration::from_millis(30)).unwrap_err();
3538
3539 assert!(start.elapsed() < Duration::from_secs(1));
3540 assert!(err.to_string().contains("timed out"));
3541 }
3542
3543 #[test]
3544 fn commit_message_formatter_escapes_leading_dash_lines() {
3545 let formatted = format_commit_message("subject\n- rendered literally\npath\\with`tick`");
3546
3547 assert_eq!(
3548 formatted,
3549 "subject\n\\- rendered literally\npath\\\\with\\`tick\\`"
3550 );
3551 }
3552
3553 #[test]
3554 fn front_page_lists_only_readable_groups() {
3555 let tmp = tempfile::tempdir().unwrap();
3556 let mut config = cfg(tmp.path());
3557 config.allow_read = vec!["none".into()];
3558 create_repo(
3559 config.repositories_dir.join("public/alpha"),
3560 "README.md",
3561 "alpha readme\n",
3562 );
3563 create_repo(
3564 config.repositories_dir.join("private/secret"),
3565 "README.md",
3566 "secret\n",
3567 );
3568 fs::write(
3569 config.repositories_dir.join("public/group.allowed"),
3570 "read = all\n",
3571 )
3572 .unwrap();
3573 fs::write(
3574 config.repositories_dir.join("private/group.allowed"),
3575 "read = none\n",
3576 )
3577 .unwrap();
3578 let access = access(&config);
3579
3580 let page = render_page(PATH_INDEX, &config, &access, &page_request(&[]), None).unwrap();
3581 assert!(page.contains("public"));
3582 assert!(page.contains("alpha"));
3583 assert!(!page.contains("private"));
3584 assert!(!page.contains("secret"));
3585 }
3586
3587 #[test]
3588 fn group_and_repo_pages_use_repository_metadata() {
3589 let tmp = tempfile::tempdir().unwrap();
3590 let config = cfg(tmp.path());
3591 let repo = create_repo(
3592 config.repositories_dir.join("public/alpha"),
3593 "README.md",
3594 "# Alpha\n",
3595 );
3596 fs::write(repo.join("description"), "Alpha repository\n").unwrap();
3597 let access = access(&config);
3598
3599 let group = render_page(
3600 PATH_GROUP,
3601 &config,
3602 &access,
3603 &page_request(&[("var_g", "public")]),
3604 None,
3605 )
3606 .unwrap();
3607 assert!(group.contains("Alpha repository"));
3608 assert!(group.contains("alpha"));
3609
3610 let repo = render_page(
3611 PATH_REPO,
3612 &config,
3613 &access,
3614 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3615 None,
3616 )
3617 .unwrap();
3618 assert!(repo.contains("rns://"));
3619 assert!(repo.contains("Alpha repository"));
3620 assert!(repo.contains(">Alpha"));
3621 }
3622
3623 #[test]
3624 fn repo_page_renders_fork_and_mirror_sources() {
3625 let tmp = tempfile::tempdir().unwrap();
3626 let config = cfg(tmp.path());
3627 let fork = create_repo(
3628 config.repositories_dir.join("public/forked"),
3629 "README.md",
3630 "# Forked\n",
3631 );
3632 let mirror = create_repo(
3633 config.repositories_dir.join("public/mirrored"),
3634 "README.md",
3635 "# Mirrored\n",
3636 );
3637 run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3638 "config",
3639 "repository.rngit.type",
3640 "fork",
3641 ]));
3642 run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3643 "config",
3644 "repository.rngit.upstream.source",
3645 "rns://00112233445566778899aabbccddeeff/source/repo",
3646 ]));
3647 run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3648 "config",
3649 "repository.rngit.upstream.sync",
3650 "1",
3651 ]));
3652 run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3653 "config",
3654 "repository.rngit.type",
3655 "mirror",
3656 ]));
3657 run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3658 "config",
3659 "repository.rngit.upstream.source",
3660 "https://example.invalid/upstream.git",
3661 ]));
3662 run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3663 "config",
3664 "repository.rngit.upstream.sync",
3665 "1",
3666 ]));
3667 let access = access(&config);
3668
3669 let fork_page = render_page(
3670 PATH_REPO,
3671 &config,
3672 &access,
3673 &page_request(&[("var_g", "public"), ("var_r", "forked")]),
3674 None,
3675 )
3676 .unwrap();
3677 assert!(fork_page.contains("Forked from"));
3678 assert!(fork_page.contains("rns://00112233445566778899aabbccddeeff/source/repo"));
3679 assert!(fork_page.contains("synced"));
3680 assert!(fork_page.contains("ago"));
3681
3682 let mirror_page = render_page(
3683 PATH_REPO,
3684 &config,
3685 &access,
3686 &page_request(&[("var_g", "public"), ("var_r", "mirrored")]),
3687 None,
3688 )
3689 .unwrap();
3690 assert!(mirror_page.contains("Mirrored from"));
3691 assert!(mirror_page.contains("https://example.invalid/upstream.git"));
3692 assert!(mirror_page.contains("synced"));
3693 assert!(mirror_page.contains("ago"));
3694 }
3695
3696 #[test]
3697 fn tree_blob_commits_commit_and_refs_pages_are_git_backed() {
3698 let tmp = tempfile::tempdir().unwrap();
3699 let config = cfg(tmp.path());
3700 let repo = create_repo(
3701 config.repositories_dir.join("public/alpha"),
3702 "src/lib.rs",
3703 "pub fn answer() -> u8 { 42 }\n",
3704 );
3705 run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3706 "tag",
3707 "v1",
3708 "refs/heads/main",
3709 ]));
3710 let access = access(&config);
3711
3712 let tree = render_page(
3713 PATH_TREE,
3714 &config,
3715 &access,
3716 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3717 None,
3718 )
3719 .unwrap();
3720 assert!(tree.contains("src/"));
3721
3722 let blob = render_page(
3723 PATH_BLOB,
3724 &config,
3725 &access,
3726 &page_request(&[
3727 ("var_g", "public"),
3728 ("var_r", "alpha"),
3729 ("var_path", "src/lib.rs"),
3730 ]),
3731 None,
3732 )
3733 .unwrap();
3734 assert!(blob.contains("answer"));
3735 assert!(blob.contains("pub fn answer()"));
3736
3737 let commits = render_page(
3738 PATH_COMMITS,
3739 &config,
3740 &access,
3741 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3742 None,
3743 )
3744 .unwrap();
3745 assert!(commits.contains("initial"));
3746
3747 let commit_hash = run_git(
3748 Command::new("git")
3749 .arg("--git-dir")
3750 .arg(&repo)
3751 .args(["rev-parse", "refs/heads/main"]),
3752 );
3753 run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3754 "update-ref",
3755 "refs/heads/feature",
3756 commit_hash.trim(),
3757 ]));
3758 let branch_commits = render_page(
3759 PATH_COMMITS,
3760 &config,
3761 &access,
3762 &page_request(&[
3763 ("var_g", "public"),
3764 ("var_r", "alpha"),
3765 ("var_ref", "feature"),
3766 ]),
3767 None,
3768 )
3769 .unwrap();
3770 assert!(branch_commits.contains("|ref=feature|h="));
3771 let commit = render_page(
3772 PATH_COMMIT,
3773 &config,
3774 &access,
3775 &page_request(&[
3776 ("var_g", "public"),
3777 ("var_r", "alpha"),
3778 ("var_ref", "feature"),
3779 ("var_h", commit_hash.trim()),
3780 ]),
3781 None,
3782 )
3783 .unwrap();
3784 assert!(commit.contains("initial"));
3785 assert!(commit.contains("src/lib.rs"));
3786 assert!(commit.contains("/page/commits.mu`g=public|r=alpha|ref=feature"));
3787 assert!(!commit.contains("Signature:"));
3788
3789 let refs = render_page(
3790 PATH_REFS,
3791 &config,
3792 &access,
3793 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3794 None,
3795 )
3796 .unwrap();
3797 assert!(refs.contains("main"));
3798 assert!(refs.contains("v1"));
3799 }
3800
3801 #[test]
3802 fn commit_page_shows_valid_reticulum_signature_status() {
3803 let tmp = tempfile::tempdir().unwrap();
3804 let config = cfg(tmp.path());
3805 let repo = create_repo(
3806 config.repositories_dir.join("public/alpha"),
3807 "src/lib.rs",
3808 "pub fn answer() -> u8 { 42 }\n",
3809 );
3810 let access = access(&config);
3811 let tree = run_git(
3812 Command::new("git")
3813 .arg("--git-dir")
3814 .arg(&repo)
3815 .args(["rev-parse", "refs/heads/main^{tree}"]),
3816 );
3817 let identity = Identity::new(&mut OsRng);
3818 let author = crate::util::hex(identity.hash());
3819 let signed_content = format!(
3820 "tree {}\nauthor RNS Page Test <{}> 0 +0000\ncommitter RNS Page Test <{}> 0 +0000\n\nsigned commit\n",
3821 tree.trim(),
3822 author,
3823 author
3824 );
3825 let armored =
3826 crate::commitsigs::sign_message(&identity, signed_content.as_bytes()).unwrap();
3827 let (headers, rest) = signed_content.split_once("\n\n").unwrap();
3828 let mut raw_commit = format!("{headers}\n");
3829 let mut sig_lines = armored.lines();
3830 raw_commit.push_str("gpgsig ");
3831 raw_commit.push_str(sig_lines.next().unwrap());
3832 raw_commit.push('\n');
3833 for line in sig_lines {
3834 raw_commit.push(' ');
3835 raw_commit.push_str(line);
3836 raw_commit.push('\n');
3837 }
3838 raw_commit.push('\n');
3839 raw_commit.push_str(rest);
3840 let commit_file = tmp.path().join("signed-commit");
3841 fs::write(&commit_file, raw_commit).unwrap();
3842 let commit_hash = run_git(
3843 Command::new("git")
3844 .arg("--git-dir")
3845 .arg(&repo)
3846 .args(["hash-object", "-t", "commit", "-w"])
3847 .arg(&commit_file),
3848 );
3849 run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3850 "update-ref",
3851 "refs/heads/main",
3852 commit_hash.trim(),
3853 ]));
3854
3855 let page = render_page(
3856 PATH_COMMIT,
3857 &config,
3858 &access,
3859 &page_request(&[
3860 ("var_g", "public"),
3861 ("var_r", "alpha"),
3862 ("var_h", commit_hash.trim()),
3863 ]),
3864 None,
3865 )
3866 .unwrap();
3867
3868 assert!(page.contains("signed commit"));
3869 assert!(page.contains("Signature: Valid, signed by author"));
3870 }
3871
3872 #[test]
3873 fn blob_page_renders_tree_when_path_is_directory() {
3874 let tmp = tempfile::tempdir().unwrap();
3875 let config = cfg(tmp.path());
3876 create_repo(
3877 config.repositories_dir.join("public/alpha"),
3878 "src/lib.rs",
3879 "pub fn answer() -> u8 { 42 }\n",
3880 );
3881 let access = access(&config);
3882
3883 let page = render_page(
3884 PATH_BLOB,
3885 &config,
3886 &access,
3887 &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_path", "src")]),
3888 None,
3889 )
3890 .unwrap();
3891
3892 assert!(page.contains("files / src"));
3893 assert!(page.contains("lib.rs"));
3894 assert!(page.contains(PATH_BLOB));
3895 assert!(!page.contains("Displaying Raw"));
3896 }
3897
3898 #[test]
3899 fn page_requests_reject_invalid_repository_names() {
3900 let tmp = tempfile::tempdir().unwrap();
3901 let config = cfg(tmp.path());
3902 let access = access(&config);
3903 let err = render_page(
3904 PATH_REPO,
3905 &config,
3906 &access,
3907 &page_request(&[("var_g", ".."), ("var_r", "repo")]),
3908 None,
3909 )
3910 .unwrap_err();
3911 assert!(err.to_string().contains("invalid repository name"));
3912 }
3913
3914 #[test]
3915 fn page_ref_resolution_rejects_invalid_refs_before_git_lookup() {
3916 let tmp = tempfile::tempdir().unwrap();
3917 let config = cfg(tmp.path());
3918 create_repo(
3919 config.repositories_dir.join("public/alpha"),
3920 "README.md",
3921 "# Alpha\n",
3922 );
3923 let access = access(&config);
3924
3925 for (path, vars) in [
3926 (
3927 PATH_TREE,
3928 vec![
3929 ("var_g", "public"),
3930 ("var_r", "alpha"),
3931 ("var_ref", "--upload-pack=/tmp/x"),
3932 ],
3933 ),
3934 (
3935 PATH_BLOB,
3936 vec![
3937 ("var_g", "public"),
3938 ("var_r", "alpha"),
3939 ("var_path", "README.md"),
3940 ("var_ref", "refs/heads/main:README.md"),
3941 ],
3942 ),
3943 (
3944 PATH_COMMITS,
3945 vec![
3946 ("var_g", "public"),
3947 ("var_r", "alpha"),
3948 ("var_ref", "refs/heads/main..refs/heads/other"),
3949 ],
3950 ),
3951 ] {
3952 let err = render_page(path, &config, &access, &page_request(&vars), None).unwrap_err();
3953 assert_eq!(err.to_string(), "invalid git ref");
3954 }
3955 }
3956
3957 #[test]
3958 fn pages_use_base_template_breadcrumbs_and_micron_links() {
3959 let tmp = tempfile::tempdir().unwrap();
3960 let config = cfg(tmp.path());
3961 create_repo(
3962 config.repositories_dir.join("public/alpha"),
3963 "README.md",
3964 "# Alpha\n",
3965 );
3966 let access = access(&config);
3967
3968 let repo = render_page(
3969 PATH_REPO,
3970 &config,
3971 &access,
3972 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3973 None,
3974 )
3975 .unwrap();
3976
3977 assert!(repo.starts_with("#!c=0\n>Test Git Node\n\n>>\n"));
3978 assert!(repo.contains("`!`[Node`:/page/index.mu]`!"));
3979 assert!(repo.contains("`!`[public`:/page/group.mu`g=public]`!"));
3980 assert!(repo.contains("`[Files`:/page/tree.mu`g=public|r=alpha|ref=HEAD]"));
3981 assert!(repo.contains("`[Commits"));
3982 assert!(repo.contains("`[Branches (1)`:/page/refs.mu`g=public|r=alpha|type=heads]"));
3983 assert!(repo.contains("`[Tags (0)`:/page/refs.mu`g=public|r=alpha|type=tags]"));
3984 assert!(repo.contains("<\n-\n`a`F666`[Served by rngit`:/page/index.mu]"));
3985 }
3986
3987 #[test]
3988 fn custom_templates_wrap_page_content_and_replace_variables() {
3989 let tmp = tempfile::tempdir().unwrap();
3990 let config = cfg(tmp.path());
3991 fs::create_dir_all(&config.templates_dir).unwrap();
3992 fs::write(
3993 config.templates_dir.join("base.mu"),
3994 "BASE {NODE_NAME} {VERSION} {GEN_TIME}\n{PAGE_CONTENT}\n{NAVIGATION} \n",
3995 )
3996 .unwrap();
3997 fs::write(
3998 config.templates_dir.join("repo.mu"),
3999 "REPO-BEGIN\n{PAGE_CONTENT}\nREPO-END \n",
4000 )
4001 .unwrap();
4002 create_repo(
4003 config.repositories_dir.join("public/alpha"),
4004 "README.md",
4005 "# Alpha\n",
4006 );
4007 let access = access(&config);
4008
4009 let repo = render_page(
4010 PATH_REPO,
4011 &config,
4012 &access,
4013 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4014 None,
4015 )
4016 .unwrap();
4017
4018 assert!(repo.starts_with("BASE Test Git Node "));
4019 assert!(repo.contains("\nREPO-BEGIN\n"));
4020 assert!(repo.contains(">Alpha"));
4021 assert!(repo.contains("\nREPO-END\n"));
4022 assert!(repo.contains(env!("CARGO_PKG_VERSION")));
4023 assert!(repo.contains("0ms"));
4024 assert!(!repo.ends_with(" \n"));
4025 }
4026
4027 #[test]
4028 fn blocked_unidentified_requests_use_no_ident_page_for_every_page_path() {
4029 let tmp = tempfile::tempdir().unwrap();
4030 let mut config = cfg(tmp.path());
4031 config.blocked_identities.push(NULL_IDENTITY_HASH);
4032 let access = access(&config);
4033
4034 for path in PAGE_PATHS {
4035 let page = render_page(path, &config, &access, &page_request(&[]), None).unwrap();
4036 assert!(
4037 page.contains(
4038 ">>No Identity\n\nThis page requires identification, and none was received."
4039 ),
4040 "{path} did not render the no-ident page: {page}"
4041 );
4042 }
4043 }
4044
4045 #[test]
4046 fn no_ident_page_requires_both_missing_identity_and_blocked_null_hash() {
4047 let tmp = tempfile::tempdir().unwrap();
4048 let mut config = cfg(tmp.path());
4049 let access = access(&config);
4050
4051 let allowed_unidentified =
4052 render_page(PATH_INDEX, &config, &access, &page_request(&[]), None).unwrap();
4053 assert!(!allowed_unidentified.contains(">>No Identity"));
4054
4055 config.blocked_identities.push(NULL_IDENTITY_HASH);
4056 let identified = [0x42; 16];
4057 let allowed_identified = render_page(
4058 PATH_INDEX,
4059 &config,
4060 &access,
4061 &page_request(&[]),
4062 Some(&identified),
4063 )
4064 .unwrap();
4065 assert!(!allowed_identified.contains(">>No Identity"));
4066 }
4067
4068 #[test]
4069 fn custom_no_ident_template_overrides_the_default() {
4070 let tmp = tempfile::tempdir().unwrap();
4071 let mut config = cfg(tmp.path());
4072 config.blocked_identities.push(NULL_IDENTITY_HASH);
4073 fs::create_dir_all(&config.templates_dir).unwrap();
4074 fs::write(
4075 config.templates_dir.join("no_ident.mu"),
4076 ">>Identify First\n\nCustom unidentified response for {NODE_NAME}.\n",
4077 )
4078 .unwrap();
4079 let access = access(&config);
4080
4081 let page = render_page(PATH_INDEX, &config, &access, &page_request(&[]), None).unwrap();
4082
4083 assert!(page.contains(">>Identify First"));
4084 assert!(page.contains("Custom unidentified response for Test Git Node."));
4085 assert!(!page.contains(">>No Identity"));
4086 }
4087
4088 #[test]
4089 fn template_substitution_does_not_rewrite_page_content_placeholders() {
4090 let tmp = tempfile::tempdir().unwrap();
4091 let config = cfg(tmp.path());
4092
4093 let out = apply_template(
4094 "Template {NODE_NAME}\n{PAGE_CONTENT}",
4095 "Literal {NODE_NAME}",
4096 &config,
4097 );
4098
4099 assert!(out.starts_with("Template Test Git Node\n"));
4100 assert!(out.ends_with("Literal {NODE_NAME}"));
4101 }
4102
4103 #[test]
4104 fn unicode_icon_config_changes_repository_navigation_labels() {
4105 let tmp = tempfile::tempdir().unwrap();
4106 let mut config = cfg(tmp.path());
4107 config.unicode_icons = true;
4108 create_repo(
4109 config.repositories_dir.join("public/alpha"),
4110 "README.md",
4111 "# Alpha\n",
4112 );
4113 let access = access(&config);
4114
4115 let repo = render_page(
4116 PATH_REPO,
4117 &config,
4118 &access,
4119 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4120 None,
4121 )
4122 .unwrap();
4123
4124 assert!(repo.contains("🗀 Files"));
4125 assert!(repo.contains("🖹 Commits"));
4126 assert!(repo.contains("⑃ Branches"));
4127 assert!(repo.contains("⌆ Tags"));
4128 }
4129
4130 #[test]
4131 fn repo_page_renders_and_persists_thanks_count() {
4132 let tmp = tempfile::tempdir().unwrap();
4133 let config = cfg(tmp.path());
4134 let repo_path = create_repo(
4135 config.repositories_dir.join("public/alpha"),
4136 "README.md",
4137 "# Alpha\n",
4138 );
4139 let access = access(&config);
4140
4141 let first = render_page(
4142 PATH_REPO,
4143 &config,
4144 &access,
4145 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4146 None,
4147 )
4148 .unwrap();
4149 assert!(first.contains("Thanks (0)"));
4150 assert!(!repo_path.with_extension("thanks").exists());
4151
4152 let thanked = render_page(
4153 PATH_REPO,
4154 &config,
4155 &access,
4156 &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
4157 None,
4158 )
4159 .unwrap();
4160 assert!(thanked.contains("Thanks (1)"));
4161 assert_eq!(
4162 fs::read_to_string(repo_path.with_extension("thanks")).unwrap(),
4163 "1\n"
4164 );
4165
4166 let incremented = render_page(
4167 PATH_REPO,
4168 &config,
4169 &access,
4170 &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
4171 None,
4172 )
4173 .unwrap();
4174 assert!(incremented.contains("Thanks (2)"));
4175
4176 let viewed = render_page(
4177 PATH_REPO,
4178 &config,
4179 &access,
4180 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4181 None,
4182 )
4183 .unwrap();
4184 assert!(viewed.contains("Thanks (2)"));
4185 }
4186
4187 #[test]
4188 fn repo_page_uses_unicode_heart_for_thanks_when_enabled() {
4189 let tmp = tempfile::tempdir().unwrap();
4190 let mut config = cfg(tmp.path());
4191 config.unicode_icons = true;
4192 create_repo(
4193 config.repositories_dir.join("public/alpha"),
4194 "README.md",
4195 "# Alpha\n",
4196 );
4197 let access = access(&config);
4198
4199 let page = render_page(
4200 PATH_REPO,
4201 &config,
4202 &access,
4203 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4204 None,
4205 )
4206 .unwrap();
4207
4208 assert!(page.contains("♥ Thanks (0)"));
4209 assert!(page.contains("thanks=y"));
4210 }
4211
4212 #[test]
4213 fn empty_page_request_data_is_deterministic() {
4214 let tmp = tempfile::tempdir().unwrap();
4215 let config = cfg(tmp.path());
4216 let access = access(&config);
4217
4218 let index = render_page(PATH_INDEX, &config, &access, &[], None).unwrap();
4219 assert!(index.contains(">Groups"));
4220
4221 let err = render_page(PATH_REPO, &config, &access, &[], None).unwrap_err();
4222 assert!(err.to_string().contains("missing page variable g"));
4223 }
4224
4225 #[test]
4226 fn tree_blob_and_commit_pages_escape_content_and_format_metadata() {
4227 let tmp = tempfile::tempdir().unwrap();
4228 let config = cfg(tmp.path());
4229 let repo = create_repo(
4230 config.repositories_dir.join("public/alpha"),
4231 "src/lib.rs",
4232 "pub fn `answer`() -> u8 { 42 }\n",
4233 );
4234 let access = access(&config);
4235
4236 let tree = render_page(
4237 PATH_TREE,
4238 &config,
4239 &access,
4240 &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
4241 None,
4242 )
4243 .unwrap();
4244 assert!(tree.contains("`[src/`:/page/tree.mu`g=public|r=alpha|ref=HEAD|path=src]"));
4245
4246 let blob = render_page(
4247 PATH_BLOB,
4248 &config,
4249 &access,
4250 &page_request(&[
4251 ("var_g", "public"),
4252 ("var_r", "alpha"),
4253 ("var_path", "src/lib.rs"),
4254 ]),
4255 None,
4256 )
4257 .unwrap();
4258 assert!(blob.contains("Text, "));
4259 assert!(!blob.contains("View raw"));
4260 assert!(blob.contains("answer"));
4261 assert!(blob.contains("\\`"));
4262
4263 let silly_blob = render_page(
4264 PATH_BLOB,
4265 &config,
4266 &access,
4267 &page_request(&[
4268 ("var_g", "public"),
4269 ("var_r", "alpha"),
4270 ("var_path", "./src/./lib.rs"),
4271 ]),
4272 None,
4273 )
4274 .unwrap();
4275 assert!(silly_blob.contains(">src/lib.rs `F666"));
4276 assert!(silly_blob.contains("answer"));
4277
4278 let commit_hash = run_git(
4279 Command::new("git")
4280 .arg("--git-dir")
4281 .arg(&repo)
4282 .args(["rev-parse", "refs/heads/main"]),
4283 );
4284 let commit = render_page(
4285 PATH_COMMIT,
4286 &config,
4287 &access,
4288 &page_request(&[
4289 ("var_g", "public"),
4290 ("var_r", "alpha"),
4291 ("var_h", commit_hash.trim()),
4292 ]),
4293 None,
4294 )
4295 .unwrap();
4296 assert!(commit.contains("Author: RNS Page Test"));
4297 assert!(commit.contains("Files changed"));
4298 assert!(commit.contains("A src/lib.rs"));
4299 }
4300
4301 #[test]
4302 fn markdown_converter_handles_links_code_lists_rules_and_tables() {
4303 let input = "# Title\n\
4304\n\
4305See [the docs](https://example.invalid/a_b) and `literal *code*`.\n\
4306\n\
4307- **bold** and *italic*\n\
4308---\n\
4309| Name | Link |\n\
4310| --- | --- |\n\
4311| Row | [go](rns://abc) |\n";
4312
4313 let out = markdown_to_micron(input);
4314 assert!(out.contains(">Title"));
4315 assert!(out.contains("`_`!`[the docs`https://example.invalid/a_b]`!`_"));
4316 assert!(out.contains("`BT383838`Fdddliteral *code*`f`b"));
4317 assert!(out.contains(" • `!bold`! and `*italic`*"));
4318 assert!(out.contains("\n-\n"));
4319 assert!(out.contains("│ Name"));
4320 assert!(out.contains("`_`!`[go`rns://abc]`!`_"));
4321 }
4322
4323 #[test]
4324 fn markdown_converter_handles_blockquotes_and_heading_inline_markup() {
4325 let out = markdown_to_micron(
4326 "# **Important**\n\
4327> **Quoted** text\n\
4328> with [link](rns://abc)\n\
4329\n\
4330after\n",
4331 );
4332 assert!(out.contains(">`!Important`!"));
4333 assert!(out.contains(" │ `!Quoted`! text with `_`!`[link`rns://abc]`!`_"));
4334 assert!(out.contains("\nafter\n"));
4335 }
4336
4337 #[test]
4338 fn markdown_link_conversion_is_isolated_from_later_substitutions() {
4339 let out = markdown_to_micron("[**bold** `code`](https://example.invalid?q=*x*)");
4340 assert!(out.contains("`_`!`[**bold** code`https://example.invalid?q=*x*]`!`_"));
4341 assert!(!out.contains("`!`!"));
4342 assert!(!out.contains("`*x`*"));
4343 }
4344
4345 #[test]
4346 fn markdown_link_style_can_disable_wrappers_and_apply_color() {
4347 let plain = format_markdown_link(
4348 "label",
4349 "rns://abc",
4350 None,
4351 MarkdownLinkStyle {
4352 bold: false,
4353 underline: false,
4354 color: None,
4355 },
4356 );
4357 assert_eq!(plain, "`[label`rns://abc]");
4358
4359 let short_color = format_markdown_link(
4360 "label",
4361 "rns://abc",
4362 None,
4363 MarkdownLinkStyle {
4364 bold: true,
4365 underline: false,
4366 color: Some("abc"),
4367 },
4368 );
4369 assert_eq!(short_color, "`Fabc`!`[label`rns://abc]`!`f");
4370
4371 let true_color = format_markdown_link(
4372 "label",
4373 "rns://abc",
4374 None,
4375 MarkdownLinkStyle {
4376 bold: false,
4377 underline: true,
4378 color: Some("aabbcc"),
4379 },
4380 );
4381 assert_eq!(true_color, "`FTaabbcc`_`[label`rns://abc]`_`f");
4382 }
4383
4384 #[test]
4385 fn markdown_inline_code_that_looks_like_link_does_not_hide_real_links() {
4386 let out = markdown_to_micron(
4387 "`[literal](https://example.invalid/no)` and [real](https://example.invalid/yes)",
4388 );
4389
4390 assert!(out.contains("`BT383838`Fddd[literal](https://example.invalid/no)`f`b"));
4391 assert!(out.contains("`_`!`[real`https://example.invalid/yes]`!`_"));
4392 assert!(!out.contains("`_`!`[literal`https://example.invalid/no]`!`_"));
4393 }
4394
4395 #[test]
4396 fn markdown_tables_handle_empty_cells_escaped_pipes_alignment_and_visible_width() {
4397 let input = "| Name | Status | Notes |\n\
4398| :--- | ---: | :---: |\n\
4399| `code` | **ok** | a\\|b |\n\
4400| empty | | [go](rns://abc) |\n";
4401
4402 let out = markdown_to_micron(input);
4403 assert!(out.contains("┌"));
4404 assert!(out.contains("├"));
4405 assert!(out.contains("└"));
4406 assert!(out.contains("│ Name"));
4407 assert!(out.contains("`BT383838`Fdddcode`f`b"));
4408 assert!(out.contains("`!ok`!"));
4409 assert!(out.contains("a|b"));
4410 assert!(out.contains("empty"));
4411 assert!(out.contains("`_`!`[go`rns://abc]`!`_"));
4412 assert!(!out.contains("a\\|b"));
4413 }
4414
4415 #[test]
4416 fn markdown_table_width_ignores_generated_micron_tags() {
4417 let input = "| A | B |\n\
4418| --- | --- |\n\
4419| [x](rns://abc) | `y` |\n";
4420
4421 let out = markdown_to_micron(input);
4422 assert!(out.contains("│ `_`!`[x`rns://abc]`!`_ "));
4423 assert!(out.contains("│ `BT383838`Fdddy`f`b "));
4424 assert!(out.contains("┌─────┬─────┐"));
4425 }
4426
4427 #[test]
4428 fn markdown_escaping_does_not_create_or_corrupt_micron_tags() {
4429 let out =
4430 markdown_to_micron("literal `!not bold`! and [bad`label](rns://abc`def) plus `raw`");
4431 assert!(out.contains("literal \\`!not bold\\`!"));
4432 assert!(out.contains("`_`!`[badlabel`rns://abcdef]`!`_"));
4433 assert!(out.contains("`BT383838`Fdddraw`f`b"));
4434 assert!(!out.contains("`!not bold`!"));
4435 }
4436
4437 #[test]
4438 fn markdown_escapes_backslashes_and_expands_tabs_before_inline_formatting() {
4439 let out = markdown_to_micron("plain\\path\t**bold\\path** and `code\\path`\n");
4440
4441 assert!(out.contains("plain\\\\path `!bold\\\\path`!"));
4442 assert!(out.contains("`BT383838`Fdddcode\\\\path`f`b"));
4443 }
4444
4445 #[test]
4446 fn markdown_plain_lines_escape_micron_line_start_controls() {
4447 let out = markdown_to_micron("-not a list\n<not alignment\n- list item\n---\n");
4448 assert!(out.contains("\\-not a list\n"));
4449 assert!(out.contains("\\<not alignment\n"));
4450 assert!(out.contains(" • list item\n"));
4451 assert!(out.contains("-\n"));
4452 }
4453
4454 #[test]
4455 fn markdown_converter_keeps_code_blocks_and_unmatched_markers_literal() {
4456 let out = markdown_to_micron(
4457 "```rust\n\
4458**not bold** and `not inline`\n\
4459```\n\
4460Unmatched * marker\n\
44611. numbered\n",
4462 );
4463 assert!(out.contains("not bold"));
4464 assert!(out.contains("\\`not inline\\`"));
4465 assert!(out.contains("`=\n**not bold** and \\`not inline\\`\n`=\n"));
4466 assert!(!out.contains("`!not bold`!"));
4467 assert!(out.contains("Unmatched * marker"));
4468 assert!(out.contains("1. numbered"));
4469 }
4470
4471 #[test]
4472 fn rawmu_fenced_blocks_pass_through_without_escaping() {
4473 let markdown = markdown_to_micron(
4474 "```rawmu
4475`!raw micron`!
4476>Heading
4477```
4478",
4479 );
4480
4481 assert!(markdown.contains(
4482 "`!raw micron`!
4483>Heading
4484"
4485 ));
4486 assert!(!markdown.contains(r"\`!raw micron"));
4487 assert!(!markdown.contains(
4488 "`=
4489"
4490 ));
4491 }
4492
4493 #[test]
4494 fn unknown_extension_and_fence_language_fall_back_to_plain_literals() {
4495 let blob = crate::highlight::literal_block("value `tick`\n", Some("blob.unknown"), None);
4496 assert!(!blob.contains("`FT"));
4497 assert!(blob.contains("value \\`tick\\`"));
4498
4499 let markdown = markdown_to_micron("```not-a-language\nvalue `tick`\n```\n");
4500 assert!(!markdown.contains("`FT"));
4501 assert!(markdown.contains("value \\`tick\\`"));
4502 }
4503
4504 #[test]
4505 fn binary_and_oversized_blobs_use_plain_fallback_messages() {
4506 let tmp = tempfile::tempdir().unwrap();
4507 let config = cfg(tmp.path());
4508 create_repo_bytes(
4509 config.repositories_dir.join("public/binary"),
4510 "payload.bin",
4511 b"abc\0def",
4512 );
4513 let large = vec![b'x'; 256 * 1024 + 1];
4514 create_repo_bytes(
4515 config.repositories_dir.join("public/large"),
4516 "payload.rs",
4517 &large,
4518 );
4519 let access = access(&config);
4520
4521 let binary = render_page(
4522 PATH_BLOB,
4523 &config,
4524 &access,
4525 &page_request(&[
4526 ("var_g", "public"),
4527 ("var_r", "binary"),
4528 ("var_path", "payload.bin"),
4529 ]),
4530 None,
4531 )
4532 .unwrap();
4533 assert!(binary.contains("Binary"));
4534 assert!(binary.contains("Binary file is not displayed."));
4535 assert!(!binary.contains("`FT"));
4536
4537 let oversized = render_page(
4538 PATH_BLOB,
4539 &config,
4540 &access,
4541 &page_request(&[
4542 ("var_g", "public"),
4543 ("var_r", "large"),
4544 ("var_path", "payload.rs"),
4545 ]),
4546 None,
4547 )
4548 .unwrap();
4549 assert!(oversized.contains("File is too large to display"));
4550 assert!(!oversized.contains("`FT"));
4551 }
4552
4553 #[test]
4554 fn markdown_blob_defaults_to_rendered_view_and_raw_view_escapes_source() {
4555 let tmp = tempfile::tempdir().unwrap();
4556 let config = cfg(tmp.path());
4557 create_repo(
4558 config.repositories_dir.join("public/docs"),
4559 "docs/readme.md",
4560 "# Title\n\nSee [docs](https://example.invalid), [guide](guide.md#intro) and `tick`.\n",
4561 );
4562 let access = access(&config);
4563
4564 let rendered = render_page(
4565 PATH_BLOB,
4566 &config,
4567 &access,
4568 &page_request(&[
4569 ("var_g", "public"),
4570 ("var_r", "docs"),
4571 ("var_path", "docs/readme.md"),
4572 ]),
4573 None,
4574 )
4575 .unwrap();
4576 assert!(rendered.contains("Displaying Rendered"));
4577 assert!(rendered.contains("View raw"));
4578 assert!(rendered.contains(">Title"));
4579 assert!(rendered.contains("`_`!`[docs`https://example.invalid]`!`_"));
4580 assert!(rendered.contains(
4581 "`_`!`[guide`:/page/blob.mu`g=public|r=docs|ref=HEAD|path=docs/guide.md|anchor=intro]`!`_"
4582 ));
4583 assert!(rendered.contains("`BT383838`Fdddtick`f`b"));
4584 assert!(!rendered.contains("# Title"));
4585
4586 let raw = render_page(
4587 PATH_BLOB,
4588 &config,
4589 &access,
4590 &page_request(&[
4591 ("var_g", "public"),
4592 ("var_r", "docs"),
4593 ("var_path", "docs/readme.md"),
4594 ("var_raw", "y"),
4595 ]),
4596 None,
4597 )
4598 .unwrap();
4599 assert!(raw.contains("Displaying Raw"));
4600 assert!(raw.contains("View rendered"));
4601 assert!(raw.contains("Title"));
4602 assert!(raw.contains("\\`"));
4603 assert!(raw.contains("tick"));
4604 assert!(!raw.contains("`_`!`[docs`https://example.invalid]`!`_"));
4605 }
4606
4607 #[test]
4608 fn micron_blob_defaults_to_rendered_passthrough_and_raw_view_is_literal() {
4609 let tmp = tempfile::tempdir().unwrap();
4610 let config = cfg(tmp.path());
4611 create_repo(
4612 config.repositories_dir.join("public/micron"),
4613 "README.mu",
4614 ">Micron\n\n`!Already formatted`!\n",
4615 );
4616 let access = access(&config);
4617
4618 let rendered = render_page(
4619 PATH_BLOB,
4620 &config,
4621 &access,
4622 &page_request(&[
4623 ("var_g", "public"),
4624 ("var_r", "micron"),
4625 ("var_path", "README.mu"),
4626 ]),
4627 None,
4628 )
4629 .unwrap();
4630 assert!(rendered.contains("Displaying Rendered"));
4631 assert!(rendered.contains(">Micron"));
4632 assert!(rendered.contains("`!Already formatted`!"));
4633 assert!(!rendered.contains("\\`!Already formatted\\`!"));
4634
4635 let raw = render_page(
4636 PATH_BLOB,
4637 &config,
4638 &access,
4639 &page_request(&[
4640 ("var_g", "public"),
4641 ("var_r", "micron"),
4642 ("var_path", "README.mu"),
4643 ("var_raw", "y"),
4644 ]),
4645 None,
4646 )
4647 .unwrap();
4648 assert!(raw.contains("Displaying Raw"));
4649 assert!(raw.contains("\\`!Already formatted\\`!"));
4650 }
4651
4652 #[test]
4653 fn explicit_render_parameter_renders_markdown_blob() {
4654 let tmp = tempfile::tempdir().unwrap();
4655 let config = cfg(tmp.path());
4656 create_repo(
4657 config.repositories_dir.join("public/docs"),
4658 "docs/readme.md",
4659 "# Explicit\n",
4660 );
4661 let access = access(&config);
4662
4663 let rendered = render_page(
4664 PATH_BLOB,
4665 &config,
4666 &access,
4667 &page_request(&[
4668 ("var_g", "public"),
4669 ("var_r", "docs"),
4670 ("var_path", "docs/readme.md"),
4671 ("var_raw", "y"),
4672 ("var_render", "y"),
4673 ]),
4674 None,
4675 )
4676 .unwrap();
4677 assert!(rendered.contains("Displaying Raw"));
4678 assert!(rendered.contains("#"));
4679 assert!(rendered.contains("Explicit"));
4680
4681 let rendered = render_page(
4682 PATH_BLOB,
4683 &config,
4684 &access,
4685 &page_request(&[
4686 ("var_g", "public"),
4687 ("var_r", "docs"),
4688 ("var_path", "docs/readme.md"),
4689 ("var_render", "y"),
4690 ]),
4691 None,
4692 )
4693 .unwrap();
4694 assert!(rendered.contains("Displaying Rendered"));
4695 assert!(rendered.contains(">Explicit"));
4696 assert!(!rendered.contains("# Explicit"));
4697 }
4698
4699 #[test]
4700 fn markdown_blob_downloads_as_scoped_micron() {
4701 let tmp = tempfile::tempdir().unwrap();
4702 let config = cfg(tmp.path());
4703 create_repo(
4704 config.repositories_dir.join("public/docs"),
4705 "docs/readme.md",
4706 "# Guide\n\n[Next](next.md)\n",
4707 );
4708 let access = access(&config);
4709 let page = render_page(
4710 PATH_BLOB,
4711 &config,
4712 &access,
4713 &page_request(&[
4714 ("var_g", "public"),
4715 ("var_r", "docs"),
4716 ("var_path", "docs/readme.md"),
4717 ]),
4718 None,
4719 )
4720 .unwrap();
4721 assert!(page.contains("Download"));
4722 assert!(page.contains("as micron"));
4723 assert!(page.contains("fmt=mu"));
4724
4725 let request = page_request(&[
4726 ("var_g", "public"),
4727 ("var_r", "docs"),
4728 ("var_path", "docs/readme.md"),
4729 ("var_fmt", "mu"),
4730 ]);
4731 let response = download_file(&config, &access, &request, None).unwrap();
4732 let RequestResponse::Resource { data, metadata, .. } = response else {
4733 panic!("expected converted resource");
4734 };
4735 let converted = String::from_utf8(data).unwrap();
4736 assert!(converted.contains(">Guide"));
4737 assert!(converted.contains("path=docs/next.md"));
4738 let metadata = msgpack::unpack_exact(&metadata.unwrap()).unwrap();
4739 assert_eq!(
4740 metadata.map_get("name").and_then(Value::as_bin),
4741 Some(&b"readme.mu"[..])
4742 );
4743
4744 let raw = download_file(
4745 &config,
4746 &access,
4747 &page_request(&[
4748 ("var_g", "public"),
4749 ("var_r", "docs"),
4750 ("var_path", "docs/readme.md"),
4751 ]),
4752 None,
4753 )
4754 .unwrap();
4755 assert_resource_response(raw, b"# Guide\n\n[Next](next.md)\n");
4756 }
4757
4758 #[test]
4759 fn unsupported_blob_extension_ignores_render_parameter_and_uses_raw_literal() {
4760 let tmp = tempfile::tempdir().unwrap();
4761 let config = cfg(tmp.path());
4762 create_repo(
4763 config.repositories_dir.join("public/text"),
4764 "notes.txt",
4765 "# Not a heading\npath\\name\t`literal`\n",
4766 );
4767 let access = access(&config);
4768
4769 let page = render_page(
4770 PATH_BLOB,
4771 &config,
4772 &access,
4773 &page_request(&[
4774 ("var_g", "public"),
4775 ("var_r", "text"),
4776 ("var_path", "notes.txt"),
4777 ("var_render", "y"),
4778 ]),
4779 None,
4780 )
4781 .unwrap();
4782 assert!(!page.contains("Displaying Rendered"));
4783 assert!(!page.contains("View raw"));
4784 assert!(page.contains("Displaying Raw"));
4785 assert!(page.contains("Download"));
4786 assert!(page.contains("Not a heading"));
4787 assert!(page.contains("path\\\\name \\`literal\\`"));
4788 assert!(page.contains("\\`literal\\`"));
4789 }
4790
4791 #[test]
4792 fn renderable_binary_and_oversized_blobs_do_not_render_content() {
4793 let tmp = tempfile::tempdir().unwrap();
4794 let config = cfg(tmp.path());
4795 create_repo_bytes(
4796 config.repositories_dir.join("public/binary-md"),
4797 "payload.md",
4798 b"# Binary\0payload\n",
4799 );
4800 let large = vec![b'#'; 256 * 1024 + 1];
4801 create_repo_bytes(
4802 config.repositories_dir.join("public/large-md"),
4803 "payload.md",
4804 &large,
4805 );
4806 let access = access(&config);
4807
4808 let binary = render_page(
4809 PATH_BLOB,
4810 &config,
4811 &access,
4812 &page_request(&[
4813 ("var_g", "public"),
4814 ("var_r", "binary-md"),
4815 ("var_path", "payload.md"),
4816 ]),
4817 None,
4818 )
4819 .unwrap();
4820 assert!(binary.contains("Binary"));
4821 assert!(binary.contains("Binary file is not displayed."));
4822 assert!(!binary.contains(">Binary"));
4823
4824 let oversized = render_page(
4825 PATH_BLOB,
4826 &config,
4827 &access,
4828 &page_request(&[
4829 ("var_g", "public"),
4830 ("var_r", "large-md"),
4831 ("var_path", "payload.md"),
4832 ("var_render", "y"),
4833 ]),
4834 None,
4835 )
4836 .unwrap();
4837 assert!(oversized.contains("File is too large to display"));
4838 assert!(!oversized.contains("Displaying Rendered\n\n>"));
4839 }
4840
4841 #[test]
4842 fn code_blocks_render_plain_literal_blocks() {
4843 let blob = crate::highlight::literal_block("pub fn main() {}\n", Some("main.rs"), None);
4844 assert!(!blob.contains("`FT"));
4845 assert!(blob.contains("pub fn main() {}"));
4846
4847 let markdown = markdown_to_micron("```rust\npub fn main() {}\n```\n");
4848 assert!(!markdown.contains("`FT"));
4849 assert!(markdown.contains("pub fn main() {}"));
4850 }
4851
4852 #[test]
4853 fn repo_page_renders_markdown_readme_and_passes_micron_readme_through() {
4854 let tmp = tempfile::tempdir().unwrap();
4855 let config = cfg(tmp.path());
4856 create_repo(
4857 config.repositories_dir.join("public/markdown"),
4858 "README.md",
4859 "# Markdown\n\nSee [docs](https://example.invalid) and [local](docs/intro.md).\n",
4860 );
4861 create_repo(
4862 config.repositories_dir.join("public/micron"),
4863 "README.mu",
4864 ">Micron\n\n`!Already formatted`!\n",
4865 );
4866 create_repo(
4867 config.repositories_dir.join("public/microntight"),
4868 "README.mu",
4869 ">Micron without trailing newline",
4870 );
4871 create_repo(
4872 config.repositories_dir.join("public/micronblank"),
4873 "README.mu",
4874 ">Micron with trailing blanks\n\n\n",
4875 );
4876 let access = access(&config);
4877
4878 let markdown = render_page(
4879 PATH_REPO,
4880 &config,
4881 &access,
4882 &page_request(&[("var_g", "public"), ("var_r", "markdown")]),
4883 None,
4884 )
4885 .unwrap();
4886 assert!(markdown.contains(">Markdown"));
4887 assert!(markdown.contains("`_`!`[docs`https://example.invalid]`!`_"));
4888 assert!(markdown.contains(
4889 "`_`!`[local`:/page/blob.mu`g=public|r=markdown|ref=HEAD|path=docs/intro.md]`!`_"
4890 ));
4891
4892 let micron = render_page(
4893 PATH_REPO,
4894 &config,
4895 &access,
4896 &page_request(&[("var_g", "public"), ("var_r", "micron")]),
4897 None,
4898 )
4899 .unwrap();
4900 assert!(micron.contains(">Micron"));
4901 assert!(micron.contains("`!Already formatted`!"));
4902
4903 let micron = render_page(
4904 PATH_REPO,
4905 &config,
4906 &access,
4907 &page_request(&[("var_g", "public"), ("var_r", "microntight")]),
4908 None,
4909 )
4910 .unwrap();
4911 assert!(micron.contains(">Micron without trailing newline\n"));
4912 assert!(!micron.contains(">Micron without trailing newline<"));
4913
4914 let micron = render_page(
4915 PATH_REPO,
4916 &config,
4917 &access,
4918 &page_request(&[("var_g", "public"), ("var_r", "micronblank")]),
4919 None,
4920 )
4921 .unwrap();
4922 assert!(micron.contains(">Micron with trailing blanks\n"));
4923 assert!(!micron.contains(">Micron with trailing blanks\n\n\n"));
4924 }
4925
4926 #[test]
4927 fn work_pages_render_documents_comments_and_repo_links() {
4928 let tmp = tempfile::tempdir().unwrap();
4929 let config = cfg(tmp.path());
4930 let repo_path = create_repo(
4931 config.repositories_dir.join("public/worked"),
4932 "README.md",
4933 "# Worked\n",
4934 );
4935 let work_path = crate::work::work_sidecar_path(&repo_path);
4936 let created = crate::work::create_document(
4937 &work_path,
4938 crate::work::WorkInput {
4939 title: "Active task".into(),
4940 content: "# Active Body\n".into(),
4941 format: "markdown".into(),
4942 signature: None,
4943 identity: None,
4944 author: [0x11; 16],
4945 },
4946 )
4947 .unwrap();
4948 crate::work::add_comment(
4949 &work_path,
4950 crate::work::WorkScope::Active,
4951 created.id,
4952 crate::work::WorkCommentInput {
4953 content: "Progress update".into(),
4954 format: "markdown".into(),
4955 signature: None,
4956 author: [0x22; 16],
4957 },
4958 )
4959 .unwrap();
4960 let completed = crate::work::create_document(
4961 &work_path,
4962 crate::work::WorkInput {
4963 title: "Completed task".into(),
4964 content: "Done".into(),
4965 format: "micron".into(),
4966 signature: None,
4967 identity: None,
4968 author: [0x11; 16],
4969 },
4970 )
4971 .unwrap();
4972 crate::work::complete_document(&work_path, completed.id, &[0x11; 16]).unwrap();
4973 let access = access(&config);
4974
4975 let repo = render_page(
4976 PATH_REPO,
4977 &config,
4978 &access,
4979 &page_request(&[("var_g", "public"), ("var_r", "worked")]),
4980 None,
4981 )
4982 .unwrap();
4983 assert!(repo.contains("Work (2)"));
4984 assert!(repo.contains(PATH_WORK));
4985
4986 let active = render_page(
4987 PATH_WORK,
4988 &config,
4989 &access,
4990 &page_request(&[("var_g", "public"), ("var_r", "worked")]),
4991 None,
4992 )
4993 .unwrap();
4994 assert!(active.contains(">Active Work Documents"));
4995 assert!(active.contains("[Active (1)`:/page/work.mu"));
4996 assert!(active.contains("[Completed (1)`:/page/work.mu"));
4997 assert!(active.contains("[Proposed (0)`:/page/work.mu"));
4998 assert!(active.contains("[All (2)`:/page/work.mu"));
4999 assert!(active.contains("#1 Active task"));
5000 assert!(active.contains("1 updates"));
5001 assert!(!active.contains("Completed task"));
5002
5003 let completed_page = render_page(
5004 PATH_WORK,
5005 &config,
5006 &access,
5007 &page_request(&[
5008 ("var_g", "public"),
5009 ("var_r", "worked"),
5010 ("var_scope", "completed"),
5011 ]),
5012 None,
5013 )
5014 .unwrap();
5015 assert!(completed_page.contains(">Completed Work Documents"));
5016 assert!(completed_page.contains("[Active (1)`:/page/work.mu"));
5017 assert!(completed_page.contains("Completed task"));
5018
5019 let doc = render_page(
5020 PATH_WORK_DOC,
5021 &config,
5022 &access,
5023 &page_request(&[("var_g", "public"), ("var_r", "worked"), ("var_id", "1")]),
5024 None,
5025 )
5026 .unwrap();
5027 assert!(doc.contains(">Active task"));
5028 assert!(doc.contains(">Active Body"));
5029 assert!(doc.contains(">Updates (1)"));
5030 assert!(doc.contains("Progress update"));
5031 assert!(doc.contains("`[Download`:/file/workdoc`g=public|r=worked|scope=active|id=1]"));
5032
5033 let completed_doc = render_page(
5034 PATH_WORK_DOC,
5035 &config,
5036 &access,
5037 &page_request(&[
5038 ("var_g", "public"),
5039 ("var_r", "worked"),
5040 ("var_id", &completed.id.to_string()),
5041 ]),
5042 None,
5043 )
5044 .unwrap();
5045 assert!(completed_doc.contains(">Completed task"));
5046 assert!(completed_doc.contains("Status: completed"));
5047 assert!(completed_doc.contains("`[work`:/page/work.mu`g=public|r=worked|scope=completed]"));
5048 assert!(completed_doc.contains(&format!(
5049 "`[Download`:/file/workdoc`g=public|r=worked|scope=completed|id={}]",
5050 completed.id
5051 )));
5052
5053 let active_download = download_work_document(
5054 &config,
5055 &access,
5056 &page_request(&[
5057 ("var_g", "public"),
5058 ("var_r", "worked"),
5059 ("var_scope", "active"),
5060 ("var_id", "1"),
5061 ]),
5062 None,
5063 )
5064 .unwrap();
5065 assert_resource_response(active_download, b"# Active Body");
5066
5067 let completed_download = download_work_document(
5068 &config,
5069 &access,
5070 &page_request(&[
5071 ("var_g", "public"),
5072 ("var_r", "worked"),
5073 ("var_id", &completed.id.to_string()),
5074 ]),
5075 None,
5076 )
5077 .unwrap();
5078 assert_resource_response(completed_download, b"Done");
5079
5080 crate::work::set_document_permissions(&work_path, created.id, "read = none\n").unwrap();
5081 let active_after_deny = render_page(
5082 PATH_WORK,
5083 &config,
5084 &access,
5085 &page_request(&[("var_g", "public"), ("var_r", "worked")]),
5086 None,
5087 )
5088 .unwrap();
5089 assert!(active_after_deny.contains("[Active (0)`:/page/work.mu"));
5090 assert!(active_after_deny.contains("[All (1)`:/page/work.mu"));
5091 assert!(!active_after_deny.contains("Active task"));
5092 let denied_doc = render_page(
5093 PATH_WORK_DOC,
5094 &config,
5095 &access,
5096 &page_request(&[("var_g", "public"), ("var_r", "worked"), ("var_id", "1")]),
5097 None,
5098 )
5099 .unwrap();
5100 assert!(denied_doc.contains("Work Document Not Found"));
5101 let denied_download = download_work_document(
5102 &config,
5103 &access,
5104 &page_request(&[("var_g", "public"), ("var_r", "worked"), ("var_id", "1")]),
5105 None,
5106 )
5107 .unwrap();
5108 assert!(matches!(
5109 denied_download,
5110 RequestResponse::Bytes(bytes) if bytes.first() == Some(&protocol::RES_NOT_FOUND)
5111 ));
5112 }
5113
5114 #[test]
5115 fn work_pages_respect_read_access() {
5116 let tmp = tempfile::tempdir().unwrap();
5117 let mut config = cfg(tmp.path());
5118 create_repo(
5119 config.repositories_dir.join("public/private-work"),
5120 "README.md",
5121 "# Private\n",
5122 );
5123 config.allow_read = vec!["none".into()];
5124 let access = access(&config);
5125
5126 assert!(render_page(
5127 PATH_WORK,
5128 &config,
5129 &access,
5130 &page_request(&[("var_g", "public"), ("var_r", "private-work")]),
5131 None,
5132 )
5133 .is_err());
5134 }
5135
5136 fn cfg(root: &std::path::Path) -> ServerConfig {
5137 ServerConfig {
5138 dir: root.to_path_buf(),
5139 reticulum_dir: None,
5140 repositories_dir: root.join("repositories"),
5141 identity_path: root.join("repositories_identity"),
5142 client_identity_path: root.join("client_identity"),
5143 node_name: "Test Git Node".into(),
5144 announce_interval_secs: 300,
5145 serve_nomadnet: true,
5146 templates_dir: root.join("templates"),
5147 unicode_icons: false,
5148 media_conversion: true,
5149 record_stats: false,
5150 stats_ignore_identities: Vec::new(),
5151 stats_push_ignore_identities: Vec::new(),
5152 blocked_identities: Vec::new(),
5153 identity_aliases: std::collections::BTreeMap::new(),
5154 allow_read: vec!["all".into()],
5155 allow_write: vec!["none".into()],
5156 allow_create: vec!["none".into()],
5157 allow_stats: vec!["none".into()],
5158 allow_release: vec!["none".into()],
5159 allow_interact: vec!["none".into()],
5160 allow_propose: vec!["none".into()],
5161 allow_admin: vec!["none".into()],
5162 log_level: logging::DEFAULT_LOG_LEVEL,
5163 }
5164 }
5165
5166 fn access(config: &ServerConfig) -> Access {
5167 Access::new(
5168 &config.allow_read,
5169 &config.allow_write,
5170 &config.allow_create,
5171 &config.allow_stats,
5172 &config.allow_release,
5173 &config.allow_interact,
5174 &config.allow_admin,
5175 config.repositories_dir.clone(),
5176 )
5177 .unwrap()
5178 }
5179
5180 fn page_request(fields: &[(&str, &str)]) -> Vec<u8> {
5181 msgpack::pack(&Value::Map(
5182 fields
5183 .iter()
5184 .map(|(k, v)| (Value::Str((*k).into()), Value::Str((*v).into())))
5185 .collect(),
5186 ))
5187 }
5188
5189 fn assert_resource_response(response: RequestResponse, expected: &[u8]) {
5190 match response {
5191 RequestResponse::Resource { data, metadata, .. } => {
5192 assert_eq!(data, expected);
5193 assert_eq!(
5194 metadata.as_deref(),
5195 Some(&protocol::metadata_status(protocol::RES_OK)[..])
5196 );
5197 }
5198 RequestResponse::Bytes(bytes) => panic!("expected resource response, got {bytes:?}"),
5199 RequestResponse::File { .. } => panic!("expected value response, got file"),
5200 }
5201 }
5202
5203 fn create_repo(path: std::path::PathBuf, file: &str, content: &str) -> std::path::PathBuf {
5204 create_repo_bytes(path, file, content.as_bytes())
5205 }
5206
5207 fn create_repo_bytes(
5208 path: std::path::PathBuf,
5209 file: &str,
5210 content: &[u8],
5211 ) -> std::path::PathBuf {
5212 let work = path.with_extension("work");
5213 fs::create_dir_all(&work).unwrap();
5214 run_git(Command::new("git").arg("init").arg(&work));
5215 let file_path = work.join(file);
5216 fs::create_dir_all(file_path.parent().unwrap()).unwrap();
5217 fs::write(&file_path, content).unwrap();
5218 run_git(Command::new("git").arg("-C").arg(&work).arg("add").arg("."));
5219 run_git(
5220 Command::new("git")
5221 .arg("-C")
5222 .arg(&work)
5223 .arg("-c")
5224 .arg("user.name=RNS Page Test")
5225 .arg("-c")
5226 .arg("user.email=rns-page-test@example.invalid")
5227 .arg("commit")
5228 .arg("-m")
5229 .arg("initial"),
5230 );
5231 run_git(
5232 Command::new("git")
5233 .arg("-C")
5234 .arg(&work)
5235 .args(["branch", "-M", "main"]),
5236 );
5237 run_git(
5238 Command::new("git")
5239 .arg("clone")
5240 .arg("--bare")
5241 .arg(&work)
5242 .arg(&path),
5243 );
5244 path
5245 }
5246
5247 fn run_git(cmd: &mut Command) -> String {
5248 let output = cmd.output().unwrap();
5249 assert!(
5250 output.status.success(),
5251 "git command failed: {}",
5252 String::from_utf8_lossy(&output.stderr)
5253 );
5254 String::from_utf8_lossy(&output.stdout).into_owned()
5255 }
5256}