@nklisch/pi-agile-workflow 0.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +28 -0
  3. package/CHANGELOG.md +591 -0
  4. package/README.md +107 -0
  5. package/docs/ARCHITECTURE.md +716 -0
  6. package/docs/MIGRATION.md +419 -0
  7. package/docs/PRINCIPLES.md +464 -0
  8. package/docs/ROADMAP.md +95 -0
  9. package/docs/SPEC.md +855 -0
  10. package/docs/VISION.md +106 -0
  11. package/extensions/agile-workflow.test.ts +419 -0
  12. package/extensions/agile-workflow.ts +467 -0
  13. package/hooks/hooks.json +52 -0
  14. package/hooks/scripts/prompt-context.py +628 -0
  15. package/hooks/scripts/substrate-maintainer.py +359 -0
  16. package/hooks/scripts/test_prompt_context.py +757 -0
  17. package/package.json +30 -0
  18. package/scripts/install-work-view.sh +165 -0
  19. package/scripts/tests/agent-metadata.test.sh +75 -0
  20. package/scripts/tests/bump-version.test.sh +552 -0
  21. package/scripts/tests/channel-parity.test.sh +117 -0
  22. package/scripts/tests/convert-content-integrity.test.sh +246 -0
  23. package/scripts/tests/convert-install-routing.test.sh +159 -0
  24. package/scripts/tests/install-work-view.test.sh +411 -0
  25. package/scripts/tests/pi-package-metadata.test.sh +119 -0
  26. package/scripts/tests/work-board-shim.test.sh +239 -0
  27. package/scripts/tests/work-view-dist-version.test.sh +105 -0
  28. package/scripts/work-board.sh +64 -0
  29. package/scripts/work-view.sh +423 -0
  30. package/skills/autopilot/SKILL.md +330 -0
  31. package/skills/autopilot/agents/openai.yaml +6 -0
  32. package/skills/board/SKILL.md +62 -0
  33. package/skills/board/agents/openai.yaml +6 -0
  34. package/skills/bold-refactor/SKILL.md +244 -0
  35. package/skills/bold-refactor/agents/openai.yaml +6 -0
  36. package/skills/bug-scan/SKILL.md +378 -0
  37. package/skills/bug-scan/agents/openai.yaml +6 -0
  38. package/skills/bug-scan/references/async-promises.md +119 -0
  39. package/skills/bug-scan/references/concurrency-races.md +148 -0
  40. package/skills/bug-scan/references/data-layer.md +167 -0
  41. package/skills/bug-scan/references/error-handling.md +197 -0
  42. package/skills/bug-scan/references/gate-item-template.md +94 -0
  43. package/skills/bug-scan/references/language-footguns.md +219 -0
  44. package/skills/bug-scan/references/parked-item-template.md +103 -0
  45. package/skills/bug-scan/references/report-template.md +115 -0
  46. package/skills/bug-scan/references/resource-leaks.md +199 -0
  47. package/skills/bug-scan/references/state-closures.md +148 -0
  48. package/skills/bug-scan/references/time-numbers.md +158 -0
  49. package/skills/convert/SKILL.md +1463 -0
  50. package/skills/convert/agents/openai.yaml +6 -0
  51. package/skills/convert/references/legacy-overlap-migration.md +179 -0
  52. package/skills/deep-code-scan/SKILL.md +378 -0
  53. package/skills/deep-code-scan/agents/openai.yaml +6 -0
  54. package/skills/deep-code-scan/references/artifact-ledger.md +148 -0
  55. package/skills/deep-code-scan/references/consolidation.md +91 -0
  56. package/skills/deep-code-scan/references/decomposition.md +127 -0
  57. package/skills/deep-code-scan/references/item-templates.md +263 -0
  58. package/skills/deep-code-scan/references/lane-catalog.md +129 -0
  59. package/skills/deep-code-scan/references/review-gauntlet.md +108 -0
  60. package/skills/deep-code-scan/references/scanner-brief.md +152 -0
  61. package/skills/e2e-test-design/SKILL.md +517 -0
  62. package/skills/e2e-test-design/references/anti-tautology.md +261 -0
  63. package/skills/e2e-test-design/references/service-mocks.md +234 -0
  64. package/skills/e2e-test-design/references/test-taxonomy.md +211 -0
  65. package/skills/epic-design/SKILL.md +476 -0
  66. package/skills/epicize/SKILL.md +210 -0
  67. package/skills/epicize/agents/openai.yaml +6 -0
  68. package/skills/feature-design/SKILL.md +472 -0
  69. package/skills/fix/SKILL.md +173 -0
  70. package/skills/gate-cruft/SKILL.md +258 -0
  71. package/skills/gate-docs/SKILL.md +278 -0
  72. package/skills/gate-patterns/SKILL.md +389 -0
  73. package/skills/gate-refactor/SKILL.md +408 -0
  74. package/skills/gate-security/SKILL.md +275 -0
  75. package/skills/gate-tests/SKILL.md +367 -0
  76. package/skills/groom/SKILL.md +162 -0
  77. package/skills/groom/agents/openai.yaml +6 -0
  78. package/skills/ideate/SKILL.md +185 -0
  79. package/skills/ideate/agents/openai.yaml +6 -0
  80. package/skills/implement/SKILL.md +288 -0
  81. package/skills/implement-orchestrator/SKILL.md +604 -0
  82. package/skills/park/SKILL.md +111 -0
  83. package/skills/perf-design/SKILL.md +427 -0
  84. package/skills/perf-scout/SKILL.md +404 -0
  85. package/skills/perf-scout/agents/openai.yaml +6 -0
  86. package/skills/perf-scout/references/algorithmic-and-data-structures.md +126 -0
  87. package/skills/perf-scout/references/approximation-and-precomputation.md +136 -0
  88. package/skills/perf-scout/references/caching-and-memoization.md +123 -0
  89. package/skills/perf-scout/references/compiler-runtime-and-language.md +136 -0
  90. package/skills/perf-scout/references/database-and-storage-internals.md +144 -0
  91. package/skills/perf-scout/references/distributed-systems.md +150 -0
  92. package/skills/perf-scout/references/game-engine-and-realtime.md +136 -0
  93. package/skills/perf-scout/references/gpu-and-accelerators.md +130 -0
  94. package/skills/perf-scout/references/idea-ranking.md +75 -0
  95. package/skills/perf-scout/references/io-and-batching.md +135 -0
  96. package/skills/perf-scout/references/memory-and-data-locality.md +137 -0
  97. package/skills/perf-scout/references/parallelism-and-vectorization.md +126 -0
  98. package/skills/perf-scout/references/parked-item-template.md +117 -0
  99. package/skills/perf-scout/references/peer-review-pass.md +150 -0
  100. package/skills/perf-scout/references/report-template.md +148 -0
  101. package/skills/principles/SKILL.md +678 -0
  102. package/skills/principles/references/models.md +191 -0
  103. package/skills/principles/references/subagents.md +158 -0
  104. package/skills/prose-author/SKILL.md +185 -0
  105. package/skills/refactor-conventions-creator/SKILL.md +359 -0
  106. package/skills/refactor-conventions-creator/agents/openai.yaml +6 -0
  107. package/skills/refactor-conventions-creator/references/common-structures.md +91 -0
  108. package/skills/refactor-conventions-creator/references/common-styles.md +90 -0
  109. package/skills/refactor-design/SKILL.md +384 -0
  110. package/skills/release-deploy/SKILL.md +667 -0
  111. package/skills/release-deploy/agents/openai.yaml +6 -0
  112. package/skills/research/SKILL.md +166 -0
  113. package/skills/research/agents/openai.yaml +6 -0
  114. package/skills/review/SKILL.md +267 -0
  115. package/skills/review/references/deep-review.md +98 -0
  116. package/skills/review/references/review-lenses.md +68 -0
  117. package/skills/review/references/substrate-side-effects.md +161 -0
  118. package/skills/review/references/target-resolution.md +52 -0
  119. package/skills/scope/SKILL.md +486 -0
  120. package/work-view/Cargo.lock +486 -0
  121. package/work-view/Cargo.toml +9 -0
  122. package/work-view/crates/cli/.work-view-version +1 -0
  123. package/work-view/crates/cli/Cargo.toml +18 -0
  124. package/work-view/crates/cli/src/actionable.rs +500 -0
  125. package/work-view/crates/cli/src/args.rs +792 -0
  126. package/work-view/crates/cli/src/board/assets/board.css +855 -0
  127. package/work-view/crates/cli/src/board/assets/board.js +259 -0
  128. package/work-view/crates/cli/src/board/assets/card.js +139 -0
  129. package/work-view/crates/cli/src/board/assets/components.css +290 -0
  130. package/work-view/crates/cli/src/board/assets/dependency.js +1193 -0
  131. package/work-view/crates/cli/src/board/assets/detail.js +256 -0
  132. package/work-view/crates/cli/src/board/assets/filters.js +389 -0
  133. package/work-view/crates/cli/src/board/assets/index.html +77 -0
  134. package/work-view/crates/cli/src/board/assets/kanban.js +231 -0
  135. package/work-view/crates/cli/src/board/assets/markdown.js +260 -0
  136. package/work-view/crates/cli/src/board/assets/motion.css +131 -0
  137. package/work-view/crates/cli/src/board/assets/state.js +245 -0
  138. package/work-view/crates/cli/src/board/assets/table.js +304 -0
  139. package/work-view/crates/cli/src/board/assets/tokens.css +602 -0
  140. package/work-view/crates/cli/src/board/assets/views.js +41 -0
  141. package/work-view/crates/cli/src/board/assets.rs +162 -0
  142. package/work-view/crates/cli/src/board/feed.rs +204 -0
  143. package/work-view/crates/cli/src/board/mod.rs +246 -0
  144. package/work-view/crates/cli/src/board/open.rs +144 -0
  145. package/work-view/crates/cli/src/board/server.rs +407 -0
  146. package/work-view/crates/cli/src/main.rs +195 -0
  147. package/work-view/crates/cli/src/render.rs +354 -0
  148. package/work-view/crates/cli/src/scope.rs +157 -0
  149. package/work-view/crates/cli/src/stale.rs +581 -0
  150. package/work-view/crates/cli/tests/board-js/dependency-table.test.mjs +64 -0
  151. package/work-view/crates/cli/tests/board-js/expanded-browsing.test.mjs +79 -0
  152. package/work-view/crates/cli/tests/board-js/harness-smoke.test.mjs +11 -0
  153. package/work-view/crates/cli/tests/board-js/harness.mjs +437 -0
  154. package/work-view/crates/cli/tests/board-js/kanban-detail.test.mjs +75 -0
  155. package/work-view/crates/cli/tests/board-js/markdown-filter.test.mjs +60 -0
  156. package/work-view/crates/cli/tests/fixtures/golden/.work/CONVENTIONS.md +3 -0
  157. package/work-view/crates/cli/tests/fixtures/golden/.work/active/epics/epic-alpha.md +16 -0
  158. package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-a.md +16 -0
  159. package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-b.md +16 -0
  160. package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-alpha-1.md +16 -0
  161. package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-research-1.md +19 -0
  162. package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-done.md +16 -0
  163. package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-shipped.md +18 -0
  164. package/work-view/crates/cli/tests/fixtures/golden/.work/backlog/idea-backlog.md +7 -0
  165. package/work-view/crates/cli/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
  166. package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/CONVENTIONS.md +6 -0
  167. package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-dep-done.md +17 -0
  168. package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-design-ready.md +17 -0
  169. package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-impl-ready.md +17 -0
  170. package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/stories/story-review-ready.md +17 -0
  171. package/work-view/crates/cli/tests/fixtures/with-malformed/.work/CONVENTIONS.md +4 -0
  172. package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/epics/malformed-no-closing-fence.md +8 -0
  173. package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/features/good-item.md +16 -0
  174. package/work-view/crates/cli/tests/integration.rs +2833 -0
  175. package/work-view/crates/core/Cargo.toml +13 -0
  176. package/work-view/crates/core/src/error.rs +61 -0
  177. package/work-view/crates/core/src/filter.rs +804 -0
  178. package/work-view/crates/core/src/graph.rs +324 -0
  179. package/work-view/crates/core/src/index.rs +590 -0
  180. package/work-view/crates/core/src/lib.rs +54 -0
  181. package/work-view/crates/core/src/model.rs +202 -0
  182. package/work-view/crates/core/src/parse.rs +494 -0
  183. package/work-view/crates/core/tests/fixtures/golden/.work/CONVENTIONS.md +15 -0
  184. package/work-view/crates/core/tests/fixtures/golden/.work/active/epics/epic-core.md +16 -0
  185. package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-parser.md +16 -0
  186. package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-query.md +16 -0
  187. package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/malformed-no-id.md +15 -0
  188. package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-parse-unit.md +16 -0
  189. package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-security-gate.md +16 -0
  190. package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-old.md +16 -0
  191. package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-parser.md +17 -0
  192. package/work-view/crates/core/tests/fixtures/golden/.work/backlog/idea-future-board.md +7 -0
  193. package/work-view/crates/core/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
  194. package/work-view/crates/core/tests/fixtures/precedence/.work/CONVENTIONS.md +10 -0
  195. package/work-view/crates/core/tests/fixtures/precedence/.work/archive/dup-item.md +18 -0
  196. package/work-view/crates/core/tests/fixtures/precedence/.work/releases/v2.0/dup-item.md +18 -0
  197. package/work-view/crates/core/tests/integration.rs +541 -0
  198. package/work-view/dist/.gitattributes +1 -0
  199. package/work-view/dist/README.md +42 -0
  200. package/work-view/dist/aarch64-apple-darwin/work-view +0 -0
  201. package/work-view/dist/aarch64-unknown-linux-musl/work-view +0 -0
  202. package/work-view/dist/x86_64-apple-darwin/work-view +0 -0
  203. package/work-view/dist/x86_64-unknown-linux-musl/work-view +0 -0
