- OCaml 76%
- Perl 16%
- Raku 4%
- 3%
- Dune 1%
hermetic #
The OCaml standard library with the ambient world refused.
A protocol state machine, a codec or the model of a tool is meant to decide
from its inputs alone, so that the same inputs give the same outputs and a
test can replay any run. The build can refuse it Unix, Eio or a clock library
with dune's forbidden_libraries, but the standard library reads the world
outside the program too: Sys.getenv, Sys.time, open_in, In_channel,
Random.self_init and exit link with nothing extra. A model that calls
them builds and passes its tests, and a run then depends on the machine it ran
on.
hermetic makes those calls compile errors. Its Hermetic module restates
the standard library with every value that reaches the world without being
handed it marked by an ambient alert, and a library opens it before it is
typed:
(library
(name model)
(libraries hermetic)
(flags (:standard -open Hermetic -alert @ambient)))
File "lib/machine.ml", line 3, characters 14-24:
3 | let home () = Sys.getenv "HOME"
^^^^^^^^^^
Error (alert ambient): Hermetic.Sys.getenv
Reads the process environment: take the value as an input.
The hermetic-check command then reads the library's compiled units and
refuses what the prelude cannot see: a unit compiled without the two flags, an
attribute that makes the alert a warning again, a unit of the standard library
named by its own name, and an external declaration, which names a primitive
directly.
It needs OCaml 5.4 or later.
Installation #
$ opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git
$ opam install hermetic
What it refuses #
A refused value carries the alert, so the error names the value and says
what to pass in instead. A module refused as a whole is empty, and the three
mutable globals of the standard library (Sys.argv, Sys.interactive and the
domain's exit actions) are (), so that no use of them compiles even where
the alert is silenced. A channel, a formatter or a buffer the caller hands in
is an input: the functions that use one stay, and Format.fprintf,
output_string oc and In_channel.input_all ic compile.
- The process environment and the process's own facts:
Sys.getenv,Sys.argv,Sys.executable_name,Sys.getcwd,Sys.runtime_parameters. - The clock:
Sys.time. - Randomness:
Random.self_init,Random.State.make_self_init, and every function of the generator the whole process shares (Random.intand the rest). ARandom.State.tmade withRandom.State.makefrom a seed the caller passes stays. - The order of a generic hash table, which
OCAMLRUNPARAM=Rseeds from the system:Hashtbl.iter,fold,to_seqand the rest of what observes it,Hashtbl.randomizeandHashtbl.MakeSeeded. Lookups stay, and so do the tables ofHashtbl.Make, which never randomise. - The standard channels and the files:
stdin,stdout,stderr,print_*,prerr_*,read_*,open_in*,open_out*,flush_all, the openers and standard channels ofIn_channelandOut_channel, the file functions ofSys,Filename.temp_file,Digest.file,Printf.printf,Scanf.scanfand the files ofScanf.Scanning, and the standard formatters ofFormatwith every function that writes or sets them implicitly. - The process:
exit,at_exit,Sys.command,Sys.signal,Sys.catch_break,Arg,Callback. - State the whole process shares:
Domain,Mutex,Condition,Semaphore,Atomic,Gc,Weak,Ephemeron,Oo, and the printers and backtraces ofPrintexc(Printexc.to_string_defaultstays). - The escapes from the type system:
Obj,Marshal,input_value,unsafe_really_input,CamlinternalMod,CamlinternalOO. - In
Fmt:Fmt.stdout,Fmt.stderr,Fmt.pr,Fmt.epr, andFmt.exn, which renders through the printers the process registered.
The refusal follows the declaration, not the spelling. Stdlib.Sys.getenv,
a module alias of Sys, open Stdlib, include Sys, a functor applied to
Sys, a signature constraint over it and a first-class module packing it are
all refused: each reaches the value through a declaration of Hermetic, and
the compiler checks the alert on a signature inclusion as it does on a use.
Stdlib__Sys.getenv names the standard library's own unit, which no prelude
can shadow without defining a module of that name; hermetic-check refuses
it.
Checking the compiled units #
(rule
(alias runtest)
(deps (glob_files ../lib/.model.objs/byte/model__*.cmt))
(action (run %{bin:hermetic-check} %{deps})))
hermetic-check reads each .cmt and prints one line per reason to refuse
the unit, then exits 1:
- the command line that compiled it does not open
Hermetic, or a module given with--open; - the
ambientalert is not an error under its-alertoptions, which are replayed in order through the compiler's own parser, so a later-alert -ambientundoes an earlier-alert @ambient; - an
[@alert "..."]attribute, on the file, an item or an expression, under which theambientalert stops being an error ("-ambient","--ambient","-all"); an attribute that keeps it an error, or one that declares an alert of the library's own, passes; - it names a unit of the standard library by its own name,
Stdlib__Sysor any other, in a value, an alias, anopen, a functor argument or a first-class module; - it declares an
external, at any depth, with the primitive it names.
The first two do not apply to the alias module dune generates for a library of
several modules, which it compiles without the library's flags: a unit that
holds only module X = Lib__X aliases to units of its own library, each
checked by itself, has nothing in it to refuse. An alias to any other module
is refused as before, since it would hand that module to its users past the
prelude.
A library that also hides the ambient values of a dependency in a prelude of
its own opens that prelude after Hermetic and names it with --open, so
that dropping it from the stanza is refused too.
Libraries that do their I/O through Eio #
A library that talks to the system through Eio capabilities is not hermetic,
but it should reach the system only through the capabilities it is handed.
The hermetic.eio library holds the prelude for it, Hermetic_eio, opened the
same way:
(library
(name store)
(libraries eio hermetic.eio)
(flags (:standard -open Hermetic_eio -alert @ambient)))
It keeps the standard library an Eio program uses, hash table iteration,
Random, Atomic, Domain and the standard formatters included, and refuses
each access that bypasses a capability with a message naming the Eio call to
make instead:
- the file system:
open_in*,open_out*, the openers ofIn_channelandOut_channel, the file functions ofSys(file_exists,is_directory,readdir,remove,rename,mkdir,rmdir), the temporary files ofFilenameand the digests of a file; - the process:
exit,at_exit,Sys.command,Sys.signal,Sys.set_signal,Sys.catch_break,Sys.getcwd,Sys.chdir,Sys.argvandSys.executable_name; - the clock:
Sys.time; - the process environment:
Sys.getenvandSys.getenv_opt, which no Eio capability holds, so the value is taken as an input, as underHermetic; Mutex,ConditionandSemaphore, which block the domain under every fiber, are refused as a whole.
hermetic-check --eio reads the units against that prelude and also refuses
a unit that imports Unix, UnixLabels, Thread or Event. The standard
library's Mutex, Condition and Semaphore are the prelude's to refuse:
Eio's own interfaces name Mutex.t, so every unit typed against Eio imports
them. The rule beside the library reads its units:
(rule
(alias runtest)
(deps (glob_files .store.objs/byte/*.cmt))
(action (run %{bin:hermetic-check} --eio %{deps})))
A library that is the boundary between Eio and the system, and so binds the
system calls Eio lacks, is checked with --eio --boundary, which allows those
imports and its external declarations.
Every Eio library of a tree #
In a tree of many projects, -alert @ambient belongs once in the env of the
dune-workspace, for every profile. A library then adopts the prelude with one
change to its stanza:
(libraries eio hermetic.eio)
(flags (:standard -open Hermetic_eio))
The hermetic-tree command, of the hermetic-tree package, checks such a
tree. It indexes the source tree, selects each library whose public name ends
with -eio or .eio or that names eio among its libraries, and reads its
units as hermetic-check --eio does. A package whose dune-project carries
the tag hermetic:eio-boundary is checked as --boundary. An mdx stanza
that names an adopted library names hermetic.eio too, or its toplevel sees
the prelude's types abstract. A ratchet file lists the libraries still allowed
to violate, one library name per line, and the check refuses a listed library
that has become hermetic, so the list only shrinks.
Each package directory is checked in its own runtest, so that testing the
packages a change touches checks them, and nothing else is built.
hermetic-tree --include --ratchet hermetic.ratchet prints those rules, one
(subdir DIR ...) stanza for each package directory, for an (include) of
the root's dune file. Each depends on the compiled units of the directory's
selected libraries and on the dune files of the packages its mdx stanzas
read, and runs hermetic-tree --scope DIR. The root keeps one rule that
prints them again and fails on a difference, which --auto-promote takes, and
on a library of the ratchet the tree no longer selects:
(include hermetic.inc)
(rule
(deps (sandbox always) hermetic.ratchet (glob_files_rec dune)
(glob_files_rec dune-project) (glob_files_rec *.opam) (glob_files_rec *.inc))
(action
(with-stdout-to hermetic.inc.gen
(run %{bin:hermetic-tree} --include --ratchet hermetic.ratchet))))
(rule
(alias runtest)
(action (diff hermetic.inc hermetic.inc.gen)))
A library added to a package after the rules were printed has no compiled
unit in its rule's sandbox, and an mdx stanza that names a library its rule
does not read is refused the same way: both say to print the rules again. A
new package has no rule until they are.
What it does not cover #
A dependency is outside its reach. A function of another library that reads
the environment in its own code is refused only when that library is itself
compiled hermetic. A dependency's ambient values can be shadowed the same way,
by a module that restates it with the ambient alert, opened after
Hermetic, as the probe.hermetic library of the probe package does for
Probe. A top-level ref of the library's own is state the whole process shares,
and no name of the prelude can refuse it. An OCAMLPARAM in the environment
of the build can add compiler options the recorded command line does not
show.
Licence #
ISC. See LICENSE.md.