From adae25fb2ec1cf90d5585aba6b4fdaf8c4b5081d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:55:34 +0000 Subject: [PATCH] fix(read): assign the variables at end of input, and never a readonly one while IFS= read -r line || [ -n "$line" ]; do ...; done is the loop that also reads a last line with no newline. On hellish it never ended. At EOF with nothing read, builtin_read returned 1 without assigning anything, so $line still held the last line and the `||` kept the loop going. bash assigns the variables empty (and empties an -a array) whenever read returns, EOF included. The plugins' envload and envdiff had to work around this with an explicit `line=`. Root cause: read_one_line returns NULL for "EOF, nothing read", and builtin_read returned on NULL before rd_dispatch. It now dispatches an empty line, so every variable is assigned, and still returns 1. Assigning at EOF made a second bug reachable on a new path: read never checked readonly. `readonly r=keep; echo x | read r` changed r. rd_set_var now refuses a readonly name with bash's "r: readonly variable" message. The statuses are bash's: - a readonly variable before the last one: 2, and the ones after it are not assigned; - the last one: 1; - REPLY: 2; an -a array: 1. read is not a special builtin, so the shell goes on. tests/scripts/58_read_eof_readonly.sh, graded against bash --posix: - EOF with nothing read, for named variables, REPLY, -a, -d, -n and -N; - a last line without a newline, and the `|| [ -n "$line" ]` loop (guarded, so a regression fails instead of hanging); - readonly in each position, REPLY, -a, and the messages. develop diverges on 16 of its 26 lines and loops without the guard. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RAmeHfJNm7XjYbMNrQqvkG --- src/builtins/builtin_read2.c | 28 ++++++++++---- src/builtins/builtin_read3.c | 40 +++++++++++++------- src/builtins/builtins_private.h | 4 +- tests/scripts/58_read_eof_readonly.sh | 53 +++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 23 deletions(-) create mode 100644 tests/scripts/58_read_eof_readonly.sh diff --git a/src/builtins/builtin_read2.c b/src/builtins/builtin_read2.c index 3bd523dd..cd0a2e20 100644 --- a/src/builtins/builtin_read2.c +++ b/src/builtins/builtin_read2.c @@ -77,17 +77,29 @@ char *last_field(char *p, const char *ifs, bool raw) } /* Write a variable into the environment. `value_owned` is transferred — - do not free it after calling this; env_create takes ownership. */ -void rd_set_var(t_shell *state, char *name, char *value_owned) + do not free it after calling this; env_create takes ownership. A + readonly variable is refused with bash's message and left as it was + (false); read goes on running, as bash's does -- it is not a special + builtin, so the error is not fatal. */ +bool rd_set_var(t_shell *state, char *name, char *value_owned) { + if (is_readonly_var(state, name)) + { + ft_eprintf("%s: %s: readonly variable\n", state->ctx, name); + xfree(value_owned); + return (false); + } env_set(&state->env, env_create(ft_strdup(name), value_owned, false)); + return (true); } /* Split `line` across the variable names in argv[o->first..end-1]. All but the last variable get one next_field() result; the last one gets last_field() which includes the rest of the line minus trailing IFS - whitespace. This matches the POSIX `read` field-splitting algorithm. */ -void assign_words(t_shell *state, char *line, t_vec argv, t_rdopt *o) + whitespace. This matches the POSIX `read` field-splitting algorithm. + A readonly name stops it the way it stops bash: before the last + variable, status 2 and nothing after it assigned; the last, status 1. */ +int assign_words(t_shell *state, char *line, t_vec argv, t_rdopt *o) { size_t i; char *p; @@ -98,10 +110,12 @@ void assign_words(t_shell *state, char *line, t_vec argv, t_rdopt *o) i = o->first; while (i + 1 < argv.len) { - rd_set_var(state, ((char **)argv.ctx)[i], - next_field(&p, o->ifs, o->raw)); + if (!rd_set_var(state, ((char **)argv.ctx)[i], + next_field(&p, o->ifs, o->raw))) + return (2); skip_delim(&p, o->ifs); i++; } - rd_set_var(state, ((char **)argv.ctx)[i], last_field(p, o->ifs, o->raw)); + return (!rd_set_var(state, ((char **)argv.ctx)[i], + last_field(p, o->ifs, o->raw))); } diff --git a/src/builtins/builtin_read3.c b/src/builtins/builtin_read3.c index 5be54b17..ee19e16c 100644 --- a/src/builtins/builtin_read3.c +++ b/src/builtins/builtin_read3.c @@ -61,21 +61,26 @@ int rd_wait_input(t_rdopt *o) /* Route the completed line to its destination: -a fills the named array, no variable names sends the whole line to $REPLY, otherwise the line is IFS-split across the named variables. rd_set_var takes ownership of the - line; the array/word paths copy fields out of it, so it is freed here. */ -static void rd_dispatch(t_shell *state, t_vec argv, char *line, t_rdopt *o) + line; the array/word paths copy fields out of it, so it is freed here. + Returns the status a readonly variable forces, 0 when every assignment + was made: bash's 2 for REPLY, 1 for an -a array (assign_words has the + named-variable rules). */ +static int rd_dispatch(t_shell *state, t_vec argv, char *line, t_rdopt *o) { - if (o->aname) - { + int st; + + if (o->first >= argv.len && !o->aname) + return (2 * !rd_set_var(state, "REPLY", line)); + st = 0; + if (o->aname && is_readonly_var(state, o->aname)) + st = (ft_eprintf("%s: %s: readonly variable\n", state->ctx, + o->aname), 1); + else if (o->aname) rd_assign_array(state, line, o); - xfree(line); - } - else if (o->first >= argv.len) - rd_set_var(state, "REPLY", line); else - { - assign_words(state, line, argv, o); - xfree(line); - } + st = assign_words(state, line, argv, o); + xfree(line); + return (st); } /* read [-r] [-n N] [-N N] [-d C] [-t S] [-p PROMPT] [-a NAME] [var ...]: @@ -84,6 +89,10 @@ static void rd_dispatch(t_shell *state, t_vec argv, char *line, t_rdopt *o) Returns 1 on EOF even if some data was read (mimics bash), so `while read line; do …; done` processes the last line before stopping even when the file lacks a trailing newline. + At EOF with nothing read the variables are still assigned, empty (an + -a array emptied), as bash does. They used to keep their old values, so + `while read -r l || [ -n "$l" ]` -- the loop that also handles a last + line with no newline -- never ended: $l still held that last line. -N reads a fixed byte count and must NOT field-split: an empty IFS is exactly that, so the ordinary assign_words path handles it with no second code path to keep in step. */ @@ -92,6 +101,7 @@ int builtin_read(t_shell *state, t_vec argv) char *line; t_rdopt o; int eof; + int st; o = (t_rdopt){.nchars = -1, .delim = '\n', .tmo_ms = -1}; o.first = parse_read_opts2(argv, &o); @@ -105,7 +115,9 @@ int builtin_read(t_shell *state, t_vec argv) return (xfree(o.ifs), 142); line = read_one_line(&o, &eof); if (!line) - return (xfree(o.ifs), 1); - rd_dispatch(state, argv, line, &o); + line = ft_strdup(""); + st = rd_dispatch(state, argv, line, &o); + if (st) + return (xfree(o.ifs), st); return (xfree(o.ifs), eof != 0); } diff --git a/src/builtins/builtins_private.h b/src/builtins/builtins_private.h index 4576f40e..e34d8f1e 100644 --- a/src/builtins/builtins_private.h +++ b/src/builtins/builtins_private.h @@ -362,8 +362,8 @@ void skip_delim(char **pp, const char *ifs); char *last_field(char *p, const char *ifs, bool raw); size_t parse_read_opts2(t_vec argv, t_rdopt *o); void rd_assign_array(t_shell *state, char *line, t_rdopt *o); -void rd_set_var(t_shell *state, char *name, char *value_owned); -void assign_words(t_shell *state, char *line, t_vec argv, t_rdopt *o); +bool rd_set_var(t_shell *state, char *name, char *value_owned); +int assign_words(t_shell *state, char *line, t_vec argv, t_rdopt *o); long rd_secs_ms(const char *s); /* fc's parsed command line (builtin_fc3.c): -e NAME, the -l -n -r -s switches, and the first [last] operands that follow them. */ diff --git a/tests/scripts/58_read_eof_readonly.sh b/tests/scripts/58_read_eof_readonly.sh new file mode 100644 index 00000000..98e87c4f --- /dev/null +++ b/tests/scripts/58_read_eof_readonly.sh @@ -0,0 +1,53 @@ +# read assigns its variables even at end of input, and never a readonly one. +# +# At EOF with nothing read, hellish returned 1 without touching the +# variables, so they kept their old values. That made the common loop +# while IFS= read -r line || [ -n "$line" ]; do ...; done +# (which also takes a last line with no newline) run forever: $line still +# held the last line. bash assigns them empty. +# +# And read wrote readonly variables: `readonly r=1; echo x | read r` +# changed r. bash refuses with "r: readonly variable" and a status. +msg() { sed 's/^.*: line [0-9]*: //'; } + +echo "-- EOF with nothing read empties the variables" +x=old; y=old; REPLY=old; arr=(o l d) +printf '' | { read -r x y; echo "rc=$? [${x-unset}] [${y-unset}]"; } +printf '' | { read -r; echo "rc=$? [${REPLY-unset}]"; } +printf '' | { read -r -a arr; echo "rc=$? n=${#arr[@]}"; } +printf 'a\n' | { read -r x; read -r x; echo "rc=$? [$x]"; } +printf '' | { read -r -d : x; echo "rc=$? [$x]"; } +printf '' | { read -r -n 3 x; echo "rc=$? [$x]"; } +printf '' | { read -r -N 3 x; echo "rc=$? [$x]"; } + +echo "-- a last line with no newline, then EOF" +printf 'last' | { read -r x y; echo "rc=$? [$x] [$y]"; } +printf 'l1\nl2' | { + n=0 + while IFS= read -r line || [ -n "$line" ]; do + n=$((n + 1)); echo "got [$line]" + [ "$n" -gt 5 ] && { echo "runaway loop"; break; } + done +} +printf 'k=v\nlast=1' | { + n=0 + while IFS= read -r line || [ -n "$line" ]; do + n=$((n + 1)); echo "line: $line" + [ "$n" -gt 5 ] && { echo "runaway loop"; break; } + done + echo "done, line=[$line]" +} + +echo "-- a readonly variable is refused, the others follow bash" +readonly ro=keep +echo x | { read -r ro 2>&1 | msg; } +echo x | { read -r ro 2>/dev/null; echo "only var: rc=$? [$ro]"; } +printf '' | { read -r ro 2>/dev/null; echo "only var at EOF: rc=$? [$ro]"; } +echo "a b" | { read -r ro y 2>/dev/null; echo "first of two: rc=$? [$ro] [${y-unset}]"; } +echo "a b" | { read -r y ro 2>/dev/null; echo "last of two: rc=$? [$ro] [$y]"; } +echo "a b c" | { y=; z=old; read -r y ro z 2>/dev/null; echo "middle: rc=$? [$ro] [$y] [$z]"; } +echo a | { readonly REPLY; read -r 2>/dev/null; echo "REPLY: rc=$?"; } +ra=(1 2); readonly ra +echo "p q" | { read -r -a ra 2>&1 | msg; } +echo "p q" | { read -r -a ra 2>/dev/null; echo "array: rc=$? [${ra[*]}]"; } +echo x | { read -r ro 2>/dev/null; echo "the shell goes on"; }