vivac 0.6.10

Provenance tree for work: every node knows which node it was born from
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
//! `vivac web` — the tree served over HTTP, to a browser on this machine and
//! no other.
//!
//! `d127`/`d141`: the web is the main face and the defenses come first, with
//! a test per case, before a single page exists to attack. `mod gate` is
//! that layer, written to know nothing about a socket so its denials are
//! unit tests rather than tests that first have to stand up a server. This
//! module is the socket: it binds one, reads the handful of headers `Gate`
//! needs, and turns a `Verdict` into an HTTP response.
//!
//! **No CORS, anywhere.** Every response below carries its own set of
//! security headers and none of them is `Access-Control-Allow-*`. A page
//! served from another origin gets nothing back it can read, and `OPTIONS`
//! is answered exactly like any other method -- no preflight is ever
//! satisfied, which is what makes the missing `Access-Control-Allow-*`
//! actually matter.
//!
//! **The index is routing, not a surface.** It only ever redirects to one
//! project or lists which ones there are; the real page -- `WEB.md` §3.1,
//! the Today page a project's `id` routes to -- comes once this layer is in
//! place and proven.

mod gate;
mod map;
mod today;
mod why;

use crate::failure::{Failure, R};
use crate::output::{flush, outln};
use crate::project::{Named, Registry};
use gate::{Denial, Gate, Incoming, Verdict, SESSION_COOKIE};
use std::path::PathBuf;

const HTML: &str = "text/html; charset=utf-8";
const TEXT: &str = "text/plain; charset=utf-8";

/// `WEB.md` §4.1: no inline network resource loads, no framing, no form
/// submission anywhere else, and nothing sniffs the body into something it
/// is not.
const CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; \
                    img-src data:; form-action 'none'; frame-ancestors 'none'; base-uri 'none'";

fn header(name: &str, value: &str) -> tiny_http::Header {
    tiny_http::Header::from_bytes(name.as_bytes(), value.as_bytes())
        .expect("a header built from a literal name and an ASCII value")
}

/// The value of one header, matched by name without regard to case -- HTTP
/// never promises what case a client sends one in.
fn header_value<'a>(headers: &'a [tiny_http::Header], name: &'static str) -> Option<&'a str> {
    headers
        .iter()
        .find(|h| h.field.equiv(name))
        .map(|h| h.value.as_str())
}

/// `WEB.md` §5: embedded, because the CSP admits an inline `<style>` and no
/// external stylesheet at all, and in its own file because nobody maintains
/// two hundred lines of CSS inside a string literal. One skin for every
/// page: two stylesheets for one visual language are two places to diverge.
pub(crate) const WEB_CSS: &str = include_str!("web.css");

/// Everything that reaches a page goes through here first.
///
/// Not a nicety for this page's list of directory names: every surface that
/// comes after interpolates titles, reasons and notes, which is prose a
/// person wrote. A tree is allowed to hold a node called `<script>`, and the
/// place that decides it cannot execute is here, once, rather than every
/// call site remembering to.
pub(crate) fn escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            _ => out.push(c),
        }
    }
    out
}

/// A node's alias, as a link to its lineage.
///
/// Every alias on every page is one, and that is `d147` made concrete: it
/// kept the tree only as the way you reach a lineage, so a node you can see
/// anywhere is a node you can ask "why" about. `f189` is why this is worth
/// stating -- a page full of links nobody can follow was green for a day.
pub(crate) fn alias_link(project: &str, alias: &str) -> String {
    let alias = escape(alias);
    format!(
        "<a href=\"/p/{p}/why/{alias}\">{alias}</a>",
        p = escape(project)
    )
}

/// What an admitted request is asking for. The gate has already said the
/// request may be answered; this says what with.
enum Route<'a> {
    /// `GET /` -- the index of projects.
    Index,
    /// `GET /p/<id>/` -- one project's Today page.
    Today(&'a str),
    /// `GET /p/<id>/why/<node>` -- one node's lineage, drawn (`d145`).
    Why(&'a str, &'a str),
    /// `GET /p/<id>/tree` -- the whole tree, drawn (`WEB.md` §3.6, `d391`).
    /// The query rides along: it is where the map carries what the reader
    /// has folded away, so a view of the tree is a URL and nothing else.
    Tree(&'a str, &'a str),
    NotFound,
}

