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
unstableatc70c0099(PR #41, STD-GAP-064) on 2026-10-11 and is not yet in a tagged release. Every program below was compiled withjanus buildand run on Linux x86_64. The limits in step 3 belong to the compiled Linux backend.
Why this matters
Section titled “Why this matters”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.
Step 1: List the arguments (5 min)
Section titled “Step 1: List the arguments (5 min)”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 0endBuild and run it:
janus build args.jan args./args alpha betaOutput:
2alphabetaThree details:
- The count excludes the program path.
./argsalone prints0. - The loop counter is
var. Aletbinding 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>") endend
func main() -> i32 do const path = process.arg_at_checked(0) catch |err| do explain(err) return 2 end println(path) return 0endjanus build pick.jan pick./pick config.kdl; echo "exit $?"./pick; echo "exit $?"Output:
config.kdlexit 0usage: pick <input>exit 2Every error path prints a reason and exits non-zero. A caller, a script or a CI job can tell a refusal from a selection.
Step 3: Hit the limits (5 min)
Section titled “Step 3: Hit the limits (5 min)”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.
./pick "$(printf 'a%.0s' $(seq 4095))" | wc -c # 4095 bytes + newline./pick "$(printf 'a%.0s' $(seq 4096))"; echo "exit $?"Output:
4096input path is too longexit 2The command-line limit counts the program path too. exec -a sets that path
from the shell, which makes the limit easy to reach:
bash -c 'exec -a "$(printf "z%.0s" $(seq 8190))" ./pick a'; echo "exit $?"Output:
command line too longexit 2The 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:
echo 'system "laptop"' > i.kdllong="$(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:
4096the prefix names an existing fileinput path is too longexit 2The 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 0endjanus build two.jan two./two alpha betaOutput:
betabetafirst 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 0endjanus build legacy.jan legacy./legacy "$long" | cat -Acat -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.
What you learned
Section titled “What you learned”arg_at_checkedreturns the exact bytes or aCliArgError; 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()andarg_at(i)fail closed and stay silent; use them only where an empty result is harmless.
Next steps
Section titled “Next steps”- Reference: std.os.process arguments
- Parse flags from complete arguments: std.cli
- Practice: Monastery mission
04-honest-boundary/01-whole-or-nothing