diff --git a/docs/design/recipe_spec.md b/docs/design/recipe_spec.md index c87a291..b890776 100644 --- a/docs/design/recipe_spec.md +++ b/docs/design/recipe_spec.md @@ -421,6 +421,48 @@ package "hostname" { } ``` +### `realizer` Node (Optional, child of `package`) + +A package can provide a generation-time tool for derived artifacts without using +an arbitrary shell hook. The provider package owns the definition and must ship +the executable named by the first `command` argument. Other packages trigger it +simply by contributing paths that match its `inputs`. + +```kdl +package "glib" { + files "usr/" + + realizer "glib-schemas" { + inputs "usr/share/glib-2.0/schemas/*.xml" + command "/usr/bin/glib-compile-schemas" "usr/share/glib-2.0/schemas" + } +} +``` + +`inputs` uses the same pattern language as package `files`: paths are relative +to the generation root, POSIX `fnmatch(3)` is pathname-aware, `*` does not cross +`/`, `?` and bracket expressions are supported, a trailing `/` recursively +matches a directory, `!` excludes matches, and recipe variables are expanded. +Unlike `files`, a realizer input is a trigger rather than a package-content +assertion: matching no paths is valid and skips the realizer. Any included file +or symlink that survives exclusions makes it applicable. Mere snapshots all +applicable realizers before running any command, so generated outputs cannot +activate another realizer in the same generation build. + +`command` is an argv array, not a shell script. Its first argument must be an +absolute path supplied by the declaring package. Mere runs it in an isolated +build namespace after assembling the complete staged generation and before +publication. `/usr` and the store are read-only, the staged generation is the +working directory and is writable at `/work`, and the environment includes +`MERE_REALIZATION_ROOT=/work`. Relative command arguments therefore refer to +the staged generation, as in the GLib schema directory above. A non-zero exit or +setup failure aborts publication and leaves the currently active generation +unchanged. + +The MVP always evaluates and, when applicable, runs realizers for each newly +constructed generation. It has no host-scoped writes, incremental realization +cache, or lifecycle `scope` option. + ## Variable Interpolation Variables can be used in strings with `${...}` syntax. diff --git a/src/kdl_schema.zig b/src/kdl_schema.zig index b26b9e6..ec37159 100644 --- a/src/kdl_schema.zig +++ b/src/kdl_schema.zig @@ -502,11 +502,22 @@ const recipe_service_children = [_]NodeSpec{ .{ .name = "env", .any_child_args = .{ .kind = .string, .min = 1, .max = 1, .label = "value" } }, }; +const recipe_realizer_children = [_]NodeSpec{ + .{ .name = "inputs", .required = true, .args = .{ .kind = .string, .min = 1, .label = "pattern" } }, + .{ .name = "command", .required = true, .args = .{ .kind = .string, .min = 1, .label = "argv" } }, +}; + const recipe_package_children = [_]NodeSpec{ .{ .name = "files", .required = true, .args = .{ .kind = .string, .min = 1, .label = "value" } }, .{ .name = "strip", .args = .{ .kind = .boolean, .min = 1, .max = 1, .label = "value" } }, .{ .name = "compress-manpages", .args = .{ .kind = .boolean, .min = 1, .max = 1, .label = "value" } }, .{ .name = "arch", .args = .{ .kind = .string, .min = 1, .max = 1, .label = "value" } }, + .{ + .name = "realizer", + .repeatable = true, + .args = .{ .kind = .string, .min = 1, .max = 1, .label = "name" }, + .children = &recipe_realizer_children, + }, .{ .name = "service", .repeatable = true, diff --git a/src/meta.zig b/src/meta.zig index edb3b3d..f154a6a 100644 --- a/src/meta.zig +++ b/src/meta.zig @@ -123,6 +123,24 @@ pub const Service = struct { } }; +pub const Realizer = struct { + name: []const u8, + inputs: std.ArrayList([]const u8) = .empty, + command: std.ArrayList([]const u8) = .empty, + + pub fn init() Realizer { + return .{ .name = "" }; + } + + pub fn deinit(self: *Realizer, allocator: std.mem.Allocator) void { + if (self.name.len > 0) allocator.free(self.name); + for (self.inputs.items) |value| allocator.free(value); + self.inputs.deinit(allocator); + for (self.command.items) |value| allocator.free(value); + self.command.deinit(allocator); + } +}; + pub const Data = struct { dependencies: std.ArrayList(Dependency), provisions: std.ArrayList(Provision), @@ -134,6 +152,7 @@ pub const Data = struct { licenses: std.ArrayList([]const u8) = .empty, source_urls: std.ArrayList([]const u8) = .empty, services: std.ArrayList(Service) = .empty, + realizers: std.ArrayList(Realizer) = .empty, pub fn init(allocator: std.mem.Allocator) Data { return Data{ @@ -162,6 +181,8 @@ pub const Data = struct { self.source_urls.deinit(self.allocator); for (self.services.items) |*service| service.deinit(self.allocator); self.services.deinit(self.allocator); + for (self.realizers.items) |*realizer| realizer.deinit(self.allocator); + self.realizers.deinit(self.allocator); } pub fn addDependency(self: *Data, dep_type: DependencyType, value: []const u8) MetaError!void { @@ -248,6 +269,19 @@ pub const Data = struct { try self.services.append(self.allocator, service); } + pub fn addRealizer(self: *Data, source: anytype) MetaError!void { + var realizer = Realizer.init(); + errdefer realizer.deinit(self.allocator); + realizer.name = self.allocator.dupe(u8, source.name) catch return MetaError.OutOfMemory; + for (source.inputs.items) |value| { + try realizer.inputs.append(self.allocator, try self.allocator.dupe(u8, value)); + } + for (source.command.items) |value| { + try realizer.command.append(self.allocator, try self.allocator.dupe(u8, value)); + } + try self.realizers.append(self.allocator, realizer); + } + /// Populate recipe-level metadata (description, homepage, licenses, source URLs). /// This data is carried through to repo.db for display and upstream tracking. pub fn populateRecipeMetadata( @@ -407,6 +441,20 @@ pub const Data = struct { buffer.appendSlice(allocator, "}\n") catch return MetaError.OutOfMemory; } + if (self.realizers.items.len > 0) { + if (buffer.items.len > 0) buffer.append(allocator, '\n') catch return MetaError.OutOfMemory; + buffer.appendSlice(allocator, "realizers {\n") catch return MetaError.OutOfMemory; + for (self.realizers.items) |realizer| { + buffer.appendSlice(allocator, " realizer \"") catch return MetaError.OutOfMemory; + try appendEscaped(&buffer, allocator, realizer.name); + buffer.appendSlice(allocator, "\" {\n") catch return MetaError.OutOfMemory; + try appendMetaArgs(&buffer, allocator, "inputs", realizer.inputs.items, 8); + try appendMetaArgs(&buffer, allocator, "command", realizer.command.items, 8); + buffer.appendSlice(allocator, " }\n") catch return MetaError.OutOfMemory; + } + buffer.appendSlice(allocator, "}\n") catch return MetaError.OutOfMemory; + } + return buffer.toOwnedSlice(allocator) catch return MetaError.OutOfMemory; } @@ -465,6 +513,17 @@ pub const Data = struct { try parseServiceArgs(allocator, child, "depends-on", &service.depends_on); try meta.services.append(allocator, service); } + } else if (std.mem.eql(u8, node.name, "realizers")) { + for (node.children.items) |*child| { + if (!std.mem.eql(u8, child.name, "realizer")) continue; + var realizer = Realizer.init(); + errdefer realizer.deinit(allocator); + realizer.name = allocator.dupe(u8, child.getFirstArgString() orelse return MetaError.InvalidInput) catch return MetaError.OutOfMemory; + try parseServiceArgs(allocator, child, "inputs", &realizer.inputs); + try parseServiceArgs(allocator, child, "command", &realizer.command); + if (realizer.inputs.items.len == 0 or realizer.command.items.len == 0) return MetaError.InvalidInput; + try meta.realizers.append(allocator, realizer); + } } else if (std.mem.eql(u8, node.name, "metadata")) { for (node.children.items) |*child| { if (std.mem.eql(u8, child.name, "description")) { @@ -859,3 +918,30 @@ test "Data encode and parse split-runtime dependency" { try std.testing.expect(parsed.dependencies.items[0].version_constraint != null); try std.testing.expectEqualStrings("=3.6.1-4", parsed.dependencies.items[0].version_constraint.?); } + +test "Data generation realizer metadata roundtrip" { + const allocator = std.testing.allocator; + var value = Data.init(allocator); + defer value.deinit(); + + var realizer = Realizer.init(); + realizer.name = try allocator.dupe(u8, "glib-schemas"); + try realizer.inputs.append(allocator, try allocator.dupe(u8, "usr/share/glib-2.0/schemas/*.xml")); + try realizer.inputs.append(allocator, try allocator.dupe(u8, "!usr/share/glib-2.0/schemas/ignored.xml")); + try realizer.command.append(allocator, try allocator.dupe(u8, "/usr/bin/glib-compile-schemas")); + try realizer.command.append(allocator, try allocator.dupe(u8, "usr/share/glib-2.0/schemas")); + try value.realizers.append(allocator, realizer); + + const encoded = try value.encode(allocator); + defer allocator.free(encoded); + var parsed = try Data.parse(allocator, encoded); + defer parsed.deinit(); + + try std.testing.expectEqual(@as(usize, 1), parsed.realizers.items.len); + const parsed_realizer = parsed.realizers.items[0]; + try std.testing.expectEqualStrings("glib-schemas", parsed_realizer.name); + try std.testing.expectEqualStrings("usr/share/glib-2.0/schemas/*.xml", parsed_realizer.inputs.items[0]); + try std.testing.expectEqualStrings("!usr/share/glib-2.0/schemas/ignored.xml", parsed_realizer.inputs.items[1]); + try std.testing.expectEqualStrings("/usr/bin/glib-compile-schemas", parsed_realizer.command.items[0]); + try std.testing.expectEqualStrings("usr/share/glib-2.0/schemas", parsed_realizer.command.items[1]); +} diff --git a/src/package_staging.zig b/src/package_staging.zig index ef5599f..02feb30 100644 --- a/src/package_staging.zig +++ b/src/package_staging.zig @@ -85,6 +85,49 @@ fn matchesRecursiveDirPattern(rel_path: []const u8, pattern: []const u8) bool { return std.mem.eql(u8, rel_path, root) or std.mem.startsWith(u8, rel_path, pattern); } +pub fn validatePathPatterns(patterns: []const []const u8) PackageStagingError!void { + var has_include = false; + for (patterns) |pattern| { + const raw_pattern = basePattern(pattern); + if (raw_pattern.len == 0 or raw_pattern[0] == '/') return PackageStagingError.InvalidInput; + if (!isExclusionPattern(pattern)) has_include = true; + } + if (!has_include) return PackageStagingError.InvalidInput; +} + +/// Match one relative path using the same pathname-aware pattern language as +/// recipe `files`. Positive patterns are ORed and exclusions override them. +/// Unlike package staging, this helper does not require any pattern to match. +pub fn matchesPathPatterns( + allocator: std.mem.Allocator, + rel_path: []const u8, + patterns: []const []const u8, +) PackageStagingError!bool { + try validatePathPatterns(patterns); + if (rel_path.len == 0 or rel_path[0] == '/') return PackageStagingError.InvalidInput; + const rel_path_z = allocator.dupeZ(u8, rel_path) catch return PackageStagingError.OutOfMemory; + defer allocator.free(rel_path_z); + + var included = false; + var excluded = false; + for (patterns) |pattern| { + const raw_pattern = basePattern(pattern); + if (raw_pattern.len == 0 or raw_pattern[0] == '/') return PackageStagingError.InvalidInput; + const matched = if (isRecursiveDirPattern(raw_pattern)) + matchesRecursiveDirPattern(rel_path, raw_pattern) + else blk: { + const pattern_z = allocator.dupeZ(u8, raw_pattern) catch return PackageStagingError.OutOfMemory; + defer allocator.free(pattern_z); + const code = c.fnmatch(pattern_z.ptr, rel_path_z.ptr, c.FNM_PATHNAME); + if (code != 0 and code != c.FNM_NOMATCH) return PackageStagingError.InvalidInput; + break :blk code == 0; + }; + if (!matched) continue; + if (isExclusionPattern(pattern)) excluded = true else included = true; + } + return included and !excluded; +} + /// Convert an absolute symlink target to a relative path if it points within source_dir. /// If the target points outside source_dir, return an error. /// @@ -1201,3 +1244,19 @@ test "PackageStaging rejects absolute symlink target outside source boundary" { }); try std.testing.expectError(error.InvalidInput, result); } + +test "matchesPathPatterns reuses files globs without requiring a match" { + const allocator = std.testing.allocator; + const patterns = [_][]const u8{ + "usr/share/glib-2.0/schemas/*.xml", + "!usr/share/glib-2.0/schemas/ignored.xml", + }; + try std.testing.expect(try matchesPathPatterns(allocator, "usr/share/glib-2.0/schemas/app.xml", &patterns)); + try std.testing.expect(!try matchesPathPatterns(allocator, "usr/share/glib-2.0/schemas/ignored.xml", &patterns)); + try std.testing.expect(!try matchesPathPatterns(allocator, "usr/share/glib-2.0/schemas/nested/app.xml", &patterns)); + try std.testing.expect(!try matchesPathPatterns(allocator, "usr/share/icons/app.png", &patterns)); + try std.testing.expect(try matchesPathPatterns(allocator, "usr/share/fonts/truetype/app.ttf", &.{"usr/share/fonts/"})); + try std.testing.expect(try matchesPathPatterns(allocator, "usr/bin/tool7", &.{"usr/bin/tool[0-9]"})); + try std.testing.expectError(error.InvalidInput, validatePathPatterns(&.{"!usr/share/ignored"})); + try std.testing.expectError(error.InvalidInput, validatePathPatterns(&.{"/usr/share/data"})); +} diff --git a/src/packaging.zig b/src/packaging.zig index b98d300..13ac563 100644 --- a/src/packaging.zig +++ b/src/packaging.zig @@ -323,6 +323,12 @@ pub const Packager = struct { }; } } + for (config.artifact.realizers.items) |realizer| { + pkg_meta.addRealizer(realizer) catch { + self.ctx.allocator.free(content_hash); + return self.fail(config.staging_dir, "failed to populate realizer metadata", PackagingError.CreationFailed); + }; + } meta.writeFile(self.ctx.allocator, config.staging_dir, &pkg_meta) catch { self.ctx.allocator.free(content_hash); @@ -722,6 +728,11 @@ test "Packager handles signing and metadata generation" { defer test_artifact.deinit(test_env.ctx.allocator); // Properly allocate the name since BuildArtifact.deinit() will free it test_artifact.name = try test_env.ctx.allocator.dupe(u8, "custom-name"); + var test_realizer = recipe.RealizerDef.init(); + test_realizer.name = try test_env.ctx.allocator.dupe(u8, "glib-schemas"); + try test_realizer.inputs.append(test_env.ctx.allocator, try test_env.ctx.allocator.dupe(u8, "usr/share/glib-2.0/schemas/*.xml")); + try test_realizer.command.append(test_env.ctx.allocator, try test_env.ctx.allocator.dupe(u8, "/usr/bin/glib-compile-schemas")); + try test_artifact.realizers.append(test_env.ctx.allocator, test_realizer); // Create staging directory with content const staging_dir = try std.fs.path.join(test_env.ctx.allocator, &.{ test_env.path, "staging2" }); @@ -757,6 +768,10 @@ test "Packager handles signing and metadata generation" { defer generated_meta.deinit(); try testing.expectEqual(@as(usize, 1), generated_meta.source_urls.items.len); try testing.expectEqualStrings("https://example.org/source-2.1.0.tar.xz", generated_meta.source_urls.items[0]); + try testing.expectEqual(@as(usize, 1), generated_meta.realizers.items.len); + try testing.expectEqualStrings("glib-schemas", generated_meta.realizers.items[0].name); + try testing.expectEqualStrings("usr/share/glib-2.0/schemas/*.xml", generated_meta.realizers.items[0].inputs.items[0]); + try testing.expectEqualStrings("/usr/bin/glib-compile-schemas", generated_meta.realizers.items[0].command.items[0]); // Verify manifest.v1.sig exists in staging (was written before archiving) const manifest_sig_path = try std.fs.path.join(test_env.ctx.allocator, &.{ staging_dir, manifest.MANIFEST_SIG_FILENAME }); diff --git a/src/profile.zig b/src/profile.zig index 34c2c6b..af47f5f 100644 --- a/src/profile.zig +++ b/src/profile.zig @@ -16,6 +16,9 @@ const package_manifest = @import("manifest.zig"); const path_safety = @import("path_safety.zig"); const generation = @import("generation.zig"); const projection_index = @import("projection_index.zig"); +const meta = @import("meta.zig"); +const namespace = @import("namespace.zig"); +const package_staging = @import("package_staging.zig"); const path = @import("path.zig"); const Context = @import("mere.zig").Context; const store = @import("store.zig"); @@ -848,6 +851,198 @@ fn exchangePaths(left_path: []const u8, right_path: []const u8) ProfileError!voi } } +const RealizerRunner = struct { + context: ?*anyopaque = null, + runFn: *const fn (?*anyopaque, *Context, []const u8, []const u8, *const meta.Realizer) anyerror!void = runRealizerCommand, + + fn run(self: RealizerRunner, ctx: *Context, stage_path: []const u8, provider_store_path: []const u8, realizer: *const meta.Realizer) !void { + try self.runFn(self.context, ctx, stage_path, provider_store_path, realizer); + } +}; + +fn realizerApplies( + allocator: std.mem.Allocator, + stage_path: []const u8, + inputs: []const []const u8, +) ProfileError!bool { + package_staging.validatePathPatterns(inputs) catch |err| return switch (err) { + error.OutOfMemory => ProfileError.OutOfMemory, + else => ProfileError.InvalidInput, + }; + var root = std.Io.Dir.openDirAbsolute(path.currentIo(), stage_path, .{ .iterate = true }) catch |err| { + return switch (err) { + error.AccessDenied => ProfileError.PermissionDenied, + else => ProfileError.FileSystem, + }; + }; + defer root.close(path.currentIo()); + var walker = root.walk(allocator) catch return ProfileError.OutOfMemory; + defer walker.deinit(); + while (walker.next(path.currentIo()) catch return ProfileError.FileSystem) |entry| { + if (entry.kind != .file and entry.kind != .sym_link) continue; + if (package_staging.matchesPathPatterns(allocator, entry.path, inputs) catch |err| return switch (err) { + error.OutOfMemory => ProfileError.OutOfMemory, + else => ProfileError.InvalidInput, + }) return true; + } + return false; +} + +fn runRealizerCommand( + _: ?*anyopaque, + ctx: *Context, + stage_path: []const u8, + provider_store_path: []const u8, + realizer: *const meta.Realizer, +) !void { + const executable = realizer.command.items[0]; + if (executable.len < 2 or executable[0] != '/') { + return ctx.fail(ProfileError.InvalidInput, realizer.name, "realizer executable must be an absolute generation path"); + } + const provider_executable = std.fs.path.resolve(ctx.allocator, &.{ provider_store_path, executable[1..] }) catch + return ctx.fail(ProfileError.OutOfMemory, realizer.name, "failed to resolve provider executable"); + defer ctx.allocator.free(provider_executable); + if (!path_safety.isWithinBoundary(provider_executable, provider_store_path)) { + return ctx.fail(ProfileError.InvalidInput, realizer.name, "realizer executable escapes its provider package"); + } + std.Io.Dir.accessAbsolute(path.currentIo(), provider_executable, .{}) catch |err| { + return ctx.fail(switch (err) { + error.AccessDenied => ProfileError.PermissionDenied, + else => ProfileError.InvalidInput, + }, realizer.name, "realizer executable is not provided by its declaring package"); + }; + + const mere_root = std.fs.path.join(ctx.allocator, &.{ ctx.root(), "mere" }) catch + return ProfileError.OutOfMemory; + defer ctx.allocator.free(mere_root); + const env_values = [_][]const u8{ + "PATH=/usr/bin:/bin", + "HOME=/tmp", + "TMPDIR=/tmp", + "MERE_REALIZATION_ROOT=/work", + }; + var envp: [env_values.len][*:0]const u8 = undefined; + var env_count: usize = 0; + defer { + for (envp[0..env_count]) |entry| { + const value = std.mem.span(entry); + ctx.allocator.free(entry[0 .. value.len + 1]); + } + } + for (env_values, 0..) |value, i| { + const owned = ctx.allocator.dupeZ(u8, value) catch return ProfileError.OutOfMemory; + envp[i] = owned.ptr; + env_count += 1; + } + + const exit_code = namespace.forkAndEnterEnv(ctx.allocator, .build, .{ + .profile_root = stage_path, + .workspace = stage_path, + .cwd = "/work", + .command = realizer.command.items, + .env = &envp, + .mere_root = mere_root, + }) catch |err| { + ctx.setDiagnosticContextFmt(realizer.name, "failed to enter generation realizer environment: {s}", .{@errorName(err)}); + return switch (err) { + error.OutOfMemory => ProfileError.OutOfMemory, + error.PermissionDenied, error.UserNamespacesDisabled, error.MountRestricted => ProfileError.PermissionDenied, + else => ProfileError.FileSystem, + }; + }; + if (exit_code != 0) { + return ctx.fail(ProfileError.FileSystem, realizer.name, "generation realizer command failed"); + } +} + +fn runGenerationRealizers( + ctx: *Context, + stage_path: []const u8, + packages: []const generation.PackageEntry, + runner: RealizerRunner, +) ProfileError!void { + var names = std.StringHashMap(void).init(ctx.allocator); + defer { + var it = names.keyIterator(); + while (it.next()) |name| ctx.allocator.free(name.*); + names.deinit(); + } + + // Load every definition and snapshot applicability before executing any + // command. Realizer outputs therefore cannot trigger later realizers. + var package_metadata: std.ArrayList(meta.Data) = .empty; + defer { + for (package_metadata.items) |*pkg_meta| pkg_meta.deinit(); + package_metadata.deinit(ctx.allocator); + } + for (packages) |pkg| { + const pkg_meta = meta.readFile(ctx.allocator, pkg.store_path) catch |err| { + return ctx.fail(switch (err) { + error.OutOfMemory => ProfileError.OutOfMemory, + error.PermissionDenied => ProfileError.PermissionDenied, + error.InvalidInput, error.ParseError => ProfileError.InvalidInput, + else => ProfileError.FileSystem, + }, pkg.store_path, "failed to read generation realizer metadata"); + }; + package_metadata.append(ctx.allocator, pkg_meta) catch { + var owned = pkg_meta; + owned.deinit(); + return ProfileError.OutOfMemory; + }; + } + + const ActiveRealizer = struct { + package_index: usize, + realizer_index: usize, + }; + var active: std.ArrayList(ActiveRealizer) = .empty; + defer active.deinit(ctx.allocator); + + for (package_metadata.items, 0..) |*pkg_meta, package_index| { + for (pkg_meta.realizers.items, 0..) |*realizer, realizer_index| { + if (realizer.name.len == 0 or realizer.command.items.len == 0 or + realizer.command.items[0].len < 2 or realizer.command.items[0][0] != '/') + { + return ctx.fail(ProfileError.InvalidInput, packages[package_index].store_path, "invalid generation realizer metadata"); + } + package_staging.validatePathPatterns(realizer.inputs.items) catch |err| { + return ctx.fail(switch (err) { + error.OutOfMemory => ProfileError.OutOfMemory, + else => ProfileError.InvalidInput, + }, realizer.name, "invalid generation realizer input patterns"); + }; + + const name = ctx.allocator.dupe(u8, realizer.name) catch return ProfileError.OutOfMemory; + const entry = names.getOrPut(name) catch { + ctx.allocator.free(name); + return ProfileError.OutOfMemory; + }; + if (entry.found_existing) { + ctx.allocator.free(name); + return ctx.fail(ProfileError.InvalidInput, realizer.name, "duplicate active generation realizer name"); + } + if (try realizerApplies(ctx.allocator, stage_path, realizer.inputs.items)) { + active.append(ctx.allocator, .{ + .package_index = package_index, + .realizer_index = realizer_index, + }) catch return ProfileError.OutOfMemory; + } + } + } + + for (active.items) |selected| { + const realizer = &package_metadata.items[selected.package_index].realizers.items[selected.realizer_index]; + runner.run(ctx, stage_path, packages[selected.package_index].store_path, realizer) catch |err| { + return switch (err) { + error.OutOfMemory => ProfileError.OutOfMemory, + error.PermissionDenied => ProfileError.PermissionDenied, + error.InvalidInput => ProfileError.InvalidInput, + else => ctx.fail(ProfileError.FileSystem, realizer.name, "generation realizer failed"), + }; + }; + } +} + fn buildProfileManifest( allocator: std.mem.Allocator, packages: []const generation.PackageEntry, @@ -959,6 +1154,8 @@ pub fn publishProfileRoot( result.stats.reused_entries = apply_stats.reused_entries; result.stats.duration_ns = @intCast(std.Io.Clock.Timestamp.now(path.currentIo(), .awake).raw.toNanoseconds() - started_at); + try runGenerationRealizers(ctx, stage_dir, sorted_packages, .{}); + var manifest = try buildProfileManifest( ctx.allocator, sorted_packages, @@ -1011,6 +1208,17 @@ pub fn createGeneration( store_root: []const u8, packages: []const generation.PackageEntry, parent_generation: ?u32, +) ProfileError!u32 { + return createGenerationWithRealizerRunner(ctx, profile_dir, store_root, packages, parent_generation, .{}); +} + +fn createGenerationWithRealizerRunner( + ctx: *Context, + profile_dir: []const u8, + store_root: []const u8, + packages: []const generation.PackageEntry, + parent_generation: ?u32, + realizer_runner: RealizerRunner, ) ProfileError!u32 { const sorted_packages = try canonicalizePackages(ctx.allocator, packages); defer ctx.allocator.free(sorted_packages); @@ -1092,6 +1300,8 @@ pub fn createGeneration( result.stats.reused_entries = apply_stats.reused_entries; result.stats.duration_ns = @intCast(std.Io.Clock.Timestamp.now(path.currentIo(), .awake).raw.toNanoseconds() - started_at); + try runGenerationRealizers(ctx, stage_path, sorted_packages, realizer_runner); + var manifest = try buildProfileManifest( ctx.allocator, sorted_packages, @@ -2271,3 +2481,126 @@ test "createGeneration no false conflict when parent realization misattributes p // pkg-a sorts before pkg-b, so pkg-b is at index 1 try std.testing.expectEqual(@as(?u32, 1), btool_owner); } + +test "generation realizers use contributor inputs and block publication on failure" { + const th = @import("test_helpers.zig"); + var test_env = try th.createTestEnv(); + defer { + test_env.cleanup(); + std.testing.allocator.destroy(test_env); + } + const allocator = test_env.ctx.allocator; + const store_root = try std.fs.path.join(allocator, &.{ test_env.path, "mere", "store" }); + defer allocator.free(store_root); + const profile_dir = try std.fs.path.join(allocator, &.{ test_env.path, "profiles", "test" }); + defer allocator.free(profile_dir); + try path.ensureDirExists(store_root); + try path.ensureDirExists(profile_dir); + + const provider_path = try std.fs.path.join(allocator, &.{ store_root, "provider-glib" }); + defer allocator.free(provider_path); + const provider_bin = try std.fs.path.join(allocator, &.{ provider_path, "usr", "bin" }); + defer allocator.free(provider_bin); + try path.ensureDirExists(provider_bin); + const executable_path = try std.fs.path.join(allocator, &.{ provider_bin, "glib-compile-schemas" }); + defer allocator.free(executable_path); + var executable = try std.Io.Dir.createFileAbsolute(path.currentIo(), executable_path, .{}); + executable.close(path.currentIo()); + + var provider_meta = meta.Data.init(allocator); + defer provider_meta.deinit(); + var realizer = meta.Realizer.init(); + realizer.name = try allocator.dupe(u8, "glib-schemas"); + try realizer.inputs.append(allocator, try allocator.dupe(u8, "usr/share/glib-2.0/schemas/*.xml")); + try realizer.command.append(allocator, try allocator.dupe(u8, "/usr/bin/glib-compile-schemas")); + try realizer.command.append(allocator, try allocator.dupe(u8, "usr/share/glib-2.0/schemas")); + try provider_meta.realizers.append(allocator, realizer); + var output_trigger = meta.Realizer.init(); + output_trigger.name = try allocator.dupe(u8, "generated-output-must-not-trigger"); + try output_trigger.inputs.append(allocator, try allocator.dupe(u8, "usr/share/glib-2.0/schemas/gschemas.compiled")); + try output_trigger.command.append(allocator, try allocator.dupe(u8, "/usr/bin/glib-compile-schemas")); + try provider_meta.realizers.append(allocator, output_trigger); + try meta.writeFile(allocator, provider_path, &provider_meta); + try writeProjectionForTestPackage(allocator, provider_path); + + const contributor_path = try std.fs.path.join(allocator, &.{ store_root, "contributor-app" }); + defer allocator.free(contributor_path); + const schemas_path = try std.fs.path.join(allocator, &.{ contributor_path, "usr", "share", "glib-2.0", "schemas" }); + defer allocator.free(schemas_path); + try path.ensureDirExists(schemas_path); + const schema_path = try std.fs.path.join(allocator, &.{ schemas_path, "app.xml" }); + defer allocator.free(schema_path); + var schema = try std.Io.Dir.createFileAbsolute(path.currentIo(), schema_path, .{}); + schema.close(path.currentIo()); + try writeProjectionForTestPackage(allocator, contributor_path); + + const State = struct { + calls: usize = 0, + fail: bool = false, + + fn run(raw: ?*anyopaque, _: *Context, stage_path: []const u8, provider: []const u8, value: *const meta.Realizer) anyerror!void { + const self: *@This() = @ptrCast(@alignCast(raw.?)); + self.calls += 1; + try std.testing.expect(std.mem.endsWith(u8, provider, "provider-glib")); + try std.testing.expectEqualStrings("glib-schemas", value.name); + if (self.fail) return error.InjectedRealizerFailure; + const output = try std.fs.path.join(std.testing.allocator, &.{ stage_path, "usr", "share", "glib-2.0", "schemas", "gschemas.compiled" }); + defer std.testing.allocator.free(output); + var file = try std.Io.Dir.createFileAbsolute(path.currentIo(), output, .{}); + defer file.close(path.currentIo()); + try file.writeStreamingAll(path.currentIo(), "compiled"); + } + }; + + const packages = [_]generation.PackageEntry{ + testPackageEntry("glib", provider_path), + testPackageEntry("app", contributor_path), + }; + var state = State{}; + const gen1 = try createGenerationWithRealizerRunner( + &test_env.ctx, + profile_dir, + store_root, + &packages, + null, + .{ .context = &state, .runFn = State.run }, + ); + try std.testing.expectEqual(@as(u32, 1), gen1); + try std.testing.expectEqual(@as(usize, 1), state.calls); + // The first command created gschemas.compiled, but trigger applicability was + // snapshotted before execution, so it did not activate the second realizer. + const output_path = try std.fs.path.join(allocator, &.{ profile_dir, "gen-1", "usr", "share", "glib-2.0", "schemas", "gschemas.compiled" }); + defer allocator.free(output_path); + try std.Io.Dir.accessAbsolute(path.currentIo(), output_path, .{}); + + // A provider without matching contributor inputs is valid and is skipped. + _ = try createGenerationWithRealizerRunner( + &test_env.ctx, + profile_dir, + store_root, + &.{testPackageEntry("glib", provider_path)}, + gen1, + .{ .context = &state, .runFn = State.run }, + ); + try std.testing.expectEqual(@as(usize, 1), state.calls); + + // A failed realizer leaves neither a selectable generation nor staging. + state.fail = true; + try std.testing.expectError( + ProfileError.FileSystem, + createGenerationWithRealizerRunner( + &test_env.ctx, + profile_dir, + store_root, + &packages, + 2, + .{ .context = &state, .runFn = State.run }, + ), + ); + const failed_generation = try std.fs.path.join(allocator, &.{ profile_dir, "gen-3" }); + defer allocator.free(failed_generation); + const failed_staging = try std.fs.path.join(allocator, &.{ profile_dir, "gen-3.staging" }); + defer allocator.free(failed_staging); + try std.testing.expect(!path.fileExists(failed_generation)); + try std.testing.expect(!path.fileExists(failed_staging)); +} diff --git a/src/recipe.zig b/src/recipe.zig index 934a3ae..24c4850 100644 --- a/src/recipe.zig +++ b/src/recipe.zig @@ -229,7 +229,6 @@ fn parseKdlRecipeNode(allocator: std.mem.Allocator, node: *const kdl.Node, recip } } - // env CC="clang" CXX="clang++" (properties on an env child node) if (node.findChild("env")) |env_node| { try parseKdlEnvProperties(allocator, env_node, &recipe.env); @@ -384,9 +383,40 @@ fn parseKdlPackageNode( artifact.arch = try allocator.dupe(u8, val); } - // Parse service definitions + // Parse service and generation-realizer definitions. for (node.children.items) |*child| { - if (std.mem.eql(u8, child.name, "service")) { + if (std.mem.eql(u8, child.name, "realizer")) { + var realizer = RealizerDef.init(); + errdefer realizer.deinit(allocator); + + const realizer_name = child.getFirstArgString() orelse return RecipeError.MissingKey; + if (realizer_name.len == 0) return RecipeError.InvalidInput; + realizer.name = try allocator.dupe(u8, realizer_name); + if (child.findChild("inputs")) |inputs| { + var has_include = false; + for (inputs.arguments.items) |arg| { + const value = arg.getString() orelse return RecipeError.NonStringValue; + const expanded = try interpolate(allocator, ctx, value, recipe_ref, vars); + errdefer allocator.free(expanded); + const raw_pattern = if (expanded[0] == '!') expanded[1..] else expanded; + if (raw_pattern.len == 0 or raw_pattern[0] == '/') return RecipeError.InvalidInput; + if (expanded[0] != '!') has_include = true; + try realizer.inputs.append(allocator, expanded); + } + if (!has_include) return RecipeError.InvalidInput; + } + if (child.findChild("command")) |command| { + for (command.arguments.items) |arg| { + const value = arg.getString() orelse return RecipeError.NonStringValue; + const expanded = try interpolate(allocator, ctx, value, recipe_ref, vars); + errdefer allocator.free(expanded); + if (expanded.len == 0) return RecipeError.InvalidInput; + try realizer.command.append(allocator, expanded); + } + } + if (realizer.command.items.len == 0 or realizer.command.items[0][0] != '/') return RecipeError.InvalidInput; + try artifact.realizers.append(allocator, realizer); + } else if (std.mem.eql(u8, child.name, "service")) { var svc = try ServiceDef.init(allocator); errdefer svc.deinit(allocator); @@ -535,6 +565,24 @@ pub const BuildState = enum { Planned, Built, Scanned, Archived, Published }; pub const ServiceType = enum { daemon, oneshot }; +pub const RealizerDef = struct { + name: []const u8 = "", + inputs: std.ArrayList([]const u8) = .empty, + command: std.ArrayList([]const u8) = .empty, + + pub fn init() RealizerDef { + return .{}; + } + + pub fn deinit(self: *RealizerDef, allocator: std.mem.Allocator) void { + if (self.name.len > 0) allocator.free(self.name); + for (self.inputs.items) |value| allocator.free(value); + self.inputs.deinit(allocator); + for (self.command.items) |value| allocator.free(value); + self.command.deinit(allocator); + } +}; + pub const ServiceDef = struct { name: []const u8, service_type: ServiceType, @@ -586,6 +634,7 @@ pub const BuildArtifact = struct { compress_manpages: bool, arch: ?[]const u8, services: std.ArrayList(ServiceDef), + realizers: std.ArrayList(RealizerDef), pub fn init(allocator: std.mem.Allocator) !BuildArtifact { return BuildArtifact{ @@ -601,6 +650,7 @@ pub const BuildArtifact = struct { .compress_manpages = true, .arch = null, .services = try std.ArrayList(ServiceDef).initCapacity(allocator, 0), + .realizers = try std.ArrayList(RealizerDef).initCapacity(allocator, 0), }; } @@ -626,6 +676,10 @@ pub const BuildArtifact = struct { svc.deinit(gpa); } self.services.deinit(gpa); + for (self.realizers.items) |*realizer| { + realizer.deinit(gpa); + } + self.realizers.deinit(gpa); } pub fn markBuilt(self: *BuildArtifact, allocator: std.mem.Allocator, archive_path: []const u8, content_hash: []const u8, archive_hash: []const u8, signature: []const u8) !void { @@ -881,6 +935,14 @@ pub const Recipe = struct { } try writer.writeAll("\n"); } + for (pkg.realizers.items) |realizer| { + try writer.print(" realizer \"{s}\" {{\n", .{realizer.name}); + try writer.writeAll(" inputs"); + for (realizer.inputs.items) |input| try writer.print(" \"{s}\"", .{input}); + try writer.writeAll("\n command"); + for (realizer.command.items) |arg| try writer.print(" \"{s}\"", .{arg}); + try writer.writeAll("\n }\n"); + } try writer.writeAll("}\n\n"); } @@ -1648,3 +1710,67 @@ test "validateFile rewrites parse diagnostics to the recipe path" { try std.testing.expect(diag.subject != null); try std.testing.expectEqualStrings(recipe_path, diag.subject.?); } + +test "parse preserves interpolated generation realizer definition" { + const th = @import("test_helpers.zig"); + var test_env = try th.createTestEnv(); + defer { + test_env.cleanup(); + std.testing.allocator.destroy(test_env); + } + const input = + \\recipe { + \\ name "glib" + \\ version "2.0" + \\ release 1 + \\} + \\vars { + \\ schema-dir "usr/share/glib-2.0/schemas" + \\} + \\build { + \\ script "true" + \\} + \\package "glib" { + \\ files "usr/" + \\ realizer "glib-schemas" { + \\ inputs "${vars.schema-dir}/*.xml" "!${vars.schema-dir}/ignored.xml" + \\ command "/usr/bin/glib-compile-schemas" "${vars.schema-dir}" + \\ } + \\} + ; + var parsed = try parse(&test_env.ctx, input); + defer parsed.deinit(); + try std.testing.expectEqual(@as(usize, 1), parsed.packages.items[0].realizers.items.len); + const realizer = parsed.packages.items[0].realizers.items[0]; + try std.testing.expectEqualStrings("glib-schemas", realizer.name); + try std.testing.expectEqualStrings("usr/share/glib-2.0/schemas/*.xml", realizer.inputs.items[0]); + try std.testing.expectEqualStrings("/usr/bin/glib-compile-schemas", realizer.command.items[0]); + try std.testing.expectEqualStrings("usr/share/glib-2.0/schemas", realizer.command.items[1]); +} + +test "parse rejects a relative generation realizer executable" { + const th = @import("test_helpers.zig"); + var test_env = try th.createTestEnv(); + defer { + test_env.cleanup(); + std.testing.allocator.destroy(test_env); + } + const input = + \\recipe { + \\ name "demo" + \\ version "1" + \\ release 1 + \\} + \\build { + \\ script "true" + \\} + \\package "demo" { + \\ files "usr/" + \\ realizer "cache" { + \\ inputs "usr/share/data/" + \\ command "usr/bin/cache-tool" + \\ } + \\} + ; + try std.testing.expectError(RecipeError.InvalidInput, parse(&test_env.ctx, input)); +}