/// Same shape as `Gate::admit`, and the same reason (`d149`): its cases are
/// unit tests, not tests that first have to stand up a socket.
///
/// **No percent-decoding.** An `id` is sanitized to a character set that
/// never needs it, so a path that still carries a `%` simply matches no
/// project and falls through to `NotFound`. Writing a decoder would add a
/// parser to the one path security is watching, which is exactly what
/// `d138` refused to do for headers.
fn route(path: &str) -> Route<'_> {
    let (path, query) = match path.split_once('?') {
        Some((p, q)) => (p, q),
        None => (path, ""),
    };
    if path == "/" {
        return Route::Index;
    }
    if let Some(rest) = path.strip_prefix("/p/") {
        // The trailing slash is optional on every path under a project and
        // never redirected: no page here links to the other spelling.
        let rest = rest.strip_suffix('/').unwrap_or(rest);
        match rest.split_once('/') {
            None if !rest.is_empty() => return Route::Today(rest),
            Some((id, tail)) if !id.is_empty() => {
                // `why/<node>` and nothing deeper. A node id never contains
                // a slash, so anything that still does is not one.
                if let Some(node) = tail.strip_prefix("why/") {
                    if !node.is_empty() && !node.contains('/') {
                        return Route::Why(id, node);
                    }
                } else if tail == "tree" {
                    return Route::Tree(id, query);
                }
            }
            _ => {}
        }
    }
    Route::NotFound
}

/// The headers every response carries, `Location` on a redirect included.
fn security_headers() -> [tiny_http::Header; 4] {
    [
        header("Content-Security-Policy", CSP),
        header("X-Content-Type-Options", "nosniff"),
        header("Referrer-Policy", "no-referrer"),
        header("Cache-Control", "no-store"),
    ]
}

fn respond(request: tiny_http::Request, status: u16, content_type: &str, body: String) {
    let mut response = tiny_http::Response::from_string(body)
        .with_status_code(status)
        .with_header(header("Content-Type", content_type));
    for h in security_headers() {
        response = response.with_header(h);
    }
    // A client that closed the connection before the answer arrived is not
    // this server's failure to report.
    let _ = request.respond(response);
}

/// `302` to a project's Today page. `d145`: an index with exactly one
/// project does not make anybody click through it.
fn redirect(request: tiny_http::Request, location: &str) {
    redirect_with(request, location, None)
}

/// Spending the boot key: hand over the session cookie and land the browser
/// where the work is (`d190`).
///
/// The flags are the defence and every one of them is load-bearing.
/// `SameSite=Strict` is what keeps another page in the same browser --
/// which `gate` names as the realistic attacker -- from having the cookie
/// ride along on a request it started. `HttpOnly` keeps it out of reach of
/// script. No `Max-Age` and no `Expires` make it a session cookie: it dies
/// with the browser, and the token it carries dies with this process
/// anyway. There is no `Secure`, because this is `http://127.0.0.1` and
/// `Secure` would stop the cookie being sent at all.
fn boot_redirect(request: tiny_http::Request, token: &str) {
    let jar = format!(
        "{}={token}; Path=/; HttpOnly; SameSite=Strict",
        SESSION_COOKIE
    );
    redirect_with(request, "/", Some(header("Set-Cookie", &jar)))
}

fn redirect_with(request: tiny_http::Request, location: &str, extra: Option<tiny_http::Header>) {
    let mut response = tiny_http::Response::from_string(String::new())
        .with_status_code(302)
        .with_header(header("Location", location));
    for h in security_headers() {
        response = response.with_header(h);
    }
    if let Some(h) = extra {
        response = response.with_header(h);
    }
    let _ = request.respond(response);
}

