fix(ocaml): review findings: bounded request heads, edits survive re-renders, Unicode trim, SQLite built from source

- Run cohttp-eio's connection loop through a per-connection byte budget
  (64 KiB head, 431 beyond it; at most 256 connections).
- app.js keeps an open editor, its text, focus and selection when a
  response re-renders the list.
- Titles are trimmed of Unicode whitespace (and U+FEFF) on add and edit.
- SQLite 3.54.1 is compiled from its amalgamation into .cache for each
  target; make clean-deps deletes it and keeps the tarball.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 25c96beb28da6fa13229c51e8b7082302244276c parent 1e08ca9
12 files changed, +214 −32
  • apps/ocaml/DEPS +2 −0
  • apps/ocaml/Makefile +8 −4
  • apps/ocaml/README.md +16 −6
  • apps/ocaml/bin/dune +1 −1
  • apps/ocaml/bin/main.ml +58 −1
  • apps/ocaml/src/app.ml +2 −2
  • apps/ocaml/src/form.ml +25 −0
  • apps/ocaml/static/app.js +32 −6
  • apps/ocaml/test/test_todo.ml +11 −1
  • apps/ocaml/tools/arm64.sh +6 −2
  • apps/ocaml/tools/sqlite.sh +45 −0
  • apps/ocaml/tools/x86.sh +8 −9
