From f6b59ff6b2353d67f81a360b915fa92e97775616 Mon Sep 17 00:00:00 2001 From: Joseph Cumines Date: Sat, 22 Feb 2025 16:29:43 +1000 Subject: [PATCH] Initial commit --- .github/workflows/release.yaml | 28 ++++ LICENSE | 21 +++ README.md | 155 +++++++++++++++++ go.mod | 3 + hack/test-sigint.sh | 8 + main.go | 296 +++++++++++++++++++++++++++++++++ 6 files changed, 511 insertions(+) create mode 100644 .github/workflows/release.yaml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 go.mod create mode 100755 hack/test-sigint.sh create mode 100644 main.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..efd03fa --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,28 @@ +on: + release: + types: [ created ] +permissions: + contents: write + packages: write +jobs: + releases-matrix: + name: Release Go Binary + runs-on: ubuntu-latest + strategy: + matrix: + goos: [ linux, windows, darwin ] + goarch: [ "386", amd64, arm64 ] + exclude: + - goarch: "386" + goos: darwin + - goarch: arm64 + goos: windows + steps: + - uses: actions/checkout@v4 + - uses: wangyoucao577/go-release-action@481a2c1a0f1be199722e3e9b74d7199acafc30a8 # /~https://github.com/wangyoucao577/go-release-action/releases/tag/v1.53 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + goos: ${{ matrix.goos }} + goarch: ${{ matrix.goarch }} + extra_files: LICENSE README.md + md5sum: false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6785f02 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Joseph Cumines + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..84d98e5 --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# randoo + +randoo is a simple command-line utility written in Go that randomizes the order of command arguments before executing a +given command. It provides flexible options to control which arguments get shuffled, whether by shuffling all arguments +or only a subset delimited by special tokens. + +--- + +## Overview + +By design, randoo: + +- **Shuffles arguments:** It randomizes the order of command arguments. +- **Executes commands:** After shuffling, it calls the specified command with the randomized arguments. +- **Offers selective shuffling:** You can control which arguments are shuffled using start (`-s`) and end (`-e`) + delimiters (consumed). +- **Supports input from stdin:** With the `-l` flag, you can supply additional arguments via standard input (one per + line). + +--- + +## Usage + +```bash +randoo [options] [--] command [args...] +``` + +- **command:** The executable you want to run. +- **args...:** The arguments for the command, which will be randomized according to the options provided. + +--- + +## Command-Line Options + +- **`-s string`** + *Shuffle args after the specified argument (start delimiter).* + The argument provided with this flag marks the beginning of the segment to be shuffled. + **Behavior:** + - The tool searches for this delimiter in the provided arguments. + - If found, the arguments after this token (or up to an end delimiter, if specified) are shuffled. + - The delimiter itself is not passed on to the command. + - **Error:** If the token is not found, randoo exits with an error. + +- **`-e string`** + *Shuffle args before the specified argument (end delimiter).* + This flag designates the end of the segment to be shuffled. + **Behavior:** + - The tool searches for this token. + - If found, the arguments preceding it (or a segment defined between a start and this end delimiter) are randomized. + - Like the start delimiter, this token is not passed on. + - **Error:** If the token is not found, an error is reported. + +- **`-l`** + *Read input from stdin, one argument per line.* + **Behavior:** + - Reads additional arguments from standard input. + - These arguments are appended to any trailing arguments (which are not shuffled unless affected by the delimiters). + - This is useful for dynamically supplying arguments at runtime. + +--- + +## Implementation Details + +### Shuffling Logic + +- **Complete Shuffle:** + If neither `-s` nor `-e` is provided, all arguments are shuffled. + +- **Single Delimiter Mode:** + When only one of the delimiters is specified: + - For **`-s` only:** randoo finds the delimiter, removes it, and shuffles all arguments that follow. + - For **`-e` only:** It shuffles all arguments preceding the found delimiter and then removes the delimiter. + +- **Delimited Range Shuffle:** + If **both** `-s` and `-e` are provided: + - The program searches for the start delimiter (`-s`). + - It then looks for the end delimiter (`-e`) after the start delimiter. + - Only the arguments in between are shuffled. + - After shuffling, both delimiters are removed before execution. + +- **Random Source:** + randoo uses a custom random source based on `crypto/rand` to seed the shuffle mechanism, ensuring that the + randomization is well-seeded and secure. + +### Execution and Signal Handling + +- **Command Execution:** + After processing and shuffling the arguments, randoo executes a subprocess, with the provided command, and processed + arguments. + +- **Signal Forwarding:** + It sets up signal forwarding so that any signals received (like SIGINT) are passed to the spawned process, ensuring + proper signal handling during execution. + +- **Error Handling:** + The tool validates that: + - A command is provided. + - Required delimiters exist in the arguments (if specified). + + In case of errors (e.g., missing command, missing delimiter), it outputs a relevant error message and exits with a + non-zero status. It also attempts to propagate the exit code of the executed command, when possible. + +--- + +## Examples + +### 1. Basic Randomization + +Shuffle all arguments before executing the command: + +```bash +randoo ls -la file1.txt file2.txt file3.txt +``` + +This will shuffle the order of `file1.txt`, `file2.txt`, and `file3.txt` before calling `ls -la`. + +### 2. Using a Single Delimiter + +Shuffle only the arguments after a specified token: + +```bash +randoo -s START echo START arg1 arg2 arg3 +``` + +- **Behavior:** + - `START` is used as the start delimiter. + - The arguments following `START` (`arg1 arg2 arg3`) are shuffled. + - The delimiter is removed before executing the command. + +### 3. Using Both Delimiters + +Shuffle a specific range of arguments: + +```bash +randoo -s START -e END echo START arg1 arg2 arg3 END extraArg +``` + +- **Behavior:** + - `START` marks the beginning and `END` marks the end of the segment to be shuffled. + - Only the arguments between `START` and `END` (`arg1 arg2 arg3`) are randomized. + - Both delimiters are stripped from the final command line. + - `extraArg` remains unshuffled. + +### 4. Reading Arguments from Stdin + +Shuffle arguments provided via standard input: + +```bash +echo -e "arg1\narg2\narg3" | randoo -l mycommand +``` + +- **Behavior:** + - Reads each line from stdin as an argument. + - Shuffles the input arguments. + - Appends them to the command `mycommand` for execution. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b5a6e94 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/joeycumines/randoo + +go 1.23.6 diff --git a/hack/test-sigint.sh b/hack/test-sigint.sh new file mode 100755 index 0000000..6506297 --- /dev/null +++ b/hack/test-sigint.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +trap 'echo "Debug: SIGINT received, exiting in 5 seconds..."; sleep 5; exit 1' INT + +echo "Running... (Press Ctrl+C to trigger SIGINT)" +while :; do + sleep 1 +done diff --git a/main.go b/main.go new file mode 100644 index 0000000..6ae249a --- /dev/null +++ b/main.go @@ -0,0 +1,296 @@ +package main + +import ( + cryptoRand "crypto/rand" + "encoding/binary" + "errors" + "flag" + "fmt" + "io" + "math/rand/v2" + "os" + "os/exec" + "os/signal" + "slices" +) + +const helpText = `randoo - randomize the order of exec args + +USAGE: + randoo [options] [--] command [args...] + +DESCRIPTION: + By default, randoo will call command after shuffling args. + How args are provided and shuffled may be controlled using options. + +OPTIONS: +` + +type CLI struct { + Input io.Reader + Output io.Writer + ErrOut io.Writer + + scanLines bool + shuffleAfter string + shuffleBefore string + + rand *rand.Rand + flagSet *flag.FlagSet + command string + args []string + prep func() error +} + +func main() { + os.Exit((&CLI{ + Input: os.Stdin, + Output: os.Stdout, + ErrOut: os.Stderr, + }).Main(os.Args[1:])) +} + +func (x *CLI) Main(args []string) int { + if err := x.Init(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + + _, _ = fmt.Fprintf(x.ErrOut, "ERROR: %s\n", err) + return 2 + } + + if err := x.Run(); err != nil { + // pass through the exit code + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr != nil { + if status, _ := exitErr.Sys().(interface{ ExitStatus() int }); status != nil { + if v := status.ExitStatus(); v != 0 { + return v + } + } + } + + _, _ = fmt.Fprintf(x.ErrOut, "ERROR: %s\n", err) + return 1 + } + + return 0 +} + +func (x *CLI) Init(args []string) error { + x.flagSet = flag.NewFlagSet(`randoo`, flag.ContinueOnError) + x.flagSet.Usage = x.usage + x.flagSet.SetOutput(x.Output) + x.flagSet.BoolVar(&x.scanLines, `l`, false, `Read input from stdin, one arg per line. Appended after any trailing args, which are _not_ shuffled.`) + x.flagSet.StringVar(&x.shuffleAfter, `s`, ``, `Shuffle args after the specified arg (start delimiter). If not found, an error will occur. Not passed.`) + x.flagSet.StringVar(&x.shuffleBefore, `e`, ``, `Shuffle args before the specified arg (end delimiter). If not found, an error will occur. Not passed.`) + + if err := x.flagSet.Parse(args); err != nil { + return err + } + + // require a command, extract remaining args + x.args = x.flagSet.Args() + if len(x.args) == 0 { + return fmt.Errorf("no command specified") + } + x.command = x.args[0] + x.args = x.args[1:] + + switch { + case x.scanLines: + x.prep = x.prepScanLines + default: + x.prep = x.prepDefault + } + + x.rand = rand.New(&randSource{}) + + return nil +} + +func (x *CLI) Run() error { + if err := x.prep(); err != nil { + return err + } + + sigs := make(chan os.Signal, 512) + defer close(sigs) + signal.Notify(sigs) + defer signal.Stop(sigs) + + cmd := exec.Command(x.command, x.args...) + cmd.Stdin = x.Input + cmd.Stdout = x.Output + cmd.Stderr = x.ErrOut + + if err := cmd.Start(); err != nil { + return err + } + + go func() { + for sig := range sigs { + _ = cmd.Process.Signal(sig) + } + }() + + return cmd.Wait() +} + +func (x *CLI) usage() { + if x.Output != nil { + _, _ = x.Output.Write([]byte(helpText)) + x.flagSet.PrintDefaults() + } +} + +func (x *CLI) shuffle(args []string) ([]string, error) { + args = slices.Clone(args) + + if x.shuffleAfter == `` && x.shuffleBefore == `` { + x.rand.Shuffle(len(args), func(i, j int) { + args[i], args[j] = args[j], args[i] + }) + return args, nil + } + + { + var token string + + after := x.shuffleAfter != `` + { + before := x.shuffleBefore != `` + if !after || !before { + // only handles the one delimiter case + if after { + token = x.shuffleAfter + } else { + token = x.shuffleBefore + } + } + } + + if token != `` { + index := -1 + for i, arg := range args { + if arg == token { + index = i + break + } + } + if index == -1 { + return nil, fmt.Errorf("shuffle delimiter not found: %q", token) + } + + // remove the delimiter + copy(args[index:], args[index+1:]) + args = args[:len(args)-1] + + var shuffle []string + + if after { + shuffle = args[index:] + } else { + shuffle = args[:index] + } + + x.rand.Shuffle(len(shuffle), func(i, j int) { + shuffle[i], shuffle[j] = shuffle[j], shuffle[i] + }) + + return args, nil + } + } + + var ok bool + + for i := 0; i < len(args); i++ { + if args[i] == x.shuffleAfter { + i++ + start := i + + { + var ok bool + for ; i < len(args); i++ { + if args[i] == x.shuffleBefore { + ok = true + break + } + } + if !ok { + return nil, fmt.Errorf("shuffle end delimiter not found: %q", x.shuffleBefore) + } + } + + l := i - start + x.rand.Shuffle(i-start, func(i, j int) { + args[start+i], args[start+j] = args[start+j], args[start+i] + }) + + // remove the delimiters + copy(args[start-1:], args[start:i]) + copy(args[start-1+l:], args[i+1:]) + args = args[:len(args)-2] + i -= 2 + + ok = true + } + } + + if !ok { + return nil, fmt.Errorf("shuffle start delimiter not found: %q", x.shuffleAfter) + } + + return args, nil +} + +func (x *CLI) prepDefault() error { + args, err := x.shuffle(x.args) + if err != nil { + return err + } + x.args = args + return nil +} + +func (x *CLI) prepScanLines() error { + var lines []string + + if x.Input != nil { + for { + var line string + _, err := fmt.Fscanln(x.Input, &line) + + if err != nil && !errors.Is(err, io.EOF) { + return err + } + + if err == nil || line != `` { + lines = append(lines, line) + } + + if err != nil { + break + } + } + } + + lines, err := x.shuffle(lines) + if err != nil { + return err + } + + x.args = append(x.args, lines...) + + return nil +} + +type randSource [8]byte + +func (x *randSource) Uint64() uint64 { + if _, err := cryptoRand.Read(x[:]); err != nil { + panic(err) + } + return binary.LittleEndian.Uint64(x[:]) +}