/// The two answers that are not "one project", shared by the three routes
/// under `/p/<id>/` so each of them only has to say what it does with the
/// one it got (`d374`).
///
/// An id the registry does not carry is a 404 and the id does not come back
/// in the body. A name more than one root carries is `300 Multiple Choices`,
/// which is the one status that means exactly this: the server understood,
/// and is handing the choice back rather than making it. The status matters
/// beyond politeness here -- one of this product's two audiences reads codes,
/// not pages, and a 200 would tell it the question had been answered.
fn not_one(request: tiny_http::Request, registry: &mut Registry, named: Named) {
    match named {
        Named::Ambiguous(which) => {
            let page = today::choose_page(registry.all(), &which);
            respond(request, 300, HTML, page)
        }
        _ => respond(
            request,
            404,
            TEXT,
            "not found
"
            .to_string(),
        ),
    }
}

fn handle(
    gate: &mut Gate,
    registry: &mut Registry,
    landing: Option<&str>,
    request: tiny_http::Request,
) {
    let path = request.url().to_string();
    let host = header_value(request.headers(), "host").map(str::to_string);
    let origin = header_value(request.headers(), "origin").map(str::to_string);
    let token = header_value(request.headers(), "x-vivac-token").map(str::to_string);
    let cookie = header_value(request.headers(), "cookie").map(str::to_string);
    let incoming = Incoming {
        path: &path,
        host: host.as_deref(),
        origin: origin.as_deref(),
        token: token.as_deref(),
        cookie: cookie.as_deref(),
    };
    match gate.admit(&incoming) {
        Verdict::Boot => boot_redirect(request, gate.token()),
        Verdict::Serve => match route(&path) {
            // `d199`: `/` is still the index, and what changed is where the
            // redirect goes -- from "the only project" to "the one the
            // working directory is inside, if it is inside one". Started
            // from anywhere else, the index is the answer even with a single
            // project, because then the reader did not come here from a
            // project and has not said which one they meant.
            Route::Index => match landing {
                Some(id) => redirect(request, &format!("/p/{}/", id)),
                None => {
                    let page = today::index_page(registry.all());
                    respond(request, 200, HTML, page)
                }
            },
            Route::Today(id) => match registry.named(id) {
                Named::One(i) => {
                    let project = registry.at(i);
                    // Cloned before the refresh below borrows the project
                    // mutably, which is the same dance `mcp` does.
                    let name = project.name.clone();
                    let key = id.to_string();
                    match project.current_with_log() {
                        Ok((ctx, log)) => {
                            let page = today::today_page(&key, &name, &ctx.tree, log);
                            respond(request, 200, HTML, page)
                        }
                        // The store is on disk and this process is not its
                        // only writer, so a read can fail between one request
                        // and the next. The reason does not go in the body:
                        // an io error carries the path it failed on, and a
                        // path is the one thing the security pillar says
                        // never leaves this machine's own head.
                        Err(_) => respond(
                            request,
                            500,
                            TEXT,
                            "the store could not be read\n".to_string(),
                        ),
                    }
                }
                other => not_one(request, registry, other),
            },
            // The lineage of one node (`WEB.md` §3.2). Same dance as
            // `Today` above, and the same reason for saying nothing in the
            // body when the store cannot be read.
            Route::Why(id, node) => match registry.named(id) {
                Named::One(i) => {
                    let project = registry.at(i);
                    let name = project.name.clone();
                    let key = id.to_string();
                    match project.current_with_log() {
                        Ok((ctx, log)) => match why::why_page(&key, &name, &ctx.tree, log, node) {
                            Some(page) => respond(request, 200, HTML, page),
                            // A node this tree does not hold. The id it did
                            // not recognise does not come back in the
                            // answer, exactly as an unknown project's does
                            // not.
                            None => respond(request, 404, TEXT, "not found\n".to_string()),
                        },
                        Err(_) => respond(
                            request,
                            500,
                            TEXT,
                            "the store could not be read\n".to_string(),
                        ),
                    }
                }
                other => not_one(request, registry, other),
            },
            // The whole tree, as a map (`WEB.md` §3.6). Same dance as `Today`
            // above, and the same reason for saying nothing in the body
            // when the store cannot be read.
            Route::Tree(id, query) => match registry.named(id) {
                Named::One(i) => {
                    let project = registry.at(i);
                    let name = project.name.clone();
                    let key = id.to_string();
                    match project.current() {
                        Ok(ctx) => respond(
                            request,
                            200,
                            HTML,
                            map::map_page(&key, &name, &ctx.tree, query),
                        ),
                        Err(_) => respond(
                            request,
                            500,
                            TEXT,
                            "the store could not be read\n".to_string(),
                        ),
                    }
                }
                other => not_one(request, registry, other),
            },
            Route::NotFound => respond(request, 404, TEXT, "not found\n".to_string()),
        },
        Verdict::Deny(Denial::ForeignHost) | Verdict::Deny(Denial::ForeignOrigin) => {
            respond(request, 403, TEXT, "forbidden\n".to_string())
        }
        Verdict::Deny(Denial::NoValidToken) => respond(
            request,
            401,
            TEXT,
            "no session. run: vivac web\n".to_string(),
        ),
    }
}

