Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,8 @@ jobs:
- run: zig build
- name: Run ./tests/extensions/run (dynamic and static)
run: STATIC=1 ./tests/extensions/run
- name: Run ./tests/ini_test
run: ./tests/ini_test

compile:
needs: changes
Expand Down
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ fuzz: build ## Mutation-fuzz the pipeline and decoders on the Debug build (FUZZ_
ext: build ## Run extension API tests (builds tests/extensions/demo.c with zig cc; STATIC=1 also builds a zphp with it compiled in, BENCH=1 prints call overhead)
./tests/extensions/run

.PHONY: ini
ini: build ## Run php.ini loading tests (--ini, ZPHP_INI, -d, serve isolation, extension= lines)
./tests/ini_test

.PHONY: soak
soak: ## Run the memory soak (ReleaseFast): a string-heavy loop must hold a flat RSS
zig build -Doptimize=ReleaseFast
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ The glibc and macOS builds run on the platform they were made for; the musl buil

See the [documentation](https://nvms.github.io/zphp/) for build instructions and usage guides.

## Configuration

zphp reads a php.ini file at startup: `--ini=PATH`, then the `ZPHP_INI` environment variable, then `php.ini` in the working directory, whichever comes first. `-d name=value` overrides a directive from the command line, as with `php -d`.

```sh
zphp --ini=/etc/zphp/php.ini serve public/index.php
zphp -d date.timezone=Europe/Berlin -d memory_limit=512M run job.php
```

Values follow php.ini rules: quotes are stripped, `On`/`Off` become `1` and empty, `${VAR}` expands from the environment, and `error_reporting = E_ALL & ~E_DEPRECATED` is evaluated. `date.timezone`, `error_reporting`, and `max_execution_time` take effect at the start of every request, and `ini_set` only changes the current request; the next one starts from the file again. `extension=` lines load extensions, by path or by name inside `extension_dir`, and `php_ini_loaded_file()` reports what was read.

## Extensions

zphp loads native extensions written against a C ABI. An extension registers functions, classes, constants, ini defaults, and resource types once at load time, and every PHP call into it goes straight to a resolved function pointer. The header is `include/zphp_extension.h`; the same source builds as a shared library or gets compiled into zphp.
Expand Down
266 changes: 266 additions & 0 deletions src/ini_config.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
// php.ini support: one file read at startup into a process-lifetime list of
// directives, applied to every VM at the start of each request beneath the
// request-scoped ini_set layer. `extension=` lines load extensions through
// the same loader as --extension
const std = @import("std");
const vm_mod = @import("runtime/vm.zig");
const VM = vm_mod.VM;
const RuntimeError = vm_mod.RuntimeError;
const extension = @import("extension.zig");

pub const Directive = struct { name: []const u8, value: []const u8 };

var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
var directives: std.ArrayListUnmanaged(Directive) = .{};
var loaded_file: ?[]const u8 = null;
var extension_dir: ?[]const u8 = null;

fn persistent() std.mem.Allocator {
return arena.allocator();
}

fn fail(comptime fmt: []const u8, args: anytype) noreturn {
std.debug.print("zphp: " ++ fmt ++ "\n", args);
std.process.exit(1);
}

pub fn loadedFile() ?[]const u8 {
return loaded_file;
}

pub fn all() []const Directive {
return directives.items;
}

pub fn get(name: []const u8) ?[]const u8 {
var found: ?[]const u8 = null;
for (directives.items) |d| if (std.mem.eql(u8, d.name, name)) {
found = d.value;
};
return found;
}

// `zphp --ini=PATH` names the file; otherwise ZPHP_INI, then php.ini in the
// working directory, the way php-cli looks next to where it runs. an
// explicit path must exist; the discovered ones are optional
pub fn discover(explicit: ?[]const u8) void {
if (explicit) |path| return load(path, true);
if (std.posix.getenv("ZPHP_INI")) |path| return load(path, true);
load("php.ini", false);
}

pub fn load(path: []const u8, required: bool) void {
const bytes = std.fs.cwd().readFileAlloc(persistent(), path, 16 << 20) catch |err| {
if (required) fail("ini file '{s}': {s}", .{ path, @errorName(err) });
return;
};
loaded_file = std.fs.cwd().realpathAlloc(persistent(), path) catch persistent().dupe(u8, path) catch fail("out of memory", .{});
parse(bytes, path);
}

// `-d name=value` on the command line, applied after the file
pub fn define(assignment: []const u8) void {
const eq = std.mem.indexOfScalar(u8, assignment, '=') orelse {
add(std.mem.trim(u8, assignment, " \t"), "1");
return;
};
add(std.mem.trim(u8, assignment[0..eq], " \t"), cook(std.mem.trim(u8, assignment[eq + 1 ..], " \t")));
}

fn add(name: []const u8, value: []const u8) void {
if (name.len == 0) return;
if (std.mem.eql(u8, name, "extension")) {
loadExtensionDirective(value);
return;
}
if (std.mem.eql(u8, name, "zend_extension")) return;
if (std.mem.eql(u8, name, "extension_dir")) extension_dir = value;
directives.append(persistent(), .{ .name = name, .value = value }) catch fail("out of memory", .{});
}

fn parse(bytes: []const u8, path: []const u8) void {
var lines = std.mem.splitScalar(u8, bytes, '\n');
var line_no: usize = 0;
while (lines.next()) |raw| {
line_no += 1;
var line = std.mem.trim(u8, raw, " \t\r");
if (line.len == 0 or line[0] == ';' or line[0] == '#' or line[0] == '[') continue;
line = stripComment(line);
const eq = std.mem.indexOfScalar(u8, line, '=') orelse fail("ini file '{s}' line {d}: expected name = value", .{ path, line_no });
const name = std.mem.trim(u8, line[0..eq], " \t");
const value = std.mem.trim(u8, line[eq + 1 ..], " \t");
add(persistent().dupe(u8, name) catch fail("out of memory", .{}), cook(value));
}
}

// a ; outside quotes starts a comment
fn stripComment(line: []const u8) []const u8 {
var quoted = false;
for (line, 0..) |ch, i| {
if (ch == '"') quoted = !quoted;
if (ch == ';' and !quoted) return std.mem.trim(u8, line[0..i], " \t");
}
return line;
}

// php's ini value rules: quotes are stripped, On/Yes/True become "1" and
// Off/No/None/False become "", ${VAR} expands from the environment, and an
// expression over E_* constants (E_ALL & ~E_DEPRECATED) is evaluated
fn cook(raw: []const u8) []const u8 {
if (raw.len >= 2 and raw[0] == '"' and raw[raw.len - 1] == '"') return expandEnv(raw[1 .. raw.len - 1]);
if (raw.len == 0) return "";
inline for (.{ "on", "yes", "true" }) |word| if (std.ascii.eqlIgnoreCase(raw, word)) return "1";
inline for (.{ "off", "no", "none", "false", "null" }) |word| if (std.ascii.eqlIgnoreCase(raw, word)) return "";
if (errorLevelExpression(raw)) |level| return std.fmt.allocPrint(persistent(), "{d}", .{level}) catch fail("out of memory", .{});
return expandEnv(raw);
}

fn expandEnv(value: []const u8) []const u8 {
if (std.mem.indexOf(u8, value, "${") == null) return persistent().dupe(u8, value) catch fail("out of memory", .{});
var out = std.ArrayListUnmanaged(u8){};
var i: usize = 0;
while (i < value.len) {
if (value[i] == '$' and i + 1 < value.len and value[i + 1] == '{') {
if (std.mem.indexOfScalarPos(u8, value, i + 2, '}')) |close| {
const var_name = value[i + 2 .. close];
const name_z = persistent().dupeZ(u8, var_name) catch fail("out of memory", .{});
if (std.posix.getenv(name_z)) |env| out.appendSlice(persistent(), env) catch fail("out of memory", .{});
i = close + 1;
continue;
}
}
out.append(persistent(), value[i]) catch fail("out of memory", .{});
i += 1;
}
return out.items;
}

const error_levels = [_]struct { name: []const u8, value: i64 }{
.{ .name = "E_ERROR", .value = 1 }, .{ .name = "E_WARNING", .value = 2 }, .{ .name = "E_PARSE", .value = 4 },
.{ .name = "E_NOTICE", .value = 8 }, .{ .name = "E_CORE_ERROR", .value = 16 }, .{ .name = "E_CORE_WARNING", .value = 32 },
.{ .name = "E_COMPILE_ERROR", .value = 64 }, .{ .name = "E_COMPILE_WARNING", .value = 128 }, .{ .name = "E_USER_ERROR", .value = 256 },
.{ .name = "E_USER_WARNING", .value = 512 }, .{ .name = "E_USER_NOTICE", .value = 1024 }, .{ .name = "E_STRICT", .value = 2048 },
.{ .name = "E_RECOVERABLE_ERROR", .value = 4096 }, .{ .name = "E_DEPRECATED", .value = 8192 }, .{ .name = "E_USER_DEPRECATED", .value = 16384 },
// php 8.4 dropped E_STRICT from E_ALL; this is also zphp's default level
.{ .name = "E_ALL", .value = 30719 },
};

// grammar: expr = term (('|' | '&' | '^') term)*; term = '~' term | '(' expr ')' | E_NAME | integer
const LevelParser = struct {
src: []const u8,
pos: usize = 0,
ok: bool = true,

fn skip(self: *LevelParser) void {
while (self.pos < self.src.len and (self.src[self.pos] == ' ' or self.src[self.pos] == '\t')) self.pos += 1;
}

fn expr(self: *LevelParser) i64 {
var acc = self.term();
while (true) {
self.skip();
if (self.pos >= self.src.len) return acc;
const op = self.src[self.pos];
if (op == ')') return acc;
if (op != '|' and op != '&' and op != '^') {
self.ok = false;
return acc;
}
self.pos += 1;
const rhs = self.term();
acc = switch (op) {
'|' => acc | rhs,
'&' => acc & rhs,
else => acc ^ rhs,
};
}
}

fn term(self: *LevelParser) i64 {
self.skip();
if (self.pos >= self.src.len) {
self.ok = false;
return 0;
}
const ch = self.src[self.pos];
if (ch == '~') {
self.pos += 1;
return ~self.term();
}
if (ch == '(') {
self.pos += 1;
const inner = self.expr();
self.skip();
if (self.pos < self.src.len and self.src[self.pos] == ')') self.pos += 1 else self.ok = false;
return inner;
}
const start = self.pos;
while (self.pos < self.src.len and (std.ascii.isAlphanumeric(self.src[self.pos]) or self.src[self.pos] == '_')) self.pos += 1;
const word = self.src[start..self.pos];
if (word.len == 0) {
self.ok = false;
return 0;
}
if (std.fmt.parseInt(i64, word, 10)) |n| return n else |_| {}
for (error_levels) |level| if (std.mem.eql(u8, level.name, word)) return level.value;
self.ok = false;
return 0;
}
};

fn errorLevelExpression(raw: []const u8) ?i64 {
if (std.mem.indexOf(u8, raw, "E_") == null) return null;
var p = LevelParser{ .src = raw };
const v = p.expr();
if (!p.ok or p.pos != raw.len) return null;
return v;
}

// extension=name resolves like php: a bare name lives in extension_dir (or
// next to the ini file) and gets the platform suffix
fn loadExtensionDirective(value: []const u8) void {
if (value.len == 0) return;
const has_dir = std.mem.indexOfScalar(u8, value, '/') != null;
const suffix: []const u8 = switch (@import("builtin").os.tag) {
.macos, .ios => ".dylib",
.windows => ".dll",
else => ".so",
};
const with_suffix = if (std.mem.indexOfScalar(u8, value, '.') == null) std.mem.concat(persistent(), u8, &.{ value, suffix }) catch fail("out of memory", .{}) else value;
if (has_dir) return extension.loadDynamic(with_suffix);
const dir = extension_dir orelse if (loaded_file) |f| std.fs.path.dirname(f) orelse "." else ".";
const full = std.fs.path.join(persistent(), &.{ dir, with_suffix }) catch fail("out of memory", .{});
extension.loadDynamic(full);
}

// seed the request layer and mirror the directives the VM keeps as state;
// runs at the top of every request, after reset cleared the previous one
pub fn applyToVm(vm: *VM) RuntimeError!void {
for (directives.items) |d| {
try vm.ini_settings.put(vm.allocator, d.name, d.value);
if (std.mem.eql(u8, d.name, "error_reporting")) {
vm.error_reporting_level = std.fmt.parseInt(i64, std.mem.trim(u8, d.value, " \t"), 10) catch vm.error_reporting_level;
} else if (std.mem.eql(u8, d.name, "date.timezone")) {
if (d.value.len > 0) vm.default_tz_name = d.value;
} else if (std.mem.eql(u8, d.name, "max_execution_time")) {
vm.setExecutionLimit(std.fmt.parseInt(i64, std.mem.trim(u8, d.value, " \t"), 10) catch 0);
}
}
}

test "ini values are cooked the way php cooks them" {
try std.testing.expectEqualStrings("1", cook("On"));
try std.testing.expectEqualStrings("", cook("Off"));
try std.testing.expectEqualStrings("America/New_York", cook("\"America/New_York\""));
try std.testing.expectEqualStrings("22527", cook("E_ALL & ~E_DEPRECATED"));
try std.testing.expectEqualStrings("22519", cook("E_ALL & ~(E_NOTICE | E_DEPRECATED)"));
try std.testing.expectEqualStrings("E_ALL &", cook("E_ALL &"));
try std.testing.expectEqualStrings("E_ALL)", cook("E_ALL)"));
try std.testing.expectEqualStrings("(E_ALL", cook("(E_ALL"));
try std.testing.expectEqualStrings("256M", cook("256M"));
}

test "a semicolon outside quotes starts a comment" {
try std.testing.expectEqualStrings("a = b", stripComment("a = b ; note"));
try std.testing.expectEqualStrings("a = \"x;y\"", stripComment("a = \"x;y\""));
}
44 changes: 34 additions & 10 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const VM = @import("runtime/vm.zig").VM;
const Value = runtime_value.Value;
const CompileResult = @import("pipeline/compiler.zig").CompileResult;
const extension = @import("extension.zig");
const ini_config = @import("ini_config.zig");
const bytecode_format = @import("bytecode_format.zig");
const error_format = @import("error_format.zig");

Expand Down Expand Up @@ -35,7 +36,7 @@ pub fn main() !void {
return;
}

const args = try loadExtensionFlags(allocator, raw_args);
const args = try loadStartupFlags(allocator, raw_args);
defer allocator.free(args);

if (args.len < 2) {
Expand All @@ -46,34 +47,56 @@ pub fn main() !void {
try dispatch(allocator, args);
}

// `zphp [--extension=PATH]... <command> ...` loads dynamic extensions before
// any VM exists, then ZPHP_EXTENSION_DIR adds every library in that
// directory. returns the args with the flags removed
fn loadExtensionFlags(allocator: std.mem.Allocator, raw_args: []const []const u8) ![]const []const u8 {
// `zphp [--extension=PATH]... [--ini=PATH] [-d name=value]... <command> ...`
// loads dynamic extensions and the ini file before any VM exists. the ini
// file comes from --ini, ZPHP_INI, or php.ini in the working directory; its
// extension= lines load after the flags, -d definitions apply last, then
// ZPHP_EXTENSION_DIR adds every library in that directory. returns the args
// with the flags removed
fn loadStartupFlags(allocator: std.mem.Allocator, raw_args: []const []const u8) ![]const []const u8 {
var args = std.ArrayListUnmanaged([]const u8){};
errdefer args.deinit(allocator);
var defines = std.ArrayListUnmanaged([]const u8){};
defer defines.deinit(allocator);
var ini_path: ?[]const u8 = null;
try args.append(allocator, raw_args[0]);
var i: usize = 1;
while (i < raw_args.len) : (i += 1) {
const arg = raw_args[i];
if (std.mem.startsWith(u8, arg, "--extension=")) {
extension.loadDynamic(arg["--extension=".len..]);
} else if (std.mem.eql(u8, arg, "--extension")) {
if (i + 1 >= raw_args.len) {
try writeStderr("usage: zphp --extension=PATH <command>\n");
std.process.exit(1);
}
i += 1;
extension.loadDynamic(raw_args[i]);
extension.loadDynamic(try flagValue(raw_args, i, "usage: zphp --extension=PATH <command>\n"));
} else if (std.mem.startsWith(u8, arg, "--ini=")) {
ini_path = arg["--ini=".len..];
} else if (std.mem.eql(u8, arg, "--ini")) {
i += 1;
ini_path = try flagValue(raw_args, i, "usage: zphp --ini=PATH <command>\n");
} else if (std.mem.startsWith(u8, arg, "-d") and arg.len > 2) {
try defines.append(allocator, arg[2..]);
} else if (std.mem.eql(u8, arg, "-d") or std.mem.eql(u8, arg, "--define")) {
i += 1;
try defines.append(allocator, try flagValue(raw_args, i, "usage: zphp -d name=value <command>\n"));
} else {
try args.appendSlice(allocator, raw_args[i..]);
break;
}
}
ini_config.discover(ini_path);
for (defines.items) |d| ini_config.define(d);
if (std.posix.getenv("ZPHP_EXTENSION_DIR")) |dir| extension.loadDirectory(dir);
return args.toOwnedSlice(allocator);
}

fn flagValue(raw_args: []const []const u8, i: usize, usage: []const u8) ![]const u8 {
if (i >= raw_args.len) {
try writeStderr(usage);
std.process.exit(1);
}
return raw_args[i];
}

fn dispatch(allocator: std.mem.Allocator, args: []const []const u8) !void {
const cmd = args[1];

Expand Down Expand Up @@ -695,6 +718,7 @@ fn writeStderr(msg: []const u8) !void {
}

test {
_ = @import("ini_config.zig");
_ = @import("pipeline/token.zig");
_ = @import("pipeline/lexer.zig");
_ = @import("pipeline/ast.zig");
Expand Down
Loading
Loading