Skip to content

§ 25 — Filesystem helpers

25. Filesystem helpers (fs.*)

Normative. This chapter defines the fs.* table — the host-portable filesystem subset that Cook exposes in both register-phase and execute-phase Lua VMs — together with the project-root sandbox and the Lua-side shell escape hatch guards.

25.1. Overview

Phase. Both. The same surface is installed in register-phase and execute-phase VMs; semantics are identical.

All fs.* functions interpret relative path arguments against the Cookfile’s working directory. A conforming implementation MUST NOT silently succeed on I/O errors: failures other than those made explicit below are reported as Lua runtime errors.

25.2. fs.exists

Signature. fs.exists(path: string) -> boolean

Returns true if a filesystem entry exists at path, false otherwise. Broken symlinks and permission errors are implementation-defined with respect to the boolean result, but this function MUST NOT raise for a missing path.

25.3. fs.size

Signature. fs.size(path: string) -> integer

Returns the size of the file at path in bytes. A missing or unreadable entry is a Lua runtime error.

25.4. fs.read

Signature. fs.read(path: string) -> string

Returns the contents of path as a Lua string. A missing or unreadable entry is a Lua runtime error. Encoding is not transformed: the string carries raw bytes.

25.5. fs.write

Signature. fs.write(path: string, content: string) -> nil

Writes content to path, creating parent directories as needed. Any existing file at path is overwritten. Write failure is a Lua runtime error.

25.6. fs.remove

Signature. fs.remove(path: string) -> boolean

Removes the file at path. Returns true when a file was removed and false when nothing was there.

An absent path is NOT an error. The caller of a removal wants the path gone, and it is already gone; raising would oblige every cleanup path to guard with fs.exists first, which is both noise and a race. Returning a boolean keeps the outcome reported rather than silent, so a caller that does care can still tell the two cases apart.

A conforming implementation MUST refuse a path that is a directory, with an error naming it, and MUST NOT remove it. There is no recursive form. fs.remove does not follow symlinks: given a symlink it removes the link and MUST leave the target untouched, whether or not that target is a directory.

Removal is subject to the project-root sandbox (§ 25.10) exactly as fs.write is.

25.7. fs.mkdir_p

Signature. fs.mkdir_p(path: string) -> nil

Creates the directory at path, and any missing parent components, if they do not already exist. This function MUST succeed when path already exists and is a directory.

25.8. fs.glob

Signature. fs.glob(pattern: string | array of string) -> array of string

The pattern argument is either a single Lua string (a glob pattern) or a 1-indexed Lua sequence of strings (an array of glob patterns).

When given a single string, returns a 1-indexed sequence of absolute paths matching pattern, interpreted relative to the working directory. When given an array of strings, the implementation MUST glob each pattern in call order and concatenate the resulting per-pattern sequences in that same order; within each per-pattern block, the order is the order produced by the single-string form for that pattern (CS-0079).

A path is included only when it resolves to a regular file; entries whose final (symlink-resolved) type is a directory MUST be dropped, and MUST NOT raise an error (CS-0064). The directory-filter rule is applied per pattern, so a directory match produced by one pattern in the array does not preempt regular-file matches from other patterns. The order of returned paths within a single pattern’s result block is implementation-defined. An invalid glob pattern is a Lua runtime error; a valid pattern with no matches contributes an empty block to the concatenated result and MUST NOT raise.

If pattern is neither a Lua string nor a Lua sequence of strings, the implementation MUST raise a Lua runtime error tagged fs.glob.