/// Opens the system browser on `url`. Failing is not an error: the caller
/// already printed the same URL, so a browser that does not open costs the
/// user one copy and paste, not the session.
///
/// The Windows branch hands the URL to `cmd`, which parses it again after
/// this process has finished quoting it. That is safe for exactly one
/// reason: `Gate::boot_url` builds `http://127.0.0.1:<port>/?k=<hex>` and
/// nothing else, so the string carries no `&` for `cmd` to read as a
/// separator. A second query parameter would break that, and would have to
/// reach the browser some other way.
fn open_browser(url: &str) {
    let launched = if cfg!(target_os = "windows") {
        std::process::Command::new("cmd")
            .args(["/C", "start", "", url])
            .status()
    } else if cfg!(target_os = "macos") {
        std::process::Command::new("open").arg(url).status()
    } else {
        std::process::Command::new("xdg-open").arg(url).status()
    };
    let _ = launched;
}

/// Binds `127.0.0.1` -- and nothing else; there is no flag for another
/// address -- serves `roots`, and blocks until the process is killed.
/// `cwd_root` is the project the working directory sits inside, if it sits
/// inside one. It is where `/` lands (`d199`); `None` means the server was
/// started from somewhere that is not a project, which is the case the whole
/// decision exists for, and then `/` is the index.
pub fn serve(roots: Vec<PathBuf>, cwd_root: Option<PathBuf>, port: Option<u16>, open: bool) -> R {
    let server = tiny_http::Server::http(("127.0.0.1", port.unwrap_or(0)))
        .map_err(|e| Failure::Io(std::io::Error::other(e)))?;
    let bound_port = server
        .server_addr()
        .to_ip()
        .map(|a| a.port())
        .ok_or_else(|| Failure::usage("vivac web needs a TCP address to bind"))?;

    // The port is not known until after the bind when it was ephemeral, and
    // the gate's `Host`/`Origin` checks are pinned to it.
    let mut gate = Gate::new(bound_port)?;
    let mut registry = Registry::open(roots)?;

    // Resolved once: the registry does not change while the server is up,
    // and canonicalizing per request would put a filesystem call on the one
    // path the gate is watching. Prefers the ULID for the same reason the
    // index does -- the landing link is the one a person bookmarks.
    let landing: Option<String> = cwd_root.and_then(|c| {
        let key = std::fs::canonicalize(&c).unwrap_or(c);
        registry
            .all()
            .iter()
            .find(|p| std::fs::canonicalize(&p.root).unwrap_or_else(|_| p.root.clone()) == key)
            .map(|p| {
                p.ulid()
                    .map(str::to_string)
                    .unwrap_or_else(|| p.slug.clone())
            })
    });

    let url = gate.boot_url();
    outln!("  vivac web listening on http://127.0.0.1:{bound_port}");
    outln!("  open this to start a session: {url}");
    // This is the one line the person starting the server needs before the
    // loop below blocks for good, so it cannot wait in `output`'s buffer for
    // a `main` that will not run again until the process is killed.
    flush();
    if open {
        open_browser(&url);
    }

    // One thread, on purpose and not as a shortcut: the page loads no
    // external resource (see the module doc), so one page load is exactly
    // one request, and there is exactly one user of this process.
    loop {
        let request = server.recv().map_err(Failure::Io)?;
        handle(&mut gate, &mut registry, landing.as_deref(), request);
    }
}

