std.os
std.os is a thin wrapper over three things the C library owns: the command shell, the process environment, and errno. run shells a command out through system, env reads an environment variable, quote wraps an argument so a POSIX shell reads it as one literal word, and os_errno and errstr read back the last foreign call’s failure. Three of the five arrived together in 0.6.0, when the bootstrap needed to drive a compiler and a linker from Dusk itself; the errno read and errstr came later, in 1.4.0, alongside the release that opened the foreign boundary. That read was named errno at first and renamed os_errno in 1.6.0, so its bare symbol stops colliding with the C library’s own errno on a target whose libc owns that name; a program that called the old name updates the call. The module lives at lib/std/os.dusk.
@import std.osImported names are flat: after @import std.os you call run, env, and os_errno with no prefix. See stdlib overview for how imports work in general.
Every string argument here crosses the C boundary through std.string’s cbuf, a fresh NUL terminated raw byte buffer, because a string is typed apart from a raw pointer and the C shims cannot take one directly. The module imports std.string to get it, and a module’s imports arrive flat with it, so @import std.os puts str_len and the rest of that surface in scope for you too.
The API
Section titled “The API”| Function | Description |
|---|---|
run(cmd: string) -> int64 | Runs cmd through the platform shell and returns the child’s decoded exit code. |
env(name: string) -> string | The value of the environment variable name, or the empty string when it is unset. |
os_errno() -> int64 | The C library’s errno, as the most recent foreign call left it. Named errno before 1.6.0. |
errstr(code: int64) -> string | strerror’s message for an errno code, as a fresh heap string. |
quote(arg: string) -> string | Wraps arg in single quotes so a POSIX shell reads it as one literal word. |
Running a command
Section titled “Running a command”run hands cmd to the C library’s system, which runs it with /bin/sh -c. What comes back is the child’s exit code, already decoded, and nothing else.
The decode is worth reading, because system does not return an exit code. It returns the raw wait status word, and that word packs two different answers into one integer. The low 7 bits name the signal that terminated the child, and they are zero when the child exited on its own. Bits 8 through 15 hold the exit code. So run reads the signal byte first. When it is non-zero the child died on a signal, and the code comes back as 128 plus the signal number, the shell’s own convention, so a process the OS killed is never mistaken for a clean exit. That reading is not free of history: run first reported the raw low byte, which is zero for a signalled child, so a killed process read back as a clean success until 0.6.0 fixed it. Otherwise the child exited normally and the code is the status shifted down out of the signal byte, which is what C spells WEXITSTATUS. Both masks stay inside the low 16 bits, which also discards the high 32 bits of the widened return, where the C int to int64 read leaves nothing defined.
std.process’s proc_close decodes pclose’s wait status by exactly the same rule, so an exit code means the same thing on both sides of the library.
Which of the two you want comes down to one question. run gives you the exit code and only the exit code: the child inherits this process’s stdout, so whatever it prints goes straight where your own output goes and you never get a handle on it. Reach for run when the exit code is the whole answer, and for std.process when you need what the command actually printed.
That shared stdout has an edge on it. The child writes to the descriptor directly while Dusk’s own println is buffered, so the two do not interleave in source order whenever stdout is a pipe or a file rather than a terminal. The child’s output can land ahead of a line you printed before the call ever ran. If the ordering matters, capture the output with std.process rather than letting the child share your stdout.
Reading the environment
Section titled “Reading the environment”env reads an environment variable’s value. An unset variable reads back as the empty string, never a fault and never any kind of null, which is a deliberate choice rather than a convenience: it means you test the result with str_len or str_eq instead of reaching for a null check, which Dusk would not let you write anyway, since == rejects every pointer type outright.
What it costs you is the one distinction it folds away. A variable that is unset and a variable set to the empty string read back identically, and env alone cannot tell you which you have. If that difference matters to your program, this function does not carry it.
The result is a fresh heap string you own, so free it when you are done with it. There is no setter here. std.os reads the environment and does not write it.
The errno convention
Section titled “The errno convention”os_errno is the read side of the C library’s own error channel, and it is less a utility you reach for often than the convention the rest of the library’s foreign work is built on. The rule is one sentence: read os_errno immediately after the call whose failure it reports, before anything else crosses the C boundary.
The reason is that errno is not attached to the call that set it. It is a single location the C library writes on failure, and Dusk never writes it at all, so a read of os_errno() reports whatever the most recent foreign call left behind, whichever call that happened to be. The read is therefore positional rather than tied to anything you name in it. Any foreign call landing between the failure and your read overwrites it, including one you did not think of as a foreign call, and what you get back then describes the wrong thing with no sign that it does. Under the pthreads runtime Dusk links, errno is thread local, so a read never races another thread’s foreign call. The only thing that can clobber it is your own thread’s next one.
std.fs is built entirely on this convention. Every wrapper in it calls os_errno() immediately after the one libc call it names, decides whether that call failed, and hands the failure back to you as an error carrying errstr’s text. That is the pattern to copy when you bind a C function yourself: do the call, read errno on the very next line, and turn it into an error before anything else runs. See foreign functions for the normative reference on the boundary and on both of these functions.
errstr is strerror’s message for a code, and you pass it either the result of os_errno() or a literal like 2 for ENOENT. strerror hands back a pointer into its own static buffer, good only until the next strerror call on this thread, so errstr copies those bytes into a fresh heap string before returning and gives you that instead. You own it, so free it.
Do not pin that wording in a test. glibc’s text for a code is not fixed across platforms or locales, and examples/errno_read.dusk in the language repo checks the portable shape instead, that the message exists and is non-empty.
Quoting an argument
Section titled “Quoting an argument”run builds a shell line, not an argv array. That is what lets run("echo hi; echo there") run two commands, and it is exactly the sharp edge: every byte of the string you pass is shell syntax first and text second. A quote, a semicolon, a backtick, or a $ sitting in a value you interpolated is punctuation to /bin/sh, not data. If any part of that string came from somewhere you do not control, you have written a shell injection. std.process carries the same exposure for the same reason, since popen goes to the same shell.
quote is what the module gives you for that, and it is worth being exact about what it is.
quote(arg) wraps arg in single quotes and hands back the result. A POSIX shell treats every byte inside single quotes literally, so nothing in arg can be read as syntax. The single quote itself is the one byte that cannot appear inside single quoting, since it would end the quoting, and quote handles it the portable way: each embedded ' is written as the four bytes '\'', which closes the quoting, hands the shell one backslash escaped quote, and reopens it. So it's here quotes to 'it'\''s here', and the shell reads that back as the single word it's here. The result is a fresh heap string you own.
What that buys you is precise and it is real: a quoted argument is one literal word to a POSIX shell. Being equally precise about what it does not buy you matters more, because the gap is where the bugs live.
It quotes an argument, not a command. You apply it yourself to each untrusted piece you splice in, and the command name and everything structural around it are still yours to write correctly. Nothing checks that you remembered, and a quote you forgot on one interpolation is the whole hole. It is POSIX single quoting aimed at the /bin/sh that system invokes, so it is one layer deep: hand the quoted word to something that parses a shell line of its own, an eval, a nested sh -c, or an ssh that re-parses remotely, and it gets read from scratch, where one layer of quoting no longer covers it. And it says nothing about what the word means to the program receiving it. quote makes text one argument; it does not make it a harmless one. A quoted -rf is still an option to the command that reads it, and a quoted ../../etc/passwd is still that path.
So quote is a correct answer to one specific question, how to carry an arbitrary byte string through /bin/sh as a single literal word, and for that question it is the right tool. It is not a sanitizer and it does not make untrusted input safe in general. Running a fixed command over data you do not control, with each interpolated piece quoted, is solid ground. Assembling the command itself out of untrusted input is not, and no amount of quoting rescues it.
A tour
Section titled “A tour”Run a command for its exit code, read the environment, read errno, and watch quote turn a semicolon back into text.
@paradigm procedural
@import std.os@import std.string
func main() -> int32 { // run hands cmd to /bin/sh -c and gives back the decoded exit code. println(run("exit 0")) // 0 println(run("exit 7")) // 7
// A child a signal kills reports 128 plus the signal, so a killed // process is never mistaken for a clean exit. SIGKILL is 9. println(run("kill -9 $$")) // 137
// An unset variable reads as the empty string, never a fault, so you // test the result rather than null checking it. missing: string = env("DUSK_OS_TOUR_UNSET") if str_len(missing) == 0 { println("unset reads empty") // unset reads empty } free(missing)
home: string = env("HOME") if str_len(home) > 0 { println("HOME is set") // HOME is set } free(home)
// Nothing has failed yet, so errno still reads 0. println(os_errno()) // 0
// errstr is strerror's text for a code. glibc's wording is not pinned // across platforms, so check the shape rather than the exact message. enoent: string = errstr(2) if str_len(enoent) > 0 { println("errstr ok") // errstr ok } free(enoent)
// quote wraps arg in single quotes, writing each embedded quote as the // four bytes close quote, backslash, quote, reopen quote. q: string = quote("it's here") println(q) // 'it'\''s here' free(q)
// Unquoted, the semicolon in this text is shell syntax: it ends the test // command and runs exit 9 as a second one. println(run("test x = x; exit 9")) // 9
// Quoted, the same text is one literal word. test compares x against it, // reports not equal, and nothing else runs. arg: string = quote("x; exit 9") cb: *StringBuilder = concat("test x = ", arg) println(run(sb_cstr(cb))) // 1 sb_free(cb) free(cb) free(arg) return 0}Every command in that tour is silent on purpose. A child’s output shares this process’s stdout and is not ordered against Dusk’s own buffered println, so a sample that printed from both would not read in source order.
See also
Section titled “See also”- std.process: the module to reach for when you need a command’s output rather than just its exit code, decoding the same wait status by the same rule.
- std.fs: the file and directory module built end to end on the errno convention this page defines.
- Foreign functions: the boundary this module wraps, and the normative
os_errnoanderrstrreference. - std.string:
cbuf, which stages every string argument here for its C shim, andstr_lenfor testing whatenvhands back. - stdlib overview: the full module list and how imports resolve.