Handle State Library

Location

  • config/handle_state.sh

Purpose

handle_state.sh helps Bash libraries carry private global state information between functions, without polluting the global namespace. It also allows applications to carry several distinct states, one for each context.

The public API is built around a named state variable passed with -S <statevar>. The state value is an opaque internal token; callers should not inspect or modify its contents directly.

Dependencies

This library depends on the Command Guard Library (config/command_guard.sh). The dependency is resolved automatically when handle_state.sh is sourced.

Quick Start

source "$(dirname "$0")/config/handle_state.sh"

init_function() {
    local temp_file="/tmp/some_temp_file"
    local resource_id="resource_123"
    hs_persist_state "$@" -- temp_file resource_id || return $?
}

cleanup_function() {
    local temp_file resource_id
    eval "$(hs_read_persisted_state "$@")" || return $?
    rm -f "$temp_file"
    printf 'Cleaned up resource: %s\n' "$resource_id"
    hs_destroy_state "$@" -- temp_file resource_id || return $?
}

local state_var=""
init_function -S state_var || return $?
cleanup_function -S state_var || return $?

Public API

hs_persist_state

hs_persist_state appends the current values of selected local variables to the opaque state object named by -S.

  • Usage: hs_persist_state [forwarded args] -S <statevar> [--] var1 var2 ...

  • Preferred usage: hs_persist_state "$@" -- var1 var2 ...

  • State transport is by name only. Stdout is not part of this API.

  • If -- is present, its last occurrence starts the explicit variable list.

  • Without --, the trailing valid Bash identifiers are treated as the variable list.

  • Unknown forwarded options before the effective separator are ignored by this helper so wrappers can pass "$@" directly.

  • --list-reserved: prints one reserved internal variable name per line to stdout and returns 0. Incompatible with all other options. Intended for testing only. All three entry points produce identical output; this form is the canonical one.

Behavior:

  • Requested variables that are unset are skipped silently.

  • Scalars, indexed arrays, and associative arrays are all persisted natively.

  • Namerefs are persisted only when their target variable is also being persisted in the same call or already present in the prior state. Nameref records are always stored after their targets so restoration order is valid.

  • Function names and undeclared names are errors.

  • If the destination state already contains variables with the same names, the function fails before writing anything.

Errors:

  • HS_ERR_STATE_VAR_UNINITIALIZED=7: missing -S <statevar>.

  • HS_ERR_MULTIPLE_STATE_INPUTS=3: -S was given more than once; repeating the option is not allowed even when both occurrences name the same variable.

  • HS_ERR_INVALID_VAR_NAME=5: invalid state variable name or invalid requested variable name.

  • HS_ERR_RESERVED_VAR_NAME=1: requested name starts with the reserved prefix __hs_. Run hs_persist_state --list-reserved for the current list of prohibited names.

  • HS_ERR_VAR_NAME_COLLISION=2: one or more requested names already exist in the prior state.

  • HS_ERR_CORRUPT_STATE=4: the prior state is not a valid HS2 object.

  • HS_ERR_UNKNOWN_VAR_NAME=10: a requested variable name is not declared in the caller’s scope, or is a function name.

  • HS_ERR_NAMEREF_TARGET_NOT_PERSISTED=12: a nameref’s target variable is not being persisted in the same call and is not already in the prior state.

hs_destroy_state

hs_destroy_state removes selected variable names from an existing opaque state object and writes the rebuilt state back to the same named variable.

  • Usage: hs_destroy_state [forwarded args] -S <statevar> [--] var1 var2 ...

  • Preferred usage: hs_destroy_state "$@" -- var1 var2 ...

  • If -- is present, its last occurrence starts the explicit destroy list.

  • Without --, the trailing valid Bash identifiers are treated as the destroy list.

  • --list-reserved: prints reserved internal variable names to stdout and returns 0. Incompatible with all other options. Intended for testing only. See hs_persist_state --list-reserved for the authoritative list.

Behavior:

  • Every requested destroy variable must already exist in the input state.

  • The output state is rebuilt from the surviving variables instead of editing the original text in place.

  • After cleanup has destroyed a library’s own entries, the same named state variable can be reused by a later init call without tripping the collision checks in hs_persist_state.

Errors:

  • HS_ERR_STATE_VAR_UNINITIALIZED=7: missing -S <statevar>.

  • HS_ERR_MULTIPLE_STATE_INPUTS=3: -S was given more than once; repeating the option is not allowed even when both occurrences name the same variable.

  • HS_ERR_INVALID_VAR_NAME=5: invalid state variable name or invalid requested destroy name.

  • HS_ERR_VAR_NAME_NOT_IN_STATE=6: requested destroy name is not present in the input state.

  • HS_ERR_CORRUPT_STATE=4: the input state cannot be parsed or rebuilt safely.

hs_read_persisted_state

hs_read_persisted_state restores values from a named opaque state object.

  • Usage: hs_read_persisted_state [forwarded args] [-q] -S <statevar> [--] [var1 var2 ...]

  • Convenience form: hs_read_persisted_state state_var ... is normalized to -S state_var .... Not recommended in library code; prefer explicit -S.

  • --list-reserved: prints reserved internal variable names to stdout and returns 0. Incompatible with all other options. Intended for testing only. See hs_persist_state --list-reserved for the authoritative list.

Restore form selection

Two restore forms are available. Choose based on where the target variables live:

  • Implicit form (preferred for the common case): no -- and no variable names are passed. The function emits a snippet that the caller evals. Because the snippet runs local -p directly in the caller’s scope, it can only target variables that are declared local and unset in the immediate caller. This form is provably free of global scope pollution.

    cleanup_function() {
        local temp_file resource_id
        eval "$(hs_read_persisted_state "$@")" || return $?
        rm -f "$temp_file"
        printf 'Cleaned up resource: %s\n' "$resource_id"
    }
    
  • Explicit form: variable names are supplied after --. The function restores each name by traversing the full dynamic scope (caller chain and globals). Use this form when targeting a variable declared in a higher-level caller, or an explicitly declared but unset global. It is also appropriate when only a named subset of the state is needed.

    cleanup_function() {
        local temp_file resource_id
        hs_read_persisted_state "$@" -- temp_file resource_id || return $?
        rm -f "$temp_file"
        printf 'Cleaned up resource: %s\n' "$resource_id"
    }
    

Explicit restore

Behavior:

  • Each requested name is looked up by traversing the full dynamic scope.

  • A name not declared anywhere in the dynamic scope is an error.

  • A name that is set (including an empty-string value) is an error; unset the variable explicitly before calling if an overwrite is intended.

  • Validation is all-or-nothing: all guard conditions (declared, unset) are checked for every requested name before any restoration occurs. If any check fails, no variable is restored.

  • Requested names missing from the state object are warnings, one per variable.

  • -q suppresses those warnings.

  • Scalars, indexed arrays, associative arrays, and namerefs are all restored natively.

Implicit local restore