#[cfg(test)]
mod tests {
    use super::{escape, route, Route};

    #[test]
    fn the_characters_that_can_change_a_page_are_escaped() {
        assert_eq!(
            escape("<b>a & \"b\" 'c'</b>"),
            "&lt;b&gt;a &amp; &quot;b&quot; &#39;c&#39;&lt;/b&gt;"
        );
    }

    #[test]
    fn text_with_nothing_to_escape_comes_back_whole() {
        assert_eq!(escape("vivac-project"), "vivac-project");
    }

    #[test]
    fn a_node_title_cannot_close_the_tag_it_sits_in() {
        assert!(!escape("</li><script>alert(1)</script>").contains('<'));
    }

    #[test]
    fn the_root_path_routes_to_the_index() {
        assert!(matches!(route("/"), Route::Index));
    }

    #[test]
    fn a_query_string_does_not_change_where_the_root_routes() {
        assert!(matches!(route("/?k=abc"), Route::Index));
    }

    #[test]
    fn a_projects_today_page_routes_with_or_without_a_trailing_slash() {
        assert!(matches!(route("/p/vivac/"), Route::Today("vivac")));
        assert!(matches!(route("/p/vivac"), Route::Today("vivac")));
    }

    /// The write path (`WEB.md` §4) is the one surface `d145` reserved a URL
    /// for and nobody has built yet. Until it exists it is a 404, not an
    /// empty page.
    ///
    /// This test used to name `why/3` as the unbuilt one. It stopped being
    /// unbuilt, and then so did `tree` (`WEB.md` §3.6).
    #[test]
    fn a_path_under_a_project_that_does_not_exist_yet_is_not_found() {
        assert!(matches!(route("/p/vivac/op/push"), Route::NotFound));
    }

    #[test]
    fn a_lineage_routes_under_its_project() {
        assert!(matches!(
            route("/p/vivac/why/f4"),
            Route::Why("vivac", "f4")
        ));
        assert!(matches!(
            route("/p/vivac/why/f4/"),
            Route::Why("vivac", "f4")
        ));
    }

    /// `WEB.md` §3.6: the global graph routes with or without the trailing
    /// slash, the same as every other path under a project.
    #[test]
    fn a_tree_routes_under_its_project() {
        assert!(matches!(route("/p/vivac/tree"), Route::Tree("vivac", "")));
        assert!(matches!(route("/p/vivac/tree/"), Route::Tree("vivac", "")));
        // The query is the map's fold state, so it has to survive the router.
        assert!(matches!(
            route("/p/vivac/tree?fold=g1"),
            Route::Tree("vivac", "fold=g1")
        ));
    }

    #[test]
    fn a_lineage_path_with_no_node_on_it_is_not_found() {
        assert!(matches!(route("/p/vivac/why/"), Route::NotFound));
        assert!(matches!(route("/p/vivac/why"), Route::NotFound));
        assert!(matches!(route("/p//why/f4"), Route::NotFound));
    }

    #[test]
    fn an_empty_id_is_not_found() {
        assert!(matches!(route("/p/"), Route::NotFound));
    }

    #[test]
    fn a_percent_encoded_id_is_not_decoded_and_so_matches_nothing_real() {
        assert!(matches!(route("/p/%76ivac/"), Route::Today("%76ivac")));
    }

    #[test]
    fn an_unrecognised_path_is_not_found() {
        assert!(matches!(route("/other"), Route::NotFound));
    }
}