diff --git a/apps/ocaml/DEPS b/apps/ocaml/DEPS
index 9832193..8c7fdd4 100644
--- a/apps/ocaml/DEPS
+++ b/apps/ocaml/DEPS
@@ -3,5 +3,7 @@ eio_posix
cohttp-eio
http
uri
+cstruct
sqlite3
+sqlite (3.54.1 amalgamation, built by tools/sqlite.sh)
alcotest
diff --git a/apps/ocaml/Makefile b/apps/ocaml/Makefile
index 117ef10..d1b90dc 100644
--- a/apps/ocaml/Makefile
+++ b/apps/ocaml/Makefile
@@ -12,26 +12,30 @@ static/%.css: ../../shared/%.css
cp $< $@
build: $(CSS)
+ mkdir -p _build
APP_VERSION=$(VERSION) tools/x86.sh $(DUNE) --build-dir $(CURDIR)/_build/x86 ./bin/main.exe
mkdir -p dist && install -m 755 _build/x86/default/bin/main.exe dist/app
build-arm64: $(CSS)
+ mkdir -p _build
APP_VERSION=$(VERSION) LINK_FLAGS='-ccopt -static' \
tools/arm64.sh $(DUNE) --build-dir $(CURDIR)/_build/arm64 ./bin/main.exe
mkdir -p dist && install -m 755 _build/arm64/default/bin/main.exe dist/app-linux-arm64
echo $(VERSION) > dist/app-linux-arm64.version
test: $(CSS)
+ mkdir -p _build
tools/x86.sh dune test --root . --build-dir $(CURDIR)/_build/x86 --force
clean:
rm -rf dist _build $(CSS)
-# Deletes both opam switches (every compiled library) but keeps the opam
-# repositories and the download cache, so the next build compiles every
-# library from its already-downloaded source.
+# Deletes both opam switches and both SQLite builds (every compiled
+# library) but keeps the opam repositories, opam's download cache and the
+# SQLite tarball in .cache/src, so the next build compiles every library
+# from its already-downloaded source.
clean-deps: clean
- rm -rf .cache/opam-arm64/default .cache/opam-x86/default
+ rm -rf .cache/opam-arm64/default .cache/opam-x86/default .cache/sqlite-arm64 .cache/sqlite-x86
bump:
sed -i 's/^let build_marker = "todomvc"$$/let build_marker = "todomvc-bumped"/' src/app.ml
diff --git a/apps/ocaml/README.md b/apps/ocaml/README.md
index b59f2fb..645e1dd 100644
--- a/apps/ocaml/README.md
+++ b/apps/ocaml/README.md
@@ -14,7 +14,7 @@ Live at
|---|---|---|
| Concurrency | [Eio](https://github.com/ocaml-multicore/eio) on the `eio_posix` backend | Eio is OCaml 5's direct-style IO library: plain functions, no monads. The posix backend uses `poll`, which works everywhere; `eio_linux` needs io_uring, which sandboxes and qemu often refuse |
| HTTP | [cohttp-eio](https://github.com/mirage/ocaml-cohttp) | The long-standing OCaml HTTP library, on Eio. It serves a listening socket you hand it, so a unix socket (`--socket`) and TCP (`--listen`) are the same code |
-| SQLite | [sqlite3-ocaml](https://github.com/mmottl/sqlite3-ocaml) | Thin bindings to the C library, which links statically. Prepared statements are cached per connection |
+| SQLite | [sqlite3-ocaml](https://github.com/mmottl/sqlite3-ocaml), over SQLite 3.54.1 built from its amalgamation by `tools/sqlite.sh` | Thin bindings to the C library. SQLite is compiled into `.cache/` for each target and linked statically, so no system SQLite is involved. Prepared statements are cached per connection |
| HTML | Hand-written `Buffer` rendering with one `escape` function | It's one page. A template library would be more code than the page, and the escaping rule stays in one place you can read |
| Assets | Compiled into the binary by a dune rule (`gen/embed.ml`) | One file to ship. `shared/base.css` and `shared/index.css` are copied in at build time and served byte for byte |
| Randomness | `getrandom(2)` through a ten-line C stub | Cookie values need a CSPRNG, and this needs no `/dev/urandom` inside the sandbox and no crypto library |
@@ -30,7 +30,12 @@ The code is in four small modules under `src/`:
- `form.ml`: form bodies and cookies.
`bin/main.ml` parses the flags, opens the database, adapts `App.handle`
-to cohttp-eio, and stops cleanly on SIGTERM.
+to cohttp-eio, and stops cleanly on SIGTERM. It runs cohttp-eio's
+per-connection loop itself rather than `Cohttp_eio.Server.run`, because
+`run` reads headers into an unbounded buffer. Here every connection reads
+through a byte budget: 64 KiB for a request's line and headers (more gets
+431), 1 MiB for a body (more gets 413), and at most 256 connections at
+once.
### How the page works
@@ -75,15 +80,20 @@ make clean-deps # also the compiled libraries; downloads stay
The first `make build-arm64` takes a long time: every library compiles
under qemu. After that, a rebuild only recompiles what changed.
+SQLite is built from the amalgamation by `tools/sqlite.sh`: the tarball is
+downloaded once into `.cache/src` and checked against sqlite.org's
+SHA3-256, then compiled into `.cache/sqlite-x86` or `.cache/sqlite-arm64`,
+where the `sqlite3` opam package finds it through `pkg-config`.
+`make clean-deps` deletes those builds and keeps the tarball.
+
Two things about docs-t1 shape the scripts in `tools/`:
- The x86 compiler is the bakeoff's opam switch. opam won't treat a
compiler from another opam root as `ocaml-system`, so `tools/x86.sh`
links it into `.cache/x86-bin`.
-- docs-t1 has no SQLite headers or `pkg-config`, and no `sudo`. So
- `tools/x86.sh` unpacks the Debian packages (`apt-get download`, no
- root) into `.cache/x86-sysroot`. The local build links the system
- `libsqlite3.so.0`; the shipped arm64 build links SQLite statically.
+- docs-t1 has no `pkg-config` and no `sudo`, so `tools/x86.sh` unpacks
+ Debian's `pkgconf` (`apt-get download`, no root) into
+ `.cache/x86-sysroot`.
## Run
diff --git a/apps/ocaml/bin/dune b/apps/ocaml/bin/dune
index 9cbd141..646ba82 100644
--- a/apps/ocaml/bin/dune
+++ b/apps/ocaml/bin/dune
@@ -2,7 +2,7 @@
(name main)
(public_name todo-ocaml)
(package todo_ocaml)
- (libraries todo eio eio_posix cohttp-eio http uri)
+ (libraries todo eio eio_posix cohttp-eio http uri cstruct)
(link_flags (:standard (:include link_flags.sexp))))
; LINK_FLAGS="-ccopt -static" links statically (the shipped arm64 build).
diff --git a/apps/ocaml/bin/main.ml b/apps/ocaml/bin/main.ml
index 48a4cec..4d3523e 100644
--- a/apps/ocaml/bin/main.ml
+++ b/apps/ocaml/bin/main.ml
@@ -6,6 +6,46 @@ let usage = "app (--socket <path> | --listen 127.0.0.1:<port>) --db <path>"
(* Bodies are form posts; 1 MiB is far more than any title needs. *)
let max_body = 1 lsl 20
+(* The request line and headers of one request, together. *)
+let max_head = 64 * 1024
+
+(* Each connection costs at most about max_head + max_body of buffers. *)
+let max_connections = 256
+
+(* A socket that refuses to read more than [remaining] bytes. cohttp-eio
+ parses headers from an unbounded buffer, so every read goes through one
+ of these: a connection starts with max_head bytes, and each request it
+ carries gets max_body + max_head more (its body, then the next request's
+ head). Running out raises [Head_too_large]. *)
+module Budget = struct
+ exception Head_too_large
+
+ type t = { flow : Eio.Flow.source_ty Eio.Resource.t; mutable remaining : int }
+
+ let read_methods = []
+
+ let single_read t buf =
+ if t.remaining <= 0 then raise Head_too_large;
+ let buf = if Cstruct.length buf > t.remaining then Cstruct.sub buf 0 t.remaining else buf in
+ let n = Eio.Flow.single_read t.flow buf in
+ t.remaining <- t.remaining - n;
+ n
+
+ let handler = Eio.Flow.Pi.source (module struct
+ type nonrec t = t
+ let read_methods = read_methods
+ let single_read = single_read
+ end)
+
+ let source t : Eio.Flow.source_ty Eio.Resource.t = Eio.Resource.T (t, handler)
+
+ (* The budget of the connection whose request is being handled. *)
+ let key : t Eio.Fiber.key = Eio.Fiber.create_key ()
+
+ let next_request () =
+ Option.iter (fun t -> t.remaining <- max_body + max_head) (Eio.Fiber.get key)
+end
+
let fail fmt = Printf.ksprintf (fun s -> prerr_endline ("app: " ^ s); exit 2) fmt
let parse_listen s =
@@ -31,6 +71,7 @@ let to_app_request req body =
}
let callback app _conn req body =
+ Budget.next_request ();
let body =
match Http.Request.meth req with
| `POST -> Eio.Buf_read.(parse take_all) ~max_size:max_body body
@@ -49,6 +90,22 @@ let callback app _conn req body =
Cohttp_eio.Server.respond ~headers ~status ~body:(Eio.Flow.string_source "") ()
| _ -> Cohttp_eio.Server.respond_string ~headers ~status ~body:res.body ()
+(* One connection: cohttp-eio's own loop over keep-alive requests, reading
+ through a Budget. A head too large for it gets 431 and the connection
+ closes. *)
+let connection server socket peer =
+ Eio.Switch.run @@ fun sw ->
+ let budget = { Budget.flow = (socket :> Eio.Flow.source_ty Eio.Resource.t); remaining = max_head } in
+ let input = Eio.Buf_read.of_flow ~max_size:max_head (Budget.source budget) in
+ Eio.Fiber.with_binding Budget.key budget @@ fun () ->
+ try
+ Eio.Buf_write.with_flow socket @@ fun output ->
+ try Cohttp_eio.Server.callback server (sw, peer) input output
+ with Budget.Head_too_large | Eio.Buf_read.Buffer_limit_exceeded ->
+ Eio.Buf_write.string output
+ "HTTP/1.1 431 Request Header Fields Too Large\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"
+ with Eio.Io (Eio.Net.E (Connection_reset _), _) -> ()
+
let () =
let socket = ref "" and listen = ref "" and db = ref "" in
Arg.parse
@@ -83,7 +140,7 @@ let () =
Eio.Fiber.first
(fun () -> Eio.Condition.await_no_mutex stop)
(fun () ->
- Cohttp_eio.Server.run sock server ~on_error:(fun e ->
+ Eio.Net.run_server sock (connection server) ~max_connections ~on_error:(fun e ->
Printf.eprintf "app: %s\n%!" (Printexc.to_string e)))));
(match addr with `Unix path -> (try Sys.remove path with Sys_error _ -> ()) | _ -> ());
Todo.Store.close store;
diff --git a/apps/ocaml/src/app.ml b/apps/ocaml/src/app.ml
index b8a238b..12abd69 100644
--- a/apps/ocaml/src/app.ml
+++ b/apps/ocaml/src/app.ml
@@ -117,7 +117,7 @@ let action_of_path path =
let perform store ~list fields = function
| Add ->
- let title = String.trim (Option.value ~default:"" (List.assoc_opt "title" fields)) in
+ let title = Form.trim (Option.value ~default:"" (List.assoc_opt "title" fields)) in
if title <> "" then Store.add store ~list title;
true
| Toggle_all -> Store.toggle_all store ~list; true
@@ -125,7 +125,7 @@ let perform store ~list fields = function
| Toggle id -> Store.toggle store ~list id
| Destroy id -> Store.delete store ~list id
| Edit id -> (
- match String.trim (Option.value ~default:"" (List.assoc_opt "title" fields)) with
+ match Form.trim (Option.value ~default:"" (List.assoc_opt "title" fields)) with
| "" -> Store.delete store ~list id
| title -> Store.rename store ~list id title)
diff --git a/apps/ocaml/src/form.ml b/apps/ocaml/src/form.ml
index 701f247..d828438 100644
--- a/apps/ocaml/src/form.ml
+++ b/apps/ocaml/src/form.ml
@@ -44,3 +44,28 @@ let cookie header name =
| Some i when String.trim (String.sub part 0 i) = name ->
Some (String.trim (String.sub part (i + 1) (String.length part - i - 1)))
| _ -> None)
+
+(* Unicode White_Space, plus U+FEFF (byte order mark), the set JavaScript's
+ String.prototype.trim removes. *)
+let is_space u =
+ match Uchar.to_int u with
+ | 0x09 | 0x0A | 0x0B | 0x0C | 0x0D | 0x20 | 0x85 | 0xA0 | 0x1680 | 0x2028 | 0x2029 | 0x202F
+ | 0x205F | 0x3000 | 0xFEFF ->
+ true
+ | n -> n >= 0x2000 && n <= 0x200A
+
+(* Removes leading and trailing whitespace by that definition, so a title
+ of only non-breaking spaces trims to nothing. [s] is valid UTF-8. *)
+let trim s =
+ let n = String.length s in
+ let rec go i first last =
+ if i >= n then (first, last)
+ else
+ let d = String.get_utf_8_uchar s i in
+ let next = i + Uchar.utf_decode_length d in
+ if is_space (Uchar.utf_decode_uchar d) then go next first last
+ else go next (if first < 0 then i else first) next
+ in
+ match go 0 (-1) 0 with
+ | -1, _ -> ""
+ | first, last -> String.sub s first (last - first)
diff --git a/apps/ocaml/static/app.js b/apps/ocaml/static/app.js
index 308dfd9..78bcbda 100644
--- a/apps/ocaml/static/app.js
+++ b/apps/ocaml/static/app.js
@@ -27,14 +27,40 @@ function post(form) {
});
}
+// True while render() swaps the list, so that losing focus to the swap
+// doesn't count as finishing an edit.
+let swapping = false;
+
// Replaces section.main and footer.footer with the server's; either may be
-// absent when the list is empty. The header, and what's typed in it, stays.
+// absent when the list is empty. The header, and what's typed in it, stays,
+// and so does an edit in progress: the item being edited stays open in the
+// new list, with its unsaved text, focus and cursor.
function render(html) {
const next = new DOMParser().parseFromString(html, 'text/html').querySelector('.todoapp');
- for (const part of ['.main', '.footer']) app.querySelector(`:scope > ${part}`)?.remove();
- for (const part of ['.main', '.footer']) {
- const el = next.querySelector(`:scope > ${part}`);
- if (el) app.append(el);
+ const editing = app.querySelector('.todo-list li.editing');
+ const input = editing?.querySelector('.edit');
+ const focused = input && document.activeElement === input;
+ const [start, end] = input ? [input.selectionStart, input.selectionEnd] : [];
+
+ swapping = true;
+ try {
+ for (const part of ['.main', '.footer']) app.querySelector(`:scope > ${part}`)?.remove();
+ for (const part of ['.main', '.footer']) {
+ const el = next.querySelector(`:scope > ${part}`);
+ if (el) app.append(el);
+ }
+ } finally {
+ swapping = false;
+ }
+
+ const li = editing && app.querySelector(`.todo-list li[data-id="${editing.dataset.id}"]`);
+ if (!li) return;
+ li.classList.add('editing');
+ const fresh = li.querySelector('.edit');
+ fresh.value = input.value;
+ if (focused) {
+ fresh.focus();
+ fresh.setSelectionRange(start, end);
}
}
@@ -85,5 +111,5 @@ app.addEventListener('keydown', (e) => {
});
app.addEventListener('focusout', (e) => {
- if (e.target.matches('.edit')) save(e.target.closest('li'));
+ if (!swapping && e.target.matches('.edit')) save(e.target.closest('li'));
});
diff --git a/apps/ocaml/test/test_todo.ml b/apps/ocaml/test/test_todo.ml
index c574b88..b02d6b5 100644
--- a/apps/ocaml/test/test_todo.ml
+++ b/apps/ocaml/test/test_todo.ml
@@ -24,6 +24,14 @@ let form_decoding () =
Alcotest.(check string) "malformed escapes are kept" "100%" (Form.decode "100%");
Alcotest.(check string) "bad hex is kept" "%zz" (Form.decode "%zz")
+let trimming () =
+ Alcotest.(check string) "ascii" "a b" (Form.trim " \t a b \n");
+ Alcotest.(check string) "nbsp, ideographic, em space, bom" "naïve 🚀"
+ (Form.trim "\u{00A0}\u{3000}\u{2003}\u{FEFF}naïve 🚀\u{00A0}\u{202F}");
+ Alcotest.(check string) "only nbsp" "" (Form.trim "\u{00A0}\u{00A0}");
+ Alcotest.(check string) "inner kept" "a\u{00A0}b" (Form.trim "a\u{00A0}b");
+ Alcotest.(check string) "empty" "" (Form.trim "")
+
let cookies () =
Alcotest.(check (option string)) "finds by name" (Some "abc")
(Form.cookie "a=1; todomvc_list=abc; b=2" "todomvc_list");
@@ -100,6 +108,7 @@ let add_and_redirect () =
let r = post app c "/todos" "title=y" in
Alcotest.(check (option string)) "missing filter" (Some "/") (header r "location");
ignore (post app c "/todos" "title=+++&filter=all");
+ ignore (post app c "/todos" "title=%C2%A0%C2%A0&filter=all");
let body = page app c "/" in
Alcotest.(check int) "three items, blank skipped" 3 (count body "<li data-id=");
Alcotest.(check bool) "trimmed" true (contains body "<label>buy milk</label>");
@@ -158,7 +167,7 @@ let edit_and_destroy () =
Alcotest.(check bool) "edit form" true (contains editing {|action="/todos/2/edit"|});
ignore (post app c "/todos/2/edit" "title=++renamed++&filter=all");
Alcotest.(check bool) "renamed" true (contains (page app c "/") "<label>renamed</label>");
- ignore (post app c "/todos/2/edit" "title=+&filter=all");
+ ignore (post app c "/todos/2/edit" "title=%C2%A0%E3%80%80&filter=all");
Alcotest.(check int) "empty edit deletes" 1 (count (page app c "/") "<li data-id=");
ignore (post app c "/todos/1/destroy" "filter=all");
Alcotest.(check int) "destroyed" 0 (count (page app c "/") "<li data-id=")
@@ -209,6 +218,7 @@ let platform_routes () =
let () =
Alcotest.run "todo"
[ ("form", [ Alcotest.test_case "decoding" `Quick form_decoding;
+ Alcotest.test_case "trimming" `Quick trimming;
Alcotest.test_case "cookies" `Quick cookies;
Alcotest.test_case "escaping" `Quick escaping ]);
("app", [ Alcotest.test_case "first cookie" `Quick first_cookie;
diff --git a/apps/ocaml/tools/arm64.sh b/apps/ocaml/tools/arm64.sh
index 6a110fb..20bb937 100755
--- a/apps/ocaml/tools/arm64.sh
+++ b/apps/ocaml/tools/arm64.sh
@@ -2,17 +2,21 @@
# Runs a command in this app's arm64 opam switch (.cache/opam-arm64) inside
# the emulated Debian arm64 root, creating the switch on first use. The
# compiler is the root's Debian OCaml 5.3.0, used as ocaml-system; every
-# library is compiled from source into .cache, from the versions in
-# todo_ocaml.opam.locked.
+# library, SQLite included, is compiled from source into .cache, from the
+# versions in todo_ocaml.opam.locked and tools/sqlite.sh.
# tools/arm64.sh <command> [args...]
set -euo pipefail
app=$(cd "$(dirname "$0")/.." && pwd)
if [ -z "${IN_ARM64_ROOT:-}" ]; then
+ "$app/tools/sqlite.sh" fetch
exec "$app/../../scripts/arm64.sh" /usr/bin/env IN_ARM64_ROOT=1 \
APP_VERSION="${APP_VERSION:-dev}" LINK_FLAGS="${LINK_FLAGS:-}" "$app/tools/arm64.sh" "$@"
fi
# The root is shared: keep dune's cache off so nothing lands in its /root.
export OPAMROOT=$app/.cache/opam-arm64 OPAMYES=1 OPAMCOLOR=never DUNE_CACHE=disabled
+# SQLite is compiled here too, not taken from the root's libsqlite3-dev.
+"$app/tools/sqlite.sh" build "$app/.cache/sqlite-arm64"
+export PKG_CONFIG_PATH=$app/.cache/sqlite-arm64/lib/pkgconfig
if [ ! -d "$OPAMROOT/default" ]; then
[ -f "$OPAMROOT/config" ] || opam init -q --bare -n --disable-sandboxing default https://opam.ocaml.org
opam switch remove -q default >/dev/null 2>&1 || true # forget one make clean-deps deleted
diff --git a/apps/ocaml/tools/sqlite.sh b/apps/ocaml/tools/sqlite.sh
new file mode 100755
index 0000000..dfdbd73
--- /dev/null
+++ b/apps/ocaml/tools/sqlite.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# Builds SQLite from its amalgamation into a prefix: <prefix>/lib/libsqlite3.a,
+# <prefix>/include/sqlite3.h and a pkg-config file, which the sqlite3 opam
+# package then finds. The source tarball is downloaded once into .cache/src,
+# checked against sqlite.org's SHA3-256, and kept by make clean-deps.
+# tools/sqlite.sh fetch (on the host)
+# tools/sqlite.sh build <prefix> (with the target's C compiler)
+set -euo pipefail
+app=$(cd "$(dirname "$0")/.." && pwd)
+version=3540100
+sha3=b6ced585c750916b1052667239a12c25a7ff34e7f06e1e6e01ecb0427657d9c4
+tarball=$app/.cache/src/sqlite-autoconf-$version.tar.gz
+
+case $1 in
+fetch)
+ [ -f "$tarball" ] && exit 0
+ mkdir -p "$(dirname "$tarball")"
+ curl -sSfo "$tarball.part" "https://sqlite.org/2026/sqlite-autoconf-$version.tar.gz"
+ got=$(python3 -c 'import hashlib,sys; print(hashlib.sha3_256(open(sys.argv[1],"rb").read()).hexdigest())' "$tarball.part")
+ [ "$got" = "$sha3" ] || { echo "sqlite: SHA3-256 mismatch: $got" >&2; rm -f "$tarball.part"; exit 1; }
+ mv "$tarball.part" "$tarball"
+ ;;
+build)
+ prefix=$2
+ [ -f "$prefix/lib/libsqlite3.a" ] && exit 0
+ rm -rf "$prefix" && mkdir -p "$prefix/build" "$prefix/lib/pkgconfig" "$prefix/include"
+ tar xzf "$tarball" -C "$prefix/build" --strip-components=1 \
+ "sqlite-autoconf-$version/sqlite3.c" "sqlite-autoconf-$version/sqlite3.h" "sqlite-autoconf-$version/sqlite3ext.h"
+ # -fPIC: the x86 build also links it into the OCaml stubs' shared library.
+ cc -c -O2 -fPIC -DSQLITE_THREADSAFE=1 -DSQLITE_DQS=0 -DSQLITE_DEFAULT_MEMSTATUS=0 \
+ -DSQLITE_OMIT_DEPRECATED -DSQLITE_OMIT_SHARED_CACHE \
+ -o "$prefix/build/sqlite3.o" "$prefix/build/sqlite3.c"
+ ar rcs "$prefix/lib/libsqlite3.a" "$prefix/build/sqlite3.o"
+ cp "$prefix/build/sqlite3.h" "$prefix/build/sqlite3ext.h" "$prefix/include/"
+ rm -rf "$prefix/build"
+ cat > "$prefix/lib/pkgconfig/sqlite3.pc" <<EOS
+Name: SQLite
+Description: SQL database engine, built by tools/sqlite.sh
+Version: ${version:0:1}.${version:1:2}.${version:4:1}
+Cflags: -I$prefix/include
+Libs: -L$prefix/lib -lsqlite3
+Libs.private: -lm -lpthread
+EOS
+ ;;
+esac
diff --git a/apps/ocaml/tools/x86.sh b/apps/ocaml/tools/x86.sh
index cdfe4e9..2ec0b47 100755
--- a/apps/ocaml/tools/x86.sh
+++ b/apps/ocaml/tools/x86.sh
@@ -1,9 +1,7 @@
#!/usr/bin/env bash
# Runs a command in this app's x86 opam switch (.cache/opam-x86), creating
# it on first use. The compiler is the bakeoff's OCaml 5.3.0, used as
-# ocaml-system; libraries are compiled into .cache. docs-t1 has no SQLite
-# headers or pkg-config, so their Debian packages are unpacked into
-# .cache/x86-sysroot (no root needed).
+# ocaml-system; libraries, SQLite included, are compiled into .cache.
# tools/x86.sh <command> [args...]
set -euo pipefail
app=$(cd "$(dirname "$0")/.." && pwd)
@@ -13,25 +11,26 @@ ocaml_bin=$(dirname "$(readlink -f "$(command -v ocamlopt)")")
unset OPAMSWITCH OPAM_SWITCH_PREFIX OPAM_LAST_ENV CAML_LD_LIBRARY_PATH OCAML_TOPLEVEL_PATH OCAMLTOP_INCLUDE_PATH MANPATH
export OPAMROOT=$cache/opam-x86 OPAMYES=1 OPAMCOLOR=never DUNE_CACHE=disabled
-# opam ignores compilers inside another opam root, so expose this one by symlink.
+# opam ignores compilers inside another opam root, so expose this one by
+# symlink. docs-t1 has no pkg-config; Debian's is unpacked into .cache.
bin=$cache/x86-bin
if [ ! -x "$bin/pkg-config" ]; then
mkdir -p "$bin" "$cache/debs"
for f in "$ocaml_bin"/ocaml*; do ln -sf "$f" "$bin/"; done
- (cd "$cache/debs" && apt-get download -q libsqlite3-dev pkgconf-bin libpkgconf3 >/dev/null)
+ (cd "$cache/debs" && apt-get download -q pkgconf-bin libpkgconf3 >/dev/null)
for d in "$cache"/debs/*.deb; do dpkg-deb -x "$d" "$cache/x86-sysroot"; done
- # The local build links the system libsqlite3.so.0 (the stubs need a shared one).
- ln -sf /usr/lib/x86_64-linux-gnu/libsqlite3.so.0 "$cache/x86-sysroot/usr/lib/x86_64-linux-gnu/libsqlite3.so"
cat > "$bin/pkg-config" <<EOS
#!/bin/sh
root=$cache/x86-sysroot
-LD_LIBRARY_PATH=\$root/usr/lib/x86_64-linux-gnu PKG_CONFIG_LIBDIR=\$root/usr/lib/x86_64-linux-gnu/pkgconfig \\
- PKG_CONFIG_SYSROOT_DIR=\$root exec \$root/usr/bin/pkgconf "\$@"
+LD_LIBRARY_PATH=\$root/usr/lib/x86_64-linux-gnu PKG_CONFIG_LIBDIR=$cache/sqlite-x86/lib/pkgconfig \\
+ exec \$root/usr/bin/pkgconf "\$@"
EOS
chmod +x "$bin/pkg-config"
fi
export PATH=$bin:/usr/local/bin:/usr/bin:/bin:$HOME/.local/bin
+"$app/tools/sqlite.sh" fetch
+"$app/tools/sqlite.sh" build "$cache/sqlite-x86"
if [ ! -d "$OPAMROOT/default" ]; then
[ -f "$OPAMROOT/config" ] || opam init -q --bare -n --disable-sandboxing default https://opam.ocaml.org
opam switch remove -q default >/dev/null 2>&1 || true # forget one make clean-deps deleted