When no explicit variable names are supplied and no explicit -- is present, hs_read_persisted_state emits a small safe, locally generated implicit restore snippet. The caller must eval the snippet using the forwarded-arguments form:

cleanup_function() {
    local temp_file resource_id
    eval "$(hs_read_persisted_state "$@")" || return $?
    rm -f "$temp_file"
    printf 'Cleaned up resource: %s\n' "$resource_id"
}

The generated snippet:

  • scans local -p in the immediate caller scope,

  • keeps only unset scalar locals,

  • ignores locals whose names start with __hs_,

  • reenters hs_read_persisted_state -q -S <statevar> -- ...,

  • redirects that reentrant call’s stdout to /dev/null.

The emitted snippet is safe: the only elements derived from the transmitted state are valid Bash identifiers that are tested for existence as local variables in the caller’s scope. The caller evaluates safe probing code, not the persisted state transmitted by the caller directly.

Warning

Without an explicit variable list, every unset scalar local in the immediate caller scope may be considered for restoration. This can be the wrong behavior if the caller manages several unrelated state variables or reuses common local names. Prefer explicit variable lists in non-trivial cleanup paths rather than relying on implicit local restore.

Warning

Automatic probing only inspects the immediate caller scope. Locals in the caller’s caller are not restored automatically. They can still be restored if they are named explicitly.

If -- is present and no variable names follow it, the function emits no implicit restore snippet and returns success.

Errors:

  • HS_ERR_MISSING_ARGUMENT=8: no state variable name was supplied at all.

  • HS_ERR_MULTIPLE_STATE_INPUTS=3: -S was given more than once; repeating the option is not allowed even when both occurrences name the same variable.

  • HS_ERR_INVALID_VAR_NAME=5: invalid state variable name or invalid requested restore name.

  • HS_ERR_STATE_VAR_UNINITIALIZED=7: missing -S <statevar>, or the named state variable is unset or empty.

  • HS_ERR_CORRUPT_STATE=4: the state cannot be evaluated safely while restoring explicitly requested variables.

  • HS_ERR_UNKNOWN_VAR_NAME=10: a requested variable name (explicit form) is not declared anywhere in the dynamic scope.

  • HS_ERR_VAR_ALREADY_SET=11: a requested variable name (explicit form) is set (including empty string); unset the variable first if an overwrite is intended.

Developer Reference

Warning

The functions documented in this section are internal implementation details. They are not part of the public API and may change signature or be removed without notice. Application and library code must not call them directly.

_hs_resolve_state_inputs

_hs_resolve_state_inputs is the shared option parser used by the public entry points.

The caller must declare the following variables before calling this helper:

local -a __hs_remaining=()
local -A __hs_processed=()

The helper writes its output into those exact names through Bash dynamic scoping. On success, __hs_processed may contain:

  • state: validated state variable name from -S

  • quiet: true or false

  • vars: explicit variable-name list, serialized as a space-separated string

  • separator: present when an explicit -- was seen

Errors:

  • HS_ERR_MISSING_ARGUMENT=8: required option parameter missing.

  • HS_ERR_INVALID_VAR_NAME=5: invalid state variable name or invalid explicit variable-name token.

  • HS_ERR_STATE_VAR_UNINITIALIZED=7: missing -S <statevar>.

Error Codes

  • HS_ERR_RESERVED_VAR_NAME=1

  • HS_ERR_VAR_NAME_COLLISION=2

  • HS_ERR_MULTIPLE_STATE_INPUTS=3

  • HS_ERR_CORRUPT_STATE=4

  • HS_ERR_INVALID_VAR_NAME=5

  • HS_ERR_VAR_NAME_NOT_IN_STATE=6

  • HS_ERR_STATE_VAR_UNINITIALIZED=7

  • HS_ERR_MISSING_ARGUMENT=8

  • HS_ERR_INVALID_ARGUMENT_TYPE=9

  • HS_ERR_UNKNOWN_VAR_NAME=10

  • HS_ERR_VAR_ALREADY_SET=11

  • HS_ERR_NAMEREF_TARGET_NOT_PERSISTED=12

Known Limitations

  • The HS2 cksum detects accidental corruption but does not authenticate the state against intentional tampering; treat the state variable as trusted within the process.

Examples

Persisting and restoring a scalar:

init_function() {
    local token='a b "c" $d'
    hs_persist_state "$@" -- token || return $?
}

cleanup_function() {
    local token
    hs_read_persisted_state "$@" -- token || return $?
    printf '%s\n' "$token"
}
local state_var=""
init_function -S state_var || return $?
cleanup_function -S state_var || return $?

Persisting and restoring an indexed array:

init_function() {
    local -a items=("value1" "value2" "value with spaces")
    hs_persist_state "$@" -- items || return $?
}

cleanup_function() {
    local -a items
    hs_read_persisted_state "$@" -- items || return $?
    printf '%s\n' "${items[@]}"
}
local state_var=""
init_function -S state_var || return $?
cleanup_function -S state_var || return $?

Persisting a nameref alongside its target (active-character pattern):

init_function() {
    local -A commander=([hp]=100 [name]="Shepard")
    local -A wrex=([hp]=200 [name]="Wrex")
    local -n active=commander
    hs_persist_state "$@" -- commander wrex active || return $?
}

cleanup_function() {
    local -A commander wrex
    local -n active
    eval "$(hs_read_persisted_state "$@")" || return $?
    printf 'Active: %s (HP: %s)\n' "${active[name]}" "${active[hp]}"
}
local state_var=""
init_function -S state_var || return $?
cleanup_function -S state_var || return $?

Caveats

  • Prefer the implicit restore form (eval "$(hs_read_persisted_state "$@")" || return $?) for cleanup functions that restore into their own locals. Use the explicit form only when targeting variables in a higher-level caller, declared globals, or a named subset of the state.

  • The state format (HS2) is a structured data format, not executable code. Calling eval "$state_var" directly will fail; always restore via hs_read_persisted_state.

  • The state variable is opaque: do not inspect, modify, or concatenate its value outside the public API.

  • eval is used per-record internally (on declare statements only); the state is never evaluated as an arbitrary code block.

