diff --git a/CHANGELOG.md b/CHANGELOG.md index f187e89..994b61a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/src/constants.zig b/src/constants.zig index 8f61c66..e2c7822 100644 --- a/src/constants.zig +++ b/src/constants.zig @@ -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"; diff --git a/src/init.zig b/src/init.zig index ed5d0cf..ef798ad 100644 --- a/src/init.zig +++ b/src/init.zig @@ -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); @@ -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; } @@ -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| { @@ -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]); +} diff --git a/src/system.zig b/src/system.zig index 8f2ef02..88a85ac 100644 --- a/src/system.zig +++ b/src/system.zig @@ -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"; @@ -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); +} diff --git a/src/tasks.zig b/src/tasks.zig index 335f281..5c9893b 100644 --- a/src/tasks.zig +++ b/src/tasks.zig @@ -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 { diff --git a/tests/integration/scenarios/path-expansion/user-data.yaml b/tests/integration/scenarios/path-expansion/user-data.yaml new file mode 100644 index 0000000..42357bc --- /dev/null +++ b/tests/integration/scenarios/path-expansion/user-data.yaml @@ -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"