Skip to content

fix(export): export X before X is set keeps the attribute - #161

Merged
LESdylan merged 1 commit into
developfrom
fix/export-unset-attribute
Sep 27, 2026
Merged

LESdylan merged 1 commit into
developfrom
fix/export-unset-attribute

Conversation

@LESdylan

Copy link
Copy Markdown
Member

What & why

export A
A=1
sh -c 'echo "[$A]"'      # bash: [1]   hellish: []

POSIX says export name sets the export attribute, and every later assignment to that name is exported. hellish only marked a variable that already existed:

  • For a name not yet set, handle_identifier found no record and stored nothing, so the attribute was lost.
  • declare -x G had the same gap, in declare_assign.
  • export -p and declare -p B then said nothing about the name (declare: B: not found).

Root cause and fix. The variable table can already hold a record with no value, and it already means "declared, not set": ${X+x}, [[ -v ]], set -u, ${!p*} and compgen -v all read a NULL value as unset. env_set keeps the export flag on assignment, and unset drops the record together with its attribute. So the fix creates that record in the two places that should create it, and teaches the other places to show it the way bash does:

Where Now
export NAME / declare -x NAME on an unset name a record with no value, exported
child environment skipped: bash passes nothing, not X=
export -p export X without a value, so the list reads back as the same state. It used to print X="", which re-created X as set.
declare -p X declare -x X
set / declare with no names not listed, as in bash
cd with PWD exported but unset OLDPWD takes PWD's state, attribute included. Before, ft_strdup(NULL) would have crashed.

How I verified it

  • Reproduced first. tests/scripts/62_export_unset_attribute.sh is graded against bash --posix 5.3.9. It covers:

    • the idiom above;
    • the unset state everywhere: the environment, ${B+}, set, set -u, and the listings;
    • assignment by =, read, for, printf -v and +=, and declare -x;
    • unset, export -n and declare +x removing the attribute;
    • export -p output fed back into a shell;
    • cd with an exported but unset PWD;
    • a readonly name exported before it has a value.

    develop diverges on 9 lines; this branch matches bash byte for byte, with no sanitizer report.

  • Local gates on this commit (ASan debug build):

    • golden tests/tester: 5363/5363;
    • tests/run_scripts.sh against bash --posix: 126/126;
    • verify_alloc.sh: identical output on both heaps;
    • alloc_stress.sh: all clean;
    • tests/pty_suite.sh: 111 ok, 6 skipped, 4 failed. None of the four failures is this change:
      • prompt_compat_matrix, prompt_drift_matrix and prompt_jobs_badge expect the non-root %/$ prompt and get #, because the container runs as root. They fail the same way on develop, and CI runs them as a normal user.
      • hxp_framework_test hit the 420 s per-file limit on this 4-core container. develop's binary takes 440 s for it on the same machine, and CI's runners finish it inside the limit.
  • norminette is OK on every touched file.

Notes / trade-offs

  • Arrays are out of scope. declare -ax A=(x y) still drops -x, and export -p still prints an exported array as an encoded scalar. Both were found while writing this test, and they are a separate change in how arrays are exported.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RAmeHfJNm7XjYbMNrQqvkG


Generated by Claude Code

    export A
    A=1
    sh -c 'echo "[$A]"'      # bash: [1]   hellish: []

POSIX: `export name` sets the export attribute, and later assignments
are exported. hellish only marked a variable that already existed. For
a name not yet set, handle_identifier found no record and stored
nothing, so the attribute was lost. `declare -x G` had the same gap in
declare_assign. `export -p` and `declare -p B` then said nothing about
the name either ("declare: B: not found").

The table can already hold a record with no value, and it already
means "declared, not set": ${X+x}, [[ -v ]], set -u, ${!p*} and
compgen -v all read a NULL value as unset. env_set keeps the export
flag on assignment, and unset drops the record with its attribute. So
the fix is to create that record in the two places that should, and
to teach the rest to show it the way bash does:
  - export NAME / declare -x NAME on an unset name: a record with no
    value, exported;
  - child environment: skipped, since bash passes nothing, not "X=";
  - export -p: `export X` without a value, so the list reads back to
    the same thing (it printed X="" before, which re-created X set);
  - declare -p X: `declare -x X`;
  - set / declare with no names: not listed, as in bash;
  - cd with PWD exported but unset: OLDPWD takes PWD's state,
    attribute included, where ft_strdup(NULL) would have crashed.

tests/scripts/62_export_unset_attribute.sh, graded against
bash --posix, covers:
  - the idiom;
  - the unset state everywhere (env, ${B+}, set, set -u, the listings);
  - assignment by =, read, for, printf -v and +=, and declare -x;
  - unset, export -n and declare +x taking the attribute away;
  - export -p output fed back to a shell;
  - cd with an exported unset PWD;
  - a readonly name exported before it has a value.
develop diverges on 9 lines; this branch matches bash byte for byte,
with no sanitizer report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RAmeHfJNm7XjYbMNrQqvkG
@LESdylan
LESdylan merged commit cca04ae into develop Sep 27, 2026
55 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants