3c9ac84ad6d7
fix: Resolve fable audit pin and option leftovers (v3.4.14)
- time
- 2026-07-02 15:37 UTC · 3 months ago
- machine
- koh2
- parent
- 36df571ddf9d
↓ nina-3c9ac84a.tar.gzatlas at this save
files changed8 files
README.md+2 −0VERSION+1 −1build.zig.zon+1 −1src/api.zig+54 −16src/commands.zig+6 −17src/exec.zig+253 −0src/man.zig+2 −2src/output.zig+5 −0README.md+2 −0
@@ -83,6 +83,8 @@8383 8484 nina flake show inspect flake outputs8585 nina flake update update lock files86+nina pin <in> <rev> pin a flake input to a commit87+nina unpin <in> release a pinned flake input8688 nina develop enter a dev shell8789 nina build build a flake output8890 nina run <p> run a package without installingbuild.zig.zon+1 −1
@@ -9,7 +9,7 @@99 .name = .nina,1010 // This is a [Semantic Version](https://semver.org/).1111 // In a future version of Zig it will be used for package deduplication.12- .version = "3.4.13",12+ .version = "3.4.14",1313 // Together with name, this represents a globally unique package1414 // identifier. This field is generated by the Zig toolchain when the1515 // package is first created, and then *never changes*. This allowssrc/api.zig+54 −16
@@ -223,25 +223,54 @@223223 // technique nixos-option and the NixOS manual generator use. --impure is224224 // required for builtins.getFlake on the bare registry name and for225225 // builtins.currentSystem.226-//227-// NOTE: not exercised against real nix (none available on this host/dev228-// machine); verified only via the fixture-driven parsing test below against229-// hand-written JSON shaped like optionAttrSetToDocList's documented output.230-// Needs a real-host / VM run before this is considered fully resolved.231-const OPTIONS_DOC_LIST_EXPR =232- \\let233- \\ flake = builtins.getFlake "nixpkgs";234- \\ eval = flake.lib.nixosSystem {235- \\ system = builtins.currentSystem;236- \\ modules = [ { } ];237- \\ };238- \\in239- \\ flake.lib.optionAttrSetToDocList eval.options240-;226+fn nixStringLiteral(gpa: std.mem.Allocator, s: []const u8) ![]u8 {227+ var out: std.ArrayList(u8) = .empty;228+ errdefer out.deinit(gpa);229+ try out.append(gpa, '"');230+ var i: usize = 0;231+ while (i < s.len) : (i += 1) {232+ switch (s[i]) {233+ '\\' => try out.appendSlice(gpa, "\\\\"),234+ '"' => try out.appendSlice(gpa, "\\\""),235+ '\n' => try out.appendSlice(gpa, "\\n"),236+ '\r' => try out.appendSlice(gpa, "\\r"),237+ '\t' => try out.appendSlice(gpa, "\\t"),238+ '$' => if (i + 1 < s.len and s[i + 1] == '{') {239+ try out.appendSlice(gpa, "\\${");240+ i += 1;241+ } else {242+ try out.append(gpa, s[i]);243+ },244+ else => |ch| try out.append(gpa, ch),245+ }246+ }247+ try out.append(gpa, '"');248+ return out.toOwnedSlice(gpa);249+}241250 251+fn optionsDocListExpr(gpa: std.mem.Allocator, query: []const u8) ![]u8 {252+ const q = try nixStringLiteral(gpa, query);253+ defer gpa.free(q);254+ return std.fmt.allocPrint(gpa,255+ \\let256+ \\ flake = builtins.getFlake "nixpkgs";257+ \\ query = {s};258+ \\ queryRegex = ".*" + flake.lib.escapeRegex query + ".*";259+ \\ eval = flake.lib.nixosSystem {{260+ \\ system = builtins.currentSystem;261+ \\ modules = [ {{ }} ];262+ \\ }};263+ \\ docs = flake.lib.optionAttrSetToDocList eval.options;264+ \\in265+ \\ builtins.filter (opt: builtins.match queryRegex opt.name != null) docs266+ , .{q});267+}268+242269 pub fn searchOptions(gpa: std.mem.Allocator, io: std.Io, query: []const u8) ![]NixOption {270+ const expr = try optionsDocListExpr(gpa, query);271+ defer gpa.free(expr);243272 const result = std.process.run(gpa, io, .{244- .argv = &.{ "nix", "eval", "--json", "--impure", "--expr", OPTIONS_DOC_LIST_EXPR },273+ .argv = &.{ "nix", "eval", "--json", "--impure", "--expr", expr },245274 }) catch return error.SearchFailed;246275 defer gpa.free(result.stderr);247276 defer gpa.free(result.stdout);@@ -354,6 +383,15 @@354383 try std.testing.expect(opts[0].default != null);355384 try std.testing.expectEqualStrings("false", opts[0].default.?);356385 try std.testing.expect(std.mem.indexOf(u8, opts[0].description, "OpenSSH") != null);386+}387+388+test "optionsDocListExpr escapes query text inside Nix string literal" {389+ const expr = try optionsDocListExpr(std.testing.allocator, "foo\"${bar}\\baz");390+ defer std.testing.allocator.free(expr);391+392+ try std.testing.expect(std.mem.indexOf(u8, expr, "query = \"foo\\\"\\${bar}\\\\baz\";") != null);393+ try std.testing.expect(std.mem.indexOf(u8, expr, "builtins.filter") != null);394+ try std.testing.expect(std.mem.indexOf(u8, expr, "escapeRegex") != null);357395 }358396 359397 test "filterOptions still reads a plain-string default (pre-literalExpression nixpkgs)" {src/commands.zig+6 −17
@@ -2098,30 +2098,19 @@20982098 20992099 // --- Pin / Unpin ---21002100 2101-// `nina pin` is not implemented — editFlakeLock (exec.zig) was a stub that2102-// silently did nothing while this printed a success line implying an input2103-// had been pinned (NINA-008). Pinning an input to a commit requires resolving2104-// that input's existing source (github/gitlab/generic git, each with its own2105-// flake-ref shape) from flake.lock before nix can override it, which is real2106-// scope beyond a mechanical fix — so this fails loudly instead of lying.21072101 pub fn pin(ctx: Ctx) !void {2108- _ = ctx;2109- errors.error_info.setMessage("nina pin is not implemented yet", .{});2110- errors.error_info.setSuggestion("override manually: nix flake lock --override-input <input> <flake-ref>", .{});2111- return error.NotImplemented;2102+ const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;2103+ const rev = if (ctx.args.len > 1) ctx.args[1] else return error.Internal;2104+ const flake_ref = try exec.flakeLockOverrideRef(ctx.gpa, ctx.io, &ctx.machine, input, rev);2105+ defer ctx.gpa.free(flake_ref);2106+ _ = try exec.flakeLockOverrideInput(ctx.io, &ctx.machine, ctx.gpa, input, flake_ref);2107+ output.pinDone(input, rev);21122108 }21132109 21142110 pub fn unpin(ctx: Ctx) !void {21152111 const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;21162112 _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input, &.{});21172113 output.unpinDone(input);2118-}2119-2120-test "pin reports NotImplemented instead of a fake success" {2121- // pin() ignores every field of ctx before returning, so an undefined2122- // value is safe here and avoids constructing a real std.Io/machine/config.2123- const ctx: Ctx = undefined;2124- try std.testing.expectError(error.NotImplemented, pin(ctx));21252114 }21262115 21272116 // --- Hash / Fetch ---src/exec.zig+253 −0
@@ -1225,6 +1225,259 @@12251225 return stream(io, machine, gpa, argv.items);12261226 }12271227 1228+pub fn flakeLockOverrideInput(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, input: []const u8, flake_ref: []const u8) !u8 {1229+ return stream(io, machine, gpa, &.{ "nix", "flake", "lock", "--override-input", input, flake_ref });1230+}1231+1232+pub fn flakeLockOverrideRef(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine, input: []const u8, rev: []const u8) ![]u8 {1233+ const r = capture(gpa, io, machine, &.{ "cat", "flake.lock" }) catch return error.FileNotFound;1234+ defer gpa.free(r.stdout);1235+ defer gpa.free(r.stderr);1236+ if (r.exit_code != 0) {1237+ errors.error_info.setMessage("could not read flake.lock", .{});1238+ errors.error_info.setSuggestion("run nina pin from the flake directory", .{});1239+ return error.FileNotFound;1240+ }1241+ return flakeOverrideRefFromLock(gpa, r.stdout, input, rev);1242+}1243+1244+fn jsonObject(v: std.json.Value) ?std.json.ObjectMap {1245+ return switch (v) {1246+ .object => |obj| obj,1247+ else => null,1248+ };1249+}1250+1251+fn jsonString(obj: std.json.ObjectMap, key: []const u8) ?[]const u8 {1252+ const v = obj.get(key) orelse return null;1253+ return switch (v) {1254+ .string => |s| s,1255+ else => null,1256+ };1257+}1258+1259+fn inputNodeName(nodes: std.json.ObjectMap, root_name: []const u8, input: []const u8) ?[]const u8 {1260+ const root_v = nodes.get(root_name) orelse return null;1261+ const root = jsonObject(root_v) orelse return null;1262+ const inputs_v = root.get("inputs") orelse return null;1263+ const inputs = jsonObject(inputs_v) orelse return null;1264+ const target = inputs.get(input) orelse return null;1265+ return switch (target) {1266+ .string => |s| s,1267+ .array => |arr| {1268+ if (arr.items.len == 0) return null;1269+ return switch (arr.items[arr.items.len - 1]) {1270+ .string => |s| s,1271+ else => null,1272+ };1273+ },1274+ else => null,1275+ };1276+}1277+1278+fn appendQueryString(out: *std.ArrayList(u8), gpa: std.mem.Allocator, first: *bool, key: []const u8, value: []const u8) !void {1279+ try out.append(gpa, if (first.*) '?' else '&');1280+ first.* = false;1281+ try out.appendSlice(gpa, key);1282+ try out.append(gpa, '=');1283+ try out.appendSlice(gpa, value);1284+}1285+1286+fn appendQueryBool(out: *std.ArrayList(u8), gpa: std.mem.Allocator, first: *bool, key: []const u8, obj: std.json.ObjectMap) !void {1287+ const v = obj.get(key) orelse return;1288+ if (v != .bool) return;1289+ try appendQueryString(out, gpa, first, key, if (v.bool) "1" else "0");1290+}1291+1292+fn appendSharedFlakeRefQuery(out: *std.ArrayList(u8), gpa: std.mem.Allocator, first: *bool, source: std.json.ObjectMap) !void {1293+ if (jsonString(source, "dir")) |dir| try appendQueryString(out, gpa, first, "dir", dir);1294+ try appendQueryBool(out, gpa, first, "submodules", source);1295+ try appendQueryBool(out, gpa, first, "lfs", source);1296+ try appendQueryBool(out, gpa, first, "shallow", source);1297+ try appendQueryBool(out, gpa, first, "allRefs", source);1298+}1299+1300+fn sourceObject(node: std.json.ObjectMap) ?std.json.ObjectMap {1301+ if (node.get("original")) |v| {1302+ if (jsonObject(v)) |obj| return obj;1303+ }1304+ if (node.get("locked")) |v| {1305+ if (jsonObject(v)) |obj| return obj;1306+ }1307+ return null;1308+}1309+1310+fn forgeFlakeRef(gpa: std.mem.Allocator, prefix: []const u8, source: std.json.ObjectMap, rev: []const u8) ![]u8 {1311+ if (source.get("host") != null) {1312+ errors.error_info.setMessage("cannot pin non-default {s} host automatically", .{prefix});1313+ errors.error_info.setSuggestion("use nix flake lock --override-input <input> <flake-ref>", .{});1314+ return error.Internal;1315+ }1316+ const owner = jsonString(source, "owner") orelse {1317+ errors.error_info.setMessage("flake.lock input is missing owner", .{});1318+ return error.Internal;1319+ };1320+ const repo = jsonString(source, "repo") orelse {1321+ errors.error_info.setMessage("flake.lock input is missing repo", .{});1322+ return error.Internal;1323+ };1324+1325+ var out: std.ArrayList(u8) = .empty;1326+ errdefer out.deinit(gpa);1327+ try out.appendSlice(gpa, prefix);1328+ try out.append(gpa, ':');1329+ try out.appendSlice(gpa, owner);1330+ try out.append(gpa, '/');1331+ try out.appendSlice(gpa, repo);1332+ var first_query = true;1333+ try appendQueryString(&out, gpa, &first_query, "rev", rev);1334+ try appendSharedFlakeRefQuery(&out, gpa, &first_query, source);1335+ return out.toOwnedSlice(gpa);1336+}1337+1338+fn gitFlakeRef(gpa: std.mem.Allocator, source: std.json.ObjectMap, rev: []const u8) ![]u8 {1339+ const url = jsonString(source, "url") orelse {1340+ errors.error_info.setMessage("git flake.lock input is missing url", .{});1341+ return error.Internal;1342+ };1343+ var out: std.ArrayList(u8) = .empty;1344+ errdefer out.deinit(gpa);1345+ if (!std.mem.startsWith(u8, url, "git+")) try out.appendSlice(gpa, "git+");1346+ try out.appendSlice(gpa, url);1347+ var first_query = std.mem.indexOfScalar(u8, url, '?') == null;1348+ try appendQueryString(&out, gpa, &first_query, "rev", rev);1349+ try appendSharedFlakeRefQuery(&out, gpa, &first_query, source);1350+ return out.toOwnedSlice(gpa);1351+}1352+1353+pub fn flakeOverrideRefFromLock(gpa: std.mem.Allocator, lock_json: []const u8, input: []const u8, rev: []const u8) ![]u8 {1354+ const parsed = std.json.parseFromSlice(std.json.Value, gpa, lock_json, .{}) catch {1355+ errors.error_info.setMessage("could not parse flake.lock", .{});1356+ return error.ConfigParseError;1357+ };1358+ defer parsed.deinit();1359+1360+ const root = jsonObject(parsed.value) orelse {1361+ errors.error_info.setMessage("could not parse flake.lock", .{});1362+ return error.ConfigParseError;1363+ };1364+ const nodes_v = root.get("nodes") orelse {1365+ errors.error_info.setMessage("flake.lock has no nodes", .{});1366+ return error.ConfigParseError;1367+ };1368+ const nodes = jsonObject(nodes_v) orelse {1369+ errors.error_info.setMessage("flake.lock nodes are malformed", .{});1370+ return error.ConfigParseError;1371+ };1372+ const root_name = jsonString(root, "root") orelse "root";1373+ const node_name = inputNodeName(nodes, root_name, input) orelse input;1374+ const node_v = nodes.get(node_name) orelse {1375+ errors.error_info.setMessage("flake input '{s}' not found in flake.lock", .{input});1376+ errors.error_info.setSuggestion("run nina flake update first", .{});1377+ return error.Internal;1378+ };1379+ const node = jsonObject(node_v) orelse {1380+ errors.error_info.setMessage("flake input '{s}' is malformed", .{input});1381+ return error.ConfigParseError;1382+ };1383+ const source = sourceObject(node) orelse {1384+ errors.error_info.setMessage("flake input '{s}' has no source metadata", .{input});1385+ return error.Internal;1386+ };1387+ const typ = jsonString(source, "type") orelse {1388+ errors.error_info.setMessage("flake input '{s}' has no source type", .{input});1389+ return error.Internal;1390+ };1391+1392+ if (std.mem.eql(u8, typ, "github")) return forgeFlakeRef(gpa, "github", source, rev);1393+ if (std.mem.eql(u8, typ, "gitlab")) return forgeFlakeRef(gpa, "gitlab", source, rev);1394+ if (std.mem.eql(u8, typ, "sourcehut")) return forgeFlakeRef(gpa, "sourcehut", source, rev);1395+ if (std.mem.eql(u8, typ, "git")) return gitFlakeRef(gpa, source, rev);1396+1397+ errors.error_info.setMessage("cannot pin flake input '{s}' of type '{s}'", .{ input, typ });1398+ errors.error_info.setSuggestion("use nix flake lock --override-input <input> <flake-ref>", .{});1399+ return error.Internal;1400+}1401+1402+test "flakeOverrideRefFromLock resolves root github input" {1403+ const a = std.testing.allocator;1404+ const lock =1405+ \\{1406+ \\ "nodes": {1407+ \\ "nixpkgs": {1408+ \\ "locked": { "owner": "NixOS", "repo": "nixpkgs", "rev": "old", "type": "github" },1409+ \\ "original": { "owner": "NixOS", "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" }1410+ \\ },1411+ \\ "root": { "inputs": { "nixpkgs": "nixpkgs" } }1412+ \\ },1413+ \\ "root": "root",1414+ \\ "version": 71415+ \\}1416+ ;1417+ const ref = try flakeOverrideRefFromLock(a, lock, "nixpkgs", "abcdef");1418+ defer a.free(ref);1419+ try std.testing.expectEqualStrings("github:NixOS/nixpkgs?rev=abcdef", ref);1420+}1421+1422+test "flakeOverrideRefFromLock preserves dir and boolean source flags" {1423+ const a = std.testing.allocator;1424+ const lock =1425+ \\{1426+ \\ "nodes": {1427+ \\ "dep-node": {1428+ \\ "original": {1429+ \\ "owner": "me",1430+ \\ "repo": "mono",1431+ \\ "type": "github",1432+ \\ "dir": "sub/flake",1433+ \\ "submodules": true1434+ \\ }1435+ \\ },1436+ \\ "root": { "inputs": { "dep": "dep-node" } }1437+ \\ },1438+ \\ "root": "root",1439+ \\ "version": 71440+ \\}1441+ ;1442+ const ref = try flakeOverrideRefFromLock(a, lock, "dep", "1234");1443+ defer a.free(ref);1444+ try std.testing.expectEqualStrings("github:me/mono?rev=1234&dir=sub/flake&submodules=1", ref);1445+}1446+1447+test "flakeOverrideRefFromLock resolves generic git inputs" {1448+ const a = std.testing.allocator;1449+ const lock =1450+ \\{1451+ \\ "nodes": {1452+ \\ "dep": {1453+ \\ "original": { "type": "git", "url": "https://example.com/repo.git" }1454+ \\ },1455+ \\ "root": { "inputs": { "dep": "dep" } }1456+ \\ },1457+ \\ "root": "root",1458+ \\ "version": 71459+ \\}1460+ ;1461+ const ref = try flakeOverrideRefFromLock(a, lock, "dep", "deadbeef");1462+ defer a.free(ref);1463+ try std.testing.expectEqualStrings("git+https://example.com/repo.git?rev=deadbeef", ref);1464+}1465+1466+test "flakeOverrideRefFromLock rejects unsupported source types" {1467+ const a = std.testing.allocator;1468+ const lock =1469+ \\{1470+ \\ "nodes": {1471+ \\ "local": { "original": { "type": "path", "path": "./dep" } },1472+ \\ "root": { "inputs": { "local": "local" } }1473+ \\ },1474+ \\ "root": "root",1475+ \\ "version": 71476+ \\}1477+ ;1478+ try std.testing.expectError(error.Internal, flakeOverrideRefFromLock(a, lock, "local", "deadbeef"));1479+}1480+12281481 // Update flake inputs for the flake living in `dir` (the config dir). `nix flake12291482 // update` operates on the flake in the working directory, so we cd there first.12301483 // Used by `nina upgrade` on a flake system in place of nix-channel --update.src/man.zig+2 −2
@@ -187,7 +187,7 @@187187 .{ .cmd_block = "$ nina install neovim\n:: neovim 0.10.4\n\n [i] profile install instant no rebuild\n [s] system install opens editor requires apply\n [t] try now exits when done\n\n > i\n:: installing neovim profile\n -> nix profile install nixpkgs#neovim\n:: neovim installed available now" },188188 .{ .para = "explain why a package is installed:" },189189 .{ .cmd_block = "$ nina why ripgrep\n:: why ripgrep kyoshi\n\n ripgrep is a direct profile package" },190- .{ .para = "`nina unpin <input>` releases a pinned flake input by running `nix flake update` on it." },190+ .{ .para = "`nina pin <input> <rev>` reads the input source from `flake.lock` and runs `nix flake lock --override-input` with a commit-specific flake ref. `nina unpin <input>` releases it again by running `nix flake update` on that input." },191191 },192192 },193193 .{@@ -1047,4 +1047,4 @@10471047 try std.testing.expect(std.mem.count(u8, readme, "~/.nina.conf") == 0);10481048 try std.testing.expect(std.mem.count(u8, readme, "c copy attr") == 0);10491049 try std.testing.expect(std.mem.count(u8, readme, "~/.local/share/nina/nina.log") == 0);1050-}\ No newline at end of file1050+}src/output.zig+5 −0
@@ -1113,6 +1113,7 @@11131113 .{ "option <q>", "search nixos options" },11141114 .{ "pkg", "inspect packages" },11151115 .{ "why <pkg>", "what pulled a package in" },1116+ .{ "pin <input> <rev>", "pin a flake input to a commit" },11161117 .{ "unpin <input>", "release a pinned flake input (nix flake update)" },11171118 .{ "hash", "compute nix hash" },11181119 .{ "fetch", "fetch and hash a url" },@@ -1425,6 +1426,10 @@14251426 14261427 pub fn unpinDone(input: []const u8) void {14271428 p("{s}unpinned {s}\n", .{ hdr(), input });1429+}1430+1431+pub fn pinDone(input: []const u8, rev: []const u8) void {1432+ p("{s}pinned {s} -> {s}\n", .{ hdr(), input, rev });14281433 }14291434 14301435 pub fn hashResult(hash: []const u8) void {