Skip to content

Reading Command-Line Arguments Safely

An argument is returned byte-for-byte or not at all. Build a tool that honors that contract.

Time: 20 minutes Level: Beginner Prerequisites: Error Handling, a Linux shell What you’ll build: pick, a tool that selects an input path from its first argument and refuses when the path cannot be read whole

Status: the checked argument API merged to unstable at c70c0099 (PR #41, STD-GAP-064) on 2026-10-11 and is not yet in a tagged release. Every program below was compiled with janus build and run on Linux x86_64. The limits in step 3 belong to the compiled Linux backend.


A file path is an address. Cut it short and it can point at a different house. A 4096-byte request for ./././…/i.kdlx that loses its last byte becomes ./././…/i.kdl, and if that file exists the tool reads it, rewrites it or deletes it. No error, no warning.

Before STD-GAP-064 the compiled backend of std.os.process truncated long arguments exactly this way. Now it refuses, and the checked API tells you why.


Create args.jan:

use std.os.process
func main() -> i32 do
const count = process.args_count_checked() catch |err| do
println("command line too long or unreadable")
return 2
end
println(count)
var i: i64 = 0
while i < count do
const arg = process.arg_at_checked(i) catch |err| do
println("argument cannot be returned whole")
return 2
end
println(arg)
i = i + 1
end
return 0
end

Build and run it:

Terminal window
janus build args.jan args
./args alpha beta

Output:

2
alpha
beta

Three details:

  • The count excludes the program path. ./args alone prints 0.
  • The loop counter is var. A let binding cannot be reassigned.
  • Each argument is printed before the next one is read. Step 5 explains why that order matters.

Step 2: Select one input and name every failure (5 min)

Section titled “Step 2: Select one input and name every failure (5 min)”

arg_at_checked alone covers every way a selection can fail, including a missing argument (IndexOutOfRange). Create pick.jan:

use std.os.process
func explain(err: process.CliArgError) do
if err == process.CliArgError.ReadFailed do println("cannot read the command line") end
if err == process.CliArgError.CommandLineTooLong do println("command line too long") end
if err == process.CliArgError.ArgumentTooLong do println("input path is too long") end
if err == process.CliArgError.IndexOutOfRange do println("usage: pick <input>") end
end
func main() -> i32 do
const path = process.arg_at_checked(0) catch |err| do
explain(err)
return 2
end
println(path)
return 0
end
Terminal window
janus build pick.jan pick
./pick config.kdl; echo "exit $?"
./pick; echo "exit $?"

Output:

config.kdl
exit 0
usage: pick <input>
exit 2

Every error path prints a reason and exits non-zero. A caller, a script or a CI job can tell a refusal from a selection.


The compiled Linux backend holds a whole command line of up to 8192 bytes and returns arguments of up to 4095 bytes. Both limits are inclusive.

Terminal window
./pick "$(printf 'a%.0s' $(seq 4095))" | wc -c # 4095 bytes + newline
./pick "$(printf 'a%.0s' $(seq 4096))"; echo "exit $?"

Output:

4096
input path is too long
exit 2

The command-line limit counts the program path too. exec -a sets that path from the shell, which makes the limit easy to reach:

Terminal window
bash -c 'exec -a "$(printf "z%.0s" $(seq 8190))" ./pick a'; echo "exit $?"

Output:

command line too long
exit 2

The 8190-byte program name, its NUL, a and its NUL total 8193 bytes. The tool refuses even the short argument a: it cannot prove the snapshot is whole, so it returns nothing.


Step 4: Reproduce the hazard the contract blocks (3 min)

Section titled “Step 4: Reproduce the hazard the contract blocks (3 min)”

Create a real file, then request a path whose 4095-byte prefix names it:

Terminal window
echo 'system "laptop"' > i.kdl
long="$(printf './%.0s' $(seq 2045))i.kdlx"
echo "${#long}"
test -e "${long:0:4095}" && echo "the prefix names an existing file"
./pick "$long"; echo "exit $?"

Output:

4096
the prefix names an existing file
input path is too long
exit 2

The requested file i.kdlx does not exist. Its prefix does. A truncating backend would have handed pick the path to i.kdl and the program would have worked on the wrong file with exit code 0.


Step 5: One buffer, one argument at a time (2 min)

Section titled “Step 5: One buffer, one argument at a time (2 min)”

arg_at_checked returns a pointer into one process-wide buffer. The next arg_at_checked or arg_at call overwrites it. Create two.jan:

use std.os.process
func main() -> i32 do
const first = process.arg_at_checked(0) catch |err| do
return 2
end
const second = process.arg_at_checked(1) catch |err| do
return 2
end
println(first)
println(second)
return 0
end
Terminal window
janus build two.jan two
./two alpha beta

Output:

beta
beta

first was valid only until the second read. Consume an argument (print it, open the file, parse it) before reading the next one, as the loop in step 1 does, or copy it into storage you own.


Step 6: Know what the legacy calls do (1 min)

Section titled “Step 6: Know what the legacy calls do (1 min)”

args_count() and arg_at(i) keep their old signatures. They cannot report an error, so they fail closed. Create legacy.jan:

use std.os.process
func main() -> i32 do
println(process.args_count())
println(process.arg_at(0))
return 0
end
Terminal window
janus build legacy.jan legacy
./legacy "$long" | cat -A

cat -A marks each line end with $, so the empty line stays visible.

Output:

1$
$

The count is 1; the argument is an empty string, not a prefix. That is safe, and it exits 0 with nothing to say. An empty string also looks exactly like ./legacy "". On an over-long command line args_count() returns 0, which looks exactly like a call without arguments. Replace the legacy calls with the checked ones wherever an argument selects data.


  • arg_at_checked returns the exact bytes or a CliArgError; it never returns a prefix.
  • On the compiled Linux backend, the command line holds at most 8192 bytes (program path included) and an argument at most 4095 bytes.
  • An over-long command line refuses every argument, not only the long one.
  • The returned pointer lives until the next argument read.
  • args_count() and arg_at(i) fail closed and stay silent; use them only where an empty result is harmless.