Programming Language

Nocter

A self-contained systems language built around simplicity, encapsulation, and foolproof design.

/development/std/process/command.nct

command.nct

//! Owning command storage, input validation, and prepared launch values.
//!
//! Every byte buffer and the complete null-terminated argv vector are created before fork. The
//! target launch path therefore needs only stable addresses and cannot allocate or validate text.

see ./index.nct

use /internal/mem as internal_mem
use /internal/ptr as internal_ptr
use /mem.RawBuffer
use /mem
use /ptr
use /string
use /vec.Vec

const EXIT_KIND_CODE: i32 = 0
const EXIT_KIND_SIGNAL: i32 = 1

struct OwnedProcessText {
    storage: RawBuffer
}

struct Command {
    path: OwnedProcessText
    arguments: Vec<OwnedProcessText>
}

copy struct ExitStatus {
    kind: i32
    value: i32
}

struct PreparedCommand {
    command: Command
    argv: Vec<usize>
}

func invalid_command_input(): error {
    return error.new(
        "std.process.invalid_input",
        "process paths and arguments must be valid UTF-8 without NUL bytes",
    )
}

noalloc func validate_process_text(value: &str, require_nonempty: bool): void! {
    if require_nonempty && value.len() == 0 { return invalid_command_input() }
    if !string.is_valid_utf8(value.bytes()) { return invalid_command_input() }

    let bytes = value.bytes()
    var index: usize = 0
    while index < bytes.len() {
        if bytes[index] == 0 { return invalid_command_input() }
        index += 1
    }
    return
}

func own_process_text(value: &str, require_nonempty: bool): OwnedProcessText! {
    validate_process_text(value, require_nonempty)?
    let allocation_size = internal_mem.checked_add(value.len(), 1) otherwise {
        return invalid_command_input()
    }
    var allocator = mem.current_allocator()
    let storage = allocator.alloc(allocation_size, 1)
    internal_ptr.copy_str_to_ptr(storage.bytes().ptr(), 0, value)
    internal_ptr.store_u8_to_ptr(storage.bytes().ptr(), value.len(), 0)
    return OwnedProcessText { storage: move storage }
}

noalloc func process_text_address(value: &OwnedProcessText): usize {
    return ptr.addr(value.storage.bytes().ptr())
}

func prepare_command(command: Command): PreparedCommand {
    var source = move command
    let argv_capacity = internal_mem.checked_add(source.arguments.len(), 2) otherwise {
        return internal_mem.allocation_abort()
    }
    var argv: Vec<usize> = Vec.with_capacity(argv_capacity)
    argv.push(process_text_address(&source.path))
    for argument in &source.arguments {
        argv.push(process_text_address(argument))
    }
    argv.push(0)
    return PreparedCommand { command: move source, argv: move argv }
}

noalloc func prepared_path_address(command: &PreparedCommand): usize {
    return process_text_address(&command.command.path)
}

noalloc func prepared_argv_address(command: &PreparedCommand): usize {
    let values: &[usize] = &command.argv
    return ptr.addr(values.ptr())
}

noalloc func exited_status(code: i32): ExitStatus {
    return ExitStatus { kind: EXIT_KIND_CODE, value: code }
}

noalloc func signaled_status(signal: i32): ExitStatus {
    return ExitStatus { kind: EXIT_KIND_SIGNAL, value: signal }
}

noalloc func optional_i32_is_present(value: i32?): bool {
    let _ = value otherwise { return false }
    return true
}

construct Command {
    func new(path: &str): Self! {
        return Command {
            path: own_process_text(path, true)?,
            arguments: Vec.empty(),
        }
    }
}

instance Command {
    method &+self.arg(value: &str): void! {
        let argument = own_process_text(value, false)?
        self.arguments.push(move argument)
        return
    }
}

instance ExitStatus {
    noalloc method self.success(): bool {
        return self.kind == EXIT_KIND_CODE && self.value == 0
    }

    noalloc method self.code(): i32? {
        if self.kind == EXIT_KIND_CODE { return self.value }
        return none
    }

    noalloc method self.signal(): i32? {
        if self.kind == EXIT_KIND_SIGNAL { return self.value }
        return none
    }
}

test command_storage_is_owned_validated_and_prepared_exactly {
    var command = Command.new("/usr/bin/example")?
    command.arg("first")?
    command.arg("")?

    let path_address = process_text_address(&command.path)
    let first_address = process_text_address(&command.arguments[0])
    let second_address = process_text_address(&command.arguments[1])
    let prepared = prepare_command(move command)
    let argv: &[usize] = &prepared.argv
    if argv.len() != 4 || argv[0] != path_address || argv[1] != first_address
        || argv[2] != second_address || argv[3] != 0 {
        return error.new("std.process.test", "prepared argv changed command arguments")
    }

    var rejected = Command.new("/usr/bin/example")?
    rejected.arg("kept")?
    rejected.arg("bad\0argument") catch failure {
        if !failure.has_code("std.process.invalid_input") {
            return error.new("std.process.test", "invalid argument returned the wrong error")
        }
    }
    let rejected_prepared = prepare_command(move rejected)
    let rejected_argv: &[usize] = &rejected_prepared.argv
    if rejected_argv.len() != 3 || rejected_argv[2] != 0 {
        return error.new("std.process.test", "invalid argument partially changed command state")
    }

    return
}

test command_rejects_empty_path {
    let _result = Command.new("") catch failure {
        if failure.has_code("std.process.invalid_input") { return }
        return error.new("std.process.test", "empty path returned the wrong error")
    }
    return error.new("std.process.test", "empty command path was accepted")
}

test exit_status_observation_is_unambiguous {
    let success = exited_status(0)
    let success_code = success.code() otherwise {
        return error.new("std.process.test", "successful exit status lost its code")
    }
    if !success.success() || success_code != 0 || optional_i32_is_present(success.signal()) {
        return error.new("std.process.test", "successful exit status was misclassified")
    }

    let failure = exited_status(19)
    let failure_code = failure.code() otherwise {
        return error.new("std.process.test", "failed exit status lost its code")
    }
    if failure.success() || failure_code != 19 || optional_i32_is_present(failure.signal()) {
        return error.new("std.process.test", "failed exit status was misclassified")
    }

    let signal = signaled_status(9)
    let signal_number = signal.signal() otherwise {
        return error.new("std.process.test", "signal status lost its signal")
    }
    if signal.success() || optional_i32_is_present(signal.code()) || signal_number != 9 {
        return error.new("std.process.test", "signal status was misclassified")
    }
    return
}