@@ -0,0 +1,144 @@
1
+ //! Browser launching for the local board server.
2
+
3
+ use std::env;
4
+ use std::fs;
5
+ use std::io;
6
+ use std::process::Command;
7
+ use std::thread;
8
+
9
+ use super::BoardOptions;
10
+
11
+ #[derive(Debug, PartialEq, Eq)]
12
+ pub(crate) enum BrowserOpenStatus {
13
+ Skipped,
14
+ Headless,
15
+ Spawned(&'static str),
16
+ Unavailable,
17
+ }
18
+
19
+ pub(crate) fn open_after_bind(url: &str, opts: &BoardOptions) -> BrowserOpenStatus {
20
+ if !should_try_open(opts.no_open, opts.once, graphical_session_available()) {
21
+ if !opts.no_open && !opts.once {
22
+ println!("work-view board: no graphical session detected; open {url}");
23
+ return BrowserOpenStatus::Headless;
24
+ }
25
+ return BrowserOpenStatus::Skipped;
26
+ }
27
+
28
+ for opener in ["xdg-open", "open", "wslview"] {
29
+ match Command::new(opener).arg(url).spawn() {
30
+ Ok(mut child) => {
31
+ thread::spawn(move || {
32
+ let _ = child.wait();
33
+ });
34
+ println!("work-view board: opened browser with {opener}");
35
+ return BrowserOpenStatus::Spawned(opener);
36
+ }
37
+ Err(e) if e.kind() == io::ErrorKind::NotFound => {}
38
+ Err(_) => {}
39
+ }
40
+ }
41
+
42
+ println!("work-view board: could not open a browser automatically; open {url}");
43
+ BrowserOpenStatus::Unavailable
44
+ }
45
+
46
+ fn should_try_open(no_open: bool, once: bool, has_graphical_session: bool) -> bool {
47
+ !no_open && !once && has_graphical_session
48
+ }
49
+
50
+ fn graphical_session_available() -> bool {
51
+ graphical_session_from_env(
52
+ env::var_os("DISPLAY").as_deref(),
53
+ env::var_os("WAYLAND_DISPLAY").as_deref(),
54
+ cfg!(target_os = "macos"),
55
+ running_under_wsl(),
56
+ )
57
+ }
58
+
59
+ fn graphical_session_from_env(
60
+ display: Option<&std::ffi::OsStr>,
61
+ wayland_display: Option<&std::ffi::OsStr>,
62
+ is_macos: bool,
63
+ is_wsl: bool,
64
+ ) -> bool {
65
+ is_macos || is_wsl || nonempty_env(display) || nonempty_env(wayland_display)
66
+ }
67
+
68
+ fn nonempty_env(value: Option<&std::ffi::OsStr>) -> bool {
69
+ value.is_some_and(|v| !v.is_empty())
70
+ }
71
+
72
+ fn running_under_wsl() -> bool {
73
+ let Ok(release) = fs::read_to_string("/proc/sys/kernel/osrelease") else {
74
+ return false;
75
+ };
76
+ let release = release.to_ascii_lowercase();
77
+ release.contains("microsoft") || release.contains("wsl")
78
+ }
79
+
80
+ #[cfg(test)]
81
+ mod tests {
82
+ use super::*;
83
+ use std::ffi::OsStr;
84
+
85
+ #[test]
86
+ fn skips_when_user_disables_open() {
87
+ assert!(!should_try_open(true, false, true));
88
+ }
89
+
90
+ #[test]
91
+ fn skips_during_once_mode() {
92
+ assert!(!should_try_open(false, true, true));
93
+ }
94
+
95
+ #[test]
96
+ fn skips_without_graphical_session() {
97
+ assert!(!should_try_open(false, false, false));
98
+ }
99
+
100
+ #[test]
101
+ fn tries_when_graphical_session_available() {
102
+ assert!(should_try_open(false, false, true));
103
+ }
104
+
105
+ #[test]
106
+ fn detects_display_env() {
107
+ assert!(graphical_session_from_env(
108
+ Some(OsStr::new(":0")),
109
+ None,
110
+ false,
111
+ false
112
+ ));
113
+ }
114
+
115
+ #[test]
116
+ fn detects_wayland_env() {
117
+ assert!(graphical_session_from_env(
118
+ None,
119
+ Some(OsStr::new("wayland-0")),
120
+ false,
121
+ false
122
+ ));
123
+ }
124
+
125
+ #[test]
126
+ fn treats_macos_as_graphical() {
127
+ assert!(graphical_session_from_env(None, None, true, false));
128
+ }
129
+
130
+ #[test]
131
+ fn treats_wsl_as_openable() {
132
+ assert!(graphical_session_from_env(None, None, false, true));
133
+ }
134
+
135
+ #[test]
136
+ fn empty_env_values_do_not_count_as_graphical() {
137
+ assert!(!graphical_session_from_env(
138
+ Some(OsStr::new("")),
139
+ Some(OsStr::new("")),
140
+ false,
141
+ false
142
+ ));
143
+ }
144
+ }
@@ -0,0 +1,407 @@
1
+ //! Localhost HTTP server for `work-view board`.
2
+
3
+ use std::fmt;
4
+ use std::io::{self, Read, Write};
5
+ use std::net::{Ipv4Addr, TcpListener, TcpStream};
6
+ use std::path::{Path, PathBuf};
7
+ use std::time::Duration;
8
+
9
+ use super::{assets, feed, open, BoardOptions};
10
+
11
+ const MAX_REQUEST_HEADER_BYTES: usize = 8192;
12
+ const READ_TIMEOUT: Duration = Duration::from_secs(2);
13
+
14
+ const HEALTHZ: &[u8] = b"ok\n";
15
+ const NOT_FOUND: &[u8] = b"not found\n";
16
+ const BAD_REQUEST: &[u8] = b"bad request\n";
17
+ const FORBIDDEN: &[u8] = b"forbidden\n";
18
+ const METHOD_NOT_ALLOWED: &[u8] = b"method not allowed\n";
19
+ const INTERNAL_ERROR: &[u8] = b"internal server error\n";
20
+
21
+ pub(crate) struct BoundBoard {
22
+ pub url: String,
23
+ pub port: u16,
24
+ }
25
+
26
+ pub(crate) enum BoardServerError {
27
+ Bind { start: u16, message: String },
28
+ LocalAddr(io::Error),
29
+ Serve(io::Error),
30
+ }
31
+
32
+ impl fmt::Display for BoardServerError {
33
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34
+ match self {
35
+ BoardServerError::Bind { start, message } => {
36
+ write!(
37
+ f,
38
+ "could not bind 127.0.0.1:{start} or any higher port: {message}"
39
+ )
40
+ }
41
+ BoardServerError::LocalAddr(e) => write!(f, "could not inspect bound address: {e}"),
42
+ BoardServerError::Serve(e) => write!(f, "server error: {e}"),
43
+ }
44
+ }
45
+ }
46
+
47
+ pub(crate) fn serve_board(
48
+ root: PathBuf,
49
+ opts: BoardOptions,
50
+ ) -> Result<BoundBoard, BoardServerError> {
51
+ let listener = bind_localhost(opts.port)?;
52
+ let port = listener
53
+ .local_addr()
54
+ .map_err(BoardServerError::LocalAddr)?
55
+ .port();
56
+ let bound = BoundBoard {
57
+ url: format!("http://127.0.0.1:{port}/"),
58
+ port,
59
+ };
60
+
61
+ println!(
62
+ "work-view board: serving {} (port {})",
63
+ bound.url, bound.port
64
+ );
65
+ io::stdout().flush().map_err(BoardServerError::Serve)?;
66
+ open::open_after_bind(&bound.url, &opts);
67
+
68
+ let mut served = 0usize;
69
+ loop {
70
+ let (stream, _) = listener.accept().map_err(BoardServerError::Serve)?;
71
+ if let Err(e) = handle_connection(stream, &root) {
72
+ eprintln!("work-view board: request error: {e}");
73
+ }
74
+
75
+ served = served.saturating_add(1);
76
+ if opts.once && served >= 1 {
77
+ return Ok(bound);
78
+ }
79
+ }
80
+ }
81
+
82
+ fn bind_localhost(start: u16) -> Result<TcpListener, BoardServerError> {
83
+ let mut last_error = String::from("no ports were attempted");
84
+ for port in start..=u16::MAX {
85
+ match TcpListener::bind((Ipv4Addr::LOCALHOST, port)) {
86
+ Ok(listener) => return Ok(listener),
87
+ Err(e) if e.kind() == io::ErrorKind::AddrInUse => {
88
+ last_error = e.to_string();
89
+ }
90
+ Err(e) => {
91
+ return Err(BoardServerError::Bind {
92
+ start,
93
+ message: e.to_string(),
94
+ });
95
+ }
96
+ }
97
+ }
98
+
99
+ Err(BoardServerError::Bind {
100
+ start,
101
+ message: last_error,
102
+ })
103
+ }
104
+
105
+ fn handle_connection(mut stream: TcpStream, root: &Path) -> io::Result<()> {
106
+ let request = match read_request(&mut stream) {
107
+ Ok(request) => request,
108
+ Err(ReadRequestError::BadRequest) => {
109
+ return write_response(&mut stream, Method::Get, bad_request_response());
110
+ }
111
+ Err(ReadRequestError::Forbidden) => {
112
+ return write_response(&mut stream, Method::Get, forbidden_response());
113
+ }
114
+ Err(ReadRequestError::MethodNotAllowed) => {
115
+ return write_response(&mut stream, Method::Get, method_not_allowed_response());
116
+ }
117
+ Err(ReadRequestError::Io) => {
118
+ return write_response(&mut stream, Method::Get, internal_error_response());
119
+ }
120
+ };
121
+
122
+ let response = route_request(&request, root);
123
+ write_response(&mut stream, request.method, response)
124
+ }
125
+
126
+ #[derive(Clone, Copy)]
127
+ enum Method {
128
+ Get,
129
+ Head,
130
+ }
131
+
132
+ struct Request {
133
+ method: Method,
134
+ path: String,
135
+ }
136
+
137
+ enum ReadRequestError {
138
+ BadRequest,
139
+ Forbidden,
140
+ MethodNotAllowed,
141
+ Io,
142
+ }
143
+
144
+ fn read_request(stream: &mut TcpStream) -> Result<Request, ReadRequestError> {
145
+ stream
146
+ .set_read_timeout(Some(READ_TIMEOUT))
147
+ .map_err(|_| ReadRequestError::Io)?;
148
+
149
+ let mut bytes = Vec::new();
150
+ let mut chunk = [0u8; 512];
151
+
152
+ while bytes.len() < MAX_REQUEST_HEADER_BYTES {
153
+ let read = stream.read(&mut chunk).map_err(|_| ReadRequestError::Io)?;
154
+ if read == 0 {
155
+ break;
156
+ }
157
+ bytes.extend(chunk.iter().take(read).copied());
158
+ if header_len(&bytes).is_some() {
159
+ break;
160
+ }
161
+ }
162
+
163
+ let header_len = match header_len(&bytes) {
164
+ Some(len) if !bytes.is_empty() => len,
165
+ _ => return Err(ReadRequestError::BadRequest),
166
+ };
167
+ let header_bytes = bytes
168
+ .get(..header_len)
169
+ .ok_or(ReadRequestError::BadRequest)?;
170
+ let text = std::str::from_utf8(header_bytes).map_err(|_| ReadRequestError::BadRequest)?;
171
+ let request_line = text.lines().next().ok_or(ReadRequestError::BadRequest)?;
172
+ if request_line.trim().is_empty() {
173
+ return Err(ReadRequestError::BadRequest);
174
+ }
175
+ let mut parts = request_line.split_whitespace();
176
+ let method = match parts.next() {
177
+ Some("GET") => Method::Get,
178
+ Some("HEAD") => Method::Head,
179
+ Some(_) => return Err(ReadRequestError::MethodNotAllowed),
180
+ None => return Err(ReadRequestError::BadRequest),
181
+ };
182
+ let target = parts.next().ok_or(ReadRequestError::BadRequest)?;
183
+ let version = parts.next().ok_or(ReadRequestError::BadRequest)?;
184
+ if parts.next().is_some() || !version.starts_with("HTTP/") {
185
+ return Err(ReadRequestError::BadRequest);
186
+ }
187
+
188
+ let path = match target.split('?').next() {
189
+ Some(path) => path,
190
+ None => target,
191
+ };
192
+ if !path.starts_with('/') {
193
+ return Err(ReadRequestError::BadRequest);
194
+ }
195
+
196
+ validate_host_header(text)?;
197
+
198
+ Ok(Request {
199
+ method,
200
+ path: path.to_string(),
201
+ })
202
+ }
203
+
204
+ fn validate_host_header(header_text: &str) -> Result<(), ReadRequestError> {
205
+ let mut host = None;
206
+
207
+ for line in header_text.lines().skip(1) {
208
+ if line.trim().is_empty() {
209
+ continue;
210
+ }
211
+ let Some((name, value)) = line.split_once(':') else {
212
+ return Err(ReadRequestError::BadRequest);
213
+ };
214
+ if name.eq_ignore_ascii_case("host") {
215
+ if host.replace(value.trim()).is_some() {
216
+ return Err(ReadRequestError::Forbidden);
217
+ }
218
+ }
219
+ }
220
+
221
+ match host {
222
+ Some(value) if host_is_loopback(value) => Ok(()),
223
+ _ => Err(ReadRequestError::Forbidden),
224
+ }
225
+ }
226
+
227
+ fn host_is_loopback(raw: &str) -> bool {
228
+ let authority = raw.trim().trim_end_matches('.').to_ascii_lowercase();
229
+ if matches!(authority.as_str(), "localhost" | "127.0.0.1" | "::1") {
230
+ return true;
231
+ }
232
+
233
+ if let Some(bracketed) = authority.strip_prefix('[') {
234
+ let Some((host, rest)) = bracketed.split_once(']') else {
235
+ return false;
236
+ };
237
+ return host == "::1" && valid_optional_port(rest);
238
+ }
239
+
240
+ let Some((host, port)) = authority.rsplit_once(':') else {
241
+ return false;
242
+ };
243
+ !host.contains(':')
244
+ && !port.is_empty()
245
+ && port.chars().all(|c| c.is_ascii_digit())
246
+ && matches!(host, "localhost" | "127.0.0.1")
247
+ }
248
+
249
+ fn valid_optional_port(rest: &str) -> bool {
250
+ rest.is_empty()
251
+ || rest
252
+ .strip_prefix(':')
253
+ .is_some_and(|port| !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()))
254
+ }
255
+
256
+ fn header_len(bytes: &[u8]) -> Option<usize> {
257
+ if let Some(position) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
258
+ return Some(position.saturating_add(4));
259
+ }
260
+ bytes
261
+ .windows(2)
262
+ .position(|window| window == b"\n\n")
263
+ .map(|position| position.saturating_add(2))
264
+ }
265
+
266
+ struct Response {
267
+ status: u16,
268
+ reason: &'static str,
269
+ content_type: &'static str,
270
+ body: Vec<u8>,
271
+ allow: Option<&'static str>,
272
+ }
273
+
274
+ fn route_request(request: &Request, root: &Path) -> Response {
275
+ match request.path.as_str() {
276
+ "/healthz" => ok_response("text/plain; charset=utf-8", HEALTHZ),
277
+ "/api/substrate" => match feed::build_feed(root) {
278
+ Ok(json) => ok_response("application/json; charset=utf-8", json.into_bytes()),
279
+ Err(e) => {
280
+ eprintln!("work-view board: feed error: {e}");
281
+ internal_error_response()
282
+ }
283
+ },
284
+ path => match assets::asset_for_path(path) {
285
+ Some(asset) => ok_response(asset.content_type, asset.bytes),
286
+ None => not_found_response(),
287
+ },
288
+ }
289
+ }
290
+
291
+ fn ok_response<B: Into<Vec<u8>>>(content_type: &'static str, body: B) -> Response {
292
+ Response {
293
+ status: 200,
294
+ reason: "OK",
295
+ content_type,
296
+ body: body.into(),
297
+ allow: None,
298
+ }
299
+ }
300
+
301
+ fn not_found_response() -> Response {
302
+ Response {
303
+ status: 404,
304
+ reason: "Not Found",
305
+ content_type: "text/plain; charset=utf-8",
306
+ body: NOT_FOUND.to_vec(),
307
+ allow: None,
308
+ }
309
+ }
310
+
311
+ fn bad_request_response() -> Response {
312
+ Response {
313
+ status: 400,
314
+ reason: "Bad Request",
315
+ content_type: "text/plain; charset=utf-8",
316
+ body: BAD_REQUEST.to_vec(),
317
+ allow: None,
318
+ }
319
+ }
320
+
321
+ fn forbidden_response() -> Response {
322
+ Response {
323
+ status: 403,
324
+ reason: "Forbidden",
325
+ content_type: "text/plain; charset=utf-8",
326
+ body: FORBIDDEN.to_vec(),
327
+ allow: None,
328
+ }
329
+ }
330
+
331
+ fn method_not_allowed_response() -> Response {
332
+ Response {
333
+ status: 405,
334
+ reason: "Method Not Allowed",
335
+ content_type: "text/plain; charset=utf-8",
336
+ body: METHOD_NOT_ALLOWED.to_vec(),
337
+ allow: Some("GET, HEAD"),
338
+ }
339
+ }
340
+
341
+ fn internal_error_response() -> Response {
342
+ Response {
343
+ status: 500,
344
+ reason: "Internal Server Error",
345
+ content_type: "text/plain; charset=utf-8",
346
+ body: INTERNAL_ERROR.to_vec(),
347
+ allow: None,
348
+ }
349
+ }
350
+
351
+ #[cfg(test)]
352
+ mod tests {
353
+ use super::*;
354
+
355
+ #[test]
356
+ fn loopback_hosts_are_allowed() {
357
+ for host in [
358
+ "127.0.0.1",
359
+ "127.0.0.1:8181",
360
+ "localhost",
361
+ "LOCALHOST:8181",
362
+ "[::1]",
363
+ "[::1]:8181",
364
+ "::1",
365
+ ] {
366
+ assert!(host_is_loopback(host), "expected loopback host: {host}");
367
+ }
368
+ }
369
+
370
+ #[test]
371
+ fn non_loopback_hosts_are_rejected() {
372
+ for host in [
373
+ "",
374
+ "example.com",
375
+ "example.com:8181",
376
+ "127.0.0.2",
377
+ "0.0.0.0",
378
+ "[2001:db8::1]:8181",
379
+ "localhost:abc",
380
+ "[::1]:abc",
381
+ ] {
382
+ assert!(!host_is_loopback(host), "expected rejected host: {host}");
383
+ }
384
+ }
385
+ }
386
+
387
+ fn write_response(stream: &mut TcpStream, method: Method, response: Response) -> io::Result<()> {
388
+ let mut headers = format!(
389
+ "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\nX-Content-Type-Options: nosniff\r\n",
390
+ response.status,
391
+ response.reason,
392
+ response.content_type,
393
+ response.body.len()
394
+ );
395
+ if let Some(allow) = response.allow {
396
+ headers.push_str("Allow: ");
397
+ headers.push_str(allow);
398
+ headers.push_str("\r\n");
399
+ }
400
+ headers.push_str("\r\n");
401
+
402
+ stream.write_all(headers.as_bytes())?;
403
+ if matches!(method, Method::Get) {
404
+ stream.write_all(&response.body)?;
405
+ }
406
+ stream.flush()
407
+ }