The directory-filter rule pairs with § 22.1‘s path-shape restriction (CS-0063): downstream cook.add_unit inputs reject directory paths, so a glob like dir/* against a tree that mixes files and sub-directories would otherwise fail with is a directory for paths the author never wrote. Filtering at the glob layer makes the common “all files under a directory” pattern well-defined.

Example 25.8.1 — single-string form

local sources = fs.glob("src/*.c")

Example 25.8.2 — array form

local sources = fs.glob({"src/*.c", "src/legacy/*.c"})

Both forms return an array of absolute path strings; the array form’s result is the concatenation of the per-pattern results in call order. The two patterns above contribute their matches consecutively: every src/*.c match precedes every src/legacy/*.c match.

25.9. fs.mtime

Signature. fs.mtime(path: string) -> number

Returns the modification time of path as seconds since the Unix epoch, as a number. A missing or unreadable entry is a Lua runtime error.

Note 25.9.1

The fs.* surface is intentionally minimal. Authors needing operations not listed here (symlink manipulation, recursive deletion, permission changes) invoke those operations through the shell — fs.* is a host-portable subset for the common cases.

Note 25.9.2

The “Phase: Both” classification on fs.*, path.*, and cook.platform.* is a behavioral contract: every observable property of these tables — the function signatures, the working-directory rooting rule, the error-on-I/O-failure rule, the order-implementation-defined rule on fs.glob, the platform string values — MUST be identical between the register-phase VM and the execute-phase worker VMs. A conforming implementation realizes this by sharing a single implementation of these tables across the two VMs. The reference implementation does so via the cook-lua-stdlib crate (CS-0044), which is the sole installer of fs.*, path.*, and cook.platform.* in both cook-register and cook-execute. It offers exactly one way to install fs.*, and that one takes a sandbox policy: a caller states its confinement or does not compile. The permissive constructor it also carried, a pre-CS-0045 wrapper that supplied “no sandbox” as a default, lost its last production caller when CS-0135 retired plate and is removed (COOK-423), so the “no exempted step kind” rule below is a property of the code’s shape rather than of every call site remembering it. Implementations MAY structure the sharing differently, but a register-phase call and an execute-phase call to the same fs.*/path.*/cook.platform.* function with the same arguments MUST produce the same result, modulo the working-directory difference inherent to the multi-Cookfile-import case (§ 11.2; CS-0017).

25.10. Project-root sandbox

Beyond the working-directory rooting rule of § 25.1, every fs.* entry that takes a path argument MUST further confine that path to the project root when invoked from a sandboxed context. The project root is the workspace root (§ 20) — the directory containing the top-level Cookfile in the build invocation, even when an imported Cookfile (§ 11.2) is the immediate caller.

A path is inside the project root when its lexical normalization (with . and .. components resolved without consulting the filesystem) is component-wise prefix-equal to the lexical normalization of the project root. An absolute path that lies outside the project root MUST be rejected; a relative path that, after .. resolution against the caller’s working directory, lies outside the project root MUST also be rejected. A conforming implementation MUST raise a Lua runtime error in either case; it MUST NOT silently truncate, retarget, or fall back to the working directory. The diagnostic SHOULD identify the offending path and reference this section’s tag (CS-0045).

The sandboxed contexts are:

ContextSandboxed?
cook_step Lua body (>{ ... })yes
cook_step shell body’s expanded $<…> substitutions running through fs.*yes
test_step body (Lua or shell)yes
chore body (Lua or shell)yes
Register-phase Lua: config_block body, recipe-body module_call, register_block body, cook_step register-time cook.add_unit({...}) orchestrationyes

Every Lua execution context in the language is sandboxed; there is no exempted step kind. An author who needs to write outside the project root (deploys, uploads, package installs to $HOME, etc.) uses a chore’s raw shell_command steps (§ 8.8), which are ordinary shell text and are not mediated by fs.* at all — the confinement in this table applies only to fs.* Lua calls, not to shell text.

A conforming implementation MUST NOT apply the lexical normalization to a path whose .. resolution would walk above a filesystem root; such a candidate is treated as escaping the project root regardless of the project root’s location.

path.* (§ 26) is pure string manipulation and does not perform I/O; the sandbox does not apply to it. Authors MAY freely construct out-of-project path strings via path.* — only the I/O-performing surfaces (fs.* and the shell escape hatches of § 25.11) reject them.

25.11. Lua-side shell escape hatches

The standard Lua library includes two surfaces that run shell commands directly: os.execute(cmd) and io.popen(cmd, mode?). Both bypass cook.sh (§ 24.1) and therefore neither inherits the recipe’s working-directory rooting nor contributes to the unit’s command_hash. Every Lua execution context is sandboxed (§ 25.10), so a conforming implementation MUST disable these entries in all of them: a call MUST raise a Lua runtime error referencing this section’s tag (CS-0045) rather than spawning a child process.

This is a normative constraint on the Cook Lua sandbox surface, not a Lua language change: the Lua specification is unaffected. A conforming Cook implementation MAY install the guard by replacing os.execute and io.popen with closures that consult the active policy and either delegate to the original implementation (under Off) or raise (under Confined). The reference implementation does so via cook-lua-stdlib::install_shell_escape_guards.