Initial import
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
# Zig build artifacts
|
||||||
|
.zig-cache/
|
||||||
|
zig-out/
|
||||||
|
|
||||||
|
# Local editor/OS noise
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`have` is a tiny compiled Zig CLI that lists executable command names available
|
||||||
|
through `PATH`. It exists as an agent-safe availability primitive for comparing a
|
||||||
|
run-tool allowlist against what the local Unix system can actually execute.
|
||||||
|
|
||||||
|
## How to build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
zig build
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
```sh
|
||||||
|
zig build test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- One source file: `have.zig`.
|
||||||
|
- POSIX-shaped: inspect `PATH` directories and executable bits only.
|
||||||
|
- No package-manager knowledge, shell builtins, aliases, functions, login shells,
|
||||||
|
network access, mutation, config files, JSON default, color, pager, or daemon
|
||||||
|
behavior.
|
||||||
|
- stdout is one command name per line; diagnostics go to stderr.
|
||||||
|
- This is an inspection primitive. Keep OS/package inventory in separate tools if
|
||||||
|
it is ever needed.
|
||||||
|
|
||||||
|
## Requirement Governance
|
||||||
|
|
||||||
|
`REQUIREMENTS.md` is the requirement authority for this repository and is checked
|
||||||
|
with `redgate`. Before implementation work, read the applicable requirement row;
|
||||||
|
if none covers durable behavior, update `REQUIREMENTS.md` first or stop and ask.
|
||||||
|
For behavior intended to stick, add or update a failing smoke/contract proof
|
||||||
|
before implementation unless that would be fake or disproportionate. Before
|
||||||
|
handoff, compare the user request and diff against `REQUIREMENTS.md`; report
|
||||||
|
`REQUIREMENT IMPACT: updated <rows>` or exactly
|
||||||
|
`REQUIREMENT IMPACT: none - <specific reason>`.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# REQUIREMENTS
|
||||||
|
|
||||||
|
## cli
|
||||||
|
|
||||||
|
ring id requirement
|
||||||
|
0 001 `have` must be a compiled single-file Zig CLI with no runtime dependencies beyond the OS/libc boundary needed for POSIX executable checks.
|
||||||
|
0 002 `have` must inspect command availability through `PATH` only; it must not mutate files, execute discovered commands, invoke a shell, inspect package managers, access the network, read hidden config, prompt, page, colorize, or emit JSON by default.
|
||||||
|
1 003 With no arguments, `have` must stream executable file names discovered in `PATH`, one per stdout line, de-duplicated by first executable `PATH` occurrence without collecting the full listing before writing records.
|
||||||
|
1 004 With `NAME...` arguments, `have` must print only available command names found through `PATH`, exit 0 when all requested names are available, and exit nonzero when any requested name is missing or not a command name.
|
||||||
|
1 005 `have` must not report shell builtins, aliases, shell functions, directories, non-executable files, or package installation state unless they also exist as executable files in `PATH`.
|
||||||
|
1 006 Usage-error and runtime diagnostics must go to stderr and never corrupt stdout records.
|
||||||
|
1 007 `zig build test` must prove PATH listing, executable-bit filtering, first-occurrence de-duplication, query output, missing-command failure, help stdout behavior, and that inspected commands are not executed.
|
||||||
|
1 008 `have --help` and `have -h` must print manual text to stdout and exit 0 without emitting stderr.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
pub fn build(b: *std.Build) void {
|
||||||
|
const target = b.standardTargetOptions(.{});
|
||||||
|
const optimize = b.standardOptimizeOption(.{});
|
||||||
|
const exe = b.addExecutable(.{
|
||||||
|
.name = "have",
|
||||||
|
.root_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("have.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
.link_libc = true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
b.installArtifact(exe);
|
||||||
|
|
||||||
|
const run_cmd = b.addRunArtifact(exe);
|
||||||
|
run_cmd.step.dependOn(b.getInstallStep());
|
||||||
|
if (b.args) |args| run_cmd.addArgs(args);
|
||||||
|
b.step("run", "Run have").dependOn(&run_cmd.step);
|
||||||
|
|
||||||
|
const tests = b.addTest(.{
|
||||||
|
.root_module = exe.root_module,
|
||||||
|
});
|
||||||
|
const run_tests = b.addRunArtifact(tests);
|
||||||
|
|
||||||
|
const smoke = b.addSystemCommand(&.{ "sh", "smoke.sh", b.getInstallPath(.bin, "have") });
|
||||||
|
smoke.setCwd(b.path("."));
|
||||||
|
smoke.step.dependOn(b.getInstallStep());
|
||||||
|
|
||||||
|
const contract = b.addSystemCommand(&.{ "sh", "-c",
|
||||||
|
\\set -eu
|
||||||
|
\\tool=$1
|
||||||
|
\\root=.zig-cache/have-contract
|
||||||
|
\\rm -rf "$root"
|
||||||
|
\\mkdir -p "$root/a" "$root/b" "$root/dup-a" "$root/dup-b" "$root/filter"
|
||||||
|
\\cat > "$root/a/alpha" <<'SCRIPT'
|
||||||
|
\\#!/bin/sh
|
||||||
|
\\echo ran >> .zig-cache/have-contract/ran
|
||||||
|
\\SCRIPT
|
||||||
|
\\cat > "$root/b/beta" <<'SCRIPT'
|
||||||
|
\\#!/bin/sh
|
||||||
|
\\echo ran >> .zig-cache/have-contract/ran
|
||||||
|
\\SCRIPT
|
||||||
|
\\cat > "$root/dup-a/dup" <<'SCRIPT'
|
||||||
|
\\#!/bin/sh
|
||||||
|
\\echo ran >> .zig-cache/have-contract/ran
|
||||||
|
\\SCRIPT
|
||||||
|
\\cat > "$root/dup-b/dup" <<'SCRIPT'
|
||||||
|
\\#!/bin/sh
|
||||||
|
\\echo ran >> .zig-cache/have-contract/ran
|
||||||
|
\\SCRIPT
|
||||||
|
\\chmod +x "$root/a/alpha" "$root/b/beta" "$root/dup-a/dup" "$root/dup-b/dup"
|
||||||
|
\\printf nope > "$root/filter/not-exec"
|
||||||
|
\\mkdir "$root/filter/exec-dir"
|
||||||
|
\\chmod +x "$root/filter/exec-dir"
|
||||||
|
\\ln -s exec-dir "$root/filter/dir-link"
|
||||||
|
\\PATH="$root/a:$root/b" "$tool" > "$root/list.out"
|
||||||
|
\\test "$(sed -n '1p' "$root/list.out")" = alpha
|
||||||
|
\\test "$(sed -n '2p' "$root/list.out")" = beta
|
||||||
|
\\test ! -e "$root/ran"
|
||||||
|
\\PATH="$root/dup-a:$root/dup-b" "$tool" > "$root/dup.out"
|
||||||
|
\\test "$(grep -c '^dup$' "$root/dup.out")" = 1
|
||||||
|
\\PATH="$root/filter" "$tool" > "$root/filter.out"
|
||||||
|
\\! grep -q '^not-exec$' "$root/filter.out"
|
||||||
|
\\! grep -q '^exec-dir$' "$root/filter.out"
|
||||||
|
\\! grep -q '^dir-link$' "$root/filter.out"
|
||||||
|
\\PATH="$root/a" "$tool" alpha > "$root/query.out"
|
||||||
|
\\grep -q '^alpha$' "$root/query.out"
|
||||||
|
\\test ! -e "$root/ran"
|
||||||
|
\\PATH="$root/a" "$tool" ./alpha > "$root/slash.out" && exit 1 || true
|
||||||
|
\\test ! -s "$root/slash.out"
|
||||||
|
\\"$tool" --help > "$root/help.out" 2> "$root/help.err"
|
||||||
|
\\grep -q '^usage:' "$root/help.out"
|
||||||
|
\\test ! -s "$root/help.err"
|
||||||
|
, "have-contract", b.getInstallPath(.bin, "have") });
|
||||||
|
contract.setCwd(b.path("."));
|
||||||
|
contract.step.dependOn(b.getInstallStep());
|
||||||
|
|
||||||
|
const test_step = b.step("test", "Run unit and smoke tests");
|
||||||
|
test_step.dependOn(&run_tests.step);
|
||||||
|
test_step.dependOn(&smoke.step);
|
||||||
|
test_step.dependOn(&contract.step);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
fn usage(file: std.Io.File, io: std.Io) !void {
|
||||||
|
var buf: [1024]u8 = undefined;
|
||||||
|
var w = file.writer(io, &buf);
|
||||||
|
try w.interface.writeAll(
|
||||||
|
\\usage: have [NAME...]
|
||||||
|
\\
|
||||||
|
\\List executable command names found in PATH, one per line.
|
||||||
|
\\With NAME args, print the names that are available and exit 1 if any are missing.
|
||||||
|
\\This inspects PATH only; it does not know shell builtins, aliases, functions, or packages.
|
||||||
|
\\
|
||||||
|
\\stdout:
|
||||||
|
\\ one command name per line
|
||||||
|
\\
|
||||||
|
\\examples:
|
||||||
|
\\ have | grep '^zig$'
|
||||||
|
\\ have zig git curl
|
||||||
|
\\
|
||||||
|
);
|
||||||
|
try w.interface.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn die(io: std.Io, comptime fmt: []const u8, args: anytype) noreturn {
|
||||||
|
var buf: [512]u8 = undefined;
|
||||||
|
var w = std.Io.File.stderr().writer(io, &buf);
|
||||||
|
w.interface.print(fmt, args) catch {};
|
||||||
|
w.interface.flush() catch {};
|
||||||
|
std.process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pathEnv() []const u8 {
|
||||||
|
const raw = std.c.getenv("PATH") orelse return "";
|
||||||
|
return std.mem.span(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commandName(name: []const u8) bool {
|
||||||
|
return name.len != 0 and std.mem.indexOfScalar(u8, name, '/') == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn executableFileInDir(dir: std.Io.Dir, io: std.Io, name: []const u8) bool {
|
||||||
|
const stat = dir.statFile(io, name, .{}) catch return false;
|
||||||
|
if (stat.kind != .file) return false;
|
||||||
|
dir.access(io, name, .{ .execute = true }) catch return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn haveOne(io: std.Io, name: []const u8) bool {
|
||||||
|
if (!commandName(name)) return false;
|
||||||
|
|
||||||
|
var dirs = std.mem.splitScalar(u8, pathEnv(), ':');
|
||||||
|
while (dirs.next()) |dir_path| {
|
||||||
|
const actual_dir = if (dir_path.len == 0) "." else dir_path;
|
||||||
|
var dir = std.Io.Dir.cwd().openDir(io, actual_dir, .{}) catch continue;
|
||||||
|
defer dir.close(io);
|
||||||
|
if (executableFileInDir(dir, io, name)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listDir(io: std.Io, writer: *std.Io.Writer, seen: *std.StringHashMap(void), dir_path: []const u8) !void {
|
||||||
|
var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch return;
|
||||||
|
defer dir.close(io);
|
||||||
|
|
||||||
|
var it = dir.iterate();
|
||||||
|
while (try it.next(io)) |entry| {
|
||||||
|
if (!commandName(entry.name)) continue;
|
||||||
|
if (seen.contains(entry.name)) continue;
|
||||||
|
if (!executableFileInDir(dir, io, entry.name)) continue;
|
||||||
|
|
||||||
|
const owned = try seen.allocator.dupe(u8, entry.name);
|
||||||
|
errdefer seen.allocator.free(owned);
|
||||||
|
try seen.put(owned, {});
|
||||||
|
try writer.print("{s}\n", .{entry.name});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listAll(allocator: std.mem.Allocator, io: std.Io, writer: *std.Io.Writer) !void {
|
||||||
|
var seen = std.StringHashMap(void).init(allocator);
|
||||||
|
defer {
|
||||||
|
var keys = seen.keyIterator();
|
||||||
|
while (keys.next()) |key| allocator.free(key.*);
|
||||||
|
seen.deinit();
|
||||||
|
}
|
||||||
|
|
||||||
|
var dirs = std.mem.splitScalar(u8, pathEnv(), ':');
|
||||||
|
while (dirs.next()) |dir_path| {
|
||||||
|
const actual_dir = if (dir_path.len == 0) "." else dir_path;
|
||||||
|
try listDir(io, writer, &seen, actual_dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn main(init: std.process.Init) !void {
|
||||||
|
const allocator = init.gpa;
|
||||||
|
var args = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator);
|
||||||
|
defer args.deinit();
|
||||||
|
_ = args.skip();
|
||||||
|
|
||||||
|
var out_buf: [8192]u8 = undefined;
|
||||||
|
var out = std.Io.File.stdout().writer(init.io, &out_buf);
|
||||||
|
|
||||||
|
var names = std.ArrayList([]const u8).empty;
|
||||||
|
defer names.deinit(allocator);
|
||||||
|
while (args.next()) |arg| {
|
||||||
|
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
|
||||||
|
try usage(std.Io.File.stdout(), init.io);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try names.append(allocator, arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (names.items.len == 0) {
|
||||||
|
listAll(allocator, init.io, &out.interface) catch |err| die(init.io, "have: {s}\n", .{@errorName(err)});
|
||||||
|
try out.interface.flush();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var missing = false;
|
||||||
|
for (names.items) |name| {
|
||||||
|
if (haveOne(init.io, name)) {
|
||||||
|
try out.interface.print("{s}\n", .{name});
|
||||||
|
} else {
|
||||||
|
missing = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try out.interface.flush();
|
||||||
|
if (missing) std.process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
test commandName {
|
||||||
|
try std.testing.expect(commandName("zig"));
|
||||||
|
try std.testing.expect(commandName("."));
|
||||||
|
try std.testing.expect(!commandName(""));
|
||||||
|
try std.testing.expect(!commandName("./zig"));
|
||||||
|
try std.testing.expect(!commandName("bin/zig"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
tool=${1:-zig-out/bin/have}
|
||||||
|
mkdir -p .zig-cache/have-smoke/bin
|
||||||
|
cat > .zig-cache/have-smoke/bin/ok <<'SCRIPT'
|
||||||
|
#!/bin/sh
|
||||||
|
exit 0
|
||||||
|
SCRIPT
|
||||||
|
chmod +x .zig-cache/have-smoke/bin/ok
|
||||||
|
printf 'nope' > .zig-cache/have-smoke/bin/not-exec
|
||||||
|
|
||||||
|
PATH=.zig-cache/have-smoke/bin "$tool" > .zig-cache/have-list.out
|
||||||
|
grep -q '^ok$' .zig-cache/have-list.out
|
||||||
|
if grep -q '^not-exec$' .zig-cache/have-list.out; then
|
||||||
|
echo 'have: listed non-executable file' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PATH=.zig-cache/have-smoke/bin "$tool" ok missing > .zig-cache/have-query.out && {
|
||||||
|
echo 'have: query succeeded despite missing command' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
grep -q '^ok$' .zig-cache/have-query.out
|
||||||
|
if grep -q '^missing$' .zig-cache/have-query.out; then
|
||||||
|
echo 'have: printed missing command' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$tool" --help > .zig-cache/have-help.out 2> .zig-cache/have-help.err
|
||||||
|
grep -q '^usage: have \[NAME\.\.\.\]' .zig-cache/have-help.out
|
||||||
|
if [ -s .zig-cache/have-help.err ]; then
|
||||||
|
echo 'have: --help wrote stderr' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
"$tool" -h > .zig-cache/have-help-short.out 2> .zig-cache/have-help-short.err
|
||||||
|
grep -q '^usage: have \[NAME\.\.\.\]' .zig-cache/have-help-short.out
|
||||||
|
if [ -s .zig-cache/have-help-short.err ]; then
|
||||||
|
echo 'have: -h wrote stderr' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user