Skip to content

Commit 102d77a

Browse files
committed
Make Chilli more portable
1 parent f5992c1 commit 102d77a

4 files changed

Lines changed: 82 additions & 38 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ This command will download Chilli and add it to Zig's global cache and update yo
5959
Zig version supported by the main releases of Chilli:
6060

6161
| Zig | Chilli Tags |
62-
|----------|-------------|
63-
| `0.16.0` | `v0.3.x` |
64-
| `0.15.x` | `v0.2.x` |
62+
|----------|----------|
63+
| `0.16.0` | `v0.4.x` |
64+
| `0.15.x` | `v0.2.x` |
6565

6666
The `main` branch normally tracks the latest (non-developmental) Zig release.
6767

build.zig.zon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.{
22
.name = .chilli,
3-
.version = "0.3.1",
3+
.version = "0.4.0",
44
.fingerprint = 0x6c259741ae4f5f73, // Changing this has security and trust implications.
55
.minimum_zig_version = "0.16.0",
66
.paths = .{

src/chilli/context.zig

Lines changed: 74 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,9 @@
11
//! Provides the execution context for a command, giving access to parsed arguments and flags.
22
const std = @import("std");
3-
const builtin = @import("builtin");
43
const types = @import("types.zig");
54
const command = @import("command.zig");
65
const errors = @import("errors.zig");
76

8-
/// Returns true on targets where POSIX environment variable lookup is available.
9-
fn canLookupEnv() bool {
10-
return switch (builtin.os.tag) {
11-
.windows, .wasi, .emscripten, .freestanding, .other => false,
12-
else => true,
13-
};
14-
}
15-
167
/// (Private) Describes the source of a value for error reporting.
178
const ValueSource = enum {
189
parsed,
@@ -76,10 +67,14 @@ pub const CommandContext = struct {
7667
/// Retrieves the value for a flag, searching parsed values, then environment
7768
/// variables, and finally falling back to the default value.
7869
///
79-
/// NOTE: If the value comes from an environment variable, the returned slice
80-
/// borrows directly from the process environment block (valid for the
81-
/// lifetime of the process). If you need to hold it past an environment
82-
/// change, copy it using `app_allocator`.
70+
/// Memory model for env-var-sourced values: the raw string read from the
71+
/// environment is allocated on `tmp_allocator`. For flags of type
72+
/// `.String`, that allocation IS the returned value and stays alive until
73+
/// `tmp_allocator`'s arena is released (in the normal `execute` flow,
74+
/// this is when the `exec` function returns). For non-`.String` flags
75+
/// the raw string is freed immediately after it has been parsed into a
76+
/// primitive `FlagValue`. Tests that call `getFlag` directly should use
77+
/// an arena-backed `tmp_allocator` to avoid manual cleanup.
8378
pub fn getFlag(self: *const CommandContext, name: []const u8, comptime T: type) errors.Error!T {
8479
// 1. Check for a parsed value from the command line.
8580
if (self.command.getFlagValue(name)) |parsed_value| {
@@ -91,14 +86,24 @@ pub const CommandContext = struct {
9186
std.debug.panic("Attempted to access an undefined flag: '{s}'", .{name});
9287

9388
// 3. Check for a value from an environment variable.
94-
// getPosix is only available on POSIX targets (not Windows/WASI/freestanding).
95-
if (comptime canLookupEnv()) {
96-
if (flag_def.env_var) |env_name| {
97-
const environ = std.Options.debug_threaded_io.?.environ.process_environ;
98-
if (std.process.Environ.getPosix(environ, env_name)) |env_val_str| {
99-
const env_value = try types.parseValue(flag_def.type, env_val_str);
100-
return castFlagValueTo(env_value, T, "flag", name, .environment);
101-
}
89+
// `Environ.getAlloc` is cross-platform (POSIX and Windows): it
90+
// returns an owned WTF-8 copy of the value or `EnvironmentVariableMissing`.
91+
if (flag_def.env_var) |env_name| {
92+
const environ = std.Options.debug_threaded_io.?.environ.process_environ;
93+
if (environ.getAlloc(self.tmp_allocator, env_name)) |env_val_str| {
94+
// For `.String` flags, env_val_str becomes the returned value
95+
// and must stay allocated for the caller. For other types,
96+
// `parseValue` copies into a primitive `FlagValue`, so we
97+
// release the source string eagerly — this keeps lookups
98+
// clean even when `tmp_allocator` is not an arena.
99+
const keep_alloc = flag_def.type == .String;
100+
defer if (!keep_alloc) self.tmp_allocator.free(env_val_str);
101+
const env_value = try types.parseValue(flag_def.type, env_val_str);
102+
return castFlagValueTo(env_value, T, "flag", name, .environment);
103+
} else |err| switch (err) {
104+
error.EnvironmentVariableMissing => {}, // fall through to default
105+
error.OutOfMemory => return error.OutOfMemory,
106+
error.InvalidWtf8 => return error.InvalidWtf8,
102107
}
103108
}
104109

@@ -340,17 +345,11 @@ test "context: getArgsAs for typed variadic" {
340345
try testing.expectError(error.InvalidCharacter, ctx_int.getArgsAs(i64, "numbers", allocator));
341346
}
342347

343-
test "context: getFlag reads env var via debug_threaded_io environ" {
344-
// Verify that the Environ.getPosix lookup through debug_threaded_io
345-
// can find a real environment variable (PATH is always set on POSIX).
346-
//
347-
// `canLookupEnv()` in context.zig returns false on Windows/WASI/etc.,
348-
// so the lookup is skipped and getFlag falls back to the default on
349-
// those targets. This test only exercises the POSIX lookup path; the
350-
// sibling tests above and below cover the default-fallback path that
351-
// is what Windows runs through unconditionally.
352-
if (comptime !canLookupEnv()) return;
353-
348+
test "context: getFlag reads env var on all platforms via Environ.getAlloc" {
349+
// Regression: env-var lookup previously used the POSIX-only
350+
// `Environ.getPosix`, so Windows silently fell through to the default.
351+
// `Environ.getAlloc` works on both POSIX and Windows, and this test
352+
// covers both (PATH is set on both).
354353
const allocator = testing.allocator;
355354
var cmd = try command.Command.init(allocator, .{ .name = "test", .description = "", .exec = dummyExec });
356355
defer cmd.deinit();
@@ -363,14 +362,55 @@ test "context: getFlag reads env var via debug_threaded_io environ" {
363362
.description = "",
364363
});
365364

366-
const ctx = CommandContext{ .app_allocator = allocator, .tmp_allocator = allocator, .command = cmd, .data = null };
365+
// For `.String` flags sourced from an env var, the returned slice lives
366+
// on `tmp_allocator`. Back it with an arena here so the test does not
367+
// have to guess the origin of the slice it owns. This mirrors how the
368+
// normal `execute` flow wires up `tmp_allocator`.
369+
var arena = std.heap.ArenaAllocator.init(allocator);
370+
defer arena.deinit();
371+
const ctx = CommandContext{
372+
.app_allocator = allocator,
373+
.tmp_allocator = arena.allocator(),
374+
.command = cmd,
375+
.data = null,
376+
};
367377

368-
// PATH is always set; getFlag should return its value, not the default
378+
// PATH is set on both POSIX and Windows; getFlag should return its
379+
// value, not the default.
369380
const path_value = try ctx.getFlag("search-path", []const u8);
370381
try testing.expect(path_value.len > 0);
371382
try testing.expect(!std.mem.eql(u8, path_value, "fallback"));
372383
}
373384

385+
test "regression: env-var Int parse failure does not leak source string" {
386+
// For non-`.String` env-var flags, `getFlag` parses the raw string into
387+
// a primitive `FlagValue` and must then free the source. This test
388+
// forces a parse failure (PATH is not a valid integer) and relies on
389+
// `testing.allocator` to flag any leak.
390+
const allocator = testing.allocator;
391+
var cmd = try command.Command.init(allocator, .{ .name = "test", .description = "", .exec = dummyExec });
392+
defer cmd.deinit();
393+
394+
try cmd.addFlag(.{
395+
.name = "bad-number",
396+
.type = .Int,
397+
.default_value = .{ .Int = 0 },
398+
.env_var = "PATH", // always set, never a valid integer
399+
.description = "",
400+
});
401+
402+
const ctx = CommandContext{
403+
.app_allocator = allocator,
404+
.tmp_allocator = allocator,
405+
.command = cmd,
406+
.data = null,
407+
};
408+
409+
// Parse should fail with InvalidCharacter; the source string allocated
410+
// on `tmp_allocator` must be freed before the error propagates.
411+
try testing.expectError(error.InvalidCharacter, ctx.getFlag("bad-number", i64));
412+
}
413+
374414
test "context: getFlag env var lookup returns default for unset var" {
375415
const allocator = testing.allocator;
376416
var cmd = try command.Command.init(allocator, .{ .name = "test", .description = "", .exec = dummyExec });

src/chilli/errors.zig

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,8 @@ pub const Error = error{
3232
RequiredArgumentAfterOptional,
3333
/// A command was defined with an empty string as an alias.
3434
EmptyAlias,
35+
/// An environment variable name or value was not valid WTF-8 on Windows.
36+
/// Unreachable on POSIX targets; included so Windows callers of
37+
/// `getFlag` with an `env_var`-backed flag can handle it uniformly.
38+
InvalidWtf8,
3539
} || std.fmt.ParseIntError || std.fmt.ParseFloatError || std.mem.Allocator.Error;

0 commit comments

Comments
 (0)