Source Listing

  1#!/bin/bash
  2# File: config/handle_state.sh
  3# Description: Helper functions to carry state information between initialization and cleanup functions.
  4# Author: Jean-Marc Le Peuvédic (https://calcool.ai)
  5
  6# Sentinel
  7[[ -z ${__HANDLE_STATE_SH_INCLUDED:-} ]] && __HANDLE_STATE_SH_INCLUDED=1 || return 0
  8
  9# Source command guard for secure external command usage
 10# shellcheck source=command_guard.sh
 11source "${BASH_SOURCE%/*}/command_guard.sh"
 12
 13# Library usage — see docs/libraries/handle_state.rst for the full API.
 14
 15guard cksum
 16
 17# --- Public error codes --------------------------------------------------------
 18readonly HS_ERR_RESERVED_VAR_NAME=1
 19readonly HS_ERR_VAR_NAME_COLLISION=2
 20readonly HS_ERR_MULTIPLE_STATE_INPUTS=3
 21readonly HS_ERR_CORRUPT_STATE=4
 22readonly HS_ERR_INVALID_VAR_NAME=5
 23readonly HS_ERR_VAR_NAME_NOT_IN_STATE=6
 24readonly HS_ERR_STATE_VAR_UNINITIALIZED=7
 25readonly HS_ERR_MISSING_ARGUMENT=8
 26readonly HS_ERR_INVALID_ARGUMENT_TYPE=9
 27readonly HS_ERR_UNKNOWN_VAR_NAME=10
 28readonly HS_ERR_VAR_ALREADY_SET=11
 29readonly HS_ERR_NAMEREF_TARGET_NOT_PERSISTED=12
 30
 31
 32# --- hs_persist_state ----------------------------------------------------------
 33# Function:
 34#   hs_persist_state [options] [--] [state_variable ...]
 35# Description:
 36#   Appends the current values of the specified local variables to an HS2-format
 37#   opaque state object held in the variable named by -S. Supports scalars,
 38#   indexed arrays, associative arrays, and namerefs (nameref target must also
 39#   be persisted in the same call or already present in the prior state).
 40# Options:
 41#   -S <state> - pass the state object by name, mandatory.
 42#   Other options are ignored up to the last --, so this function is usually able
 43#   to directly process its caller's argument list, future-proofing it against
 44#   new hs_persist_state options.
 45#   -- - marks the end of options and the beginning of the list of variable names.
 46#   --list-reserved - prints the reserved internal variable names to stdout, one
 47#     per line, and returns 0. Incompatible with all other options. Intended for
 48#     testing only. The reported names are also reported by hs_read_persisted_state
 49#     and hs_destroy_state --list-reserved (identical output across all three).
 50# Arguments:
 51#   $@ - names of local variables to persist. Without `--`, the trailing
 52#        arguments that are valid Bash identifiers are treated as the variable
 53#        list. Note that the value associated with the last given option will be
 54#        mistaken for a variable unless `--` is used.
 55# Errors:
 56#   - `HS_ERR_MISSING_ARGUMENT` if no state variable name is supplied at all.
 57#   - `HS_ERR_MULTIPLE_STATE_INPUTS` if `-S` is given more than once, even with
 58#     the same variable name.
 59#   - `HS_ERR_INVALID_VAR_NAME` if the state variable name or a requested
 60#     persist variable name is not a valid Bash identifier.
 61#   - `HS_ERR_STATE_VAR_UNINITIALIZED` if `-S <statevar>` is missing.
 62#   - `HS_ERR_CORRUPT_STATE` if the existing state is not in HS2 format or
 63#     the rebuilt state cannot be verified.
 64#   - `HS_ERR_RESERVED_VAR_NAME` if a requested name starts with `__hs_`,
 65#     which is the reserved internal name prefix used by this library.
 66#   - `HS_ERR_VAR_NAME_COLLISION` if a requested name is already present in
 67#     the existing state object.
 68#   - `HS_ERR_UNKNOWN_VAR_NAME` if a requested name is not declared in scope,
 69#     or is a function name rather than a variable.
 70#   - `HS_ERR_NAMEREF_TARGET_NOT_PERSISTED` if a nameref's target is not being
 71#     persisted in the same call and is not already present in the prior state.
 72# Usage examples:
 73#   init_function() {
 74#       local token="abc" count=3
 75#       hs_persist_state "$@" -- token count || return $?
 76#   }
 77#   init_with_array() {
 78#       local -a items=(one two three)
 79#       hs_persist_state -S "$1" -- items || return $?
 80#   }
 81hs_persist_state() {
 82    local -a __hs_remaining=()
 83    local -A __hs_processed=()
 84    if [[ "${1-}" == "--list-reserved" ]]; then
 85        local list_reserved=1
 86        shift
 87        if [ $# -gt 0 ]; then
 88            echo "[ERROR] hs_persist_state: --list-reserved takes no other arguments." >&2
 89            return "$HS_ERR_INVALID_ARGUMENT_TYPE"
 90        fi
 91    else
 92        _hs_resolve_state_inputs hs_persist_state S: "$@" || return $?
 93        # $() absorbs the helper's exit status; embedding "return N" in the output
 94        # lets the surrounding eval propagate the failure to the caller.
 95        eval "$(_hs_ps_body "${__hs_processed[state]}" "${__hs_processed[vars]-}" \
 96            || printf 'return %d' "$?")" || return $?
 97    fi
 98    # List reserved
 99    if _hs_local_exists "$(local -p)" list_reserved; then
100        # Snapshot taken after all processing locals are declared. local -p runs
101        # in a subshell before the assignment completes, so lp_snapshot itself
102        # is absent from the output. Splitting declare+assign would cause
103        # lp_snapshot to appear in its own snapshot; the combined form is
104        # intentional here.
105        : "${list_reserved}"
106        # shellcheck disable=SC2155
107        local lp_snapshot="$(local -p)"
108        _hs_print_reserved_names "$lp_snapshot" list_reserved
109    fi
110}
111
112# _hs_ps_body <out_var> <vars_str>
113# Contains the validation and build logic for hs_persist_state. Runs in its
114# own frame so its locals do not appear in the entry point's collision section.
115_hs_ps_body() {
116    local existing="${!1-}"
117    local out_var="$1"
118    local -a vars=()
119    read -r -a vars <<< "${2-}"
120
121    # Parse existing state (must be empty or HS2).
122    local existing_payload=""
123    local -a existing_recs=()
124    local -A existing_names=()
125    if [[ -n "$existing" ]]; then
126        if [[ "$existing" != HS2:* ]]; then
127            echo "[ERROR] hs_persist_state: existing state is not in HS2 format." >&2
128            return "$HS_ERR_CORRUPT_STATE"
129        fi
130        _hs_hs2_parse hs_persist_state "$existing" existing_recs || return $?
131        existing_payload="${existing#HS2:}"
132        existing_payload="${existing_payload#*:}"
133        local existing_rec
134        for existing_rec in "${existing_recs[@]}"; do
135            existing_names["$(_hs_hs2_record_name "$existing_rec")"]=1
136        done
137    fi
138
139    # Phase 1: validate all names; separate non-namerefs from namerefs.
140    local -a non_namerefs=()
141    local -a namerefs=()
142    local -A this_call=()
143    local var decl flags
144    for var in "${vars[@]}"; do
145        if [[ -n "${existing_names[$var]-}" ]]; then
146            echo "[ERROR] hs_persist_state: variable '$var' already exists in the state." >&2
147            return "$HS_ERR_VAR_NAME_COLLISION"
148        fi
149        if ! decl=$(declare -p "$var" 2>/dev/null); then
150            if declare -f "$var" >/dev/null 2>&1; then
151                echo "[ERROR] hs_persist_state: '$var' is a function, not a variable." >&2
152            else
153                echo "[ERROR] hs_persist_state: '$var' is not declared in scope." >&2
154            fi
155            return "$HS_ERR_UNKNOWN_VAR_NAME"
156        fi
157        flags="${decl#declare }"
158        flags="${flags%% *}"
159        if [[ "$flags" == *n* ]]; then
160            this_call["$var"]=nameref
161        else
162            non_namerefs+=("$(_hs_strip_export "$decl")")
163            this_call["$var"]=1
164        fi
165    done
166
167    # Phase 2: validate nameref targets and build nameref records (after targets).
168    local target
169    for var in "${vars[@]}"; do
170        [[ "${this_call[$var]-}" == nameref ]] || continue
171        decl=$(declare -p "$var" 2>/dev/null)
172        target="${decl#*\"}"
173        target="${target%\"}"
174        if [[ -z "${existing_names[$target]-}" && \
175              -z "${this_call[$target]-}" ]]; then
176            echo "[ERROR] hs_persist_state: nameref '$var' target '$target' is not being persisted." >&2
177            return "$HS_ERR_NAMEREF_TARGET_NOT_PERSISTED"
178        fi
179        namerefs+=("$(_hs_strip_export "$decl")")
180    done
181
182    # Build HS2 state: existing payload + non-nameref records + nameref records.
183    # Print the assignment statement; the entry point evals it so no helper
184    # ever writes directly into a caller's variable.
185    local new_state
186    new_state=$(_hs_hs2_build "$existing_payload" \
187        "${non_namerefs[@]}" "${namerefs[@]}") || return $?
188    printf '%s=%s\n' "$out_var" "$(printf '%q' "$new_state")"
189}
190
191# --- hs_destroy_state ---------------------------------------------------------------
192# Function:
193#   hs_destroy_state [options] [--] [state_variable ...]
194# Description:
195#   Removes the specified local variables from an opaque state object.
196#   In a cleanup function, this allows the same state variable to be reused by
197#   a later init call without triggering name-collision errors.
198# Options:
199#   -S <state> - pass the state object by name, mandatory.
200#   Other options are ignored up to the last --, so this function is usually able
201#   to directly process its caller's argument list, future-proofing it against
202#   new hs_destroy_state options.
203#   -- - marks the end of options and the beginning of the list of variable names.
204#   --list-reserved - prints the reserved internal variable names to stdout, one
205#     per line, and returns 0. Incompatible with all other options. Intended for
206#     testing only. See hs_persist_state --list-reserved for the authoritative list.
207# Arguments:
208#   $@ - names of local variables to destroy. Without `--`, the trailing
209#        arguments that are valid Bash identifiers are treated as the variable list.
210#        Note that the value associated with the last given option will be mistaken
211#        for a variable unless `--` is used.
212# Errors:
213#   - `HS_ERR_STATE_VAR_UNINITIALIZED` if `-S <statevar>` is missing.
214#   - `HS_ERR_MULTIPLE_STATE_INPUTS` if `-S` is given more than once, even with
215#     the same variable name.
216#   - `HS_ERR_INVALID_VAR_NAME` if the state variable name or a requested
217#     destroy variable name is not a valid Bash identifier.
218#   - `HS_ERR_VAR_NAME_NOT_IN_STATE` if a requested destroy variable is not
219#     present in the input state object.
220#   - `HS_ERR_CORRUPT_STATE` if the input state object cannot be parsed or
221#     rebuilt safely.
222# Usage examples:
223#   cleanup_function() {
224#       hs_destroy_state "$@" -- mylib_statevar1 mylib_statevar2
225#   }
226hs_destroy_state() {
227    local -a __hs_remaining=()
228    local -A __hs_processed=()
229    if [[ "${1-}" == "--list-reserved" ]]; then
230        local list_reserved=1
231        shift
232        if [ $# -gt 0 ]; then
233            echo "[ERROR] hs_destroy_state: --list-reserved takes no other arguments." >&2
234            return "$HS_ERR_INVALID_ARGUMENT_TYPE"
235        fi
236    else
237        _hs_resolve_state_inputs hs_destroy_state S: "$@" || return $?
238        # $() absorbs the helper's exit status; embedding "return N" in the output
239        # lets the surrounding eval propagate the failure to the caller.
240        eval "$(_hs_ds_body "${__hs_processed[state]}" "${__hs_processed[vars]-}" \
241            || printf 'return %d' "$?")" || return $?
242    fi
243    if _hs_local_exists "$(local -p)" list_reserved; then
244        # Snapshot taken after all processing locals are declared. local -p runs
245        # in a subshell before the assignment completes, so lp_snapshot itself
246        # is absent from the output. Splitting declare+assign would cause
247        # lp_snapshot to appear in its own snapshot; the combined form is
248        # intentional here.
249        : "${list_reserved}"
250        # shellcheck disable=SC2155
251        local lp_snapshot="$(local -p)"
252        _hs_print_reserved_names "$lp_snapshot" list_reserved
253    fi
254}
255
256# _hs_ds_body <out_var> <vars_str>
257# Contains the validation and rebuild logic for hs_destroy_state. Runs in its
258# own frame so its locals do not appear in the entry point's collision section.
259_hs_ds_body() {
260    local out_var="$1"
261    local -a vars=()
262    read -r -a vars <<< "${2-}"
263    local existing="${!out_var-}"
264
265    if [[ "$existing" != HS2:* ]]; then
266        echo "[ERROR] hs_destroy_state: state is not in HS2 format." >&2
267        return "$HS_ERR_CORRUPT_STATE"
268    fi
269
270    local -a recs=()
271    _hs_hs2_parse hs_destroy_state "$existing" recs || return $?
272    local -A present=()
273    local rec
274    for rec in "${recs[@]}"; do
275        present["$(_hs_hs2_record_name "$rec")"]=1
276    done
277
278    local var
279    for var in "${vars[@]}"; do
280        if [[ -z "${present[$var]-}" ]]; then
281            echo "[ERROR] hs_destroy_state: variable '$var' is not defined in the state." >&2
282            return "$HS_ERR_VAR_NAME_NOT_IN_STATE"
283        fi
284    done
285
286    local -A destroy_set=()
287    for var in "${vars[@]}"; do
288        destroy_set["$var"]=1
289    done
290    local -a survivors=()
291    local record_name
292    for rec in "${recs[@]}"; do
293        record_name=$(_hs_hs2_record_name "$rec")
294        [[ -z "${destroy_set[$record_name]-}" ]] && survivors+=("$rec")
295    done
296
297    # Print the assignment statement; the entry point evals it so no helper
298    # ever writes directly into a caller's variable.
299    local new_state
300    if (( ${#survivors[@]} > 0 )); then
301        new_state=$(_hs_hs2_build "" "${survivors[@]}") || return $?
302        printf '%s=%s\n' "$out_var" "$(printf '%q' "$new_state")"
303    else
304        printf '%s=\n' "$out_var"
305    fi
306}
307# --- hs_read_persisted_state --------------------------------------------------------
308# Function:
309#   hs_read_persisted_state [options] [--] [state_variable ...]
310# Description:
311#   Restores the values of the specified local variables from the opaque state
312#   object held in the variable named by -S.
313#   Preferred (implicit) form — no -- and no variable names: emits a restore
314#   snippet to stdout that the caller must eval; the snippet uses local -p in
315#   the caller's scope so it can only target unset scalar locals of the
316#   immediate caller, making it provably free of global scope pollution.
317#   Explicit form — variable names supplied after --: restores each name by
318#   traversing the full dynamic scope (caller chain and globals). Use when
319#   targeting a variable in a higher-level caller or a declared global.
320#   With -- and no variable names: returns 0 without restoring anything,
321#   disabling the implicit-probe path.
322# Options:
323#   -q - suppresses the warning that is normally emitted when a requested
324#        state variable is not present in the state object. Does not suppress
325#        errors.
326#   -S <state> - pass the state object by name, mandatory.
327#   Other options are ignored up to the last --, so this function is usually able
328#   to directly process its caller's argument list, future-proofing it against
329#   new hs_read_persisted_state options.
330#   --list-reserved - prints the reserved internal variable names to stdout, one
331#     per line, and returns 0. Incompatible with all other options. Intended for
332#     testing only. See hs_persist_state --list-reserved for the authoritative list.
333#   -- - marks the end of options and the beginning of the list of variable names.
334# Arguments:
335#   $@ - names of variables to restore (explicit form). Without `--`, the
336#        trailing arguments that are valid Bash identifiers are treated as the
337#        variable list. Note that the value associated with the last given
338#        option will be mistaken for a variable unless that option is known or
339#        `--` is used.
340# Errors:
341#   - `HS_ERR_MISSING_ARGUMENT` if no state variable name is supplied at all.
342#   - `HS_ERR_MULTIPLE_STATE_INPUTS` if `-S` is given more than once, even with
343#     the same variable name.
344#   - `HS_ERR_INVALID_VAR_NAME` if the state variable name or a requested
345#     restore variable name is not a valid Bash identifier.
346#   - `HS_ERR_STATE_VAR_UNINITIALIZED` if `-S <statevar>` is missing, or if
347#     the named state variable is unset or empty.
348#   - `HS_ERR_CORRUPT_STATE` if the state object cannot be evaluated safely
349#     while restoring requested variables.
350#   - `HS_ERR_UNKNOWN_VAR_NAME` if a requested variable name (explicit form)
351#     is not declared anywhere in the dynamic scope.
352#   - `HS_ERR_VAR_ALREADY_SET` if a requested variable name (explicit form)
353#     is set (including empty string); unset it first if an overwrite is intended.
354#   - Missing requested variables are warnings, one per variable, unless `-q`
355#     is supplied.
356# Usage examples:
357#   # Preferred: implicit form, targets only the caller's own unset locals.
358#   cleanup() {
359#       local temp_file resource_id
360#       eval "$(hs_read_persisted_state "$@")" || return $?
361#       rm -f "$temp_file"
362#       printf 'Cleaned up resource: %s\n' "$resource_id"
363#   }
364#   # Explicit form: use when targeting a specific subset or higher-scope vars.
365#   cleanup() {
366#       local temp_file resource_id
367#       hs_read_persisted_state "$@" -- temp_file resource_id || return $?
368#       rm -f "$temp_file"
369#       printf 'Cleaned up resource: %s\n' "$resource_id"
370#   }
371hs_read_persisted_state() {
372    local -a __hs_remaining=()
373    local -A __hs_processed=()
374    if [[ "${1-}" == "--list-reserved" ]]; then
375        local list_reserved=1
376        shift
377        if [ $# -gt 0 ]; then
378            echo "[ERROR] hs_read_persisted_state: --list-reserved takes no other arguments." >&2
379            return "$HS_ERR_INVALID_ARGUMENT_TYPE"
380        fi
381    else
382        if [[ "${1-}" != -* ]]; then
383            set -- -S "$@"
384        fi
385        _hs_resolve_state_inputs hs_read_persisted_state qS: "$@" || return $?
386        if [[ -n "${__hs_processed[vars]-}" ]]; then
387            # $() absorbs the helper's exit status; embedding "return N" in the
388            # output lets the surrounding eval propagate the failure to the caller.
389            eval "$(_hs_rr_explicit_stmts "${__hs_processed[state]}" \
390                "${__hs_processed[quiet]}" "${__hs_processed[vars]}" \
391                || printf 'return %d' "$?")" || return $?
392            return 0
393        fi
394        [[ -n "${__hs_processed[separator]-}" ]] && return 0
395        _hs_rr_implicit_snippet "${__hs_processed[state]}" || return $?
396    fi
397    if _hs_local_exists "$(local -p)" list_reserved; then
398        # Snapshot taken after all processing locals are declared. local -p runs
399        # in a subshell before the assignment completes, so lp_snapshot itself
400        # is absent from the output. Splitting declare+assign would cause
401        # lp_snapshot to appear in its own snapshot; the combined form is
402        # intentional here.
403        : "${list_reserved}"
404        # shellcheck disable=SC2155
405        local lp_snapshot="$(local -p)"
406        _hs_print_reserved_names "$lp_snapshot" list_reserved
407    fi
408}
409
410# _hs_rr_explicit_stmts <state_var> <quiet> <vars_str>
411# Validates all requested variables (declared and unset in dynamic scope), then
412# prints one assignment statement per variable to stdout. The caller evals the
413# output in the entry point's frame so assignments traverse dynamic scope and
414# none of this helper's locals are in the collision section.
415_hs_rr_explicit_stmts() {
416    local existing="${!1-}"
417    local state_var="$1"
418    local quiet="$2"
419    local -a requested=()
420    read -r -a requested <<< "${3-}"
421 
422    if [ -z "$existing" ]; then
423        echo "[ERROR] hs_read_persisted_state: state variable '$state_var' is not set or is empty." >&2
424        return "$HS_ERR_STATE_VAR_UNINITIALIZED"
425    fi
426    if [[ "$existing" != HS2:* ]]; then
427        echo "[ERROR] hs_read_persisted_state: state is not in HS2 format." >&2
428        return "$HS_ERR_CORRUPT_STATE"
429    fi
430
431    local -a recs=()
432    _hs_hs2_parse hs_read_persisted_state "$existing" recs || return $?
433    local -A record_map=()
434    local rec
435    for rec in "${recs[@]}"; do
436        record_map["$(_hs_hs2_record_name "$rec")"]="$rec"
437    done
438
439    # Phase 1: all-or-nothing guard check.
440    local var caller_decl
441    for var in "${requested[@]}"; do
442        [[ -z "${record_map[$var]+x}" ]] && continue
443        if ! caller_decl=$(declare -p "$var" 2>/dev/null); then
444            echo "[ERROR] hs_read_persisted_state: '$var' is not declared in scope." >&2
445            return "$HS_ERR_UNKNOWN_VAR_NAME"
446        fi
447        if [[ "$caller_decl" == *=* ]]; then
448            echo "[ERROR] hs_read_persisted_state: '$var' is already set; refusing to overwrite." >&2
449            return "$HS_ERR_VAR_ALREADY_SET"
450        fi
451    done
452
453    # Phase 2: generate assignment statements (eval'd by the entry point).
454    local record value_part
455    for var in "${requested[@]}"; do
456        if [[ -z "${record_map[$var]+x}" ]]; then
457            [[ "$quiet" == "false" ]] && \
458                echo "[WARNING] hs_read_persisted_state: variable '$var' is not defined in the state." >&2
459            continue
460        fi
461        record="${record_map[$var]}"
462        if [[ "$record" == *=* ]]; then
463            value_part="${record#*=}"
464            printf '%s=%s\n' "$var" "$value_part"
465        fi
466    done
467}
468
469# _hs_rr_implicit_snippet <state_var>
470# Emits the eval-able restore snippet for the implicit (no-variable-names) form
471# of hs_read_persisted_state. Runs in its own frame; the snippet is eval'd by
472# the caller of hs_read_persisted_state, not by the entry point.
473_hs_rr_implicit_snippet() {
474    local existing="${!1-}"
475    local state_var="$1"
476
477    if [ -z "$existing" ]; then
478        echo "[ERROR] hs_read_persisted_state: state variable '$state_var' is not set or is empty." >&2
479        return "$HS_ERR_STATE_VAR_UNINITIALIZED"
480    fi
481    if [[ "$existing" != HS2:* ]]; then
482        echo "[ERROR] hs_read_persisted_state: state is not in HS2 format." >&2
483        return "$HS_ERR_CORRUPT_STATE"
484    fi
485
486    local -a recs=()
487    _hs_hs2_parse hs_read_persisted_state "$existing" recs || return $?
488    local -A nameref_targets=()
489    local rec rec_flags rec_name rec_target
490    for rec in "${recs[@]}"; do
491        rec_flags="${rec#declare }"
492        rec_flags="${rec_flags%% *}"
493        if [[ "$rec_flags" == *n* && "$rec" == *=* ]]; then
494            rec_name=$(_hs_hs2_record_name "$rec")
495            rec_target="${rec#*\"}"
496            rec_target="${rec_target%\"}"
497            nameref_targets["$rec_name"]="$rec_target"
498        fi
499    done
500
501    local escaped_state_var
502    escaped_state_var=$(printf '%q' "$state_var")
503    local snippet=""
504    IFS= read -r -d '' snippet <<EOF || true
505hs_read_persisted_state -q -S ${escaped_state_var} -- \$(
506  local -p | while IFS= read -r __hs_local_decl; do
507    [[ "\$__hs_local_decl" == *=* ]] && continue
508    [[ "\$__hs_local_decl" =~ ^declare\ -[^[:space:]]*n ]] && continue
509    __hs_local_name=\${__hs_local_decl##* }
510    printf '%s ' "\$__hs_local_name"
511  done
512) >/dev/null
513EOF
514
515    local nameref_name nameref_target quoted_name quoted_target
516    for nameref_name in "${!nameref_targets[@]}"; do
517        nameref_target="${nameref_targets[$nameref_name]}"
518        quoted_name=$(printf '%q' "$nameref_name")
519        quoted_target=$(printf '%q' "$nameref_target")
520        snippet+="[[ \"\$(declare -p ${quoted_name} 2>/dev/null)\" == 'declare -'*n*' ${quoted_name}' ]] && declare -n ${quoted_name}=${quoted_target}"$'\n'
521    done
522    printf '%s' "$snippet"
523}
524
525# --- Utility functions --------------------------------------------------------
526
527# Function:
528#   _hs_is_valid_variable_name
529# Description:
530#   Returns success if the argument is a syntactically valid Bash variable name.
531# Arguments:
532#   $1 - candidate variable name
533# Returns:
534#   0 if the name is valid, 1 otherwise.
535# _hs_local_exists <lp_snapshot> <name>
536# Returns 0 if <name> appears as a declared local in the local -p snapshot,
537# 1 otherwise. The snapshot must be captured with local -p in the caller's
538# own frame so that only that frame's locals are visible — not ancestor frames.
539# This avoids the dynamic-scope false-positive that [[ -v name ]] produces when
540# an ancestor frame happens to declare a local with the same name.
541_hs_local_exists() {
542    local __hs_le_name="$2"
543    local __hs_le_line __hs_le_n
544    while IFS= read -r __hs_le_line; do
545        [[ "$__hs_le_line" != declare\ * ]] && continue
546        __hs_le_n="${__hs_le_line#* }"; __hs_le_n="${__hs_le_n#* }"; __hs_le_n="${__hs_le_n%%=*}"
547        [[ "$__hs_le_n" == "$__hs_le_name" ]] && return 0
548    done <<< "$1"
549    return 1
550}
551
552_hs_is_valid_variable_name() {
553    [[ "${1-}" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]
554}
555
556# _hs_print_reserved_names <lp_snapshot> [exclude]
557# Prints every variable name found in the local -p snapshot, one per line,
558# skipping the single name given by the optional <exclude> argument. Called by
559# the --list-reserved end block of each entry point; <exclude> is the mode-flag
560# local (e.g. list_reserved) that is present only in --list-reserved mode and
561# must not be reported as part of the collision section.
562_hs_print_reserved_names() {
563    local __hs_prn_snapshot="$1"
564    local __hs_prn_exclude="${2-}"
565    local declaration name
566    while IFS= read -r declaration; do
567        [[ "$declaration" != declare\ * ]] && continue
568        name="${declaration#* }"; name="${name#* }"; name="${name%%=*}"
569        [[ -n "$__hs_prn_exclude" && "$name" == "$__hs_prn_exclude" ]] && continue
570        printf '%s\n' "$name"
571    done <<< "$__hs_prn_snapshot"
572}
573
574# Function:
575#   _hs_resolve_state_inputs
576# Description:
577#   Parses helper options for state-oriented functions. Parsed results are
578#   written directly into the caller's `__hs_remaining` (indexed array) and
579#   `__hs_processed` (associative array) variables via Bash dynamic scoping.
580#   The helper recognizes `-S <statevar>` when requested by `$2`, optional
581#   helper flags such as `-q`, unknown forwarded options, and an optional
582#   final `--` separator before an explicit variable-name list.
583# Caller contract:
584#   The caller MUST declare the following variables before calling this helper:
585#     local -a __hs_remaining=()
586#     local -A __hs_processed=()
587#   The helper writes its output into those exact names through dynamic scoping.
588#   Passing any other names is a programming error.
589# Arguments:
590#   $1 - caller function name, used in error messages; must be a valid Bash name
591#   $2 - `getopts` format string of accepted helper options; e.g. `qS:`
592#   $3... - forwarded arguments from the public helper caller; if `--` is
593#           present, its last occurrence marks the start of the explicit
594#           variable-name list
595# Returns:
596#   0 on success.
597#   On success, `__hs_processed` may contain:
598#     - `state`: the validated state variable name from `-S`
599#     - `quiet`: `true` or `false`
600#     - `vars`: the validated explicit variable-name list as a space-separated string
601#     - `separator`: set when an explicit `--` was seen
602#   `HS_ERR_MISSING_ARGUMENT` if a required option parameter such as the value
603#   for `-S` is missing.
604#   `HS_ERR_INVALID_VAR_NAME` if the state variable name or an explicit
605#   variable-name token is not a valid Bash identifier.
606#   `HS_ERR_RESERVED_VAR_NAME` if the state variable name or a variable-name
607#   token matches a name in the caller's --list-reserved output.
608#   `HS_ERR_STATE_VAR_UNINITIALIZED` if no `-S <statevar>` option is provided.
609# Usage:
610#   local -a __hs_remaining=()
611#   local -A __hs_processed=()
612#   _hs_resolve_state_inputs my_helper qS: "$@" || return $?
613_hs_resolve_state_inputs() {
614    if [ $# -lt 2 ]; then
615        echo "[ERROR] ${1-_hs_resolve_state_inputs}: missing required arguments." >&2
616        return "$HS_ERR_MISSING_ARGUMENT"
617    fi
618    local __hs_ri_caller="$1"
619    local __hs_ri_opts="$2"
620    local __hs_ri_opt
621    local OPTARG
622    local -i OPTIND=1
623    local -i __hs_ri_sep_idx=0
624    local -i __hs_ri_scan=0
625    local -i __hs_ri_last_opt_sz=0
626    local -a __hs_ri_trailing=()
627    shift 2
628
629    # Verify caller declared the required output variables with correct types.
630    if [[ "${__hs_remaining@a}" != *a* ]]; then
631        echo "[ERROR] ${__hs_ri_caller}: caller must declare 'local -a __hs_remaining=()' before calling _hs_resolve_state_inputs." >&2
632        return "$HS_ERR_INVALID_ARGUMENT_TYPE"
633    fi
634    if [[ "${__hs_processed@a}" != *A* ]]; then
635        echo "[ERROR] ${__hs_ri_caller}: caller must declare 'local -A __hs_processed=()' before calling _hs_resolve_state_inputs." >&2
636        return "$HS_ERR_INVALID_ARGUMENT_TYPE"
637    fi
638
639    __hs_processed=(["quiet"]=false)
640    __hs_remaining=()
641
642    local __hs_ri_reserved_list
643    __hs_ri_reserved_list=$("$__hs_ri_caller" --list-reserved 2>/dev/null) || true
644
645    while (( "$#" >= "$OPTIND" )); do
646        __hs_ri_scan=${OPTIND}
647        if getopts ":$__hs_ri_opts" __hs_ri_opt; then
648            case "$__hs_ri_opt" in
649                \?)
650                    __hs_remaining+=("-$OPTARG")
651                    ;;
652                S)
653                    if ! _hs_is_valid_variable_name "$OPTARG"; then
654                        echo "[ERROR] ${__hs_ri_caller}: invalid variable name '${OPTARG}'." >&2
655                        return "$HS_ERR_INVALID_VAR_NAME"
656                    fi
657                    if [[ -n "$__hs_ri_reserved_list" && \
658                          $'\n'"$__hs_ri_reserved_list"$'\n' == *$'\n'"$OPTARG"$'\n'* ]]; then
659                        echo "[ERROR] ${__hs_ri_caller}: state variable name '$OPTARG' is reserved; choose a different variable name." >&2
660                        return "$HS_ERR_RESERVED_VAR_NAME"
661                    fi
662                    if [[ -n "${__hs_processed[state]-}" ]]; then
663                        echo "[ERROR] ${__hs_ri_caller}: option -S may only be given once." >&2
664                        return "$HS_ERR_MULTIPLE_STATE_INPUTS"
665                    fi
666                    __hs_processed["state"]="$OPTARG"
667                    __hs_ri_last_opt_sz=${#__hs_remaining[@]}
668                    ;;
669                q)
670                    __hs_processed["quiet"]=true
671                    __hs_ri_last_opt_sz=${#__hs_remaining[@]}
672                    ;;
673                :)
674                    echo "[ERROR] ${__hs_ri_caller}: missing required parameter to option -${OPTARG}." >&2
675                    return "$HS_ERR_MISSING_ARGUMENT"
676                    ;;
677            esac
678        elif (( __hs_ri_scan == OPTIND )); then
679            __hs_remaining+=("${!OPTIND}")
680            OPTIND=$(( OPTIND + 1 ))
681        else
682            # Hit --. Find the last occurrence to handle multiple separators.
683            __hs_processed["separator"]=true
684            __hs_ri_sep_idx=$(( OPTIND - 1 ))
685            for (( __hs_ri_scan = OPTIND; __hs_ri_scan <= $#; __hs_ri_scan++ )); do
686                [[ "${!__hs_ri_scan}" == "--" ]] && __hs_ri_sep_idx=$__hs_ri_scan
687            done
688            if (( __hs_ri_sep_idx > OPTIND - 1 )); then
689                __hs_remaining+=("--")
690            fi
691            while (( OPTIND < __hs_ri_sep_idx )); do
692                __hs_remaining+=("${!OPTIND}")
693                OPTIND=$(( OPTIND + 1 ))
694            done
695            OPTIND=$(( __hs_ri_sep_idx + 1 ))
696            break
697        fi
698    done
699
700    : "${__hs_processed["vars"]:=}"
701    if [[ -n "${__hs_processed[separator]-}" ]]; then
702        while (( "$#" >= "$OPTIND" )); do
703            if ! _hs_is_valid_variable_name "${!OPTIND}"; then
704                echo "[ERROR] ${__hs_ri_caller}: invalid variable name '${!OPTIND}'." >&2
705                return "$HS_ERR_INVALID_VAR_NAME"
706            fi
707            if [[ -n "$__hs_ri_reserved_list" && \
708                  $'\n'"$__hs_ri_reserved_list"$'\n' == *$'\n'"${!OPTIND}"$'\n'* ]]; then
709                echo "[ERROR] ${__hs_ri_caller}: variable name '${!OPTIND}' is reserved." >&2
710                return "$HS_ERR_RESERVED_VAR_NAME"
711            fi
712            printf -v '__hs_processed[vars]' "%s%s " "${__hs_processed[vars]}" "${!OPTIND}"
713            OPTIND=$(( OPTIND + 1 ))
714        done
715    else
716        while (( ${#__hs_remaining[@]} > __hs_ri_last_opt_sz )) && \
717              _hs_is_valid_variable_name "${__hs_remaining[-1]}"; do
718            if [[ -n "$__hs_ri_reserved_list" && \
719                  $'\n'"$__hs_ri_reserved_list"$'\n' == *$'\n'"${__hs_remaining[-1]}"$'\n'* ]]; then
720                echo "[ERROR] ${__hs_ri_caller}: variable name '${__hs_remaining[-1]}' is reserved." >&2
721                return "$HS_ERR_RESERVED_VAR_NAME"
722            fi
723            __hs_ri_trailing=("${__hs_remaining[-1]}" "${__hs_ri_trailing[@]}")
724            unset '__hs_remaining[-1]'
725        done
726        local IFS=' '
727        __hs_processed["vars"]="${__hs_ri_trailing[*]}"
728        if [[ "${__hs_processed[quiet]}" == false ]] && \
729           (( ${#__hs_remaining[@]} > 0 )); then
730            echo "[WARNING] ${__hs_ri_caller}: forwarded arguments remain after implicit variable-list parsing; use -- before the variable names." >&2
731        fi
732    fi
733
734    if [[ -z "${__hs_processed[state]-}" ]]; then
735        echo "[ERROR] ${__hs_ri_caller}: state variable is uninitialized; missing required -S <statevar> option." >&2
736        return "$HS_ERR_STATE_VAR_UNINITIALIZED"
737    fi
738}
739
740# --- HS2 helper functions -------------------------------------------------------
741
742# _hs_strip_export <decl>
743# Prints a declare -p record with the export flag (-x) removed.
744_hs_strip_export() {
745    local __decl="$1"
746    if [[ "$__decl" != "declare -"*x* ]]; then
747        printf '%s' "$__decl"
748        return 0
749    fi
750    local __rest="${__decl#declare }"
751    local __attrs="${__rest%% *}"
752    local __nameandval="${__rest#* }"
753    __attrs="${__attrs//x/}"
754    [[ "$__attrs" == "-" ]] && __attrs="--"
755    printf 'declare %s %s' "$__attrs" "$__nameandval"
756}
757
758# _hs_hs2_record_name <record>
759# Prints the variable name from a declare -p record.
760_hs_hs2_record_name() {
761    local __rest="${1#declare }"
762    __rest="${__rest#* }"
763    printf '%s' "${__rest%%=*}"
764}
765
766# _hs_hs2_build <existing_payload> [record ...]
767# Builds an HS2 state string from existing payload and new records and prints
768# it to stdout. Callers are responsible for assigning the result.
769_hs_hs2_build() {
770    local __hs2b_payload="$1"
771    shift 1
772    local __hs2b_rec
773    for __hs2b_rec in "$@"; do
774        if [[ -n "$__hs2b_payload" ]]; then
775            __hs2b_payload+=$'\001'
776        fi
777        __hs2b_payload+="$__hs2b_rec"
778    done
779    local __hs2b_cksum
780    __hs2b_cksum=$(printf '%s' "$__hs2b_payload" | cksum)
781    __hs2b_cksum="${__hs2b_cksum%% *}"
782    printf 'HS2:%s:%s' "$__hs2b_cksum" "$__hs2b_payload"
783}
784
785# _hs_hs2_parse <caller> <state> <out_array>
786# Verifies an HS2 state string and splits its records (SOH-delimited) into the
787# indexed array named by <out_array>.
788_hs_hs2_parse() {
789    local __hs2p_caller="$1"
790    local __hs2p_state="$2"
791    local -n __hs2p_out="$3"
792
793    if [[ "$__hs2p_state" != HS2:* ]]; then
794        echo "[ERROR] ${__hs2p_caller}: state is not in HS2 format." >&2
795        return "$HS_ERR_CORRUPT_STATE"
796    fi
797    local __hs2p_rest="${__hs2p_state#HS2:}"
798    local __hs2p_stored="${__hs2p_rest%%:*}"
799    local __hs2p_payload="${__hs2p_rest#*:}"
800
801    local __hs2p_computed
802    __hs2p_computed=$(printf '%s' "$__hs2p_payload" | cksum)
803    __hs2p_computed="${__hs2p_computed%% *}"
804    if [[ "$__hs2p_stored" != "$__hs2p_computed" ]]; then
805        echo "[ERROR] ${__hs2p_caller}: HS2 state checksum mismatch." >&2
806        return "$HS_ERR_CORRUPT_STATE"
807    fi
808
809    __hs2p_out=()
810    [[ -z "$__hs2p_payload" ]] && return 0
811    local __hs2p_old_ifs="$IFS"
812    IFS=$'\001' read -ra __hs2p_out <<< "$__hs2p_payload"
813    IFS="$__hs2p_old_ifs"
814}
815
816return 0
817
818# --- Change History -------------------------------------------------------
819# | PR    | Summary                                                        |
820# |-------|----------------------------------------------------------------|
821# | #32   | batch security fixes: guard commands [closes #7]               |
822# | #38   | do not return state via stdout                                 |
823# | #60   | use ${BASH:-bash} for collision-check subprocess [closes #59]  |
824# | #63   | refactor safer handle-state restoration flow [closes #62]      |
825# | #83   | fix hs_destroy_state rebuild subprocess helper [closes #82]    |
826# | #87   | fix top-of-file usage example, IFS-safe join [closes #64]      |
827# | #88   | clarify hs_persist_state_as_code as opaque token [closes #65]  |
828# | #89   | describe sandboxed eval and nameref restore [closes #68]       |
829# | #92   | replace non-standard hs_read_persisted_state example [cls #75] |
830# | #93   | rename probe-snippet to implicit local restore [closes #76]    |
831# | #99   | error on undeclared variable names [closes #1]                 |
832# | #102  | guard nameref restore against undeclared variables [cls #100]  |
833# | #103  | reject function names with HS_ERR_UNKNOWN_VAR_NAME             |
834# | #105  | fix hs_persist_state dropping indexed array elements [cls #3]  |
835# | #109  | reduce nameref collision surface [closes #104]                 |
836# | #110  | document HS_ERR_MULTIPLE_STATE_INPUTS for all entry points     |

Change History

PR Summary —– —————————————————————– #23 feature/skills update #32 batch security fixes [closes #7] #38 do not return state via stdout #63 refactor safer handle-state restoration flow [closes #62] #83 fix hs_destroy_state rebuild subprocess helper [closes #82] #90 remove internal-format mention, convenience form non-preferred #91 add forwarded-args eval example for probe-snippet mode #93 rename probe-snippet to implicit local restore [closes #76] #94 emphasize implicit restore snippet is safe local code #95 clarify caller evaluates probe code, not transmitted state #96 add -S calling context to Examples section [closes #80] #98 remove caveat implying raw eval of state is valid [closes #81] #99 error on undeclared variable names [closes #1] #102 guard nameref restore against undeclared variables [closes #100] #103 reject function names with HS_ERR_UNKNOWN_VAR_NAME #105 fix hs_persist_state dropping indexed array elements [closes #3] #109 reduce nameref collision surface [closes #104] #110 document HS_ERR_MULTIPLE_STATE_INPUTS for all entry points