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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [0.5.1] - 2026-04-23

### Fixed

- Fix regression from Rust implementation: ensure `command` has `PATH` defined in its environment, and convert `command` to an absolute path when it is defined as relative before calling execve().

## [0.5.0] - 2026-04-19

### Changed
Expand Down Expand Up @@ -81,6 +87,7 @@ The most user visible change is the addition of "template" pseudo-volumes to ena

Initial release

[0.5.1]: https://github.com/cloudboss/easyto-init/releases/tag/v0.5.1
[0.5.0]: https://github.com/cloudboss/easyto-init/releases/tag/v0.5.0
[0.4.1]: https://github.com/cloudboss/easyto-init/releases/tag/v0.4.1
[0.4.0]: https://github.com/cloudboss/easyto-init/releases/tag/v0.4.0
Expand Down
2 changes: 2 additions & 0 deletions src/constants.zig
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ pub const FILE_METADATA = "metadata.json";
pub const FILE_USER_DATA = "user-data";
pub const FILE_NETWORK_JSON = "network.json";

pub const ENV_PATH = "/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin";

pub const GROUP_NAME_WHEEL = "wheel";

pub const SSL_CERT_FILE = "amazon.pem";
Expand Down
81 changes: 75 additions & 6 deletions src/init.zig
Original file line number Diff line number Diff line change
Expand Up @@ -695,21 +695,26 @@ pub fn expandEnvValues(
allocator: Allocator,
vmspec_alloc: Allocator,
vmspec: *VmSpec,
env: []const NameValue,
env: ?[]const NameValue,
) !void {
// Build mapping from current env
const env_slice: []const NameValue = env orelse &.{};

var mapping = std.StringHashMap([]const u8).init(allocator);
defer mapping.deinit();

for (env) |nv| {
for (env_slice) |nv| {
try mapping.put(nv.name, nv.value);
}

const context = [_]*const std.StringHashMap([]const u8){&mapping};

// Expand values and update env
var new_env = try vmspec_alloc.alloc(NameValue, env.len);
for (env, 0..) |nv, i| {
// Guarantee PATH is always set in the resulting env: the env is
// used both to look up argv[0] and as the environment of the
// spawned process, so a default is appended when none is given.
const has_path = mapping.contains("PATH");
const out_len = env_slice.len + @as(usize, if (has_path) 0 else 1);
var new_env = try vmspec_alloc.alloc(NameValue, out_len);
for (env_slice, 0..) |nv, i| {
const expanded_value = try k8s_expand.expand(allocator, nv.value, &context);
defer allocator.free(expanded_value);

Expand All @@ -721,6 +726,12 @@ pub fn expandEnvValues(
nv.value,
};
}
if (!has_path) {
new_env[env_slice.len] = NameValue{
.name = try vmspec_alloc.dupe(u8, "PATH"),
.value = try vmspec_alloc.dupe(u8, constants.ENV_PATH),
};
}
vmspec.env = new_env;
}

Expand Down Expand Up @@ -1040,6 +1051,22 @@ pub fn expandCommandAndArgs(
cmd_count += 1;
}

// Resolve argv[0] against PATH when it is not already absolute,
// since execve (used at the end of init) does not search PATH.
// PATH is always present in env because expandEnvValues appends
// a default when none is provided.
if (expanded_command.len > 0 and
!std.mem.startsWith(u8, expanded_command[0], constants.DIR_ROOT))
{
const path_var = mapping.get("PATH").?;
if (try system.findExecutableInPath(allocator, path_var, expanded_command[0])) |resolved| {
allocator.free(expanded_command[0]);
expanded_command[0] = resolved;
} else {
return error.ExecutableNotFoundInPath;
}
}

// Expand args if present
var expanded_args: ?[]const []const u8 = null;
if (args) |args_slice| {
Expand Down Expand Up @@ -1204,3 +1231,45 @@ test "ExpandedCommand.deinit frees args" {
const expanded = try expandCommandAndArgs(allocator, &command, &args, null);
expanded.deinit(allocator);
}

test "expandCommandAndArgs resolves relative command[0] via PATH" {
const allocator = testing.allocator;

var tmp = testing.tmpDir(.{});
defer tmp.cleanup();

var bin_dir = try tmp.dir.makeOpenPath("bin", .{});
defer bin_dir.close();
const f = try bin_dir.createFile("myprog", .{ .mode = 0o755 });
f.close();

var abs_buf: [std.fs.max_path_bytes]u8 = undefined;
const bin_abs = try tmp.dir.realpath("bin", &abs_buf);

var env_buf: [std.fs.max_path_bytes]u8 = undefined;
const path_value = try std.fmt.bufPrint(&env_buf, "{s}", .{bin_abs});

var command = [_][]const u8{"myprog"};
var env = [_]NameValue{
.{ .name = "PATH", .value = path_value },
};
const expanded = try expandCommandAndArgs(allocator, &command, null, &env);
defer expanded.deinit(allocator);

var expected_buf: [std.fs.max_path_bytes]u8 = undefined;
const expected = try std.fmt.bufPrint(&expected_buf, "{s}/myprog", .{bin_abs});
try testing.expectEqualStrings(expected, expanded.command[0]);
}

test "expandCommandAndArgs leaves absolute command[0] unchanged" {
const allocator = testing.allocator;

var command = [_][]const u8{"/some/absolute/path"};
var env = [_]NameValue{
.{ .name = "PATH", .value = "/usr/bin:/bin" },
};
const expanded = try expandCommandAndArgs(allocator, &command, null, &env);
defer expanded.deinit(allocator);

try testing.expectEqualStrings("/some/absolute/path", expanded.command[0]);
}
85 changes: 85 additions & 0 deletions src/system.zig
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,36 @@ pub fn poweroff() void {
}
}

/// Search each colon-separated directory in `path_var` for `executable`.
/// Returns an allocated absolute path for the first executable match, or
/// null if none is found. Caller owns the returned slice.
pub fn findExecutableInPath(
allocator: Allocator,
path_var: []const u8,
executable: []const u8,
) !?[]u8 {
var iter = std.mem.splitScalar(u8, path_var, ':');
while (iter.next()) |dir| {
if (dir.len == 0) continue;
const candidate = try fmt.allocPrint(allocator, "{s}/{s}", .{ dir, executable });
errdefer allocator.free(candidate);
const st = std.fs.cwd().statFile(candidate) catch {
allocator.free(candidate);
continue;
};
if (st.kind == .directory) {
allocator.free(candidate);
continue;
}
if (st.mode & 0o111 == 0) {
allocator.free(candidate);
continue;
}
return candidate;
}
return null;
}

/// Load a kernel module using modprobe.
pub fn loadModule(name: []const u8) !void {
const modprobe_path = constants.DIR_ET_SBIN ++ "/modprobe";
Expand Down Expand Up @@ -963,3 +993,58 @@ test "device_has_numeric_suffix" {
try testing.expect(device_has_numeric_suffix("sda1") == true);
try testing.expect(device_has_numeric_suffix("sda10") == true);
}

test "findExecutableInPath finds executable in first matching dir" {
const allocator = testing.allocator;

var tmp = testing.tmpDir(.{});
defer tmp.cleanup();

var bin_dir = try tmp.dir.makeOpenPath("bin", .{});
defer bin_dir.close();
const f = try bin_dir.createFile("myprog", .{ .mode = 0o755 });
f.close();

var abs_buf: [std.fs.max_path_bytes]u8 = undefined;
const bin_abs = try tmp.dir.realpath("bin", &abs_buf);

const path_var = try fmt.allocPrint(allocator, "/nonexistent:{s}", .{bin_abs});
defer allocator.free(path_var);

const result = try findExecutableInPath(allocator, path_var, "myprog");
try testing.expect(result != null);
defer allocator.free(result.?);

const expected = try fmt.allocPrint(allocator, "{s}/myprog", .{bin_abs});
defer allocator.free(expected);
try testing.expectEqualStrings(expected, result.?);
}

test "findExecutableInPath returns null when not found" {
const allocator = testing.allocator;

var tmp = testing.tmpDir(.{});
defer tmp.cleanup();

var abs_buf: [std.fs.max_path_bytes]u8 = undefined;
const tmp_abs = try tmp.dir.realpath(".", &abs_buf);

const result = try findExecutableInPath(allocator, tmp_abs, "nothere_xyzzy_12345");
try testing.expect(result == null);
}

test "findExecutableInPath rejects non-executable files" {
const allocator = testing.allocator;

var tmp = testing.tmpDir(.{});
defer tmp.cleanup();

const f = try tmp.dir.createFile("notexe", .{ .mode = 0o644 });
f.close();

var abs_buf: [std.fs.max_path_bytes]u8 = undefined;
const tmp_abs = try tmp.dir.realpath(".", &abs_buf);

const result = try findExecutableInPath(allocator, tmp_abs, "notexe");
try testing.expect(result == null);
}
9 changes: 6 additions & 3 deletions src/tasks.zig
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,12 @@ pub fn resolveEnvFrom(ctx: *BootContext) !void {
}

pub fn expandEnvValues(ctx: *BootContext) !void {
if (ctx.vmspec.?.env) |env| {
try init_mod.expandEnvValues(ctx.allocator, ctx.vmspecAllocator(), &ctx.vmspec.?, env);
}
try init_mod.expandEnvValues(
ctx.allocator,
ctx.vmspecAllocator(),
&ctx.vmspec.?,
ctx.vmspec.?.env,
);
}

pub fn loadModules(ctx: *BootContext) !void {
Expand Down
21 changes: 21 additions & 0 deletions tests/integration/scenarios/path-expansion/user-data.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
disable-services:
- chrony
- ssh
command:
- sh
- -c
- |
exec >/dev/ttyS0 2>&1
set -e
echo "=== path-expansion test ==="
# argv[0] must have been resolved from "sh" to /bin/sh by init
# before execve, since execve (unlike execvpe) does not search PATH.
# The metadata.json baked into the test image sets PATH starting
# with /usr/local/sbin; the first directory in that PATH that
# actually contains an executable "sh" on alpine is /bin.
if [ "$0" != "/bin/sh" ]; then
echo "FAIL: expected argv[0] to be /bin/sh, got '$0'"
exit 1
fi
echo "argv[0] resolved to: $0"
echo "PASS"
Loading