2c6af5e43e
Each open is visible to the next, so `set -C; echo x > a > a` creates `a` on the first redirect and refuses the second. Probing every target against one pre-command snapshot passed both and wrote the output. The opens a refused statement already performed are applied, since bash leaves the file existing and empty. `declare -rx X=1` carries both attributes. Readonly answers first, so the export stamp moved into a helper both branches call. `export -n ARR=(b)` takes the attribute off an array. The store keeps whatever the name carried, so an unapplied mark left it exported. `set -v` echoes comments and blank lines, which the reader consumes like any other line.
214 lines
9.3 KiB
Python
214 lines
9.3 KiB
Python
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
|
|
|
import pytest
|
|
|
|
from mirage.shell.variable import VarAttr
|
|
from mirage.workspace.session.session import Session
|
|
from mirage.workspace.session.state import env_snapshot
|
|
|
|
# Every case pinned against GNU bash 5.2.37 on debian:stable-slim.
|
|
# The environment is the *exported* set, not every string-valued
|
|
# variable, which is the whole subject of this file.
|
|
ENV_CASES = [
|
|
# A plain assignment is a shell variable and never reaches `env`.
|
|
('X=hello; export Y=world; env | grep -E "^(X|Y)="', "Y=world\n"),
|
|
# A name marked but never assigned is not in the environment either:
|
|
# `export Z` declares, it does not give Z a value.
|
|
('export Z; env | grep -c "^Z="', "0\n"),
|
|
# `export -n` keeps the value and drops the name from the env.
|
|
('export Y=world; export -n Y; env | grep -c "^Y="', "0\n"),
|
|
# `set -a` marks what is assigned *while it is on*, and nothing else.
|
|
("B=1; set -a; C=2; set +a; D=3;"
|
|
' env | grep -cE "^(B|C|D)="', "1\n"),
|
|
("set -a; A=auto; env | grep '^A='", "A=auto\n"),
|
|
]
|
|
|
|
DECLARE_CASES = [
|
|
# `--` for a plain scalar, `-x` once exported.
|
|
("X=hello; export Y=world; declare -p X Y",
|
|
'declare -- X="hello"\ndeclare -x Y="world"\n'),
|
|
# The declared-but-unset third state prints bare, with no `=`.
|
|
("export Z; declare -p Z", "declare -x Z\n"),
|
|
# `declare -x` on an existing name keeps the value and adds the mark.
|
|
("E=val; declare -x E; declare -p E", 'declare -x E="val"\n'),
|
|
# `export -n` is the off direction and leaves an ordinary variable.
|
|
("export Y=world; export -n Y; declare -p Y", 'declare -- Y="world"\n'),
|
|
# Never exported, so `-n` is a no-op rather than an error.
|
|
("P=plain; export -n P; echo rc=$?; declare -p P",
|
|
'rc=0\ndeclare -- P="plain"\n'),
|
|
# Two attributes print in bash's own order (`r` before `x`), which
|
|
# is `attr_letters`' order and not the order they were set in.
|
|
("export RO=v; readonly RO; declare -p RO", 'declare -rx RO="v"\n'),
|
|
# `set -a` again, read through declare rather than env.
|
|
("B=1; set -a; C=2; set +a; D=3; declare -p B C D",
|
|
'declare -- B="1"\ndeclare -x C="2"\ndeclare -- D="3"\n'),
|
|
]
|
|
|
|
EXPORT_P_CASES = [
|
|
# Only exported names, and the unset one prints without a value.
|
|
# Grepped to the three names under test because a real shell (and a
|
|
# mirage session, which exports PWD) carries plenty of others.
|
|
("X=hello; export Y=world; export Z;"
|
|
' export -p | grep -E "^declare -x (X|Y|Z)"',
|
|
'declare -x Y="world"\ndeclare -x Z\n'),
|
|
# `export -n` removes it from the listing entirely.
|
|
('export Q; export -n Q; export -p | grep -c "declare -x Q"', "0\n"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("cmd,out", ENV_CASES)
|
|
def test_env_carries_only_exported_names(shell, cmd, out):
|
|
assert shell.mirage(cmd) == out
|
|
|
|
|
|
@pytest.mark.parametrize("cmd,out", DECLARE_CASES)
|
|
def test_declare_p_renders_the_attributes(shell, cmd, out):
|
|
assert shell.mirage(cmd) == out
|
|
|
|
|
|
@pytest.mark.parametrize("cmd,out", EXPORT_P_CASES)
|
|
def test_export_p_lists_the_exported_set(shell, cmd, out):
|
|
assert shell.mirage(cmd) == out
|
|
|
|
|
|
def test_printenv_is_a_process_view(shell):
|
|
# printenv is a separate binary in GNU, so the only names it can
|
|
# possibly see are exported ones; a plain variable exits 1.
|
|
assert shell.mirage_result("X=plain; printenv X") == (1, "", "")
|
|
assert shell.mirage("export X=e; printenv X") == "e\n"
|
|
|
|
|
|
def test_declare_p_reports_an_unknown_name(shell):
|
|
# GNU prints the names it knows, refuses only the ones it does not,
|
|
# and exits 1. Deliberate divergence: mirage's diagnostic carries no
|
|
# `line N:` (GNU says `bash: line 1: declare: NOPE: not found`),
|
|
# matching how every other mirage builtin words its errors.
|
|
rc, out, err = shell.mirage_result("G=good; declare -p G NOPE")
|
|
assert rc == 1
|
|
assert out == 'declare -- G="good"\n'
|
|
assert err == "bash: declare: NOPE: not found\n"
|
|
|
|
|
|
def test_pwd_is_exported_from_startup(shell):
|
|
# bash exports $PWD, so a session that has never run `cd` still
|
|
# hands one to a child.
|
|
assert "PWD=" in shell.mirage("env")
|
|
|
|
|
|
def test_cd_exports_pwd_and_oldpwd(shell):
|
|
# GNU prints `declare -x OLDPWD` too, and carries both into a child.
|
|
# $OLDPWD is created by the first `cd`, so it is marked there; $PWD
|
|
# keeps the mark it was seeded with because `seed_var` replaces the
|
|
# value rather than the record.
|
|
out = shell.mirage("mkdir -p /data/d; cd /data/d; cd /data;"
|
|
" declare -p PWD OLDPWD")
|
|
assert out == ('declare -x PWD="/data"\n'
|
|
'declare -x OLDPWD="/data/d"\n')
|
|
env = shell.mirage('env | grep -E "^(PWD|OLDPWD)=" | sort')
|
|
assert env == "OLDPWD=/data/d\nPWD=/data\n"
|
|
|
|
|
|
def test_fork_keeps_pwd_exported():
|
|
# `fork(cwd=...)` rebuilds $PWD to name where the fork is, and has to
|
|
# rebuild the attribute with it: a fresh record would drop the mark
|
|
# and the forked session's env would lose PWD entirely.
|
|
session = Session(session_id="s1", cwd="/")
|
|
forked = session.fork(cwd="/data")
|
|
assert VarAttr.EXPORT in forked.vars["PWD"].attrs
|
|
assert env_snapshot(forked)["PWD"] == "/data"
|
|
|
|
|
|
def test_a_plain_variable_still_expands_and_lists(shell):
|
|
# The narrowing is the *process* view only: `$X` and the bare `set`
|
|
# listing are the shell's own view and see every variable.
|
|
assert shell.mirage("X=hello; echo $X") == "hello\n"
|
|
assert "X=hello\n" in shell.mirage("X=hello; set")
|
|
|
|
|
|
# `export -p` prints the whole cluster, not just `-x`, and an array is
|
|
# exportable like anything else. Pinned on bash 5.2.37.
|
|
ARRAY_EXPORT_CASES = [
|
|
("export ARR=(a b); declare -p ARR",
|
|
'declare -ax ARR=([0]="a" [1]="b")\n'),
|
|
("declare -x ARR=(a b); declare -p ARR",
|
|
'declare -ax ARR=([0]="a" [1]="b")\n'),
|
|
("ARR=(a b); export ARR; declare -p ARR",
|
|
'declare -ax ARR=([0]="a" [1]="b")\n'),
|
|
("readonly R=1; export R; export -p | grep ' R='", 'declare -rx R="1"\n'),
|
|
("export ARR=(a b); export -p | grep ARR",
|
|
'declare -ax ARR=([0]="a" [1]="b")\n'),
|
|
# `-n` is the off direction for an array literal too. The store keeps
|
|
# whatever attributes the name already carried, so an unapplied mark
|
|
# left the array exported and GNU's `declare -a` came out
|
|
# `declare -ax`.
|
|
("declare -x ARR=(a); export -n ARR=(b); declare -p ARR",
|
|
'declare -a ARR=([0]="b")\n'),
|
|
# Both attributes at once: readonly answers first and still owes the
|
|
# export mark.
|
|
('declare -rx X=1; declare -p X', 'declare -rx X="1"\n'),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("cmd,want", ARRAY_EXPORT_CASES)
|
|
def test_export_marks_and_renders_arrays(shell, cmd, want):
|
|
assert shell.mirage(cmd) == want
|
|
|
|
|
|
def test_an_exported_array_stays_out_of_the_process_view(shell):
|
|
# Marked, listed by `export -p`, and still absent from `env`: bash
|
|
# puts no array in a child's environment.
|
|
assert shell.mirage("export ARR=(a b); env | grep -c ARR || true") == "0\n"
|
|
|
|
|
|
def test_a_bare_local_declares_without_assigning(shell):
|
|
# The same third state `export Z` has: declared, unset, so `${L-d}`
|
|
# still expands to `d` and `declare -p` prints no `=`. Writing `""`
|
|
# here was the invented-empty-string bug the mark door exists to fix.
|
|
assert shell.mirage('f() { local L; echo "[${L-UNSET}]"; }; f') == (
|
|
"[UNSET]\n")
|
|
assert shell.mirage("f() { local L; declare -p L; }; f") == (
|
|
"declare -- L\n")
|
|
assert shell.mirage("declare D; declare -p D") == "declare -- D\n"
|
|
# An explicit empty value is a value, and prints as one.
|
|
assert shell.mirage('f() { local L=; echo "[${L-UNSET}]"; }; f') == "[]\n"
|
|
|
|
|
|
def test_local_outside_a_function_is_refused(shell):
|
|
_, out, err = shell.mirage_result("local x=1; echo rc=$?")
|
|
assert err == "bash: local: can only be used in a function\n"
|
|
assert out == "rc=1\n"
|
|
# `declare` is the spelling that is legal at top level.
|
|
assert shell.mirage("declare x=1; declare -p x") == 'declare -- x="1"\n'
|
|
|
|
|
|
# A prefix assignment goes in the *command's* environment, which is the
|
|
# whole point of the form. Pinned on bash 5.2.37.
|
|
PREFIX_CASES = [
|
|
("TOKEN=x printenv TOKEN", "x\n"),
|
|
("TOKEN=x env | grep '^TOKEN='", "TOKEN=x\n"),
|
|
# And only for that command: the shell is unchanged afterwards.
|
|
("TOKEN=x true; echo \"[${TOKEN-unset}]\"", "[unset]\n"),
|
|
# A plain assignment is still a shell variable, not an exported one.
|
|
("T2=y; env | grep -c '^T2=' || true", "0\n"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("cmd,want", PREFIX_CASES)
|
|
def test_a_prefix_assignment_reaches_the_command_environment(shell, cmd, want):
|
|
# `env_snapshot` narrowing to the exported set is what broke this:
|
|
# the prefix was seeded plain, so the command, an installed CLI and a
|
|
# guest runtime all stopped seeing it.
|
|
assert shell.mirage(cmd) == want
|