3c9ac84ad6d7

fix: Resolve fable audit pin and option leftovers (v3.4.14)

- Implement nina pin via flake.lock source parsing plus nix flake lock --override-input - Filter NixOS option docs inside nix eval and live-verify option search on golden - Document NINA-008/NINA-009 closeout evidence and bump version

time
2026-07-02 15:37 UTC · 3 months ago
machine
koh2
parent
36df571ddf9d
8 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 −0
README.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 installing

view file

VERSION+1 −1
@@ -1 +1 @@1-3.4.131+3.4.14

view file

build.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 allows

view file

src/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)" {

view file

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 ---

view file

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.

view file

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+}

view file

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 {

view file