narju/
adapter.rs

1use rustyline::error::ReadlineError;
2use rustyline::DefaultEditor;
3use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
4use tokio::sync::mpsc::{Receiver, Sender};
5
6use crate::floor::host::Str;
7use crate::floor::{list, list_elements, pair, Val};
8use crate::world::{reply, request, Event};
9
10fn sym(s: &str) -> Val {
11    Val::Sym(s.into())
12}
13
14fn throw(why: Val) -> Val {
15    pair(sym("throw"), why)
16}
17
18/// A string as itself, anything else as it prints: a capability taking only
19/// strings would make the object language convert before it could say anything.
20fn text(v: &Val) -> String {
21    match Str::of(v) {
22        Some(s) => s.to_string(),
23        None => v.to_string(),
24    }
25}
26
27/// Answer a request, or say nothing if the message was a cast. The reply port
28/// closing ends the adapter: the world it was answering is gone.
29async fn answer(back: &Sender<Event>, msg: &Val, v: Val) -> bool {
30    match request(msg) {
31        Some((from, id, _)) => back.send(Event::Post(from, reply(id, v))).await.is_ok(),
32        None => true,
33    }
34}
35
36/// The body of a request, or the whole message when it is a cast.
37fn body(msg: &Val) -> Val {
38    match request(msg) {
39        Some((_, _, body)) => body,
40        None => msg.clone(),
41    }
42}
43
44/// `('host stdout)`: `('write v)` and `('line v)`, answering `'ok`.
45pub async fn stdout(mut inbox: Receiver<Val>, back: Sender<Event>) {
46    let mut out = tokio::io::stdout();
47    while let Some(msg) = inbox.recv().await {
48        let v = match list_elements(&body(&msg)).as_deref() {
49            Some([verb, arg]) if *verb == sym("write") => put(&mut out, &text(arg)).await,
50            Some([verb, arg]) if *verb == sym("line") => {
51                put(&mut out, &format!("{}\n", text(arg))).await
52            }
53            _ => throw(pair(sym("bad-request"), body(&msg))),
54        };
55        if !answer(&back, &msg, v).await {
56            return;
57        }
58    }
59}
60
61async fn put(out: &mut tokio::io::Stdout, s: &str) -> Val {
62    match out.write_all(s.as_bytes()).await {
63        Ok(()) => match out.flush().await {
64            Ok(()) => sym("ok"),
65            Err(e) => throw(pair(sym("io"), Str::val(e.to_string()))),
66        },
67        Err(e) => throw(pair(sym("io"), Str::val(e.to_string()))),
68    }
69}
70
71/// `('host stdin)`: `('read)` or `('read prompt)`, answering the text or
72/// `'eof`. A prompt is ignored on a stream that is not a terminal, and a cast
73/// is dropped since there is nowhere to put the line.
74pub async fn stdin(mut inbox: Receiver<Val>, back: Sender<Event>) {
75    let mut lines = BufReader::new(tokio::io::stdin()).lines();
76    while let Some(msg) = inbox.recv().await {
77        let Some((from, id, ask)) = request(&msg) else {
78            continue;
79        };
80        let v = match list_elements(&ask).as_deref() {
81            Some([verb] | [verb, _]) if *verb == sym("read") => match lines.next_line().await {
82                Ok(Some(s)) => Str::val(s),
83                Ok(None) => sym("eof"),
84                Err(e) => throw(pair(sym("io"), Str::val(e.to_string()))),
85            },
86            _ => throw(pair(sym("bad-request"), ask)),
87        };
88        if back.send(Event::Post(from, reply(id, v))).await.is_err() {
89            return;
90        }
91    }
92}
93
94/// The interactive console: `('write v)`, `('line v)` and `('read prompt)` on
95/// one peer, because ordering between printing and prompting is the whole
96/// difficulty. Sharing one inbox makes the channel's order the screen's order;
97/// two channels would let a print land mid-line. Reading a tty line blocks, so
98/// a thread is occupied either way.
99///
100/// Registered under both `stdout` and `stdin`, so an object program says the
101/// same thing whether its output is a terminal or a pipe.
102pub async fn terminal(mut inbox: Receiver<Val>, back: Sender<Event>) {
103    let _ = tokio::task::spawn_blocking(move || {
104        let Ok(mut rl) = DefaultEditor::new() else {
105            return;
106        };
107        while let Some(msg) = inbox.blocking_recv() {
108            let v = match list_elements(&body(&msg)).as_deref() {
109                Some([verb, arg]) if *verb == sym("write") => emit(&text(arg)),
110                Some([verb, arg]) if *verb == sym("line") => emit(&format!("{}\n", text(arg))),
111                Some([verb] | [verb, _]) if *verb == sym("read") => {
112                    let prompt = match list_elements(&body(&msg)).as_deref() {
113                        Some([_, p]) => text(p),
114                        _ => String::new(),
115                    };
116                    match rl.readline(&prompt) {
117                        Ok(line) => {
118                            rl.add_history_entry(line.as_str()).ok();
119                            Str::val(line)
120                        }
121                        // Ctrl-c abandons the line, not the session.
122                        Err(ReadlineError::Interrupted) => Str::val(""),
123                        Err(ReadlineError::Eof) => sym("eof"),
124                        Err(e) => throw(pair(sym("io"), Str::val(e.to_string()))),
125                    }
126                }
127                _ => throw(pair(sym("bad-request"), body(&msg))),
128            };
129            if let Some((from, id, _)) = request(&msg) {
130                if back.blocking_send(Event::Post(from, reply(id, v))).is_err() {
131                    return;
132                }
133            }
134        }
135    })
136    .await;
137}
138
139/// `('host files)`: `('read path)`, answering the text, and `('root)`,
140/// answering where fetched modules were put.
141///
142/// Reading a file is a capability rather than a verb, so a task with no route
143/// to this peer cannot open one - which is what makes loading a module by name
144/// an ordinary message. `('root)` is the host's answer to give, since a build
145/// system may stage sources somewhere unpredictable; it grants no authority,
146/// as `('read path)` already takes any path.
147pub async fn files(root: String, mut inbox: Receiver<Val>, back: Sender<Event>) {
148    while let Some(msg) = inbox.recv().await {
149        let Some((from, id, ask)) = request(&msg) else {
150            continue;
151        };
152        let v = match list_elements(&ask).as_deref() {
153            Some([verb, path]) if *verb == sym("read") => {
154                let name = text(path);
155                match tokio::fs::read_to_string(&name).await {
156                    Ok(s) => Str::val(s),
157                    // Named, because a caller does not always choose the path:
158                    // `need` reads a lock file the program never wrote down.
159                    Err(e) => throw(pair(sym("io"), Str::val(format!("{name}: {e}")))),
160                }
161            }
162            Some([verb]) if *verb == sym("root") => Str::val(root.clone()),
163            _ => throw(pair(sym("bad-request"), ask)),
164        };
165        if back.send(Event::Post(from, reply(id, v))).await.is_err() {
166            return;
167        }
168    }
169}
170
171/// `('host disk)`: `('write path text)`, answering `'ok`.
172///
173/// A second peer rather than a second verb on [`files`], because a peer is the
174/// unit of granting and a host may want reads without writes. It creates
175/// nothing but the file: a missing directory is an error, since a mistyped
176/// path would otherwise get a tree instead of a complaint.
177pub async fn disk(mut inbox: Receiver<Val>, back: Sender<Event>) {
178    while let Some(msg) = inbox.recv().await {
179        let Some((from, id, ask)) = request(&msg) else {
180            continue;
181        };
182        let v = match list_elements(&ask).as_deref() {
183            Some([verb, path, body]) if *verb == sym("write") => {
184                let name = text(path);
185                match tokio::fs::write(&name, text(body)).await {
186                    Ok(()) => sym("ok"),
187                    Err(e) => throw(pair(sym("io"), Str::val(format!("{name}: {e}")))),
188                }
189            }
190            _ => throw(pair(sym("bad-request"), ask)),
191        };
192        if back.send(Event::Post(from, reply(id, v))).await.is_err() {
193            return;
194        }
195    }
196}
197
198/// A repository's place under the store root: its url without the scheme.
199/// Derived once here and written into the lock as a field, so `naj/prelude.naj`
200/// and `flake.nix` join strings rather than reimplement the rule.
201///
202/// Checked rather than trusted, since it comes from a file the program was
203/// given and ends up as an argument to `git`. Not about quoting - there is no
204/// shell - but a segment beginning with `-` would be read as an option and
205/// `..` would place the source outside the store. `https` only, which rules
206/// out git's scp-like syntax and a `file://` url naming somewhere unreadable.
207fn store_path(url: &str) -> Result<&str, String> {
208    let rest = url
209        .strip_prefix("https://")
210        .ok_or_else(|| format!("{url}: want an https url"))?;
211    if rest.split('/').count() < 2 {
212        return Err(format!("{url}: want a host and a path"));
213    }
214    for part in rest.split('/') {
215        if part.is_empty() || part.starts_with('-') || part.starts_with('.') {
216            return Err(format!("{url}: bad url"));
217        }
218        if !part
219            .chars()
220            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
221        {
222            return Err(format!("{url}: bad url"));
223        }
224    }
225    Ok(rest)
226}
227
228/// Checked for the reason [`store_path`] is: a revision names a directory in
229/// the store.
230fn rev(s: &str) -> Result<&str, String> {
231    if s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit()) {
232        Ok(s)
233    } else {
234        Err(format!("{s}: not an object name"))
235    }
236}
237
238/// A subprocess rather than a library: an HTTP client, TLS stack and hashing
239/// crate for a step that runs once when a lock file is written. `tokio::process`,
240/// so nothing here holds a thread.
241async fn git(args: &[&str]) -> Result<String, String> {
242    let out = tokio::process::Command::new("git")
243        .args(args)
244        .output()
245        .await
246        .map_err(|e| format!("git: {e}"))?;
247    if out.status.success() {
248        String::from_utf8(out.stdout).map_err(|e| format!("git: {e}"))
249    } else {
250        Err(format!(
251            "git {}: {}",
252            args.join(" "),
253            String::from_utf8_lossy(&out.stderr).trim()
254        ))
255    }
256}
257
258/// `('host modules)`: the impure half of the module system.
259///
260///   `('fetch url ref)`    `(rev path)` - the revision `ref` names and
261///                         where under the root its source now is
262///
263/// The only thing in the tree that reaches the network. Registered only by
264/// `naj --lock`, so an ordinary program cannot reach it: what a program may do
265/// is what the world it runs in registered. Resolution never comes here - it
266/// reads the store through [`files`] - which is what lets a program run
267/// offline.
268///
269/// A revision is a content address, so a store entry never changes and an
270/// existing one is answered without touching the network. `.git` is removed
271/// afterwards, since nix populates the same layout with a plain source tree.
272pub async fn modules(root: String, mut inbox: Receiver<Val>, back: Sender<Event>) {
273    while let Some(msg) = inbox.recv().await {
274        let Some((from, id, ask)) = request(&msg) else {
275            continue;
276        };
277        let v = match list_elements(&ask).as_deref() {
278            Some([verb, url, want]) if *verb == sym("fetch") => {
279                match fetch(&root, &text(url), &text(want)).await {
280                    Ok((at, where_)) => list(&[Str::val(at), Str::val(where_)]),
281                    Err(why) => throw(pair(sym("fetch"), Str::val(why))),
282                }
283            }
284            _ => throw(pair(sym("bad-request"), ask)),
285        };
286        if back.send(Event::Post(from, reply(id, v))).await.is_err() {
287            return;
288        }
289    }
290}
291
292/// Resolving is a separate step from fetching because it decides the directory:
293/// naming the entry after the revision is what makes it immutable, and a
294/// `--branch` clone would leave the name to a ref that can move underneath it.
295async fn fetch(root: &str, url: &str, want: &str) -> Result<(String, String), String> {
296    let path = store_path(url)?;
297    let at = match rev(want) {
298        Ok(known) => known.to_string(),
299        Err(_) => {
300            let line = git(&["ls-remote", url, want]).await?;
301            let head = line
302                .split_whitespace()
303                .next()
304                .ok_or(format!("{url}: no ref {want}"))?;
305            rev(head)?.to_string()
306        }
307    };
308
309    let dest = format!("{root}/{path}/{at}");
310    if tokio::fs::metadata(&dest).await.is_ok() {
311        return Ok((at, path.to_string()));
312    }
313
314    tokio::fs::create_dir_all(&dest)
315        .await
316        .map_err(|e| format!("{dest}: {e}"))?;
317    git(&["init", "-q", &dest]).await?;
318    git(&["-C", &dest, "fetch", "-q", "--depth", "1", url, &at]).await?;
319    git(&["-C", &dest, "checkout", "-q", "FETCH_HEAD"]).await?;
320    tokio::fs::remove_dir_all(format!("{dest}/.git"))
321        .await
322        .map_err(|e| format!("{dest}/.git: {e}"))?;
323    Ok((at, path.to_string()))
324}
325
326/// `('host clock)`: nothing in the floor can read a clock and nothing in a task
327/// can wait for anything but a message.
328///
329///   `('now)`          milliseconds since the epoch
330///   `('sleep ms)`     `'ok`, that much later
331///   `('after ms to msg)`  `msg` is posted to `to`, that much later
332///
333/// `after` is a cast, since the task that arms a timer is usually not the one
334/// that should hear it fire. It is what a timeout is built from: the dispatch
335/// loop arms one carrying the reply it wants, and the duplicate-reply path
336/// drops whichever loses (`naj/prelude.naj`).
337///
338/// Each timer is a task that sleeps and then sends, so nothing is polled. It
339/// holds a port, which keeps a world with a timer outstanding from concluding
340/// that nothing could wake it.
341pub async fn clock(mut inbox: Receiver<Val>, back: Sender<Event>) {
342    while let Some(msg) = inbox.recv().await {
343        let ask = body(&msg);
344        // A sleep is a timer whose message is the reply, so it is armed and
345        // left: an adapter that waited would answer nobody until it was over.
346        if let Some([verb, Val::Num(ms)]) = list_elements(&ask).as_deref() {
347            if *verb == sym("sleep") {
348                if let Some((from, id, _)) = request(&msg) {
349                    let (ms, port) = (*ms.max(&0) as u64, back.clone());
350                    tokio::spawn(async move {
351                        tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
352                        let _ = port.send(Event::Post(from, reply(id, sym("ok")))).await;
353                    });
354                }
355                continue;
356            }
357        }
358        let v = match list_elements(&ask).as_deref() {
359            Some([verb]) if *verb == sym("now") => Val::Num(now_ms()),
360            Some([verb, Val::Num(ms), to, note]) if *verb == sym("after") => {
361                let (ms, to, note) = (*ms.max(&0) as u64, to.clone(), note.clone());
362                let port = back.clone();
363                tokio::spawn(async move {
364                    tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
365                    let _ = port.send(Event::Post(to, note)).await;
366                });
367                sym("ok")
368            }
369            _ => throw(pair(sym("bad-request"), ask)),
370        };
371        if !answer(&back, &msg, v).await {
372            return;
373        }
374    }
375}
376
377fn now_ms() -> i64 {
378    std::time::SystemTime::now()
379        .duration_since(std::time::UNIX_EPOCH)
380        .map(|d| d.as_millis() as i64)
381        .unwrap_or(0)
382}
383
384fn emit(s: &str) -> Val {
385    use std::io::Write;
386    let mut out = std::io::stdout();
387    match out.write_all(s.as_bytes()).and_then(|()| out.flush()) {
388        Ok(()) => sym("ok"),
389        Err(e) => throw(pair(sym("io"), Str::val(e.to_string()))),
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::floor::list;
397
398    #[test]
399    fn a_url_becomes_its_place_in_the_store() {
400        assert_eq!(
401            store_path("https://github.com/octocat/Hello-World"),
402            Ok("github.com/octocat/Hello-World")
403        );
404        assert_eq!(
405            store_path("https://git.avery.garden/thorn/narju"),
406            Ok("git.avery.garden/thorn/narju")
407        );
408    }
409
410    #[test]
411    fn a_url_that_would_leave_the_store_is_refused() {
412        for bad in [
413            "https://forge/../etc/passwd",
414            "https://forge/-x/y",
415            "https://forge/a/.ssh",
416            "http://forge/a/b",
417            "git@github.com:a/b",
418            "file:///etc",
419            "https://forge",
420            "https://forge//b",
421        ] {
422            assert!(store_path(bad).is_err(), "{bad}");
423        }
424    }
425    fn clocked() -> (Sender<Val>, Receiver<Event>) {
426        let (to, inbox) = tokio::sync::mpsc::channel(8);
427        let (back, out) = tokio::sync::mpsc::channel(8);
428        tokio::spawn(clock(inbox, back));
429        (to, out)
430    }
431
432    fn ask(id: i64, body: Val) -> Val {
433        list(&[sym("req"), sym("caller"), Val::Num(id), body])
434    }
435
436    async fn posted(out: &mut Receiver<Event>) -> (Val, Val) {
437        match out.recv().await {
438            Some(Event::Post(to, msg)) => (to, msg),
439            _ => panic!("nothing was posted"),
440        }
441    }
442
443    #[tokio::test]
444    async fn the_clock_says_what_time_it_is() {
445        let (to, mut out) = clocked();
446        to.send(ask(1, list(&[sym("now")]))).await.expect("sent");
447        let (_, answer) = posted(&mut out).await;
448        match list_elements(&answer).as_deref() {
449            Some([tag, id, Val::Num(ms)]) if *tag == sym("reply") => {
450                assert_eq!(*id, Val::Num(1));
451                assert!(*ms > 1_700_000_000_000, "{ms} is not a time");
452            }
453            _ => panic!("not a reply: {answer}"),
454        }
455    }
456
457    #[tokio::test]
458    async fn a_timer_posts_the_message_it_was_given_where_it_was_told() {
459        let (to, mut out) = clocked();
460        let note = list(&[
461            sym("reply"),
462            Val::Num(7),
463            pair(sym("throw"), sym("timeout")),
464        ]);
465        to.send(list(&[
466            sym("after"),
467            Val::Num(1),
468            sym("elsewhere"),
469            note.clone(),
470        ]))
471        .await
472        .expect("sent");
473        assert_eq!(posted(&mut out).await, (sym("elsewhere"), note));
474    }
475
476    #[tokio::test]
477    async fn a_sleep_does_not_stop_the_clock_answering_anyone_else() {
478        let (to, mut out) = clocked();
479        to.send(ask(1, list(&[sym("sleep"), Val::Num(80)])))
480            .await
481            .expect("sent");
482        to.send(ask(2, list(&[sym("sleep"), Val::Num(1)])))
483            .await
484            .expect("sent");
485        assert_eq!(
486            posted(&mut out).await,
487            (sym("caller"), reply(Val::Num(2), sym("ok")))
488        );
489        assert_eq!(
490            posted(&mut out).await,
491            (sym("caller"), reply(Val::Num(1), sym("ok")))
492        );
493    }
494
495    #[tokio::test]
496    async fn a_verb_the_clock_does_not_know_is_refused() {
497        let (to, mut out) = clocked();
498        to.send(ask(1, list(&[sym("nonsense")])))
499            .await
500            .expect("sent");
501        assert_eq!(
502            posted(&mut out).await,
503            (
504                sym("caller"),
505                reply(
506                    Val::Num(1),
507                    throw(pair(sym("bad-request"), list(&[sym("nonsense")])))
508                )
509            )
510        );
511    }
512
513    #[tokio::test]
514    async fn a_call_that_runs_out_of_time_raises_and_leaves_the_loop_usable() {
515        let mut t = crate::tower::Tower::load().expect("loads");
516        let mut entry = |src: &str| {
517            let forms = crate::floor::read::read(src).expect("reads");
518            t.task(&crate::surface::desugar_body(&forms).expect("desugars"))
519                .expect("no fault")
520        };
521
522        let driver = entry(
523            "(task-loop self
524               (lambda h (st msg)
525                 (let ((deaf (spawn (lambda (me)
526                                      (task-loop me
527                                                 (lambda h2 (s2 m2)
528                                                   (if (eq? s2 'asked) (cons 'stop 'ok) 'asked))
529                                                 0)))))
530                   (let ((late (attempt (lambda () (call-within 20 deaf '(hello))))))
531                     (let ((now (call-within 10000 '(host clock) '(now))))
532                       (begin (send deaf 'bye)
533                              (cons 'stop (list late now)))))))
534               0)",
535        );
536
537        let mut w = crate::world::World::new(50_000_000);
538        let d = w.heap().spawn(driver);
539        w.heap().post(&crate::sched::addr(d), sym("go"));
540
541        let (tx, rx) = tokio::sync::mpsc::channel(8);
542        w.serve("clock", tx);
543        tokio::spawn(clock(rx, w.port()));
544
545        let ended = w.run().await.expect("no bug");
546        let Some((id, Val::Pair(tag, answered))) = ended.iter().find(|(id, _)| *id == d) else {
547            panic!("the driver did not end: {ended:?}")
548        };
549        assert_eq!((*id, &**tag), (d, &sym("ok")));
550        match list_elements(answered).as_deref() {
551            Some([late, Val::Num(_)]) => {
552                assert_eq!(*late, pair(sym("throw"), sym("timeout")));
553            }
554            _ => panic!("not what the driver answered: {answered}"),
555        }
556    }
557
558    fn written() -> (Sender<Val>, Receiver<Event>) {
559        let (to, inbox) = tokio::sync::mpsc::channel(8);
560        let (back, out) = tokio::sync::mpsc::channel(8);
561        tokio::spawn(disk(inbox, back));
562        (to, out)
563    }
564
565    #[tokio::test]
566    async fn a_write_puts_the_text_where_it_was_told() {
567        let path = std::env::temp_dir().join("narju-disk-writes.txt");
568        let (to, mut out) = written();
569        to.send(ask(
570            1,
571            list(&[
572                sym("write"),
573                Str::val(path.to_string_lossy().into_owned()),
574                Str::val("hi\n"),
575            ]),
576        ))
577        .await
578        .expect("sent");
579        let (_, answer) = posted(&mut out).await;
580        assert_eq!(answer, list(&[sym("reply"), Val::Num(1), sym("ok")]));
581        assert_eq!(std::fs::read_to_string(&path).expect("written"), "hi\n");
582        std::fs::remove_file(&path).expect("removed");
583    }
584
585    #[tokio::test]
586    async fn a_write_makes_no_directory_to_write_into() {
587        let dir = std::env::temp_dir().join("narju-disk-absent");
588        let (to, mut out) = written();
589        to.send(ask(
590            2,
591            list(&[
592                sym("write"),
593                Str::val(dir.join("f.txt").to_string_lossy().into_owned()),
594                Str::val(""),
595            ]),
596        ))
597        .await
598        .expect("sent");
599        let (_, answer) = posted(&mut out).await;
600        match list_elements(&answer).as_deref() {
601            Some([_, _, Val::Pair(tag, why)]) => {
602                assert_eq!(**tag, sym("throw"));
603                assert!(
604                    matches!(&**why, Val::Pair(t, _) if **t == sym("io")),
605                    "{why}"
606                );
607            }
608            _ => panic!("not a refusal: {answer}"),
609        }
610        assert!(!dir.exists(), "the directory was made");
611    }
612}