diff --git a/.claude/skills/review-fix-loop/SKILL.md b/.claude/skills/review-fix-loop/SKILL.md index 96ae9c72..a0bc821a 100644 --- a/.claude/skills/review-fix-loop/SKILL.md +++ b/.claude/skills/review-fix-loop/SKILL.md @@ -143,6 +143,8 @@ Run the **address-pr-comments** skill: ``` This reads all unresolved review comments, evaluates validity, implements fixes, commits, pushes, and replies/resolves threads. +**Commit message prefix:** All commits created in this sub-step MUST be prefixed with the current loop iteration number, e.g. `[iter 3] Fix null check in parser`. + Wait for completion before proceeding to 2C. ### Sub-step 2C — Fix CI failures @@ -153,6 +155,8 @@ Run the **fix-ci-tests** skill: ``` This checks for failing CI jobs, downloads logs, reproduces failures locally, fixes them, and pushes. +**Commit message prefix:** All commits created in this sub-step MUST be prefixed with the current loop iteration number, e.g. `[iter 3] Fix flaky test timeout`. + Wait for completion before proceeding to 2D. --- diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 33cceaa4..2eb5d070 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -15,6 +15,7 @@ Blocked features are rejected before execution with exit code 2. - ✅ `grep [-EFGivclLnHhoqsxw] [-e PATTERN] [-m NUM] [-A NUM] [-B NUM] [-C NUM] PATTERN [FILE]...` — print lines that match patterns; uses RE2 regex engine (linear-time, no backtracking) - ✅ `head [-n N|-c N] [-q|-v] [FILE]...` — output the first part of files (default: first 10 lines); `-z`/`--zero-terminated` and `--follow` are rejected - ✅ `ls [-1aAdFhlpRrSt] [FILE]...` — list directory contents +- ✅ `printf FORMAT [ARGUMENT]...` — format and print data to stdout; supports `%s`, `%b`, `%c`, `%d`, `%i`, `%o`, `%u`, `%x`, `%X`, `%e`, `%E`, `%f`, `%F`, `%g`, `%G`, `%%`; format reuse for excess arguments; `%n` rejected (security risk); `-v` rejected - ✅ `strings [-a] [-n MIN] [-t o|d|x] [-o] [-f] [-s SEP] [FILE]...` — print printable character sequences in files (default min length 4); offsets via `-t`/`-o`; filename prefix via `-f`; custom separator via `-s` - ✅ `tail [-n N|-c N] [-q|-v] [-z] [FILE]...` — output the last part of files (default: last 10 lines); supports `+N` offset mode; `-f`/`--follow` is rejected - ✅ `tr [-cdsCt] SET1 [SET2]` — translate, squeeze, and/or delete characters from stdin diff --git a/interp/builtins/printf/printf.go b/interp/builtins/printf/printf.go new file mode 100644 index 00000000..c7538cee --- /dev/null +++ b/interp/builtins/printf/printf.go @@ -0,0 +1,1185 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Package printf implements the printf builtin command. +// +// printf — format and print data +// +// Usage: printf FORMAT [ARGUMENT]... +// +// Write formatted output to standard output. FORMAT is a string that +// contains literal text and format specifiers (introduced by %). Each +// format specifier consumes the next ARGUMENT and formats it. +// +// If there are more ARGUMENTs than format specifiers, the FORMAT string +// is reused from the beginning until all arguments are consumed (bounded +// to 10,000 iterations to prevent runaway loops). +// +// Missing arguments default to "" for string specifiers and 0 for +// numeric specifiers. +// +// Accepted flags: +// +// --help +// Print a usage message to stderr and exit 2. +// +// Rejected flags: +// +// -v varname +// Bash extension to assign output to a variable. Not supported +// in the restricted shell. +// +// Format specifiers: +// +// %s String. +// %b String with backslash escape interpretation (like echo -e). +// \c in %b stops all further output. +// %c First character of the argument. +// %d, %i Signed decimal integer. +// %o Unsigned octal integer. +// %u Unsigned decimal integer. +// %x, %X Unsigned hexadecimal integer (lower/upper). +// %e, %E Scientific notation float. +// %f, %F Decimal float. +// %g, %G Shortest float representation. +// %% Literal percent sign. +// +// Width and precision modifiers are supported (e.g. %10s, %-10s, %.5f, +// %010d). Flag characters: - (left-align), + (sign), ' ' (space), +// 0 (zero-pad), # (alternate form). +// +// Escape sequences in FORMAT string: +// +// \\ backslash +// \a alert (BEL) +// \b backspace +// \f form feed +// \n newline +// \r carriage return +// \t horizontal tab +// \v vertical tab +// \" double quote +// \NNN octal byte value (1-3 digits) +// \0NNN octal byte value (0 + 1-3 digits) +// \xHH hexadecimal byte value (1-2 digits) +// \uHHHH Unicode code point (1-4 hex digits) +// \UHHHHHHHH Unicode code point (1-8 hex digits) +// +// Numeric argument extensions: +// +// Arguments for numeric specifiers may be: +// - Decimal integers: 42, -7, +3 +// - Octal: 0755 +// - Hexadecimal: 0xff, 0XFF +// - Character constants: "'A" or '"A' gives the ASCII value of A +// +// Not implemented (rejected): +// +// %n Byte count write (security risk). Produces an error. +// %q Shell-quoting (bash extension, not POSIX). +// %a, %A Hexadecimal float (deferred). +// +// Exit codes: +// +// 0 Successful completion (conversion warnings may still be emitted). +// 1 Format error (invalid number, unknown specifier, incomplete specifier). +// 2 Usage error (no format string provided). +// +// Memory safety: +// +// printf does not read files or stdin. All output is generated from +// the format string and arguments. The format reuse loop is bounded +// to maxFormatIterations (10,000) and checks ctx.Err() on each +// iteration to honour the shell's execution timeout. +package printf + +import ( + "context" + "errors" + "fmt" + "math" + "strconv" + "strings" + + "github.com/DataDog/rshell/interp/builtins" +) + +// isRangeErr returns true if err is a strconv range overflow error. +func isRangeErr(err error) bool { + var ne *strconv.NumError + if errors.As(err, &ne) { + return ne.Err == strconv.ErrRange + } + return false +} + +// Cmd is the printf builtin command descriptor. +// printf uses NoFlags because its arguments (format string and data) can look +// like flags (e.g. printf "%d" -42). Manual pre-parsing handles --help and -v. +var Cmd = builtins.Command{Name: "printf", MakeFlags: builtins.NoFlags(run)} + +// maxFormatIterations bounds the format-reuse loop to prevent runaway output. +const maxFormatIterations = 10_000 + +// bashFloat fixes Go's NaN/Inf casing to match bash's lowercase output +// for lowercase format verbs (f, e, g). Go outputs "NaN" and "+Inf"/"-Inf" +// but bash outputs "nan", "inf", "-inf". +// The flags parameter is the parsed format flags string, used to determine +// whether the + sign should be preserved for positive infinity. +func bashFloat(s string, flags string) string { + s = strings.ReplaceAll(s, "NaN", "nan") + if strings.ContainsRune(flags, '+') { + s = strings.ReplaceAll(s, "+Inf", "+inf") + } else if strings.ContainsRune(flags, ' ') { + s = strings.ReplaceAll(s, "+Inf", " inf") + } else { + s = strings.ReplaceAll(s, "+Inf", "inf") + } + s = strings.ReplaceAll(s, "-Inf", "-inf") + s = strings.ReplaceAll(s, "Inf", "inf") + return s +} + +// bashFloatUpper fixes Go's NaN/Inf casing to match bash's uppercase output +// for uppercase format verbs (F, E, G). Go outputs "NaN" and "+Inf"/"-Inf" +// but bash outputs "NAN", "INF", "-INF". +// The flags parameter is the parsed format flags string, used to determine +// whether the + sign should be preserved for positive infinity. +func bashFloatUpper(s string, flags string) string { + s = strings.ReplaceAll(s, "NaN", "NAN") + if strings.ContainsRune(flags, '+') { + s = strings.ReplaceAll(s, "+Inf", "+INF") + } else if strings.ContainsRune(flags, ' ') { + s = strings.ReplaceAll(s, "+Inf", " INF") + } else { + s = strings.ReplaceAll(s, "+Inf", "INF") + } + s = strings.ReplaceAll(s, "-Inf", "-INF") + s = strings.ReplaceAll(s, "Inf", "INF") + return s +} + +// maxWidthOrPrec caps width/precision values to prevent huge allocations. +const maxWidthOrPrec = 10_000 + +// stripSignFlags removes '+' and ' ' from a flag string. +// Bash ignores these flags for unsigned conversions (%o, %u, %x, %X). +func stripSignFlags(flags string) string { + var b strings.Builder + for i := 0; i < len(flags); i++ { + if flags[i] != '+' && flags[i] != ' ' { + b.WriteByte(flags[i]) + } + } + return b.String() +} + +func run(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + // Manual flag handling: only --help, -v, and -- are recognised. + // Any other flag starting with - is rejected (bash compat). + if len(args) > 0 { + switch { + case args[0] == "--help": + callCtx.Errf("printf: usage: printf [-v var] format [arguments]\n") + return builtins.Result{Code: 2} + case args[0] == "-v": + callCtx.Errf("printf: -v: not supported in restricted shell\n") + return builtins.Result{Code: 1} + case args[0] == "--": + args = args[1:] // skip -- + case len(args[0]) > 1 && args[0][0] == '-' && args[0][1] != '-': + // Unknown single-dash flag (e.g. -h, -f, -z). + // Bash rejects these with "invalid option" and exit 2. + callCtx.Errf("printf: %c%c: invalid option\n", args[0][0], args[0][1]) + callCtx.Errf("printf: usage: printf [-v var] format [arguments]\n") + return builtins.Result{Code: 2} + case len(args[0]) > 2 && args[0][0] == '-' && args[0][1] == '-': + // Unknown long flag (e.g. --follow, --foo). + // Bash rejects these with "--: invalid option" and exit 2. + callCtx.Errf("printf: --: invalid option\n") + callCtx.Errf("printf: usage: printf [-v var] format [arguments]\n") + return builtins.Result{Code: 2} + } + } + + if len(args) == 0 { + callCtx.Errf("printf: usage: printf [-v var] format [arguments]\n") + return builtins.Result{Code: 2} + } + + format := args[0] + fmtArgs := args[1:] + + argIdx := 0 + hadError := false + iterations := 0 + + for { + if ctx.Err() != nil { + break + } + if iterations >= maxFormatIterations { + break + } + iterations++ + + startArgIdx := argIdx + stop, err := processFormat(callCtx, format, fmtArgs, &argIdx, &hadError) + if err { + hadError = true + } + if stop { + // \c in %b — stop all output immediately. + break + } + + // If no args were consumed in this pass, or we've consumed all args, stop. + if argIdx <= startArgIdx || argIdx >= len(fmtArgs) { + break + } + // More args remain — reuse the format string. + } + + if hadError { + return builtins.Result{Code: 1} + } + return builtins.Result{} +} + +// processFormat walks the format string once, outputting literal text and +// processing format specifiers. It returns (stop, hadError). +// stop is true if \c was encountered in a %b argument. +func processFormat(callCtx *builtins.CallContext, format string, args []string, argIdx *int, hadError *bool) (bool, bool) { + i := 0 + for i < len(format) { + ch := format[i] + + if ch == '\\' { + // Process escape sequence in format string. + s, advance, errMsg := processFormatEscape(format[i:]) + callCtx.Out(s) + if errMsg != "" { + callCtx.Errf("%s", errMsg) + } + i += advance + continue + } + + if ch == '%' { + if i+1 < len(format) && format[i+1] == '%' { + callCtx.Out("%") + i += 2 + continue + } + stop, advance, err := processSpecifier(callCtx, format[i:], args, argIdx) + if err { + *hadError = true + } + if stop { + return true, *hadError + } + i += advance + continue + } + + // Batch consecutive literal characters into a single write. + start := i + for i < len(format) && format[i] != '\\' && format[i] != '%' { + i++ + } + callCtx.Out(format[start:i]) + } + return false, *hadError +} + +// processFormatEscape handles a backslash escape in the format string (not in %b arguments). +// Returns the replacement string, the number of bytes consumed from s, and an optional +// error message to emit to stderr (empty string if no error). +func processFormatEscape(s string) (string, int, string) { + if len(s) < 2 { + return "\\", 1, "" + } + switch s[1] { + case '\\': + return "\\", 2, "" + case 'a': + return "\a", 2, "" + case 'b': + return "\b", 2, "" + case 'f': + return "\f", 2, "" + case 'n': + return "\n", 2, "" + case 'r': + return "\r", 2, "" + case 't': + return "\t", 2, "" + case 'v': + return "\v", 2, "" + case '"': + return "\"", 2, "" + case '0': + // \0NN — octal (0 counts as first digit, up to 2 more). + // Bash treats the leading 0 as the first of 3 octal digits, + // so \0123 = \012 (newline) + literal '3'. + val, consumed := parseOctal(s[2:], 2) + return string([]byte{byte(val)}), 2 + consumed, "" + case 'x': + // \xHH — hex (up to 2 digits) + val, consumed := parseHex(s[2:], 2) + if consumed == 0 { + return "\\x", 2, "" + } + return string([]byte{byte(val)}), 2 + consumed, "" + case 'u': + // \uHHHH — 4-digit Unicode code point + val, consumed := parseHex(s[2:], 4) + if consumed == 0 { + return "\\u", 2, "printf: missing unicode digit for \\u\n" + } + return string(rune(val)), 2 + consumed, "" + case 'U': + // \UHHHHHHHH — 8-digit Unicode code point + val, consumed := parseHex(s[2:], 8) + if consumed == 0 { + return "\\U", 2, "printf: missing unicode digit for \\U\n" + } + // Clamp to max valid Unicode code point. + if val > 0x10FFFF { + val = 0xFFFD // Unicode replacement character + } + return string(rune(val)), 2 + consumed, "" + + default: + if s[1] >= '1' && s[1] <= '7' { + // \NNN — octal without leading 0 (1-3 digits) + val, consumed := parseOctal(s[1:], 3) + return string([]byte{byte(val)}), 1 + consumed, "" + } + // Unknown escape: output backslash and character. + return string([]byte{'\\', s[1]}), 2, "" + } +} + +// processSpecifier handles a single % format specifier starting at s[0]=='%'. +// Returns (stop, bytesConsumed, hadError). +func processSpecifier(callCtx *builtins.CallContext, s string, args []string, argIdx *int) (bool, int, bool) { + i := 1 // skip '%' + hadError := false + + // Parse flags: -, +, ' ', 0, # + var flags strings.Builder + for i < len(s) { + switch s[i] { + case '-', '+', ' ', '0', '#': + flags.WriteByte(s[i]) + i++ + continue + } + break + } + + // Parse width (digits or *) + var width string + if i < len(s) && s[i] == '*' { + // Width from argument. + w, err := getIntArg(args, argIdx, callCtx) + if err { + hadError = true + } + width = strconv.Itoa(w) + i++ + } else { + start := i + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + width = s[start:i] + } + + // Parse precision + var precision string + hasPrecision := false + if i < len(s) && s[i] == '.' { + hasPrecision = true + i++ // skip '.' + if i < len(s) && s[i] == '*' { + p, err := getIntArg(args, argIdx, callCtx) + if err { + hadError = true + } + if p < 0 { + // Negative precision from * means "no precision specified" in bash. + hasPrecision = false + } else { + precision = strconv.Itoa(p) + } + i++ + } else { + start := i + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + precision = s[start:i] + } + } + + // Clamp width/precision for safety. + if w, err := strconv.Atoi(width); err == nil && (w > maxWidthOrPrec || w < -maxWidthOrPrec) { + if w > 0 { + width = strconv.Itoa(maxWidthOrPrec) + } else { + width = strconv.Itoa(-maxWidthOrPrec) + } + } + if p, err := strconv.Atoi(precision); err == nil && p > maxWidthOrPrec { + precision = strconv.Itoa(maxWidthOrPrec) + } + + if i >= len(s) { + // Incomplete specifier — bash errors on this. + callCtx.Errf("printf: `%s': missing format character\n", s[:i]) + return false, i, true + } + + // Skip C-style length modifiers (l, ll, h, hh, j, t, z, q). + // Bash accepts and effectively ignores them. + for i < len(s) { + switch s[i] { + case 'l', 'h', 'j', 't', 'z', 'q': + i++ + continue + } + break + } + + if i >= len(s) { + // Incomplete specifier after length modifiers. + callCtx.Errf("printf: `%s': missing format character\n", s[:i]) + return false, i, true + } + + verb := s[i] + i++ // consume verb + + // Build Go format string. + // For unsigned verbs (o, u, x, X), strip '+' and ' ' sign flags + // because bash ignores them for unsigned conversions. + flagStr := flags.String() + if verb == 'o' || verb == 'u' || verb == 'x' || verb == 'X' { + flagStr = stripSignFlags(flagStr) + } + var goFmt strings.Builder + goFmt.WriteByte('%') + goFmt.WriteString(flagStr) + goFmt.WriteString(width) + if hasPrecision { + goFmt.WriteByte('.') + goFmt.WriteString(precision) + } + + switch verb { + case 's': + arg := getStringArg(args, argIdx) + goFmt.WriteByte('s') + callCtx.Out(fmt.Sprintf(goFmt.String(), arg)) + + case 'b': + arg := getStringArg(args, argIdx) + processed, stop, warns := processBEscapes(arg) + if warns != "" { + callCtx.Errf("%s", warns) + } + // Apply width/precision formatting to the processed string. + goFmt.WriteByte('s') + callCtx.Out(fmt.Sprintf(goFmt.String(), processed)) + if stop { + return true, i, hadError + } + + case 'c': + arg := getStringArg(args, argIdx) + // %c prints the first byte of the argument as a raw byte. + // We use %s with a single-byte string instead of Go's %c, because + // Go's %c treats the byte as a rune and UTF-8 encodes values >= 0x80. + // Empty arg produces a NUL byte (bash behavior). + // Bash ignores precision for %c — always emits exactly one byte. + var charStr string + if len(arg) > 0 { + charStr = string([]byte{arg[0]}) + } else { + charStr = "\x00" + } + // Build a format without precision — bash ignores precision for %c. + var cFmt strings.Builder + cFmt.WriteByte('%') + cFmt.WriteString(flagStr) + cFmt.WriteString(width) + cFmt.WriteByte('s') + callCtx.Out(fmt.Sprintf(cFmt.String(), charStr)) + + case 'd', 'i': + arg := getStringArg(args, argIdx) + val, err := parseIntArg(arg) + if err != nil && arg != "" { + if isRangeErr(err) { + // Bash treats overflow as a warning, not an error: exit code stays 0. + callCtx.Errf("printf: warning: %s: Numerical result out of range\n", arg) + } else { + callCtx.Errf("printf: '%s': invalid number\n", arg) + } + // Bash uses the clamped/prefix value and sets exit code only for non-overflow. + goFmt.WriteByte('d') + callCtx.Out(fmt.Sprintf(goFmt.String(), val)) + return false, i, !isRangeErr(err) + } + goFmt.WriteByte('d') + callCtx.Out(fmt.Sprintf(goFmt.String(), val)) + + case 'o': + arg := getStringArg(args, argIdx) + val, err := parseUintArg(arg) + if err != nil && arg != "" { + if isRangeErr(err) { + callCtx.Errf("printf: warning: %s: Numerical result out of range\n", arg) + } else { + callCtx.Errf("printf: '%s': invalid number\n", arg) + } + goFmt.WriteByte('o') + callCtx.Out(fmt.Sprintf(goFmt.String(), val)) + return false, i, !isRangeErr(err) + } + goFmt.WriteByte('o') + callCtx.Out(fmt.Sprintf(goFmt.String(), val)) + + case 'u': + arg := getStringArg(args, argIdx) + val, err := parseUintArg(arg) + if err != nil && arg != "" { + if isRangeErr(err) { + callCtx.Errf("printf: warning: %s: Numerical result out of range\n", arg) + } else { + callCtx.Errf("printf: '%s': invalid number\n", arg) + } + goFmt.WriteByte('d') + callCtx.Out(fmt.Sprintf(goFmt.String(), val)) + return false, i, !isRangeErr(err) + } + goFmt.WriteByte('d') + callCtx.Out(fmt.Sprintf(goFmt.String(), val)) + + case 'x': + arg := getStringArg(args, argIdx) + val, err := parseUintArg(arg) + if err != nil && arg != "" { + if isRangeErr(err) { + callCtx.Errf("printf: warning: %s: Numerical result out of range\n", arg) + } else { + callCtx.Errf("printf: '%s': invalid number\n", arg) + } + goFmt.WriteByte('x') + callCtx.Out(fmt.Sprintf(goFmt.String(), val)) + return false, i, !isRangeErr(err) + } + goFmt.WriteByte('x') + callCtx.Out(fmt.Sprintf(goFmt.String(), val)) + + case 'X': + arg := getStringArg(args, argIdx) + val, err := parseUintArg(arg) + if err != nil && arg != "" { + if isRangeErr(err) { + callCtx.Errf("printf: warning: %s: Numerical result out of range\n", arg) + } else { + callCtx.Errf("printf: '%s': invalid number\n", arg) + } + goFmt.WriteByte('X') + callCtx.Out(fmt.Sprintf(goFmt.String(), val)) + return false, i, !isRangeErr(err) + } + goFmt.WriteByte('X') + callCtx.Out(fmt.Sprintf(goFmt.String(), val)) + + case 'e': + arg := getStringArg(args, argIdx) + fa, err := parseFloatArg(arg) + if err != nil && arg != "" { + if isRangeErr(err) { + callCtx.Errf("printf: warning: %s: Numerical result out of range\n", arg) + goFmt.WriteByte('e') + callCtx.Out(bashFloat(fmt.Sprintf(goFmt.String(), fa.f), flagStr)) + return false, i, false + } + callCtx.Errf("printf: '%s': invalid number\n", arg) + goFmt.WriteByte('e') + callCtx.Out(bashFloat(fmt.Sprintf(goFmt.String(), fa.f), flagStr)) + return false, i, true + } + goFmt.WriteByte('e') + callCtx.Out(bashFloat(fmt.Sprintf(goFmt.String(), fa.f), flagStr)) + + case 'E': + arg := getStringArg(args, argIdx) + fa, err := parseFloatArg(arg) + if err != nil && arg != "" { + if isRangeErr(err) { + callCtx.Errf("printf: warning: %s: Numerical result out of range\n", arg) + goFmt.WriteByte('E') + callCtx.Out(bashFloatUpper(fmt.Sprintf(goFmt.String(), fa.f), flagStr)) + return false, i, false + } + callCtx.Errf("printf: '%s': invalid number\n", arg) + goFmt.WriteByte('E') + callCtx.Out(bashFloatUpper(fmt.Sprintf(goFmt.String(), fa.f), flagStr)) + return false, i, true + } + goFmt.WriteByte('E') + callCtx.Out(bashFloatUpper(fmt.Sprintf(goFmt.String(), fa.f), flagStr)) + + case 'f': + arg := getStringArg(args, argIdx) + fa, err := parseFloatArg(arg) + if err != nil && arg != "" { + if isRangeErr(err) { + callCtx.Errf("printf: warning: %s: Numerical result out of range\n", arg) + goFmt.WriteByte('f') + callCtx.Out(bashFloat(fmt.Sprintf(goFmt.String(), fa.f), flagStr)) + return false, i, false + } + callCtx.Errf("printf: '%s': invalid number\n", arg) + goFmt.WriteByte('f') + callCtx.Out(bashFloat(fmt.Sprintf(goFmt.String(), fa.f), flagStr)) + return false, i, true + } + goFmt.WriteByte('f') + callCtx.Out(bashFloat(fmt.Sprintf(goFmt.String(), fa.f), flagStr)) + + case 'F': + arg := getStringArg(args, argIdx) + fa, err := parseFloatArg(arg) + if err != nil && arg != "" { + if isRangeErr(err) { + callCtx.Errf("printf: warning: %s: Numerical result out of range\n", arg) + goFmt.WriteByte('f') + callCtx.Out(bashFloatUpper(fmt.Sprintf(goFmt.String(), fa.f), flagStr)) + return false, i, false + } + callCtx.Errf("printf: '%s': invalid number\n", arg) + } + // Go doesn't have %F; use %f and fix Inf/NaN casing to match bash. + goFmt.WriteByte('f') + out := bashFloatUpper(fmt.Sprintf(goFmt.String(), fa.f), flagStr) + callCtx.Out(out) + if err != nil && arg != "" { + return false, i, true + } + + case 'g': + arg := getStringArg(args, argIdx) + fa, err := parseFloatArg(arg) + if err != nil && arg != "" { + if isRangeErr(err) { + callCtx.Errf("printf: warning: %s: Numerical result out of range\n", arg) + goFmt.WriteByte('g') + callCtx.Out(bashFloat(fmt.Sprintf(goFmt.String(), fa.f), flagStr)) + return false, i, false + } + callCtx.Errf("printf: '%s': invalid number\n", arg) + goFmt.WriteByte('g') + callCtx.Out(bashFloat(fmt.Sprintf(goFmt.String(), fa.f), flagStr)) + return false, i, true + } + goFmt.WriteByte('g') + callCtx.Out(bashFloat(fmt.Sprintf(goFmt.String(), fa.f), flagStr)) + + case 'G': + arg := getStringArg(args, argIdx) + fa, err := parseFloatArg(arg) + if err != nil && arg != "" { + if isRangeErr(err) { + callCtx.Errf("printf: warning: %s: Numerical result out of range\n", arg) + goFmt.WriteByte('G') + callCtx.Out(bashFloatUpper(fmt.Sprintf(goFmt.String(), fa.f), flagStr)) + return false, i, false + } + callCtx.Errf("printf: '%s': invalid number\n", arg) + goFmt.WriteByte('G') + callCtx.Out(bashFloatUpper(fmt.Sprintf(goFmt.String(), fa.f), flagStr)) + return false, i, true + } + goFmt.WriteByte('G') + callCtx.Out(bashFloatUpper(fmt.Sprintf(goFmt.String(), fa.f), flagStr)) + + case 'n': + callCtx.Errf("printf: %%n: not supported (security risk)\n") + _ = getStringArg(args, argIdx) // consume arg + return false, i, true + + case 'q': + callCtx.Errf("printf: %%q: not supported\n") + _ = getStringArg(args, argIdx) + return false, i, true + + case 'a', 'A': + callCtx.Errf("printf: %%%c: not supported\n", verb) + _ = getStringArg(args, argIdx) + return false, i, true + + default: + // Unknown specifier — bash treats this as an error and stops processing + // the rest of the format string. + callCtx.Errf("printf: %%%c: invalid format character\n", verb) + return true, i, true + } + + return false, i, hadError +} + +// getStringArg returns the next argument, or "" if exhausted. +func getStringArg(args []string, idx *int) string { + if *idx >= len(args) { + return "" + } + s := args[*idx] + *idx++ + return s +} + +// getIntArg returns the next argument parsed as an int (for * width/precision), or 0. +// Like bash, it accepts decimal, octal (0-prefix), hex (0x-prefix), and +// character constants ('X or "X). +// The second return value is true if parsing failed. +func getIntArg(args []string, idx *int, callCtx *builtins.CallContext) (int, bool) { + s := getStringArg(args, idx) + if s == "" { + return 0, false + } + // Character constant: 'X or "X — bare quote with no following char yields 0. + if s[0] == '\'' || s[0] == '"' { + if len(s) >= 2 { + return int(s[1]), false + } + return 0, false + } + v, err := strconv.ParseInt(s, 0, strconv.IntSize) + if err != nil { + // Bash extracts the leading numeric prefix (e.g. "3.14" → 3, "10abc" → 10). + if prefix := extractIntPrefix(s); prefix != "" { + pv, perr := strconv.ParseInt(prefix, 0, strconv.IntSize) + if perr == nil { + callCtx.Errf("printf: '%s': invalid number\n", s) + return int(pv), true + } + } + callCtx.Errf("printf: '%s': invalid number\n", s) + return 0, true + } + return int(v), false +} + +// parseIntArg parses a string as a signed integer, supporting decimal, octal (0-prefix), +// hex (0x-prefix), and character constants ('X or "X). +func parseIntArg(s string) (int64, error) { + if s == "" { + return 0, nil + } + + // Character constant: 'X or "X — bare quote with no following char yields 0. + if s[0] == '\'' || s[0] == '"' { + if len(s) >= 2 { + return int64(s[1]), nil + } + return 0, nil + } + + // Try parsing with automatic base detection. + val, err := strconv.ParseInt(s, 0, 64) + if err != nil { + // For range overflow, strconv.ParseInt returns the clamped value + // (MaxInt64 or MinInt64). Return it so the caller can emit it. + if isRangeErr(err) { + return val, err + } + // Bash extracts the leading numeric prefix (e.g. "3.14" → 3, "123abc" → 123). + if prefix := extractIntPrefix(s); prefix != "" { + pv, perr := strconv.ParseInt(prefix, 0, 64) + if perr == nil { + return pv, err // return value from prefix but still report original error + } + } + return 0, err + } + return val, nil +} + +// parseUintArg parses a string as an unsigned integer. +func parseUintArg(s string) (uint64, error) { + if s == "" { + return 0, nil + } + + // Character constant: 'X or "X — bare quote with no following char yields 0. + if s[0] == '\'' || s[0] == '"' { + if len(s) >= 2 { + return uint64(s[1]), nil + } + return 0, nil + } + + // Handle negative numbers: parse as signed, then interpret as unsigned. + if len(s) > 0 && s[0] == '-' { + val, err := strconv.ParseInt(s, 0, 64) + if err != nil { + if isRangeErr(err) { + // For unsigned, any out-of-range negative clamps to MaxUint64 (bash compat). + return math.MaxUint64, err + } + // Bash extracts the leading numeric prefix for unsigned too. + if prefix := extractIntPrefix(s); prefix != "" { + pv, perr := strconv.ParseInt(prefix, 0, 64) + if perr == nil { + return uint64(pv), err + } + } + return 0, err + } + // Bash wraps negatives as unsigned. + return uint64(val), nil + } + + val, err := strconv.ParseUint(s, 0, 64) + if err != nil { + if isRangeErr(err) { + return val, err + } + // Try signed parse for large hex values that may be negative in two's complement. + sval, serr := strconv.ParseInt(s, 0, 64) + if serr == nil { + return uint64(sval), nil + } + // Bash extracts the leading numeric prefix for unsigned too. + if prefix := extractIntPrefix(s); prefix != "" { + pv, perr := strconv.ParseUint(prefix, 0, 64) + if perr == nil { + return pv, err + } + } + return 0, err + } + return val, nil +} + +// extractIntPrefix returns the longest leading substring of s that is a valid +// integer literal (optional sign, then decimal digits, or 0x hex, or 0-octal). +// Bash uses this prefix when the full string is not a valid integer +// (e.g. "3.14" → "3", "123abc" → "123", "0x1G" → "0x1"). +// Returns "" if no valid numeric prefix can be extracted. +// extractFloatPrefix extracts the longest leading valid float literal from s. +// Returns "" if s is already a valid float or has no numeric prefix. +func extractFloatPrefix(s string) string { + if len(s) == 0 { + return "" + } + i := 0 + // Optional sign. + if s[i] == '+' || s[i] == '-' { + i++ + } + if i >= len(s) || (s[i] < '0' && s[i] != '.') || s[i] > '9' { + return "" + } + // Integer part. + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + // Decimal part. + if i < len(s) && s[i] == '.' { + i++ + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + } + // Exponent part. + if i < len(s) && (s[i] == 'e' || s[i] == 'E') { + j := i + 1 + if j < len(s) && (s[j] == '+' || s[j] == '-') { + j++ + } + if j < len(s) && s[j] >= '0' && s[j] <= '9' { + i = j + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + } + } + if i == len(s) { + return "" // full string is already valid + } + if i == 0 || (i == 1 && (s[0] == '+' || s[0] == '-')) { + return "" + } + return s[:i] +} + +func extractIntPrefix(s string) string { + if len(s) == 0 { + return "" + } + i := 0 + // Optional sign. + if s[i] == '+' || s[i] == '-' { + i++ + } + if i >= len(s) || s[i] < '0' || s[i] > '9' { + return "" + } + // Hex prefix. + if s[i] == '0' && i+1 < len(s) && (s[i+1] == 'x' || s[i+1] == 'X') { + i += 2 + start := i + for i < len(s) && isHexDigit(s[i]) { + i++ + } + if i == start { + return "" // "0x" with no hex digits is not valid + } + if i == len(s) { + return "" // full string is already valid — no prefix extraction needed + } + return s[:i] + } + // Decimal/octal digits. + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + if i == len(s) { + return "" // full string is already all digits — no prefix extraction needed + } + if i == 0 || (i == 1 && (s[0] == '+' || s[0] == '-')) { + return "" // sign-only or empty + } + return s[:i] +} + +// isHexDigit returns true if ch is a valid hex digit. +func isHexDigit(ch byte) bool { + return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') +} + +// floatArg holds the result of parsing a float argument. +type floatArg struct { + f float64 +} + +// parseFloatArg parses a string as a float64, supporting hex/octal integer prefixes +// and character constants. Uses float64 for all formatting (matching bash behavior). +func parseFloatArg(s string) (floatArg, error) { + if s == "" { + return floatArg{}, nil + } + + // Character constant: 'X or "X — bare quote with no following char yields 0. + if s[0] == '\'' || s[0] == '"' { + if len(s) >= 2 { + return floatArg{f: float64(s[1])}, nil + } + return floatArg{}, nil + } + + // Handle hex integers used as float args (0xff, -0xff, etc). + // Bash accepts hex for %f/%e/%g and converts to float. + // NOTE: Bash treats leading-zero args as DECIMAL for float verbs, + // so 0755 → 755.0, NOT octal 493.0. Only 0x/0X triggers integer parsing. + prefix := s + isNeg := false + if len(prefix) > 0 && (prefix[0] == '-' || prefix[0] == '+') { + isNeg = prefix[0] == '-' + prefix = prefix[1:] + } + if len(prefix) > 1 && prefix[0] == '0' && (prefix[1] == 'x' || prefix[1] == 'X') { + if isNeg { + val, err := strconv.ParseInt(s, 0, 64) + if err != nil { + return floatArg{}, err + } + return floatArg{f: float64(val)}, nil + } + // Try unsigned first to handle values > math.MaxInt64 (e.g. 0xffffffffffffffff). + uval, err := strconv.ParseUint(prefix, 0, 64) + if err != nil { + val, serr := strconv.ParseInt(s, 0, 64) + if serr != nil { + return floatArg{}, err + } + return floatArg{f: float64(val)}, nil + } + return floatArg{f: float64(uval)}, nil + } + + // Handle infinity and NaN (including signed forms like +nan, -nan). + lower := strings.ToLower(s) + if lower == "inf" || lower == "infinity" || lower == "+inf" || lower == "+infinity" { + return floatArg{f: math.Inf(1)}, nil + } + if lower == "-inf" || lower == "-infinity" { + return floatArg{f: math.Inf(-1)}, nil + } + if lower == "nan" || lower == "+nan" || lower == "-nan" { + return floatArg{f: math.NaN()}, nil + } + + val, err := strconv.ParseFloat(s, 64) + if err != nil { + // For range overflow, ParseFloat returns +Inf/-Inf with ErrRange. + // Return the value so the caller can output it (matching bash). + if isRangeErr(err) { + return floatArg{f: val}, err + } + // Bash extracts the leading numeric prefix for float args too (e.g. "1abc" → 1.0). + if pfx := extractFloatPrefix(s); pfx != "" { + pv, perr := strconv.ParseFloat(pfx, 64) + if perr == nil { + return floatArg{f: pv}, err + } + } + return floatArg{f: val}, err + } + return floatArg{f: val}, nil +} + + +// processBEscapes handles backslash escapes for %b (like echo -e). +// Returns the processed string, whether \c was seen (stop all output), +// and any warning messages to emit to stderr. +func processBEscapes(s string) (string, bool, string) { + var b strings.Builder + var warns strings.Builder + b.Grow(len(s)) + i := 0 + for i < len(s) { + if s[i] != '\\' || i+1 >= len(s) { + b.WriteByte(s[i]) + i++ + continue + } + i++ // skip '\' + switch s[i] { + case '\\': + b.WriteByte('\\') + case 'a': + b.WriteByte('\a') + case 'b': + b.WriteByte('\b') + case 'c': + return b.String(), true, warns.String() + case 'f': + b.WriteByte('\f') + case 'n': + b.WriteByte('\n') + case 'r': + b.WriteByte('\r') + case 't': + b.WriteByte('\t') + case 'v': + b.WriteByte('\v') + case '0': + // Octal: \0nnn (up to 3 digits after '0') + i++ + val, consumed := parseOctal(s[i:], 3) + i += consumed + b.WriteByte(byte(val)) + continue + case 'x': + // Hex: \xHH (up to 2 digits) + i++ + val, consumed := parseHex(s[i:], 2) + if consumed == 0 { + b.WriteByte('\\') + b.WriteByte('x') + continue + } + i += consumed + b.WriteByte(byte(val)) + continue + case 'u': + // Unicode: \uHHHH (up to 4 hex digits) + i++ + val, consumed := parseHex(s[i:], 4) + if consumed == 0 { + b.WriteByte('\\') + b.WriteByte('u') + warns.WriteString("printf: missing unicode digit for \\u\n") + continue + } + i += consumed + b.WriteString(string(rune(val))) + continue + case 'U': + // Unicode: \UHHHHHHHH (up to 8 hex digits) + i++ + val, consumed := parseHex(s[i:], 8) + if consumed == 0 { + b.WriteByte('\\') + b.WriteByte('U') + warns.WriteString("printf: missing unicode digit for \\U\n") + continue + } + i += consumed + if val > 0x10FFFF { + val = 0xFFFD // Unicode replacement character + } + b.WriteString(string(rune(val))) + continue + default: + if s[i] >= '1' && s[i] <= '7' { + // \NNN — octal without leading 0 (1-3 digits). + // Bash %b supports both \0NNN and \NNN. + val, consumed := parseOctal(s[i:], 3) + i += consumed + b.WriteByte(byte(val)) + continue + } + // Unrecognized: output backslash and character. + b.WriteByte('\\') + b.WriteByte(s[i]) + } + i++ + } + return b.String(), false, warns.String() +} + +// parseOctal reads up to maxDigits octal digits from s and returns the +// accumulated value and the number of bytes consumed. +func parseOctal(s string, maxDigits int) (int, int) { + val := 0 + n := 0 + for n < maxDigits && n < len(s) && s[n] >= '0' && s[n] <= '7' { + val = val*8 + int(s[n]-'0') + n++ + } + return val, n +} + +// parseHex reads up to maxDigits hexadecimal digits from s and returns +// the accumulated value and the number of bytes consumed. +func parseHex(s string, maxDigits int) (int, int) { + val := 0 + n := 0 + for n < maxDigits && n < len(s) { + ch := s[n] + switch { + case ch >= '0' && ch <= '9': + val = val*16 + int(ch-'0') + case ch >= 'a' && ch <= 'f': + val = val*16 + int(ch-'a') + 10 + case ch >= 'A' && ch <= 'F': + val = val*16 + int(ch-'A') + 10 + default: + return val, n + } + n++ + } + return val, n +} diff --git a/interp/builtins/printf/printf_gnu_compat_test.go b/interp/builtins/printf/printf_gnu_compat_test.go new file mode 100644 index 00000000..a7de406d --- /dev/null +++ b/interp/builtins/printf/printf_gnu_compat_test.go @@ -0,0 +1,269 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package printf_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// GNU compatibility tests for printf. +// +// These tests verify byte-for-byte output equivalence with GNU coreutils +// printf (captured from bash on Debian bookworm). Each test documents the +// exact GNU invocation used to produce the reference output. + +// TestGNUCompatSimpleString — basic string output. +// +// GNU command: printf "%s\n" hello +// Expected: "hello\n" +func TestGNUCompatSimpleString(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%s\n" hello`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello\n", stdout) +} + +// TestGNUCompatFormatReuse — format reuse for excess arguments. +// +// GNU command: printf "%s\n" a b c +// Expected: "a\nb\nc\n" +func TestGNUCompatFormatReuse(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%s\n" a b c`) + assert.Equal(t, 0, code) + assert.Equal(t, "a\nb\nc\n", stdout) +} + +// TestGNUCompatMissingArgs — missing args default to "" and 0. +// +// GNU command: printf "%s:%d\n" hello +// Expected: "hello:0\n" +func TestGNUCompatMissingArgs(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%s:%d\n" hello`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello:0\n", stdout) +} + +// TestGNUCompatPercentLiteral — %% produces a single %. +// +// GNU command: printf "100%%\n" +// Expected: "100%\n" +func TestGNUCompatPercentLiteral(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "100%%\n"`) + assert.Equal(t, 0, code) + assert.Equal(t, "100%\n", stdout) +} + +// TestGNUCompatZeroPad — zero-padded integer. +// +// GNU command: printf "%05d\n" 42 +// Expected: "00042\n" +func TestGNUCompatZeroPad(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%05d\n" 42`) + assert.Equal(t, 0, code) + assert.Equal(t, "00042\n", stdout) +} + +// TestGNUCompatWidthString — right-aligned string with width. +// +// GNU command: printf "%10s\n" hi +// Expected: " hi\n" +func TestGNUCompatWidthString(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%10s\n" hi`) + assert.Equal(t, 0, code) + assert.Equal(t, " hi\n", stdout) +} + +// TestGNUCompatLeftAlign — left-aligned string. +// +// GNU command: printf "%-10s|\n" hi +// Expected: "hi |\n" +func TestGNUCompatLeftAlign(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%-10s|\n" hi`) + assert.Equal(t, 0, code) + assert.Equal(t, "hi |\n", stdout) +} + +// TestGNUCompatPrecisionFloat — float with precision. +// +// GNU command: printf "%.2f\n" 3.14159 +// Expected: "3.14\n" +func TestGNUCompatPrecisionFloat(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%.2f\n" 3.14159`) + assert.Equal(t, 0, code) + assert.Equal(t, "3.14\n", stdout) +} + +// TestGNUCompatPrecisionString — string truncation with precision. +// +// GNU command: printf "%.3s\n" hello +// Expected: "hel\n" +func TestGNUCompatPrecisionString(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%.3s\n" hello`) + assert.Equal(t, 0, code) + assert.Equal(t, "hel\n", stdout) +} + +// TestGNUCompatOctalOutput — %o format. +// +// GNU command: printf "%o\n" 255 +// Expected: "377\n" +func TestGNUCompatOctalOutput(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%o\n" 255`) + assert.Equal(t, 0, code) + assert.Equal(t, "377\n", stdout) +} + +// TestGNUCompatHexOutput — %x and %X format. +// +// GNU command: printf "%x %X\n" 255 255 +// Expected: "ff FF\n" +func TestGNUCompatHexOutput(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%x %X\n" 255 255`) + assert.Equal(t, 0, code) + assert.Equal(t, "ff FF\n", stdout) +} + +// TestGNUCompatScientific — %e format. +// +// GNU command: printf "%e\n" 3.14 +// Expected: "3.140000e+00\n" +func TestGNUCompatScientific(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%e\n" 3.14`) + assert.Equal(t, 0, code) + assert.Equal(t, "3.140000e+00\n", stdout) +} + +// TestGNUCompatShortestFloat — %g format. +// +// GNU command: printf "%g\n" 3.14 +// Expected: "3.14\n" +func TestGNUCompatShortestFloat(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%g\n" 3.14`) + assert.Equal(t, 0, code) + assert.Equal(t, "3.14\n", stdout) +} + +// TestGNUCompatCharConstant — character constant argument. +// +// GNU command: printf "%d\n" "'A" +// Expected: "65\n" +func TestGNUCompatCharConstant(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%d\n" "'A"`) + assert.Equal(t, 0, code) + assert.Equal(t, "65\n", stdout) +} + +// TestGNUCompatHexInput — hex input parsing. +// +// GNU command: printf "%d\n" 0xff +// Expected: "255\n" +func TestGNUCompatHexInput(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%d\n" 0xff`) + assert.Equal(t, 0, code) + assert.Equal(t, "255\n", stdout) +} + +// TestGNUCompatOctalInput — octal input parsing. +// +// GNU command: printf "%d\n" 0755 +// Expected: "493\n" +func TestGNUCompatOctalInput(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%d\n" 0755`) + assert.Equal(t, 0, code) + assert.Equal(t, "493\n", stdout) +} + +// TestGNUCompatHashFlag — %#x adds 0x prefix. +// +// GNU command: printf "%#x\n" 255 +// Expected: "0xff\n" +func TestGNUCompatHashFlag(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%#x\n" 255`) + assert.Equal(t, 0, code) + assert.Equal(t, "0xff\n", stdout) +} + +// TestGNUCompatPlusFlag — %+d adds sign. +// +// GNU command: printf "%+d\n" 42 +// Expected: "+42\n" +func TestGNUCompatPlusFlag(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%+d\n" 42`) + assert.Equal(t, 0, code) + assert.Equal(t, "+42\n", stdout) +} + +// TestGNUCompatInvalidNumber — non-numeric arg for %d. +// +// GNU command: printf "%d\n" abc +// Expected stdout: "0\n", stderr: "printf: 'abc': invalid number", exit code: 1 +func TestGNUCompatInvalidNumber(t *testing.T) { + stdout, stderr, code := cmdRun(t, `printf "%d\n" abc`) + assert.Equal(t, 1, code) + assert.Equal(t, "0\n", stdout) + assert.Contains(t, stderr, "printf:") +} + +// TestGNUCompatBSpecifierBackslashC — %b with \c stops output. +// +// GNU command: printf "%b" 'hello\cworld' +// Expected: "hello" +func TestGNUCompatBSpecifierBackslashC(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%b" 'hello\cworld'`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello", stdout) +} + +// TestGNUCompatEmptyFormat — empty format string. +// +// GNU command: printf "" +// Expected: "" +func TestGNUCompatEmptyFormat(t *testing.T) { + stdout, _, code := cmdRun(t, `printf ""`) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +// TestGNUCompatCharFirstOnly — %c takes only the first character. +// +// GNU command: printf "%c\n" hello +// Expected: "h\n" +func TestGNUCompatCharFirstOnly(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%c\n" hello`) + assert.Equal(t, 0, code) + assert.Equal(t, "h\n", stdout) +} + +// TestGNUCompatUnsigned — %u format. +// +// GNU command: printf "%u\n" 42 +// Expected: "42\n" +func TestGNUCompatUnsigned(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%u\n" 42`) + assert.Equal(t, 0, code) + assert.Equal(t, "42\n", stdout) +} + +// TestGNUCompatDefaultFloat — %f default precision is 6. +// +// GNU command: printf "%f\n" 3.14 +// Expected: "3.140000\n" +func TestGNUCompatDefaultFloat(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%f\n" 3.14`) + assert.Equal(t, 0, code) + assert.Equal(t, "3.140000\n", stdout) +} + +// TestGNUCompatOctalEscapeInFormat — \NNN in format string. +// +// GNU command: printf "\101\n" +// Expected: "A\n" +func TestGNUCompatOctalEscapeInFormat(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "\101\n"`) + assert.Equal(t, 0, code) + assert.Equal(t, "A\n", stdout) +} diff --git a/interp/builtins/printf/printf_pentest_test.go b/interp/builtins/printf/printf_pentest_test.go new file mode 100644 index 00000000..bca7f7e0 --- /dev/null +++ b/interp/builtins/printf/printf_pentest_test.go @@ -0,0 +1,325 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package printf_test + +import ( + "context" + "math" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// --- Integer edge cases --- + +func TestPentestIntZero(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%d\n" 0`) + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) +} + +func TestPentestIntOne(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%d\n" 1`) + assert.Equal(t, 0, code) + assert.Equal(t, "1\n", stdout) +} + +func TestPentestIntMaxInt32(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%d\n" 2147483647`) + assert.Equal(t, 0, code) + assert.Equal(t, "2147483647\n", stdout) +} + +func TestPentestIntMaxInt64(t *testing.T) { + max := strconv.FormatInt(math.MaxInt64, 10) + stdout, _, code := cmdRun(t, `printf "%d\n" `+max) + assert.Equal(t, 0, code) + assert.Equal(t, max+"\n", stdout) +} + +func TestPentestIntMaxInt64PlusOne(t *testing.T) { + // MaxInt64 + 1 = 9223372036854775808 — overflow clamps to MaxInt64, exit 0 (bash compat) + stdout, stderr, code := cmdRun(t, `printf "%d\n" 9223372036854775808`) + assert.Equal(t, 0, code) + assert.Equal(t, "9223372036854775807\n", stdout) + assert.Contains(t, stderr, "Numerical result out of range") +} + +func TestPentestIntHugeNumber(t *testing.T) { + stdout, stderr, code := cmdRun(t, `printf "%d\n" 99999999999999999999`) + assert.Equal(t, 0, code) + assert.Equal(t, "9223372036854775807\n", stdout) + assert.Contains(t, stderr, "Numerical result out of range") +} + +func TestPentestIntNegativeOne(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%d\n" -1`) + assert.Equal(t, 0, code) + assert.Equal(t, "-1\n", stdout) +} + +func TestPentestIntNegativeHuge(t *testing.T) { + stdout, stderr, code := cmdRun(t, `printf "%d\n" -9999999999999999999`) + assert.Equal(t, 0, code) + assert.Equal(t, "-9223372036854775808\n", stdout) + assert.Contains(t, stderr, "Numerical result out of range") +} + +func TestPentestIntPlusZero(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%d\n" +0`) + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) +} + +func TestPentestIntPlusOne(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%d\n" +1`) + assert.Equal(t, 0, code) + assert.Equal(t, "1\n", stdout) +} + +func TestPentestIntEmpty(t *testing.T) { + // Empty string for %d → default 0 + stdout, _, code := cmdRun(t, `printf "%d\n" ""`) + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) +} + +func TestPentestIntWhitespace(t *testing.T) { + // Whitespace-only string for %d → invalid + stdout, stderr, code := cmdRun(t, `printf "%d\n" " "`) + assert.Equal(t, 1, code) + assert.Equal(t, "0\n", stdout) + assert.Contains(t, stderr, "printf:") +} + +// --- Same for bytes (%u, %o, %x) --- + +func TestPentestUnsignedMaxInt64(t *testing.T) { + max := strconv.FormatInt(math.MaxInt64, 10) + stdout, _, code := cmdRun(t, `printf "%u\n" `+max) + assert.Equal(t, 0, code) + assert.Equal(t, max+"\n", stdout) +} + +func TestPentestHexMaxInt32(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%x\n" 2147483647`) + assert.Equal(t, 0, code) + assert.Equal(t, "7fffffff\n", stdout) +} + +// --- Flag and argument injection --- + +func TestPentestUnknownFlags(t *testing.T) { + // Unknown single-dash flag is rejected with exit 2 (bash compat) + _, stderr, code := cmdRun(t, `printf -f "%s" hello`) + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "invalid option") +} + +func TestPentestFollowFlag(t *testing.T) { + // Unknown long flag is rejected with exit 2 (bash compat) + _, stderr, code := cmdRun(t, `printf --follow "%s" hello`) + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "invalid option") +} + +func TestPentestEndOfFlagsWithFlagLikeFilename(t *testing.T) { + // After --, "-v" is treated as the format string (no specifiers), + // and "hello" is an unused extra argument. Output is just "-v". + stdout, _, code := cmdRun(t, `printf -- "-v" hello`) + assert.Equal(t, 0, code) + assert.Equal(t, "-v", stdout) +} + +func TestPentestEndOfFlagsWithPercentS(t *testing.T) { + // After --, "%s" is the format string and "hello" is the argument. + stdout, _, code := cmdRun(t, `printf -- "%s" hello`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello", stdout) +} + +func TestPentestMultipleStdinDash(t *testing.T) { + // printf doesn't read stdin, so "-" is just a string + stdout, _, code := cmdRun(t, `printf "%s %s\n" - -`) + assert.Equal(t, 0, code) + assert.Equal(t, "- -\n", stdout) +} + +// --- Format reuse bounding --- + +func TestPentestFormatReuseMany(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // 100 args should be fine + args := strings.Repeat("x ", 100) + stdout, _, code := runScriptCtx(ctx, t, `printf "%s\n" `+args, "") + assert.Equal(t, 0, code) + lines := strings.Split(strings.TrimRight(stdout, "\n"), "\n") + assert.Equal(t, 100, len(lines)) +} + +func TestPentestNoSpecifiersExtraArgs(t *testing.T) { + // Format with no specifiers and extra args — format is printed once + stdout, _, code := cmdRun(t, `printf "hello\n" a b c d e`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello\n", stdout) +} + +// --- Width/precision bounds --- + +func TestPentestHugeWidth(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + stdout, _, code := runScriptCtx(ctx, t, `printf "%99999d\n" 42`, "") + assert.Equal(t, 0, code) + // Width should be clamped to 10000 + assert.LessOrEqual(t, len(stdout), 10002) + assert.Contains(t, stdout, "42") +} + +func TestPentestHugePrecision(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + stdout, _, code := runScriptCtx(ctx, t, `printf "%.99999f\n" 3.14`, "") + assert.Equal(t, 0, code) + // Precision should be clamped to 10000 + assert.LessOrEqual(t, len(stdout), 10010) +} + +// --- Rejected specifiers --- + +func TestPentestPercentN(t *testing.T) { + _, stderr, code := cmdRun(t, `printf "%n" foo`) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "printf:") + assert.Contains(t, stderr, "not supported") +} + +func TestPentestPercentQ(t *testing.T) { + _, stderr, code := cmdRun(t, `printf "%q" foo`) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "printf:") +} + +func TestPentestPercentA(t *testing.T) { + _, stderr, code := cmdRun(t, `printf "%a" 3.14`) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "printf:") +} + +func TestPentestPercentAUpper(t *testing.T) { + _, stderr, code := cmdRun(t, `printf "%A" 3.14`) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "printf:") +} + +// --- V flag rejection --- + +func TestPentestVFlag(t *testing.T) { + _, stderr, code := cmdRun(t, `printf -v myvar "%s" hello`) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "printf:") +} + +// --- Special characters in format and args --- + +func TestPentestNulByteInArg(t *testing.T) { + // Args containing special characters should be handled safely + stdout, _, code := cmdRun(t, `printf "%s\n" hello`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello\n", stdout) +} + +func TestPentestEmptyArgs(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%s|%s|%s\n" "" "" ""`) + assert.Equal(t, 0, code) + assert.Equal(t, "||\n", stdout) +} + +// --- Float edge cases --- + +func TestPentestFloatInfinity(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%f\n" inf`) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "inf") +} + +func TestPentestFloatNaN(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%f\n" nan`) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "nan") +} + +func TestPentestFloatZero(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%f\n" 0`) + assert.Equal(t, 0, code) + assert.Equal(t, "0.000000\n", stdout) +} + +// --- Behavior matching --- + +func TestPentestBashCompatPercentD(t *testing.T) { + // Bash: printf "%d\n" 42 → "42\n" + stdout, _, code := cmdRun(t, `printf "%d\n" 42`) + assert.Equal(t, 0, code) + assert.Equal(t, "42\n", stdout) +} + +func TestPentestBashCompatFormatReusePartial(t *testing.T) { + // Bash: printf "%s=%d\n" a 1 b → "a=1\nb=0\n" + stdout, _, code := cmdRun(t, `printf "%s=%d\n" a 1 b`) + assert.Equal(t, 0, code) + assert.Equal(t, "a=1\nb=0\n", stdout) +} + +// --- Star width/precision --- + +func TestPentestStarWidth(t *testing.T) { + // printf "%*s\n" 10 hello → right-aligned in 10-char field + stdout, _, code := cmdRun(t, `printf "%*s\n" 10 hello`) + assert.Equal(t, 0, code) + assert.Equal(t, " hello\n", stdout) +} + +func TestPentestStarPrecision(t *testing.T) { + // printf "%.*f\n" 2 3.14159 → "3.14\n" + stdout, _, code := cmdRun(t, `printf "%.*f\n" 2 3.14159`) + assert.Equal(t, 0, code) + assert.Equal(t, "3.14\n", stdout) +} + +func TestPentestStarWidthInvalid(t *testing.T) { + // Invalid number for * width → exit code 1 + stdout, stderr, code := cmdRun(t, `printf "%*d\n" abc 42`) + assert.Equal(t, 1, code) + assert.Equal(t, "42\n", stdout) + assert.Contains(t, stderr, "printf:") +} + +func TestPentestStarPrecisionInvalid(t *testing.T) { + // Invalid number for * precision → exit code 1 + _, stderr, code := cmdRun(t, `printf "%.*f\n" abc 3.14`) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "printf:") +} + +func TestPentestStarWidthNegative(t *testing.T) { + // Negative width via * → left-align (bash behavior) + stdout, _, code := cmdRun(t, `printf "%*s|\n" -10 hi`) + assert.Equal(t, 0, code) + assert.Equal(t, "hi |\n", stdout) +} + +func TestPentestBashCompatInvalidNumContinues(t *testing.T) { + // Bash prints 0 and continues with exit code 1 + stdout, stderr, code := cmdRun(t, `printf "%d %d\n" abc 42`) + assert.Equal(t, 1, code) + assert.Equal(t, "0 42\n", stdout) + assert.Contains(t, stderr, "printf:") +} diff --git a/interp/builtins/printf/printf_test.go b/interp/builtins/printf/printf_test.go new file mode 100644 index 00000000..7f8e14a7 --- /dev/null +++ b/interp/builtins/printf/printf_test.go @@ -0,0 +1,832 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package printf_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/DataDog/rshell/interp" + "github.com/DataDog/rshell/interp/builtins/testutil" +) + +// runScriptCtx runs a shell script with a context and returns stdout, stderr, +// and the exit code. +func runScriptCtx(ctx context.Context, t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + return testutil.RunScriptCtx(ctx, t, script, dir, opts...) +} + +// runScript runs a shell script and returns stdout, stderr, and the exit code. +func runScript(t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + return testutil.RunScript(t, script, dir, opts...) +} + +// cmdRun runs a printf command (no file access needed). +func cmdRun(t *testing.T, script string) (stdout, stderr string, exitCode int) { + t.Helper() + return runScript(t, script, "") +} + +// --- Basic functionality --- + +func TestPrintfSimpleString(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%s\n" hello`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello\n", stdout) +} + +func TestPrintfNoArgs(t *testing.T) { + _, stderr, code := cmdRun(t, `printf`) + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "printf:") +} + +func TestPrintfFormatOnly(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "hello world\n"`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello world\n", stdout) +} + +func TestPrintfMultipleArgs(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%s %s\n" hello world`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello world\n", stdout) +} + +func TestPrintfFormatReuse(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%s\n" a b c`) + assert.Equal(t, 0, code) + assert.Equal(t, "a\nb\nc\n", stdout) +} + +func TestPrintfMissingArgString(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%s and %s\n" hello`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello and \n", stdout) +} + +func TestPrintfMissingArgNumber(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%d and %d\n" 42`) + assert.Equal(t, 0, code) + assert.Equal(t, "42 and 0\n", stdout) +} + +func TestPrintfPercentLiteral(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "100%%\n"`) + assert.Equal(t, 0, code) + assert.Equal(t, "100%\n", stdout) +} + +func TestPrintfEmptyFormat(t *testing.T) { + stdout, _, code := cmdRun(t, `printf ""`) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) +} + +func TestPrintfNoNewline(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "hello"`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello", stdout) +} + +// --- Escape sequences --- + +func TestPrintfEscapeNewline(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "a\nb\n"`) + assert.Equal(t, 0, code) + assert.Equal(t, "a\nb\n", stdout) +} + +func TestPrintfEscapeTab(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "a\tb\n"`) + assert.Equal(t, 0, code) + assert.Equal(t, "a\tb\n", stdout) +} + +func TestPrintfEscapeBackslash(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "a\\\\b\n"`) + assert.Equal(t, 0, code) + assert.Equal(t, "a\\b\n", stdout) +} + +func TestPrintfEscapeCarriageReturn(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "hello\rworld\n"`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello\rworld\n", stdout) +} + +func TestPrintfEscapeOctal(t *testing.T) { + // \101 = octal 101 = 65 = 'A' + stdout, _, code := cmdRun(t, `printf "\101\n"`) + assert.Equal(t, 0, code) + assert.Equal(t, "A\n", stdout) +} + +func TestPrintfEscapeHex(t *testing.T) { + // \x41 = hex 41 = 65 = 'A' + stdout, _, code := cmdRun(t, `printf "\x41\n"`) + assert.Equal(t, 0, code) + assert.Equal(t, "A\n", stdout) +} + +func TestPrintfEscapeBell(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "\a"`) + assert.Equal(t, 0, code) + assert.Equal(t, "\a", stdout) +} + +func TestPrintfEscapeFormFeed(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "\f"`) + assert.Equal(t, 0, code) + assert.Equal(t, "\f", stdout) +} + +func TestPrintfEscapeVerticalTab(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "\v"`) + assert.Equal(t, 0, code) + assert.Equal(t, "\v", stdout) +} + +func TestPrintfEscapeBackspace(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "\b"`) + assert.Equal(t, 0, code) + assert.Equal(t, "\b", stdout) +} + +// --- Format specifiers --- + +func TestPrintfSpecifierString(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%s" hello`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello", stdout) +} + +func TestPrintfSpecifierChar(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%c\n" A`) + assert.Equal(t, 0, code) + assert.Equal(t, "A\n", stdout) +} + +func TestPrintfSpecifierCharEmpty(t *testing.T) { + // Empty arg for %c should produce a NUL byte (bash behavior) + stdout, _, code := cmdRun(t, `printf "%c" ""`) + assert.Equal(t, 0, code) + assert.Equal(t, "\x00", stdout) +} + +func TestPrintfSpecifierDecimal(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%d\n" 42`) + assert.Equal(t, 0, code) + assert.Equal(t, "42\n", stdout) +} + +func TestPrintfSpecifierInteger(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%i\n" 42`) + assert.Equal(t, 0, code) + assert.Equal(t, "42\n", stdout) +} + +func TestPrintfSpecifierOctal(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%o\n" 255`) + assert.Equal(t, 0, code) + assert.Equal(t, "377\n", stdout) +} + +func TestPrintfSpecifierUnsigned(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%u\n" 42`) + assert.Equal(t, 0, code) + assert.Equal(t, "42\n", stdout) +} + +func TestPrintfSpecifierHexLower(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%x\n" 255`) + assert.Equal(t, 0, code) + assert.Equal(t, "ff\n", stdout) +} + +func TestPrintfSpecifierHexUpper(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%X\n" 255`) + assert.Equal(t, 0, code) + assert.Equal(t, "FF\n", stdout) +} + +func TestPrintfSpecifierFloat(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%f\n" 3.14`) + assert.Equal(t, 0, code) + assert.Equal(t, "3.140000\n", stdout) +} + +func TestPrintfSpecifierScientific(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%e\n" 3.14`) + assert.Equal(t, 0, code) + assert.Equal(t, "3.140000e+00\n", stdout) +} + +func TestPrintfSpecifierScientificUpper(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%E\n" 3.14`) + assert.Equal(t, 0, code) + assert.Equal(t, "3.140000E+00\n", stdout) +} + +func TestPrintfSpecifierShortest(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%g\n" 3.14`) + assert.Equal(t, 0, code) + assert.Equal(t, "3.14\n", stdout) +} + +func TestPrintfSpecifierShortestUpper(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%G\n" 3.14`) + assert.Equal(t, 0, code) + assert.Equal(t, "3.14\n", stdout) +} + +func TestPrintfSpecifierFloatF(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%F\n" 3.14`) + assert.Equal(t, 0, code) + assert.Equal(t, "3.140000\n", stdout) +} + +// --- %b specifier --- + +func TestPrintfSpecifierBEscapes(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%b\n" 'hello\tworld'`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello\tworld\n", stdout) +} + +func TestPrintfSpecifierBBackslashC(t *testing.T) { + // \c stops all output + stdout, _, code := cmdRun(t, `printf "%b" 'hello\cworld'`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello", stdout) +} + +func TestPrintfSpecifierBOctal(t *testing.T) { + // %b uses \0NNN (with leading zero) for octal + stdout, _, code := cmdRun(t, `printf "%b\n" '\0101'`) + assert.Equal(t, 0, code) + assert.Equal(t, "A\n", stdout) +} + +// --- Width and precision --- + +func TestPrintfWidthRightAlign(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%10s\n" hi`) + assert.Equal(t, 0, code) + assert.Equal(t, " hi\n", stdout) +} + +func TestPrintfWidthLeftAlign(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%-10s|\n" hi`) + assert.Equal(t, 0, code) + assert.Equal(t, "hi |\n", stdout) +} + +func TestPrintfWidthZeroPad(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%05d\n" 42`) + assert.Equal(t, 0, code) + assert.Equal(t, "00042\n", stdout) +} + +func TestPrintfPrecisionFloat(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%.2f\n" 3.14159`) + assert.Equal(t, 0, code) + assert.Equal(t, "3.14\n", stdout) +} + +func TestPrintfPrecisionString(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%.3s\n" hello`) + assert.Equal(t, 0, code) + assert.Equal(t, "hel\n", stdout) +} + +func TestPrintfWidthAndPrecision(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%10.3s\n" hello`) + assert.Equal(t, 0, code) + assert.Equal(t, " hel\n", stdout) +} + +func TestPrintfFlagPlus(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%+d\n" 42`) + assert.Equal(t, 0, code) + assert.Equal(t, "+42\n", stdout) +} + +func TestPrintfFlagSpace(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "% d\n" 42`) + assert.Equal(t, 0, code) + assert.Equal(t, " 42\n", stdout) +} + +func TestPrintfFlagHash(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%#x\n" 255`) + assert.Equal(t, 0, code) + assert.Equal(t, "0xff\n", stdout) +} + +func TestPrintfFlagHashOctal(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%#o\n" 255`) + assert.Equal(t, 0, code) + assert.Equal(t, "0377\n", stdout) +} + +// --- Numeric argument formats --- + +func TestPrintfNumericNegative(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%d\n" -42`) + assert.Equal(t, 0, code) + assert.Equal(t, "-42\n", stdout) +} + +func TestPrintfNumericHexInput(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%d\n" 0xff`) + assert.Equal(t, 0, code) + assert.Equal(t, "255\n", stdout) +} + +func TestPrintfNumericOctalInput(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%d\n" 0755`) + assert.Equal(t, 0, code) + assert.Equal(t, "493\n", stdout) +} + +func TestPrintfNumericCharConstant(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%d\n" "'A"`) + assert.Equal(t, 0, code) + assert.Equal(t, "65\n", stdout) +} + +func TestPrintfNumericZero(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%d\n" 0`) + assert.Equal(t, 0, code) + assert.Equal(t, "0\n", stdout) +} + +// --- Error handling --- + +func TestPrintfInvalidNumber(t *testing.T) { + stdout, stderr, code := cmdRun(t, `printf "%d\n" abc`) + assert.Equal(t, 1, code) + assert.Equal(t, "0\n", stdout) + assert.Contains(t, stderr, "printf:") +} + +func TestPrintfRejectedPercentN(t *testing.T) { + _, stderr, code := cmdRun(t, `printf "%n" foo`) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "printf:") +} + +func TestPrintfRejectedVFlag(t *testing.T) { + _, stderr, code := cmdRun(t, `printf -v var "%s" hello`) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "printf:") +} + +// --- Help --- + +func TestPrintfHelp(t *testing.T) { + _, stderr, code := cmdRun(t, `printf --help`) + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "printf: usage:") +} + +func TestPrintfHelpShort(t *testing.T) { + // -h is not a valid flag in bash; it's rejected with exit 2 + _, stderr, code := cmdRun(t, `printf -h`) + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "invalid option") +} + +// --- Format reuse edge cases --- + +func TestPrintfFormatReuseMultipleSpecifiers(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%s=%d\n" a 1 b 2 c 3`) + assert.Equal(t, 0, code) + assert.Equal(t, "a=1\nb=2\nc=3\n", stdout) +} + +func TestPrintfFormatReusePartialFill(t *testing.T) { + // When format has 2 specifiers but odd number of extra args + stdout, _, code := cmdRun(t, `printf "%s=%d\n" a 1 b`) + assert.Equal(t, 0, code) + assert.Equal(t, "a=1\nb=0\n", stdout) +} + +func TestPrintfNoSpecifiers(t *testing.T) { + // Format with no specifiers and extra args — format is still printed + // but args are not consumed (no specifiers to consume them) + stdout, _, code := cmdRun(t, `printf "hello\n" extra args`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello\n", stdout) +} + +// --- Shell integration --- + +func TestPrintfInPipeline(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%s\n" hello | cat`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello\n", stdout) +} + +func TestPrintfInForLoop(t *testing.T) { + stdout, _, code := cmdRun(t, `for i in 1 2 3; do printf "%d " "$i"; done; printf "\n"`) + assert.Equal(t, 0, code) + assert.Equal(t, "1 2 3 \n", stdout) +} + +func TestPrintfVariableExpansion(t *testing.T) { + stdout, _, code := cmdRun(t, `NAME=world; printf "hello %s\n" "$NAME"`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello world\n", stdout) +} + +func TestPrintfZeroPaddedInt(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%05d\n" 42`) + assert.Equal(t, 0, code) + assert.Equal(t, "00042\n", stdout) +} + +// --- Context cancellation --- + +func TestPrintfContextCancellation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + // Large format reuse should respect context cancellation + // This script tries to print many items but should be bounded + _, _, code := runScriptCtx(ctx, t, `printf "%s\n" a b c d e f g h i j`, "") + assert.Equal(t, 0, code) +} + +// --- Double-dash separator --- + +func TestPrintfDoubleDash(t *testing.T) { + stdout, _, code := cmdRun(t, `printf -- "%s\n" hello`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello\n", stdout) +} + +// --- Octal escape edge cases --- + +func TestPrintfEscapeOctalZeroPrefix(t *testing.T) { + // \0101: the leading 0 counts as the first of 3 octal digits, + // so \010 = backspace (octal 010 = 8), then literal '1'. + // This matches bash behavior. + stdout, _, code := cmdRun(t, `printf "\0101\n"`) + assert.Equal(t, 0, code) + assert.Equal(t, "\x081\n", stdout) +} + +func TestPrintfEscapeOctalNulByte(t *testing.T) { + // \0 alone = NUL byte + stdout, _, code := cmdRun(t, `printf "a\0b"`) + assert.Equal(t, 0, code) + assert.Equal(t, "a\x00b", stdout) +} + +// --- Mixed format string and args --- + +func TestPrintfMixedText(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "Name: %s, Age: %d\n" Alice 30`) + assert.Equal(t, 0, code) + assert.Equal(t, "Name: Alice, Age: 30\n", stdout) +} + +func TestPrintfMultiplePercent(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%d%%\n" 100`) + assert.Equal(t, 0, code) + assert.Equal(t, "100%\n", stdout) +} + +// --- Coverage: rejected specifiers --- + +func TestPrintfRejectedQ(t *testing.T) { + _, stderr, code := cmdRun(t, `printf "%q" hello`) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "printf:") +} + +func TestPrintfRejectedA(t *testing.T) { + _, stderr, code := cmdRun(t, `printf "%a" 3.14`) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "printf:") +} + +// --- Coverage: unknown specifier --- + +func TestPrintfUnknownSpecifier(t *testing.T) { + // Bash stops processing format string after unknown specifier — no \n output. + stdout, stderr, code := cmdRun(t, `printf "%z\n"`) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "invalid format character") +} + +// --- Coverage: escape edge cases --- + +func TestPrintfEscapeDoubleQuote(t *testing.T) { + stdout, _, code := cmdRun(t, `printf '\"hello\"'`) + assert.Equal(t, 0, code) + assert.Equal(t, "\"hello\"", stdout) +} + +func TestPrintfEscapeUnknown(t *testing.T) { + // Unknown escape should output backslash and character + stdout, _, code := cmdRun(t, `printf '\q'`) + assert.Equal(t, 0, code) + assert.Equal(t, "\\q", stdout) +} + +func TestPrintfTrailingBackslash(t *testing.T) { + stdout, _, code := cmdRun(t, `printf 'hello\'`) + assert.Equal(t, 0, code) + assert.Equal(t, "hello\\", stdout) +} + +// --- Coverage: %b escape sequences --- + +func TestPrintfBEscapeTab(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%b" 'a\tb'`) + assert.Equal(t, 0, code) + assert.Equal(t, "a\tb", stdout) +} + +func TestPrintfBEscapeNewline(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%b" 'a\nb'`) + assert.Equal(t, 0, code) + assert.Equal(t, "a\nb", stdout) +} + +func TestPrintfBEscapeBackslash(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%b" 'a\\b'`) + assert.Equal(t, 0, code) + assert.Equal(t, "a\\b", stdout) +} + +func TestPrintfBEscapeHex(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%b" '\x41'`) + assert.Equal(t, 0, code) + assert.Equal(t, "A", stdout) +} + +func TestPrintfBEscapeHexInvalid(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%b" '\xZZ'`) + assert.Equal(t, 0, code) + assert.Equal(t, "\\xZZ", stdout) +} + +func TestPrintfBEscapeBell(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%b" '\a'`) + assert.Equal(t, 0, code) + assert.Equal(t, "\a", stdout) +} + +func TestPrintfBEscapeFormFeed(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%b" '\f'`) + assert.Equal(t, 0, code) + assert.Equal(t, "\f", stdout) +} + +func TestPrintfBEscapeCarriageReturn(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%b" '\r'`) + assert.Equal(t, 0, code) + assert.Equal(t, "\r", stdout) +} + +func TestPrintfBEscapeVerticalTab(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%b" '\v'`) + assert.Equal(t, 0, code) + assert.Equal(t, "\v", stdout) +} + +func TestPrintfBEscapeBackspace(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%b" '\b'`) + assert.Equal(t, 0, code) + assert.Equal(t, "\b", stdout) +} + +func TestPrintfBEscapeUnknown(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%b" '\q'`) + assert.Equal(t, 0, code) + assert.Equal(t, "\\q", stdout) +} + +// --- Coverage: parseFloatArg --- + +func TestPrintfFloatHexInput(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%f\n" 0xff`) + assert.Equal(t, 0, code) + assert.Equal(t, "255.000000\n", stdout) +} + +func TestPrintfFloatInfinity(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%f\n" inf`) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "inf") +} + +func TestPrintfFloatNegInfinity(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%f\n" -inf`) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "-inf") +} + +func TestPrintfFloatCharConstant(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%f\n" "'A"`) + assert.Equal(t, 0, code) + assert.Equal(t, "65.000000\n", stdout) +} + +func TestPrintfFloatInvalid(t *testing.T) { + stdout, stderr, code := cmdRun(t, `printf "%f\n" abc`) + assert.Equal(t, 1, code) + assert.Equal(t, "0.000000\n", stdout) + assert.Contains(t, stderr, "printf:") +} + +// --- Coverage: parseUintArg --- + +func TestPrintfUnsignedCharConstant(t *testing.T) { + stdout, _, code := cmdRun(t, `printf "%u\n" "'A"`) + assert.Equal(t, 0, code) + assert.Equal(t, "65\n", stdout) +} + +func TestPrintfUnsignedInvalid(t *testing.T) { + stdout, stderr, code := cmdRun(t, `printf "%u\n" abc`) + assert.Equal(t, 1, code) + assert.Equal(t, "0\n", stdout) + assert.Contains(t, stderr, "printf:") +} + +func TestPrintfOctalInvalid(t *testing.T) { + stdout, stderr, code := cmdRun(t, `printf "%o\n" abc`) + assert.Equal(t, 1, code) + assert.Equal(t, "0\n", stdout) + assert.Contains(t, stderr, "printf:") +} + +func TestPrintfHexInvalid(t *testing.T) { + stdout, stderr, code := cmdRun(t, `printf "%x\n" abc`) + assert.Equal(t, 1, code) + assert.Equal(t, "0\n", stdout) + assert.Contains(t, stderr, "printf:") +} + +func TestPrintfHexUpperInvalid(t *testing.T) { + stdout, stderr, code := cmdRun(t, `printf "%X\n" abc`) + assert.Equal(t, 1, code) + assert.Equal(t, "0\n", stdout) + assert.Contains(t, stderr, "printf:") +} + +// --- Coverage: float specifiers errors --- + +func TestPrintfScientificInvalid(t *testing.T) { + stdout, stderr, code := cmdRun(t, `printf "%e\n" abc`) + assert.Equal(t, 1, code) + assert.Equal(t, "0.000000e+00\n", stdout) + assert.Contains(t, stderr, "printf:") +} + +func TestPrintfScientificUpperInvalid(t *testing.T) { + stdout, stderr, code := cmdRun(t, `printf "%E\n" abc`) + assert.Equal(t, 1, code) + assert.Equal(t, "0.000000E+00\n", stdout) + assert.Contains(t, stderr, "printf:") +} + +func TestPrintfShortestInvalid(t *testing.T) { + stdout, stderr, code := cmdRun(t, `printf "%g\n" abc`) + assert.Equal(t, 1, code) + assert.Equal(t, "0\n", stdout) + assert.Contains(t, stderr, "printf:") +} + +func TestPrintfShortestUpperInvalid(t *testing.T) { + stdout, stderr, code := cmdRun(t, `printf "%G\n" abc`) + assert.Equal(t, 1, code) + assert.Equal(t, "0\n", stdout) + assert.Contains(t, stderr, "printf:") +} + +func TestPrintfFloatFUpperInvalid(t *testing.T) { + stdout, stderr, code := cmdRun(t, `printf "%F\n" abc`) + assert.Equal(t, 1, code) + assert.Equal(t, "0.000000\n", stdout) + assert.Contains(t, stderr, "printf:") +} + +// --- Coverage: incomplete specifier --- + +func TestPrintfIncompleteSpecifier(t *testing.T) { + stdout, stderr, code := cmdRun(t, `printf "%"`) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "missing format character") +} + +// --- Coverage: hex escape in format with no valid digits --- + +func TestPrintfHexEscapeNoDigits(t *testing.T) { + stdout, _, code := cmdRun(t, `printf '\xZZ'`) + assert.Equal(t, 0, code) + assert.Equal(t, "\\xZZ", stdout) +} + +// --- Coverage: width clamping --- + +func TestPrintfWidthClamped(t *testing.T) { + // Very large width should be clamped, not cause OOM + stdout, _, code := cmdRun(t, `printf "%99999s\n" hi`) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "hi") + // Width clamped to 10000 + assert.LessOrEqual(t, len(stdout), 10002) +} + +// --- Coverage: negative width clamping --- + +func TestPrintfNegativeWidthClamped(t *testing.T) { + // Very large negative width should be clamped to -10000 + stdout, _, code := cmdRun(t, `printf "%-99999s|\n" hi`) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "hi") + assert.LessOrEqual(t, len(stdout), 10003) // 10000 + |+ \n +} + +// --- Coverage: precision clamping boundary --- + +func TestPrintfPrecisionClamped(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + stdout, _, code := runScriptCtx(ctx, t, `printf "%.99999s\n" hello`, "") + assert.Equal(t, 0, code) + // Precision on strings truncates; clamped to 10000 but "hello" is only 5 chars + assert.Equal(t, "hello\n", stdout) +} + +// NOTE: unsigned negative wrapping, double-quote char constants, %b escapes, +// octal/hex truncation, incomplete specifiers, conflicting flags, star +// width/precision with zero — all covered by YAML scenario tests in +// tests/scenarios/cmd/printf/ + +// --- Coverage: star width/precision clamping --- + +func TestPrintfStarWidthClamped(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + stdout, _, code := runScriptCtx(ctx, t, `printf "%*d\n" 99999 42`, "") + assert.Equal(t, 0, code) + assert.LessOrEqual(t, len(stdout), 10002) + assert.Contains(t, stdout, "42") +} + +func TestPrintfStarPrecisionClamped(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + stdout, _, code := runScriptCtx(ctx, t, `printf "%.*f\n" 99999 3.14`, "") + assert.Equal(t, 0, code) + assert.LessOrEqual(t, len(stdout), 10010) +} + +// NOTE: %c multi-byte, NaN case, empty arg with width, octal digits 8/9, +// %F uppercase inf/nan, zero-padded scientific, %b \c stops reuse — +// all covered by YAML scenario tests in tests/scenarios/cmd/printf/ + +// --- Coverage: format reuse iteration limit --- + +func TestPrintfFormatReuseIterationLimit(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // Generate 20001 args: format reuse should stop at 10000 iterations + args := strings.Repeat("x ", 20001) + stdout, _, code := runScriptCtx(ctx, t, `printf "%s" `+args, "") + assert.Equal(t, 0, code) + // Should produce at most 10001 x's (first pass + 10000 iterations) + // Actually the first x is consumed in the first pass, then 10000 more iterations + assert.LessOrEqual(t, len(stdout), 10001) +} + +// --- Coverage: context cancellation actually stops loop --- + +func TestPrintfContextCancellationStopsLoop(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + // Try to print a very large number of args; timeout should kill it + args := strings.Repeat("x ", 100000) + _, _, _ = runScriptCtx(ctx, t, `printf "%s" `+args, "") + // We only care that it didn't hang — the timeout handled it +} + +// NOTE: unsigned large hex, star width float, star width/precision empty — +// all covered by YAML scenario tests in tests/scenarios/cmd/printf/ diff --git a/interp/register_builtins.go b/interp/register_builtins.go index fedccfc9..a86488a6 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -19,6 +19,7 @@ import ( "github.com/DataDog/rshell/interp/builtins/grep" "github.com/DataDog/rshell/interp/builtins/head" "github.com/DataDog/rshell/interp/builtins/ls" + printfcmd "github.com/DataDog/rshell/interp/builtins/printf" "github.com/DataDog/rshell/interp/builtins/strings_cmd" "github.com/DataDog/rshell/interp/builtins/tail" "github.com/DataDog/rshell/interp/builtins/testcmd" @@ -43,6 +44,7 @@ func registerBuiltins() { grep.Cmd, head.Cmd, ls.Cmd, + printfcmd.Cmd, strings_cmd.Cmd, tail.Cmd, testcmd.Cmd, diff --git a/tests/allowed_symbols_test.go b/tests/allowed_symbols_test.go index 59d56f88..0b272b2f 100644 --- a/tests/allowed_symbols_test.go +++ b/tests/allowed_symbols_test.go @@ -38,6 +38,8 @@ var builtinAllowedSymbols = []string{ "bufio.SplitFunc", // context.Context — deadline/cancellation plumbing; pure interface, no side effects. "context.Context", + // errors.As — error type assertion; pure function, no I/O. + "errors.As", // errors.Is — error comparison; pure function, no I/O. "errors.Is", // errors.New — creates a simple error value; pure function, no I/O. @@ -72,12 +74,18 @@ var builtinAllowedSymbols = []string{ "io.ReadSeeker", // io.SeekCurrent — whence constant for Seek(offset, SeekCurrent); pure constant. "io.SeekCurrent", + // math.Inf — returns positive or negative infinity; pure function, no I/O. + "math.Inf", // math.MaxInt32 — integer constant; no side effects. "math.MaxInt32", // math.MaxInt64 — integer constant; no side effects. "math.MaxInt64", + // math.MaxUint64 — integer constant; no side effects. + "math.MaxUint64", // math.MinInt64 — integer constant; no side effects. "math.MinInt64", + // math.NaN — returns IEEE 754 NaN value; pure function, no I/O. + "math.NaN", // os.FileInfo — file metadata interface returned by Stat; no I/O side effects. "os.FileInfo", // os.O_RDONLY — read-only file flag constant; cannot open files by itself. @@ -94,8 +102,16 @@ var builtinAllowedSymbols = []string{ "slices.SortFunc", // strings.Builder — efficient string concatenation; pure in-memory buffer, no I/O. "strings.Builder", + // strings.ContainsRune — checks if a rune is in a string; pure function, no I/O. + "strings.ContainsRune", // strings.Join — concatenates a slice of strings with a separator; pure function, no I/O. "strings.Join", + // strings.ReplaceAll — replaces all occurrences of a substring; pure function, no I/O. + "strings.ReplaceAll", + // strings.ToLower — converts string to lowercase; pure function, no I/O. + "strings.ToLower", + // strconv.IntSize — platform int size constant (32 or 64); pure constant, no I/O. + "strconv.IntSize", // strings.Split — splits a string by separator into a slice; pure function, no I/O. "strings.Split", // strconv.Atoi — string-to-int conversion; pure function, no I/O. @@ -108,8 +124,12 @@ var builtinAllowedSymbols = []string{ "strconv.ErrRange", // strconv.NumError — error type for numeric conversion failures; pure type. "strconv.NumError", + // strconv.ParseFloat — string-to-float conversion; pure function, no I/O. + "strconv.ParseFloat", // strconv.ParseInt — string-to-int conversion with base/bit-size; pure function, no I/O. "strconv.ParseInt", + // strconv.ParseUint — string-to-unsigned-int conversion; pure function, no I/O. + "strconv.ParseUint", // strconv.FormatInt — int-to-string conversion; pure function, no I/O. "strconv.FormatInt", // strings.HasPrefix — pure function for prefix matching; no I/O. diff --git a/tests/scenarios/cmd/printf/basic/format_only.yaml b/tests/scenarios/cmd/printf/basic/format_only.yaml new file mode 100644 index 00000000..f6f43674 --- /dev/null +++ b/tests/scenarios/cmd/printf/basic/format_only.yaml @@ -0,0 +1,9 @@ +description: Printf with only a format string and no arguments prints the format string. +input: + script: |+ + printf "hello world\n" +expect: + stdout: |+ + hello world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/basic/format_reuse.yaml b/tests/scenarios/cmd/printf/basic/format_reuse.yaml new file mode 100644 index 00000000..eb6fec8c --- /dev/null +++ b/tests/scenarios/cmd/printf/basic/format_reuse.yaml @@ -0,0 +1,11 @@ +description: Printf reuses the format string for excess arguments. +input: + script: |+ + printf "%s\n" a b c +expect: + stdout: |+ + a + b + c + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/basic/missing_arg_number.yaml b/tests/scenarios/cmd/printf/basic/missing_arg_number.yaml new file mode 100644 index 00000000..81faf920 --- /dev/null +++ b/tests/scenarios/cmd/printf/basic/missing_arg_number.yaml @@ -0,0 +1,9 @@ +description: Printf uses 0 for missing %d arguments. +input: + script: |+ + printf "%d and %d\n" 42 +expect: + stdout: |+ + 42 and 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/basic/missing_arg_string.yaml b/tests/scenarios/cmd/printf/basic/missing_arg_string.yaml new file mode 100644 index 00000000..0ad88930 --- /dev/null +++ b/tests/scenarios/cmd/printf/basic/missing_arg_string.yaml @@ -0,0 +1,8 @@ +description: Printf uses empty string for missing %s arguments. +input: + script: |+ + printf "%s and %s\n" hello +expect: + stdout: "hello and \n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/basic/multiple_args.yaml b/tests/scenarios/cmd/printf/basic/multiple_args.yaml new file mode 100644 index 00000000..b65f99ed --- /dev/null +++ b/tests/scenarios/cmd/printf/basic/multiple_args.yaml @@ -0,0 +1,9 @@ +description: Printf formats multiple arguments with multiple specifiers. +input: + script: |+ + printf "%s %s\n" hello world +expect: + stdout: |+ + hello world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/basic/no_args.yaml b/tests/scenarios/cmd/printf/basic/no_args.yaml new file mode 100644 index 00000000..46d60366 --- /dev/null +++ b/tests/scenarios/cmd/printf/basic/no_args.yaml @@ -0,0 +1,8 @@ +description: Printf with no arguments produces an error. +input: + script: |+ + printf +expect: + stdout: "" + stderr_contains: ["printf: usage: printf"] + exit_code: 2 diff --git a/tests/scenarios/cmd/printf/basic/no_specifiers_extra_args.yaml b/tests/scenarios/cmd/printf/basic/no_specifiers_extra_args.yaml new file mode 100644 index 00000000..5c36e2dd --- /dev/null +++ b/tests/scenarios/cmd/printf/basic/no_specifiers_extra_args.yaml @@ -0,0 +1,7 @@ +description: Format with no specifiers prints once ignoring extra args. +input: + script: |+ + printf "hello\n" extra args here +expect: + stdout: "hello\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/basic/percent_literal.yaml b/tests/scenarios/cmd/printf/basic/percent_literal.yaml new file mode 100644 index 00000000..0110e4d5 --- /dev/null +++ b/tests/scenarios/cmd/printf/basic/percent_literal.yaml @@ -0,0 +1,9 @@ +description: Printf outputs a literal percent sign with %%. +input: + script: |+ + printf "100%%\n" +expect: + stdout: |+ + 100% + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/basic/simple_string.yaml b/tests/scenarios/cmd/printf/basic/simple_string.yaml new file mode 100644 index 00000000..52bd6c6e --- /dev/null +++ b/tests/scenarios/cmd/printf/basic/simple_string.yaml @@ -0,0 +1,9 @@ +description: Printf formats and prints a simple string with %s specifier. +input: + script: |+ + printf "%s\n" hello +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/errors/invalid_number.yaml b/tests/scenarios/cmd/printf/errors/invalid_number.yaml new file mode 100644 index 00000000..b7eedc83 --- /dev/null +++ b/tests/scenarios/cmd/printf/errors/invalid_number.yaml @@ -0,0 +1,9 @@ +description: Printf with an invalid number argument prints 0 and produces a warning. +input: + script: |+ + printf "%d\n" abc +expect: + stdout: |+ + 0 + stderr_contains: ["printf:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/printf/errors/rejected_n_specifier.yaml b/tests/scenarios/cmd/printf/errors/rejected_n_specifier.yaml new file mode 100644 index 00000000..f9f1b601 --- /dev/null +++ b/tests/scenarios/cmd/printf/errors/rejected_n_specifier.yaml @@ -0,0 +1,9 @@ +description: Printf rejects the %n specifier for safety. +input: + script: |+ + printf "%n" foo +expect: + stdout: "" + stderr: "printf: %n: not supported (security risk)\n" + exit_code: 1 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/printf/errors/rejected_v_flag.yaml b/tests/scenarios/cmd/printf/errors/rejected_v_flag.yaml new file mode 100644 index 00000000..3ee2cadf --- /dev/null +++ b/tests/scenarios/cmd/printf/errors/rejected_v_flag.yaml @@ -0,0 +1,9 @@ +skip_assert_against_bash: true +description: Printf rejects the -v flag which assigns to a variable in bash. +input: + script: |+ + printf -v var "%s" hello +expect: + stdout: "" + stderr_contains: ["printf:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/printf/errors/star_precision_invalid.yaml b/tests/scenarios/cmd/printf/errors/star_precision_invalid.yaml new file mode 100644 index 00000000..08047a00 --- /dev/null +++ b/tests/scenarios/cmd/printf/errors/star_precision_invalid.yaml @@ -0,0 +1,8 @@ +description: Invalid number for star precision produces error with exit 1. +input: + script: |+ + printf "%.*f\n" abc 3.14 +expect: + stdout: "3\n" + stderr_contains: ["printf:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/printf/errors/star_width_float.yaml b/tests/scenarios/cmd/printf/errors/star_width_float.yaml new file mode 100644 index 00000000..6bc92499 --- /dev/null +++ b/tests/scenarios/cmd/printf/errors/star_width_float.yaml @@ -0,0 +1,8 @@ +description: Float argument for star width uses numeric prefix (bash compat). +input: + script: |+ + printf "%*s\n" 3.14 hello +expect: + stdout: "hello\n" + stderr_contains: ["printf:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/printf/errors/star_width_invalid.yaml b/tests/scenarios/cmd/printf/errors/star_width_invalid.yaml new file mode 100644 index 00000000..945aec6c --- /dev/null +++ b/tests/scenarios/cmd/printf/errors/star_width_invalid.yaml @@ -0,0 +1,8 @@ +description: Invalid number for star width produces error with exit 1. +input: + script: |+ + printf "%*d\n" abc 42 +expect: + stdout: "42\n" + stderr_contains: ["printf:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/printf/errors/unknown_flag_h.yaml b/tests/scenarios/cmd/printf/errors/unknown_flag_h.yaml new file mode 100644 index 00000000..acec5e6a --- /dev/null +++ b/tests/scenarios/cmd/printf/errors/unknown_flag_h.yaml @@ -0,0 +1,8 @@ +description: Printf rejects unknown single-dash flag -h with exit 2. +input: + script: |+ + printf -h +expect: + stdout: "" + stderr_contains: ["invalid option"] + exit_code: 2 diff --git a/tests/scenarios/cmd/printf/errors/unknown_flag_long.yaml b/tests/scenarios/cmd/printf/errors/unknown_flag_long.yaml new file mode 100644 index 00000000..f1eb179f --- /dev/null +++ b/tests/scenarios/cmd/printf/errors/unknown_flag_long.yaml @@ -0,0 +1,8 @@ +description: Printf rejects unknown long flag --follow with exit 2. +input: + script: |+ + printf --follow "%s" hello +expect: + stdout: "" + stderr_contains: ["invalid option"] + exit_code: 2 diff --git a/tests/scenarios/cmd/printf/errors/unknown_specifier_stops.yaml b/tests/scenarios/cmd/printf/errors/unknown_specifier_stops.yaml new file mode 100644 index 00000000..6119a4c0 --- /dev/null +++ b/tests/scenarios/cmd/printf/errors/unknown_specifier_stops.yaml @@ -0,0 +1,8 @@ +description: Printf stops processing format string after unknown specifier (no trailing output). +input: + script: |+ + printf '%yABC\n' +expect: + stdout: "" + stderr_contains: ["invalid format character"] + exit_code: 1 diff --git a/tests/scenarios/cmd/printf/escapes/backslash.yaml b/tests/scenarios/cmd/printf/escapes/backslash.yaml new file mode 100644 index 00000000..d81295b3 --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/backslash.yaml @@ -0,0 +1,8 @@ +description: Printf interprets double backslash as a literal backslash. +input: + script: |+ + printf "a\\\\b\n" +expect: + stdout: "a\\b\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/bell_and_others.yaml b/tests/scenarios/cmd/printf/escapes/bell_and_others.yaml new file mode 100644 index 00000000..a1952f30 --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/bell_and_others.yaml @@ -0,0 +1,8 @@ +description: Printf interprets special escape sequences like bell, backspace, form feed, and vertical tab. +input: + script: |+ + printf "\a\b\f\v" +expect: + stdout: "\a\b\f\v" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/carriage_return.yaml b/tests/scenarios/cmd/printf/escapes/carriage_return.yaml new file mode 100644 index 00000000..c6b6c489 --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/carriage_return.yaml @@ -0,0 +1,8 @@ +description: Printf interprets backslash-r as a carriage return. +input: + script: |+ + printf "hello\rworld\n" +expect: + stdout: "hello\rworld\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/hex.yaml b/tests/scenarios/cmd/printf/escapes/hex.yaml new file mode 100644 index 00000000..f3cb7cf8 --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/hex.yaml @@ -0,0 +1,9 @@ +description: Printf interprets hex escape sequences in the format string. +input: + script: |+ + printf "\x41\n" +expect: + stdout: |+ + A + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/hex_single_digit.yaml b/tests/scenarios/cmd/printf/escapes/hex_single_digit.yaml new file mode 100644 index 00000000..851166f2 --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/hex_single_digit.yaml @@ -0,0 +1,7 @@ +description: Single hex digit escape in format string. +input: + script: |+ + printf '\xF' +expect: + stdout: "\x0f" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/hex_truncation.yaml b/tests/scenarios/cmd/printf/escapes/hex_truncation.yaml new file mode 100644 index 00000000..5010f0bd --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/hex_truncation.yaml @@ -0,0 +1,7 @@ +description: Hex escape consumes at most 2 digits, third character is literal. +input: + script: |+ + printf '\x414' +expect: + stdout: "A4" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/invalid_octal_digit_8.yaml b/tests/scenarios/cmd/printf/escapes/invalid_octal_digit_8.yaml new file mode 100644 index 00000000..83d214ab --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/invalid_octal_digit_8.yaml @@ -0,0 +1,7 @@ +description: "\\8 is not valid octal, output as literal backslash-8." +input: + script: |+ + printf '\8' +expect: + stdout: "\\8" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/invalid_octal_digit_9.yaml b/tests/scenarios/cmd/printf/escapes/invalid_octal_digit_9.yaml new file mode 100644 index 00000000..f617b6c8 --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/invalid_octal_digit_9.yaml @@ -0,0 +1,7 @@ +description: "\\9 is not valid octal, output as literal backslash-9." +input: + script: |+ + printf '\9' +expect: + stdout: "\\9" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/newline.yaml b/tests/scenarios/cmd/printf/escapes/newline.yaml new file mode 100644 index 00000000..7d968678 --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/newline.yaml @@ -0,0 +1,10 @@ +description: Printf interprets backslash-n as a newline in the format string. +input: + script: |+ + printf "a\nb\n" +expect: + stdout: |+ + a + b + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/octal.yaml b/tests/scenarios/cmd/printf/escapes/octal.yaml new file mode 100644 index 00000000..a9844e2b --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/octal.yaml @@ -0,0 +1,9 @@ +description: Printf interprets octal escape sequences in the format string. +input: + script: |+ + printf "\101\n" +expect: + stdout: |+ + A + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/octal_single_digit.yaml b/tests/scenarios/cmd/printf/escapes/octal_single_digit.yaml new file mode 100644 index 00000000..5c84cae6 --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/octal_single_digit.yaml @@ -0,0 +1,7 @@ +description: Single octal digit escape in format string. +input: + script: |+ + printf "\1" +expect: + stdout: "\x01" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/octal_truncation.yaml b/tests/scenarios/cmd/printf/escapes/octal_truncation.yaml new file mode 100644 index 00000000..64faff05 --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/octal_truncation.yaml @@ -0,0 +1,7 @@ +description: Octal escape consumes at most 3 digits in format string. +input: + script: |+ + printf "\1234" +expect: + stdout: "S4" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/octal_two_digits.yaml b/tests/scenarios/cmd/printf/escapes/octal_two_digits.yaml new file mode 100644 index 00000000..4b63f1f7 --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/octal_two_digits.yaml @@ -0,0 +1,7 @@ +description: Two octal digit escape in format string produces newline. +input: + script: |+ + printf "\12" +expect: + stdout: "\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/octal_zero_prefix_truncation.yaml b/tests/scenarios/cmd/printf/escapes/octal_zero_prefix_truncation.yaml new file mode 100644 index 00000000..d0d7f9b1 --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/octal_zero_prefix_truncation.yaml @@ -0,0 +1,7 @@ +description: Octal \0 prefix consumes at most 2 more digits (3 total including the leading 0). +input: + script: |+ + printf '\077end' +expect: + stdout: "?end" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/tab.yaml b/tests/scenarios/cmd/printf/escapes/tab.yaml new file mode 100644 index 00000000..2f183ef7 --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/tab.yaml @@ -0,0 +1,8 @@ +description: Printf interprets backslash-t as a tab in the format string. +input: + script: |+ + printf "a\tb\n" +expect: + stdout: "a\tb\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/unicode_b_specifier.yaml b/tests/scenarios/cmd/printf/escapes/unicode_b_specifier.yaml new file mode 100644 index 00000000..aec0108b --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/unicode_b_specifier.yaml @@ -0,0 +1,9 @@ +description: Printf interprets \u and \U Unicode escapes in %b arguments. +input: + script: |+ + printf "%b\n" "\u0042" +expect: + stdout: |+ + B + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/unicode_emoji.yaml b/tests/scenarios/cmd/printf/escapes/unicode_emoji.yaml new file mode 100644 index 00000000..c91c2447 --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/unicode_emoji.yaml @@ -0,0 +1,9 @@ +description: Printf interprets \U for emoji Unicode code points. +skip_assert_against_bash: true # bash outputs literal \U in POSIX locale; rshell always emits UTF-8 +input: + script: |+ + printf "\U0001F600\n" +expect: + stdout: "😀\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/unicode_multibyte.yaml b/tests/scenarios/cmd/printf/escapes/unicode_multibyte.yaml new file mode 100644 index 00000000..1a01a1fe --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/unicode_multibyte.yaml @@ -0,0 +1,10 @@ +description: Printf interprets \u for multi-byte Unicode characters. +skip_assert_against_bash: true # bash outputs literal \u in POSIX locale; rshell always emits UTF-8 +input: + script: |+ + printf "\u00e9\n" +expect: + stdout: |+ + é + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/unicode_no_digits.yaml b/tests/scenarios/cmd/printf/escapes/unicode_no_digits.yaml new file mode 100644 index 00000000..2a87a550 --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/unicode_no_digits.yaml @@ -0,0 +1,9 @@ +description: Printf outputs literal \u and emits error when no hex digits follow. +input: + script: |+ + printf "\uz\n" +expect: + stdout: |+ + \uz + stderr_contains: ["printf: missing unicode digit for \\u"] + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/unicode_u.yaml b/tests/scenarios/cmd/printf/escapes/unicode_u.yaml new file mode 100644 index 00000000..1eb0e666 --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/unicode_u.yaml @@ -0,0 +1,9 @@ +description: Printf interprets \u Unicode escape sequences in the format string. +input: + script: |+ + printf "\u0041\n" +expect: + stdout: |+ + A + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/escapes/unicode_upper_U.yaml b/tests/scenarios/cmd/printf/escapes/unicode_upper_U.yaml new file mode 100644 index 00000000..2158a5aa --- /dev/null +++ b/tests/scenarios/cmd/printf/escapes/unicode_upper_U.yaml @@ -0,0 +1,9 @@ +description: Printf interprets \U Unicode escape sequences in the format string. +input: + script: |+ + printf "\U00000041\n" +expect: + stdout: |+ + A + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/bare_quote_char_constant.yaml b/tests/scenarios/cmd/printf/numeric/bare_quote_char_constant.yaml new file mode 100644 index 00000000..f0b2c0f4 --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/bare_quote_char_constant.yaml @@ -0,0 +1,8 @@ +description: Printf treats a bare single-quote character constant as value 0. +input: + script: |+ + printf '%d\n' "'" +expect: + stdout: |+ + 0 + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/char_constant.yaml b/tests/scenarios/cmd/printf/numeric/char_constant.yaml new file mode 100644 index 00000000..be6d4921 --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/char_constant.yaml @@ -0,0 +1,9 @@ +description: Printf converts a character constant to its ASCII value. +input: + script: |+ + printf "%d\n" "'A" +expect: + stdout: |+ + 65 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/char_constant_double_quote.yaml b/tests/scenarios/cmd/printf/numeric/char_constant_double_quote.yaml new file mode 100644 index 00000000..0446bf5b --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/char_constant_double_quote.yaml @@ -0,0 +1,7 @@ +description: Double-quote character constant gives ASCII value. +input: + script: |+ + printf "%d\n" '"A' +expect: + stdout: "65\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/char_constant_double_quote_float.yaml b/tests/scenarios/cmd/printf/numeric/char_constant_double_quote_float.yaml new file mode 100644 index 00000000..63fdc025 --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/char_constant_double_quote_float.yaml @@ -0,0 +1,7 @@ +description: Double-quote character constant with float format gives ASCII value. +input: + script: |+ + printf "%f\n" '"A' +expect: + stdout: "65.000000\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/char_constant_double_quote_unsigned.yaml b/tests/scenarios/cmd/printf/numeric/char_constant_double_quote_unsigned.yaml new file mode 100644 index 00000000..9abdc383 --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/char_constant_double_quote_unsigned.yaml @@ -0,0 +1,7 @@ +description: Double-quote character constant with unsigned format gives ASCII value. +input: + script: |+ + printf "%u\n" '"A' +expect: + stdout: "65\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/empty_arg_with_width.yaml b/tests/scenarios/cmd/printf/numeric/empty_arg_with_width.yaml new file mode 100644 index 00000000..65957da1 --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/empty_arg_with_width.yaml @@ -0,0 +1,7 @@ +description: Empty string arg with width for %d defaults to 0 with padding. +input: + script: |+ + printf "%5d\n" "" +expect: + stdout: " 0\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/float_octal_decimal.yaml b/tests/scenarios/cmd/printf/numeric/float_octal_decimal.yaml new file mode 100644 index 00000000..eaa9755a --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/float_octal_decimal.yaml @@ -0,0 +1,10 @@ +description: Printf %f treats leading-zero args as decimal, not octal (bash compat). +input: + script: |+ + printf "%f\n" 0755 + printf "%f\n" 010 +expect: + stdout: |+ + 755.000000 + 10.000000 + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/float_overflow.yaml b/tests/scenarios/cmd/printf/numeric/float_overflow.yaml new file mode 100644 index 00000000..139a16cd --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/float_overflow.yaml @@ -0,0 +1,9 @@ +description: Float range overflow outputs inf with warning and exit 0 (Go uses +Inf for overflow). +skip_assert_against_bash: true # Go's strconv.ParseFloat returns +Inf; C's strtod + printf outputs the actual number +input: + script: |+ + printf "%f\n" 1e999 +expect: + stdout: "inf\n" + stderr_contains: ["Numerical result out of range"] + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/float_prefix_extraction.yaml b/tests/scenarios/cmd/printf/numeric/float_prefix_extraction.yaml new file mode 100644 index 00000000..de7b7ed9 --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/float_prefix_extraction.yaml @@ -0,0 +1,8 @@ +description: Float args with trailing garbage use numeric prefix (bash compat). +input: + script: |+ + printf "%f\n" 1abc +expect: + stdout: "1.000000\n" + stderr_contains: ["invalid number"] + exit_code: 1 diff --git a/tests/scenarios/cmd/printf/numeric/hex_input.yaml b/tests/scenarios/cmd/printf/numeric/hex_input.yaml new file mode 100644 index 00000000..abad7a70 --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/hex_input.yaml @@ -0,0 +1,9 @@ +description: Printf converts hexadecimal input to decimal. +input: + script: |+ + printf "%d\n" 0xff +expect: + stdout: |+ + 255 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/hex_negative_wrap.yaml b/tests/scenarios/cmd/printf/numeric/hex_negative_wrap.yaml new file mode 100644 index 00000000..76a2e42a --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/hex_negative_wrap.yaml @@ -0,0 +1,7 @@ +description: Hex format wraps negative -1 to ffffffffffffffff. +input: + script: |+ + printf "%x\n" -1 +expect: + stdout: "ffffffffffffffff\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/int_overflow_negative.yaml b/tests/scenarios/cmd/printf/numeric/int_overflow_negative.yaml new file mode 100644 index 00000000..9f21a989 --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/int_overflow_negative.yaml @@ -0,0 +1,8 @@ +description: Negative integer overflow clamps to MinInt64 with warning and exit 0. +input: + script: |+ + printf "%d\n" -9999999999999999999 +expect: + stdout: "-9223372036854775808\n" + stderr_contains: ["Numerical result out of range"] + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/int_overflow_positive.yaml b/tests/scenarios/cmd/printf/numeric/int_overflow_positive.yaml new file mode 100644 index 00000000..ceca6faa --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/int_overflow_positive.yaml @@ -0,0 +1,8 @@ +description: Integer overflow clamps to MaxInt64 with warning and exit 0 (bash compat). +input: + script: |+ + printf "%d\n" 9223372036854775808 +expect: + stdout: "9223372036854775807\n" + stderr_contains: ["Numerical result out of range"] + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/int_prefix_float_input.yaml b/tests/scenarios/cmd/printf/numeric/int_prefix_float_input.yaml new file mode 100644 index 00000000..dbae1711 --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/int_prefix_float_input.yaml @@ -0,0 +1,8 @@ +description: Printf %d extracts numeric prefix from float input (bash compat). +input: + script: |+ + printf "%d\n" 3.14 +expect: + stdout: "3\n" + stderr_contains: ["printf:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/printf/numeric/int_prefix_mixed_input.yaml b/tests/scenarios/cmd/printf/numeric/int_prefix_mixed_input.yaml new file mode 100644 index 00000000..2cb58757 --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/int_prefix_mixed_input.yaml @@ -0,0 +1,8 @@ +description: Printf %d extracts numeric prefix from mixed alphanumeric input (bash compat). +input: + script: |+ + printf "%d\n" 123abc +expect: + stdout: "123\n" + stderr_contains: ["printf:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/printf/numeric/negative.yaml b/tests/scenarios/cmd/printf/numeric/negative.yaml new file mode 100644 index 00000000..2365e634 --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/negative.yaml @@ -0,0 +1,9 @@ +description: Printf handles negative integer arguments. +input: + script: |+ + printf "%d\n" -42 +expect: + stdout: |+ + -42 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/octal_input.yaml b/tests/scenarios/cmd/printf/numeric/octal_input.yaml new file mode 100644 index 00000000..edc06112 --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/octal_input.yaml @@ -0,0 +1,9 @@ +description: Printf converts octal input to decimal. +input: + script: |+ + printf "%d\n" 0755 +expect: + stdout: |+ + 493 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/octal_negative_wrap.yaml b/tests/scenarios/cmd/printf/numeric/octal_negative_wrap.yaml new file mode 100644 index 00000000..c961df4d --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/octal_negative_wrap.yaml @@ -0,0 +1,7 @@ +description: Octal format wraps negative -1 to max uint64 in octal. +input: + script: |+ + printf "%o\n" -1 +expect: + stdout: "1777777777777777777777\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/signed_hex_float.yaml b/tests/scenarios/cmd/printf/numeric/signed_hex_float.yaml new file mode 100644 index 00000000..e5defa86 --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/signed_hex_float.yaml @@ -0,0 +1,8 @@ +description: Printf accepts signed hex integer as float argument. +input: + script: |+ + printf '%f\n' -0xff +expect: + stdout: |+ + -255.000000 + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/signed_nan.yaml b/tests/scenarios/cmd/printf/numeric/signed_nan.yaml new file mode 100644 index 00000000..db8bb3de --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/signed_nan.yaml @@ -0,0 +1,8 @@ +description: Signed NaN forms +nan and -nan are accepted (bash compat). +skip_assert_against_bash: true # Go's math.NaN() doesn't preserve sign; bash outputs -nan for -nan +input: + script: |+ + printf "%f %f %f\n" nan +nan -nan +expect: + stdout: "nan nan nan\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/uint_overflow.yaml b/tests/scenarios/cmd/printf/numeric/uint_overflow.yaml new file mode 100644 index 00000000..2954357e --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/uint_overflow.yaml @@ -0,0 +1,8 @@ +description: Unsigned integer overflow clamps to MaxUint64 with warning and exit 0. +input: + script: |+ + printf "%u\n" 99999999999999999999 +expect: + stdout: "18446744073709551615\n" + stderr_contains: ["Numerical result out of range"] + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/uint_overflow_negative.yaml b/tests/scenarios/cmd/printf/numeric/uint_overflow_negative.yaml new file mode 100644 index 00000000..f3efa357 --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/uint_overflow_negative.yaml @@ -0,0 +1,8 @@ +description: Unsigned overflow for huge negative clamps to MaxUint64 with warning (bash compat). +input: + script: |+ + printf "%u\n" -999999999999999999999 +expect: + stdout: "18446744073709551615\n" + stderr_contains: ["Numerical result out of range"] + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/unsigned_large_hex.yaml b/tests/scenarios/cmd/printf/numeric/unsigned_large_hex.yaml new file mode 100644 index 00000000..0a229c25 --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/unsigned_large_hex.yaml @@ -0,0 +1,7 @@ +description: Large hex value parsed correctly for unsigned format. +input: + script: |+ + printf "%u\n" 0x7FFFFFFFFFFFFFFF +expect: + stdout: "9223372036854775807\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/unsigned_negative_wrap.yaml b/tests/scenarios/cmd/printf/numeric/unsigned_negative_wrap.yaml new file mode 100644 index 00000000..d7045131 --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/unsigned_negative_wrap.yaml @@ -0,0 +1,7 @@ +description: Unsigned format wraps negative -1 to max uint64. +input: + script: |+ + printf "%u\n" -1 +expect: + stdout: "18446744073709551615\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/numeric/zero.yaml b/tests/scenarios/cmd/printf/numeric/zero.yaml new file mode 100644 index 00000000..83a7adcf --- /dev/null +++ b/tests/scenarios/cmd/printf/numeric/zero.yaml @@ -0,0 +1,9 @@ +description: Printf handles zero as an integer argument. +input: + script: |+ + printf "%d\n" 0 +expect: + stdout: |+ + 0 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/shell_features/command_substitution.yaml b/tests/scenarios/cmd/printf/shell_features/command_substitution.yaml new file mode 100644 index 00000000..3ddade23 --- /dev/null +++ b/tests/scenarios/cmd/printf/shell_features/command_substitution.yaml @@ -0,0 +1,10 @@ +description: Printf output can be captured via command substitution. +skip_assert_against_bash: true +input: + script: |+ + X=$(printf "%05d" 42); echo "$X" +expect: + stdout: "" + stderr: |+ + command substitution is not supported + exit_code: 2 diff --git a/tests/scenarios/cmd/printf/shell_features/in_for_loop.yaml b/tests/scenarios/cmd/printf/shell_features/in_for_loop.yaml new file mode 100644 index 00000000..9bad84c1 --- /dev/null +++ b/tests/scenarios/cmd/printf/shell_features/in_for_loop.yaml @@ -0,0 +1,8 @@ +description: Printf works inside a for loop. +input: + script: |+ + for i in 1 2 3; do printf "%d " "$i"; done; printf "\n" +expect: + stdout: "1 2 3 \n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/shell_features/in_pipeline.yaml b/tests/scenarios/cmd/printf/shell_features/in_pipeline.yaml new file mode 100644 index 00000000..5a124df1 --- /dev/null +++ b/tests/scenarios/cmd/printf/shell_features/in_pipeline.yaml @@ -0,0 +1,9 @@ +description: Printf output can be piped to another command. +input: + script: |+ + printf "%s\n" hello | cat +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/shell_features/variable_expansion.yaml b/tests/scenarios/cmd/printf/shell_features/variable_expansion.yaml new file mode 100644 index 00000000..a1ef4967 --- /dev/null +++ b/tests/scenarios/cmd/printf/shell_features/variable_expansion.yaml @@ -0,0 +1,9 @@ +description: Printf works with shell variable expansion. +input: + script: |+ + NAME=world; printf "hello %s\n" "$NAME" +expect: + stdout: |+ + hello world + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/b_backslash_c_stops_reuse.yaml b/tests/scenarios/cmd/printf/specifiers/b_backslash_c_stops_reuse.yaml new file mode 100644 index 00000000..1ffb0ee8 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/b_backslash_c_stops_reuse.yaml @@ -0,0 +1,7 @@ +description: "%b \\c stops all output including format reuse." +input: + script: |+ + printf "%b %s\n" 'stop\c' notprinted extra +expect: + stdout: "stop" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/b_escape.yaml b/tests/scenarios/cmd/printf/specifiers/b_escape.yaml new file mode 100644 index 00000000..53f252ff --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/b_escape.yaml @@ -0,0 +1,8 @@ +description: Printf %b specifier interprets backslash escapes in the argument. +input: + script: |+ + printf "%b\n" 'hello\tworld' +expect: + stdout: "hello\tworld\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/b_hex_one_digit.yaml b/tests/scenarios/cmd/printf/specifiers/b_hex_one_digit.yaml new file mode 100644 index 00000000..58ea791b --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/b_hex_one_digit.yaml @@ -0,0 +1,8 @@ +description: "%b \\xF with single hex digit works correctly." +input: + script: |+ + printf "%b" '\xF' +expect: + stdout: "\x0f" + exit_code: 0 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/printf/specifiers/b_multiple_escapes.yaml b/tests/scenarios/cmd/printf/specifiers/b_multiple_escapes.yaml new file mode 100644 index 00000000..c4090a1f --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/b_multiple_escapes.yaml @@ -0,0 +1,7 @@ +description: "%b handles multiple escape sequences in one argument." +input: + script: |+ + printf "%b" 'a\tb\nc' +expect: + stdout: "a\tb\nc" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/b_octal_no_digits.yaml b/tests/scenarios/cmd/printf/specifiers/b_octal_no_digits.yaml new file mode 100644 index 00000000..5ac00f8a --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/b_octal_no_digits.yaml @@ -0,0 +1,8 @@ +description: "%b \\0 followed by non-octal produces NUL byte." +input: + script: |+ + printf "%b" '\0x' +expect: + stdout: "\0x" + exit_code: 0 +skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/printf/specifiers/b_octal_without_leading_zero.yaml b/tests/scenarios/cmd/printf/specifiers/b_octal_without_leading_zero.yaml new file mode 100644 index 00000000..1662af8a --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/b_octal_without_leading_zero.yaml @@ -0,0 +1,8 @@ +description: Printf %b supports \NNN octal escapes without leading zero (e.g. \101 = A). +input: + script: |+ + printf '%b\n' '\101' +expect: + stdout: |+ + A + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/b_with_backslash_c.yaml b/tests/scenarios/cmd/printf/specifiers/b_with_backslash_c.yaml new file mode 100644 index 00000000..5e076da2 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/b_with_backslash_c.yaml @@ -0,0 +1,8 @@ +description: Printf %b with backslash-c in argument stops output immediately. +input: + script: |+ + printf "%b" 'hello\cworld' +expect: + stdout: "hello" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/char_c.yaml b/tests/scenarios/cmd/printf/specifiers/char_c.yaml new file mode 100644 index 00000000..46bac25d --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/char_c.yaml @@ -0,0 +1,9 @@ +description: Printf %c specifier outputs the first character of the argument. +input: + script: |+ + printf "%c\n" A +expect: + stdout: |+ + A + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/char_c_ignore_precision.yaml b/tests/scenarios/cmd/printf/specifiers/char_c_ignore_precision.yaml new file mode 100644 index 00000000..44f50468 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/char_c_ignore_precision.yaml @@ -0,0 +1,8 @@ +description: "%c ignores precision — bash always prints one byte regardless of precision." +input: + script: |+ + printf '%.0c\n' A + printf '%5.0c\n' A +expect: + stdout: "A\n A\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/char_c_multibyte.yaml b/tests/scenarios/cmd/printf/specifiers/char_c_multibyte.yaml new file mode 100644 index 00000000..d2e337c7 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/char_c_multibyte.yaml @@ -0,0 +1,7 @@ +description: "%c takes the first byte of a multi-character argument." +input: + script: |+ + printf "%c" hello +expect: + stdout: "h" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/decimal_d.yaml b/tests/scenarios/cmd/printf/specifiers/decimal_d.yaml new file mode 100644 index 00000000..5a309e05 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/decimal_d.yaml @@ -0,0 +1,9 @@ +description: Printf %d specifier outputs a decimal integer. +input: + script: |+ + printf "%d\n" 42 +expect: + stdout: |+ + 42 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/float_f.yaml b/tests/scenarios/cmd/printf/specifiers/float_f.yaml new file mode 100644 index 00000000..4eb36928 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/float_f.yaml @@ -0,0 +1,9 @@ +description: Printf %f specifier outputs a floating point number with default precision. +input: + script: |+ + printf "%f\n" 3.14 +expect: + stdout: |+ + 3.140000 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/float_f_upper_infinity.yaml b/tests/scenarios/cmd/printf/specifiers/float_f_upper_infinity.yaml new file mode 100644 index 00000000..2aa01b43 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/float_f_upper_infinity.yaml @@ -0,0 +1,7 @@ +description: "%F uppercases infinity to INF." +input: + script: |+ + printf "%F\n" inf +expect: + stdout: "INF\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/float_f_upper_nan.yaml b/tests/scenarios/cmd/printf/specifiers/float_f_upper_nan.yaml new file mode 100644 index 00000000..6b2618b9 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/float_f_upper_nan.yaml @@ -0,0 +1,7 @@ +description: "%F uppercases NaN to NAN." +input: + script: |+ + printf "%F\n" nan +expect: + stdout: "NAN\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/float_hex_large_unsigned.yaml b/tests/scenarios/cmd/printf/specifiers/float_hex_large_unsigned.yaml new file mode 100644 index 00000000..e47c8425 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/float_hex_large_unsigned.yaml @@ -0,0 +1,8 @@ +description: "%f handles large unsigned hex values like 0xffffffffffffffff." +skip_assert_against_bash: true # Go float64 rounds 2^64-1 to 2^64; C printf preserves exact value +input: + script: |+ + printf "%f\n" 0xffffffffffffffff +expect: + stdout: "18446744073709551616.000000\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/float_nan_case.yaml b/tests/scenarios/cmd/printf/specifiers/float_nan_case.yaml new file mode 100644 index 00000000..ad3d1525 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/float_nan_case.yaml @@ -0,0 +1,7 @@ +description: NaN with mixed case is accepted by float format. +input: + script: |+ + printf "%f\n" NaN +expect: + stdout: "nan\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/float_plus_flag_infinity.yaml b/tests/scenarios/cmd/printf/specifiers/float_plus_flag_infinity.yaml new file mode 100644 index 00000000..01ea3782 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/float_plus_flag_infinity.yaml @@ -0,0 +1,10 @@ +description: "%+f preserves + sign for positive infinity." +input: + script: |+ + printf "%+f\n" inf + printf "%+F\n" inf + printf "% f\n" inf + printf "%f\n" inf +expect: + stdout: "+inf\n+INF\n inf\ninf\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/hex_lower.yaml b/tests/scenarios/cmd/printf/specifiers/hex_lower.yaml new file mode 100644 index 00000000..bf670a9c --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/hex_lower.yaml @@ -0,0 +1,9 @@ +description: Printf %x specifier outputs lowercase hexadecimal. +input: + script: |+ + printf "%x\n" 255 +expect: + stdout: |+ + ff + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/hex_upper.yaml b/tests/scenarios/cmd/printf/specifiers/hex_upper.yaml new file mode 100644 index 00000000..05102eb0 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/hex_upper.yaml @@ -0,0 +1,9 @@ +description: Printf %X specifier outputs uppercase hexadecimal. +input: + script: |+ + printf "%X\n" 255 +expect: + stdout: |+ + FF + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/incomplete_with_flags.yaml b/tests/scenarios/cmd/printf/specifiers/incomplete_with_flags.yaml new file mode 100644 index 00000000..90f32fa4 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/incomplete_with_flags.yaml @@ -0,0 +1,8 @@ +description: Incomplete specifier with flags produces an error. +input: + script: |+ + printf "%-" +expect: + stdout: "" + stderr_contains: ["printf: `%-': missing format character"] + exit_code: 1 diff --git a/tests/scenarios/cmd/printf/specifiers/incomplete_with_width.yaml b/tests/scenarios/cmd/printf/specifiers/incomplete_with_width.yaml new file mode 100644 index 00000000..d39a8369 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/incomplete_with_width.yaml @@ -0,0 +1,8 @@ +description: Incomplete specifier with width produces an error. +input: + script: |+ + printf "%10" +expect: + stdout: "" + stderr_contains: ["printf: `%10': missing format character"] + exit_code: 1 diff --git a/tests/scenarios/cmd/printf/specifiers/integer_i.yaml b/tests/scenarios/cmd/printf/specifiers/integer_i.yaml new file mode 100644 index 00000000..10c4279b --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/integer_i.yaml @@ -0,0 +1,9 @@ +description: Printf %i specifier outputs a decimal integer (same as %d). +input: + script: |+ + printf "%i\n" 42 +expect: + stdout: |+ + 42 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/length_modifiers.yaml b/tests/scenarios/cmd/printf/specifiers/length_modifiers.yaml new file mode 100644 index 00000000..0f017b89 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/length_modifiers.yaml @@ -0,0 +1,12 @@ +description: Printf accepts and ignores C-style length modifiers (bash compat). +input: + script: |+ + printf "%ld\n" 42 + printf "%hd\n" 42 + printf "%lld\n" 42 +expect: + stdout: |+ + 42 + 42 + 42 + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/octal_o.yaml b/tests/scenarios/cmd/printf/specifiers/octal_o.yaml new file mode 100644 index 00000000..dd6af69c --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/octal_o.yaml @@ -0,0 +1,9 @@ +description: Printf %o specifier outputs an octal representation. +input: + script: |+ + printf "%o\n" 255 +expect: + stdout: |+ + 377 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/scientific_e.yaml b/tests/scenarios/cmd/printf/specifiers/scientific_e.yaml new file mode 100644 index 00000000..a8fd73d7 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/scientific_e.yaml @@ -0,0 +1,9 @@ +description: Printf %e specifier outputs a number in scientific notation. +input: + script: |+ + printf "%e\n" 3.14 +expect: + stdout: |+ + 3.140000e+00 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/shortest_g.yaml b/tests/scenarios/cmd/printf/specifiers/shortest_g.yaml new file mode 100644 index 00000000..ceffe019 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/shortest_g.yaml @@ -0,0 +1,9 @@ +description: Printf %g specifier outputs the shortest representation of a float. +input: + script: |+ + printf "%g\n" 3.14 +expect: + stdout: |+ + 3.14 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/string_s.yaml b/tests/scenarios/cmd/printf/specifiers/string_s.yaml new file mode 100644 index 00000000..b0e4b131 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/string_s.yaml @@ -0,0 +1,9 @@ +description: Printf %s specifier outputs a string argument. +input: + script: |+ + printf "%s\n" hello +expect: + stdout: |+ + hello + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/unsigned_sign_flags.yaml b/tests/scenarios/cmd/printf/specifiers/unsigned_sign_flags.yaml new file mode 100644 index 00000000..79ea0f69 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/unsigned_sign_flags.yaml @@ -0,0 +1,9 @@ +description: Printf unsigned conversions ignore + and space sign flags (bash compat). +input: + script: |+ + printf "%+u|% u|%+x|% o\n" 42 42 42 42 +expect: + stdout: |+ + 42|42|2a|52 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/specifiers/unsigned_u.yaml b/tests/scenarios/cmd/printf/specifiers/unsigned_u.yaml new file mode 100644 index 00000000..8093ae18 --- /dev/null +++ b/tests/scenarios/cmd/printf/specifiers/unsigned_u.yaml @@ -0,0 +1,9 @@ +description: Printf %u specifier outputs an unsigned decimal integer. +input: + script: |+ + printf "%u\n" 42 +expect: + stdout: |+ + 42 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/width_precision/conflicting_minus_zero.yaml b/tests/scenarios/cmd/printf/width_precision/conflicting_minus_zero.yaml new file mode 100644 index 00000000..2cd8761f --- /dev/null +++ b/tests/scenarios/cmd/printf/width_precision/conflicting_minus_zero.yaml @@ -0,0 +1,7 @@ +description: Left-align flag overrides zero-pad flag. +input: + script: |+ + printf "%-05d|\n" 42 +expect: + stdout: "42 |\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/width_precision/conflicting_plus_space.yaml b/tests/scenarios/cmd/printf/width_precision/conflicting_plus_space.yaml new file mode 100644 index 00000000..42041a97 --- /dev/null +++ b/tests/scenarios/cmd/printf/width_precision/conflicting_plus_space.yaml @@ -0,0 +1,7 @@ +description: Plus flag overrides space flag. +input: + script: |+ + printf "%+ d\n" 42 +expect: + stdout: "+42\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/width_precision/left_align.yaml b/tests/scenarios/cmd/printf/width_precision/left_align.yaml new file mode 100644 index 00000000..de1a3fa5 --- /dev/null +++ b/tests/scenarios/cmd/printf/width_precision/left_align.yaml @@ -0,0 +1,9 @@ +description: Printf left-aligns a string within a specified width using the minus flag. +input: + script: |+ + printf "%-10s|\n" hi +expect: + stdout: |+ + hi | + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/width_precision/precision_float.yaml b/tests/scenarios/cmd/printf/width_precision/precision_float.yaml new file mode 100644 index 00000000..6d7c64ac --- /dev/null +++ b/tests/scenarios/cmd/printf/width_precision/precision_float.yaml @@ -0,0 +1,9 @@ +description: Printf applies precision to a floating point number. +input: + script: |+ + printf "%.2f\n" 3.14159 +expect: + stdout: |+ + 3.14 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/width_precision/precision_string.yaml b/tests/scenarios/cmd/printf/width_precision/precision_string.yaml new file mode 100644 index 00000000..b212a37b --- /dev/null +++ b/tests/scenarios/cmd/printf/width_precision/precision_string.yaml @@ -0,0 +1,9 @@ +description: Printf applies precision to truncate a string. +input: + script: |+ + printf "%.3s\n" hello +expect: + stdout: |+ + hel + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/width_precision/right_align.yaml b/tests/scenarios/cmd/printf/width_precision/right_align.yaml new file mode 100644 index 00000000..75fdae9b --- /dev/null +++ b/tests/scenarios/cmd/printf/width_precision/right_align.yaml @@ -0,0 +1,8 @@ +description: Printf right-aligns a string within a specified width. +input: + script: |+ + printf "%10s\n" hi +expect: + stdout: " hi\n" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/width_precision/star_precision.yaml b/tests/scenarios/cmd/printf/width_precision/star_precision.yaml new file mode 100644 index 00000000..128273b9 --- /dev/null +++ b/tests/scenarios/cmd/printf/width_precision/star_precision.yaml @@ -0,0 +1,7 @@ +description: Star precision from argument controls float decimals. +input: + script: |+ + printf "%.*f\n" 2 3.14159 +expect: + stdout: "3.14\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/width_precision/star_precision_empty.yaml b/tests/scenarios/cmd/printf/width_precision/star_precision_empty.yaml new file mode 100644 index 00000000..d43dd500 --- /dev/null +++ b/tests/scenarios/cmd/printf/width_precision/star_precision_empty.yaml @@ -0,0 +1,7 @@ +description: Star precision with no argument defaults to 0 precision. +input: + script: |+ + printf "%.*f\n" +expect: + stdout: "0\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/width_precision/star_precision_float_prefix.yaml b/tests/scenarios/cmd/printf/width_precision/star_precision_float_prefix.yaml new file mode 100644 index 00000000..b20bab4a --- /dev/null +++ b/tests/scenarios/cmd/printf/width_precision/star_precision_float_prefix.yaml @@ -0,0 +1,8 @@ +description: Printf star precision extracts numeric prefix from float arg (bash compat). +input: + script: |+ + printf "%.*s\n" 3.14 hello +expect: + stdout: "hel\n" + stderr_contains: ["printf:"] + exit_code: 1 diff --git a/tests/scenarios/cmd/printf/width_precision/star_precision_zero.yaml b/tests/scenarios/cmd/printf/width_precision/star_precision_zero.yaml new file mode 100644 index 00000000..03f84516 --- /dev/null +++ b/tests/scenarios/cmd/printf/width_precision/star_precision_zero.yaml @@ -0,0 +1,7 @@ +description: Star precision of 0 on float suppresses decimals. +input: + script: |+ + printf "%.*f\n" 0 3.14159 +expect: + stdout: "3\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/width_precision/star_width.yaml b/tests/scenarios/cmd/printf/width_precision/star_width.yaml new file mode 100644 index 00000000..f98d93c6 --- /dev/null +++ b/tests/scenarios/cmd/printf/width_precision/star_width.yaml @@ -0,0 +1,7 @@ +description: Star width from argument right-aligns string. +input: + script: |+ + printf "%*s\n" 10 hello +expect: + stdout: " hello\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/width_precision/star_width_char_constant.yaml b/tests/scenarios/cmd/printf/width_precision/star_width_char_constant.yaml new file mode 100644 index 00000000..0867b9ef --- /dev/null +++ b/tests/scenarios/cmd/printf/width_precision/star_width_char_constant.yaml @@ -0,0 +1,7 @@ +description: Bare quote character constant as star width argument yields width 0. +input: + script: |+ + printf '%*d\n' "'" 42 +expect: + stdout: "42\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/width_precision/star_width_empty.yaml b/tests/scenarios/cmd/printf/width_precision/star_width_empty.yaml new file mode 100644 index 00000000..5052dbb7 --- /dev/null +++ b/tests/scenarios/cmd/printf/width_precision/star_width_empty.yaml @@ -0,0 +1,7 @@ +description: Star width with no argument defaults to 0 width. +input: + script: |+ + printf "%*s|" +expect: + stdout: "|" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/width_precision/star_width_negative.yaml b/tests/scenarios/cmd/printf/width_precision/star_width_negative.yaml new file mode 100644 index 00000000..cd6fa321 --- /dev/null +++ b/tests/scenarios/cmd/printf/width_precision/star_width_negative.yaml @@ -0,0 +1,7 @@ +description: Negative star width left-aligns the output. +input: + script: |+ + printf "%*s|\n" -10 hi +expect: + stdout: "hi |\n" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/width_precision/star_width_zero.yaml b/tests/scenarios/cmd/printf/width_precision/star_width_zero.yaml new file mode 100644 index 00000000..0ae2d484 --- /dev/null +++ b/tests/scenarios/cmd/printf/width_precision/star_width_zero.yaml @@ -0,0 +1,7 @@ +description: Star width of 0 produces no padding. +input: + script: |+ + printf "%*s|" 0 hello +expect: + stdout: "hello|" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/width_precision/zero_pad.yaml b/tests/scenarios/cmd/printf/width_precision/zero_pad.yaml new file mode 100644 index 00000000..3e3da0f2 --- /dev/null +++ b/tests/scenarios/cmd/printf/width_precision/zero_pad.yaml @@ -0,0 +1,9 @@ +description: Printf zero-pads a number to a specified width. +input: + script: |+ + printf "%05d\n" 42 +expect: + stdout: |+ + 00042 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/printf/width_precision/zero_padded_scientific.yaml b/tests/scenarios/cmd/printf/width_precision/zero_padded_scientific.yaml new file mode 100644 index 00000000..bc2064dd --- /dev/null +++ b/tests/scenarios/cmd/printf/width_precision/zero_padded_scientific.yaml @@ -0,0 +1,7 @@ +description: Zero-padded scientific notation with width and precision. +input: + script: |+ + printf "%015.2e\n" 3.14 +expect: + stdout: "00000003.14e+00\n" + exit_code: 0