lantern-system: add fs.home, and return listings in canonical order
fs.home #{} -> #{ path: } lets a client expand "~" without hardcoding a host convention. The home that matters belongs to the machine being BROWSED, which for a remote node is not the machine typing, so it has to come from the capability surface. Grant-checked inside sigil-system (as a read of that directory), so a scoped embedding cannot use it to discover where the owner's home lives; the desktop posture is allow-all, so in practice it just answers.
Canonical listing order. sigil-system now sorts fs-read-dir (dirs before files, hidden before visible within each, alphabetical case-insensitively) — but this command deliberately does NOT go through fs-read-dir, because that stats every name and overflows at /gnu/store scale. It lists names, caps, then stats the survivors. So sorting in sigil-system alone would never have reached a user: this path is the only one Slate's folder view and find-file see.
It now calls sigil-system's fs-sort-entries on the entries it kept, so the order is identical to fs-read-dir's without re-deriving the rule here and drifting from it. Sorting the KEPT entries rather than all names is the point: sorting first would need a stat per name, which is the overflow this path exists to avoid. An ordinary directory is fully ordered; a capped one is an ordered view of the same arbitrary slice it already showed. Browsing past the cap remains dired virtualization's problem.
Also fixes fs.write-file's doc comment, which advertised create-dirs?: while the code read create-dirs:. A caller trusted the comment, sent a keyword nothing reads, and got no mkdir and no error — the flag is optional, so a wrong name is indistinguishable from an absent one. Pins the contract with tests that assert the file exists ON DISK (the caller-side test that only proved what was SENT passed happily while the feature was broken), including the one-missing- level limit the grant model imposes: canonical-target needs a real anchor, so it realpaths the parent and re-appends the basename. Two missing levels leave nothing to realpath and are denied — the safe default, since resolving further would re-append an unresolved tail whose ".." could escape the grant root.
dev-redirects.sgl points sigil-system at the local checkout: fs.home and fs-sort-entries are unreleased, and lantern-system pins ^0.1 from git, so the two cannot be developed together without it. It stops mattering once sigil-system 0.1.1 is tagged and lantern-system relocks.
dev-redirects.sgl | 16 ++++++++++++++++
lantern-system/src/lantern-system/fs.sgl | 45 +++++++++++++++++++++++++++++++++++++--------
lantern-system/test/test-lantern-system.sgl | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 135 insertions(+), 8 deletions(-)dev-redirects.sgladded
;; Development redirects — point dependencies at local sibling checkouts during;; iteration. Pass `--redirects ./dev-redirects.sgl` to sigil commands.;;;; lantern-system pins `sigil-system ^0.1` from git, so it resolves the RELEASED;; tag. This redirect is what lets the two be developed together: fs.home and;; fs-sort-entries live in an unreleased sigil-system, and without it lantern-system;; cannot see them (the build fails on an unbound fs-home rather than on anything;; meaningful).;;;; Not a substitute for the release: once sigil-system 0.1.1 is tagged and pushed,;; lantern-system relocks onto it and this stops mattering for a normal build.(redirects repos: (list (for-repo url: "codeberg:sigil/sigil-system" use: (from-path dir: "../sigil-system"))))lantern-system/src/lantern-system/fs.sglmodified
(reverse acc) (loop (cdr ns) (+ i 1) (cons (car ns) acc)))) names)) (entries (let loop ((ns kept) (acc '())) (if (null? ns) (reverse acc) (let ((st (guard (e (#t #f)) (fs-stat g (path-join path (car ns)))))) (loop (cdr ns) (if st (cons (stat->json st) acc) acc))))))) ;; Stat the survivors, then put them in sigil-system's CANONICAL ;; order (dirs before files, hidden before visible, alphabetical ;; case-insensitively within each tier) by calling its sort ;; rather than re-deriving it here — this path cannot use ;; fs-read-dir (which stats every name), but it must not drift ;; from fs-read-dir's ORDER. ;; ;; Sorting the KEPT entries, not all names: sorting first would ;; need a stat per name, which is the overflow this path exists ;; to avoid. So an ordinary directory (under the cap) is fully ;; ordered, while a capped one is an ordered view of an ;; arbitrary slice — the same slice it already showed, now at ;; least sorted. Browsing past the cap is dired virtualization's ;; problem, not this one's. (stats (let loop ((ns kept) (acc '())) (if (null? ns) (reverse acc) (let ((st (guard (e (#t #f)) (fs-stat g (path-join path (car ns)))))) (loop (cdr ns) (if st (cons st acc) acc)))))) (entries (map stat->json (fs-sort-entries stats)))) (dict entries: entries total: total))))) ;; fs.stat #{ path: } -> a stat dict. (lambda (args ctx) (stat->json (fs-stat g (req args path:))))) ;; fs.home #{} -> #{ path: <home dir> }. ;; So a client can expand "~" without hardcoding a host convention: the ;; home that matters belongs to the machine being BROWSED, which for a ;; remote node is not the machine typing. Grant-checked (as a read of that ;; directory) inside sigil-system, so a scoped embedding cannot use this to ;; discover where the owner's home is. (lantern-command "fs.home" (lambda (args ctx) (dict path: (fs-home g)))) ;; fs.read-file #{ path: encoding?: } — delivered on the bridge's BULK ;; lane: the handler returns #{ bulk-b64: <base64 of the raw bytes> ;; meta: #{ encoding: } }, so the file body never passes through (dict bulk-b64: (base64-encode (fs-read-file-bytes g path)) meta: (dict encoding: enc))))) ;; fs.write-file #{ path: content: encoding?: create-dirs?: } -> stat. ;; fs.write-file #{ path: content: encoding: create-dirs: } -> stat. ;; (This comment used to advertise `create-dirs?:` while the code below ;; read `create-dirs:`. A caller trusted the comment, sent a keyword ;; nothing reads, and got no mkdir and no error — the flag is optional, so ;; a wrong name is indistinguishable from an absent one. Keep the arg names ;; here identical to the dict-refs below.) ;; Atomic (tmp + rename) via sigil-system. base64 content decodes to a ;; bytevector (byte-exact — binary-safe writes), plain content is written ;; as UTF-8 text; both paths are accepted by fs-write-file. Large saveslantern-system/test/test-lantern-system.sglmodified
(sigil io) (sigil fs) (sigil path) (sigil process) ; getenv (fs.home tests) (sigil async) (lantern-system base64) (sigil system grant) (else (loop (cdr es))))))) (assert-equal (dict-ref sub type: #f) "directory")))))(test "fs.read-dir returns entries in sigil-system's canonical order" ;; This is the ONLY path Slate's folder view and find-file actually see, and it ;; does NOT go through sigil-system's fs-read-dir (which stats every name and ;; overflows at /gnu/store scale) — so sorting there alone would never reach a ;; user. It calls fs-sort-entries on the entries it kept instead, which is what ;; keeps this order identical to fs-read-dir's without re-deriving the rule. (let* ((fx (fs-fixture)) (dir (dict-ref fx dir:)) (g (dict-ref fx g:)) (rd (handler-for (dict-ref fx cmds:) "fs.read-dir"))) ;; the fixture holds a.txt + sub/; add the other two tiers, out of order (write-file-string (path-join dir "README.md") "r") (write-file-string (path-join dir ".gitignore") "i") (ensure-directory (path-join dir ".git")) (let* ((r (rd (dict path: dir) #f)) (names (map (lambda (e) (dict-ref e name: #f)) (dict-ref r entries: '())))) ;; hidden dirs, dirs, hidden files, files — alphabetical within each tier (assert-equal names (list ".git" "sub" ".gitignore" "a.txt" "README.md")))))(test "fs.read-dir honors limit: and always reports the full total" (let* ((fx (fs-fixture)) (rd (handler-for (dict-ref fx cmds:) "fs.read-dir"))) (assert-equal (dict-ref (dict-ref r meta: #{}) encoding: #f) "base64") (assert-equal (utf8->string (base64-decode (dict-ref r bulk-b64: #f))) "hello A"))))(test "fs.home reports the home directory" ;; Lets a client expand "~" without hardcoding a host convention — for a remote ;; node the home that matters is the NODE's, not the one typing. The fixture's ;; grants are scoped to a temp dir, so this asserts the scoped DENIAL; the ;; allowed path is covered in sigil-system, which owns the grant check. (let* ((fx (fs-fixture)) (home-cmd (handler-for (dict-ref fx cmds:) "fs.home"))) (assert-true (guard (e (#t #t)) (home-cmd (dict) #f) #f))))(test "fs.home reports the home directory when granted" (let* ((dir (make-temp-directory)) (g (make-grants)) (home (getenv "HOME"))) (grant-add! g (string-append "fs:ro:" home)) (let* ((cmds (lantern-system-commands g)) (home-cmd (handler-for cmds "fs.home"))) (assert-equal (dict-ref (home-cmd (dict) #f) path: #f) (or (realpath home) home)))))(test "fs.write-file writes atomically and returns a stat" (let* ((fx (fs-fixture)) (wf (handler-for (dict-ref fx cmds:) "fs.write-file")) (wf (dict path: path bulk: "bulk body wins") #f) (assert-equal (read-file-string path) "bulk body wins")))(test "fs.write-file create-dirs: creates a missing parent directory" ;; The ONLY test that can prove this contract: it asserts the file exists ON ;; DISK, i.e. that the host ACTED. A caller-side test can only prove what was ;; sent — and one did exactly that while the feature was broken, because the ;; doc comment above fs.write-file advertised `create-dirs?:` and the code read ;; `create-dirs:`. The flag is optional, so a misspelled name is ;; indistinguishable from an absent one: no mkdir, no error. ;; ;; Slate's find-file needs this — you CREATE a file by typing a path that does ;; not exist, and that path can name a directory that does not exist either. (let* ((fx (fs-fixture)) (wf (handler-for (dict-ref fx cmds:) "fs.write-file")) (path (path-join (dict-ref fx dir:) "fresh/new.txt"))) (wf (dict path: path content: "made the parent" create-dirs: #t) #f) (assert-equal (read-file-string path) "made the parent")))(test "fs.write-file create-dirs: reaches exactly ONE missing level, by design" ;; Pins a real limit of the GRANT model rather than of this command. To decide ;; whether a path is inside a grant, sigil-system's canonical-target needs a real ;; anchor: it realpaths the path, or (for a write) realpaths the PARENT and ;; re-appends the basename. Two missing levels leave nothing to realpath, so the ;; write is denied before mkdir is ever reached. ;; ;; That is the SAFE default, not an oversight: resolving further up would mean ;; re-appending an unresolved tail, and a ".." in that tail could escape the ;; grant root — precisely what realpath'ing the parent prevents today. Lifting it ;; needs normalization of the missing tail, i.e. a deliberate change to security ;; code, not a drive-by. (let* ((fx (fs-fixture)) (wf (handler-for (dict-ref fx cmds:) "fs.write-file")) (path (path-join (dict-ref fx dir:) "deep/deeper/new.txt"))) (assert-true (guard (e (#t #t)) (wf (dict path: path content: "x" create-dirs: #t) #f) #f))))(test "fs.write-file WITHOUT create-dirs: does not invent directories" ;; The other half of the contract: mkdir is opt-in, so a typo'd path fails loudly ;; instead of silently scattering directories across the filesystem. (let* ((fx (fs-fixture)) (wf (handler-for (dict-ref fx cmds:) "fs.write-file")) (path (path-join (dict-ref fx dir:) "absent/new.txt"))) (assert-true (guard (e (#t #t)) (wf (dict path: path content: "x") #f) #f))))(test "fs.mkdir / fs.rename / fs.delete take effect on disk" (let* ((fx (fs-fixture)) (dir (dict-ref fx dir:))