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

The library depends on the command_guard.sh library and fails at load time if it cannot load its dependency.

Errors:

  • HS_ERR_DEPENDENCY_MISSING=19: The library failed to load due to a missing dependency.

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.

hs_extract_token

hs_extract_token has two call forms.

Direct query form$1 is --list-reserved:

hs_extract_token --list-reserved

Prints the names forming the collision surface of hs_extract_token itself, one per line. These are the minimum set of names to avoid when naming a -S state variable; write-capable or ill-designed entry points may add further prohibited names. The list is derived dynamically from local -p so that future edits to this function are automatically reflected.

As of the current release the output is:

__hs_processed
__hs_remaining

When used in a read-write entry-point pattern, calling the entry point with --list-reserved prints the merged collision surface, which includes hs_extract_token’s own names plus the source-local name ($2). For __wt_tok as the source local, the output is:

__hs_processed
__hs_remaining
__wt_tok

Note

The reserved-name list is part of the minor API: its prefix conventions will not change across minor versions, but individual names may be added or removed. Code that avoids the entire __hs_ namespace is unaffected by such changes; code that checks for specific names may break on a minor update.

Eval form$1 is the calling function name, $2 is the local name, ${@:3} are the forwarded args:

eval "$(hs_extract_token mylib_func __mod_state_token "$@")" || return $?

The eval form has two operational modes selected automatically by the caller’s argument list:

Normal mode ($3 is not --list-reserved): parses -S <statevar> from ${@:3} and emits local __mod_state_token='<token_value>' on success or bash -c 'exit N' on error. Runs in a $(...) subshell; the collision surface at fork time consists of hs_extract_token’s own locals.

Auto –list-reserved mode ($3 == --list-reserved): activated when the caller passes --list-reserved as their first argument (so ${@:3} of hs_extract_token is exactly --list-reserved). Emits two sentinels into the entry-point frame:

local list_reserved=$'__hs_processed\n__hs_remaining'  # own reserved names; merged by hs_write_token
local __mod_state_token=''   # token local pre-declared for frame inspection

No further arguments are valid in this mode. The entry-point detects list_reserved with local -p list_reserved >/dev/null 2>&1 (returns 0 when the variable is declared as a local). In a read-write pattern, hs_write_token reads list_reserved from the inherited frame, merges it with its own names and the source-local name, and emits a printf statement plus return 0 so that eval prints all names and exits the entry point.

Errors: same set as the shared option parser; no new codes.

hs_write_token

hs_write_token writes an updated state token value back to the caller’s variable.

  • Usage: eval "$(hs_write_token <API_function> <source_local> "$@")" where ${@:3} from the entry point’s perspective contains -S <statevar>.

  • $1 is the name of the calling API function (used in error messages).

  • $2 is the name of the local holding the updated token (accessed by position).

  • The forwarded parameter list (${@:3}) must contain -S <statevar>.

  • Runs in a $(...) subshell, inheriting the calling frame read-only.

  • --list-reserved (when ${@:3} is exactly --list-reserved): computes own collision surface, merges it with list_reserved from the inherited entry-point frame (set by hs_extract_token’s eval-code form when present), adds $2 (the source-local name), and emits eval-code that prints all merged names and returns 0. After eval, the entry-point prints the complete collision surface and exits.

Behaviour:

  • Parses -S from the forwarded arguments.

  • On success: prints <statevar>='<updated_value>' — a plain assignment (not local) that eval executes to write the token back.

  • On error: prints bash -c 'exit N'.

The collision surface includes at minimum the source-local name ($2) plus hs_extract_token’s own locals. Any other locals declared in the entry-point frame before this call also add to the surface. When the body-helper pattern is followed strictly — where the body helper (not the entry point) declares and updates the token local — only those minimum names appear.

Note

hs_write_token cannot avoid a name collision when the -S state variable and the source local share the same name. This edge case is addressed in issue #139.

Errors: same set as the shared option parser; HS_ERR_MISSING_ARGUMENT if $1 is absent.

See Entry-Point Pattern for canonical usage examples of both functions.

Entry-Point Pattern

Libraries that expose -S <statevar> and use handle_state.sh internally should structure each stateful entry point as follows to minimize name collision risk.

Read-write entry point (restores and persists state):

mylib_func() {
    # No extra locals; hs_extract_token emits __mylib_state_token via eval.
    eval "$(hs_extract_token mylib_func __mylib_state_token "$@")" || return $?
    if ! local -p list_reserved >/dev/null 2>&1; then
        # Body helper reads from and persists to __mylib_state_token via dynamic scoping.
        _mylib_func "$@" || return $?
    fi
    # hs_write_token handles --list-reserved when active; otherwise writes the token back.
    eval "$(hs_write_token mylib_func __mylib_state_token "$@")" || return $?
}

_mylib_func() {
    local var1 var2
    eval "$(hs_read_persisted_state -S __mylib_state_token)" || return $?   # implicit form preferred
    # ... work ...
    hs_destroy_state -S __mylib_state_token -- var1 var2 || return $?
    hs_persist_state -S __mylib_state_token -- var1 var2 || return $?
}

Read-only entry point (restores state but does not persist):

A well-designed read-only entry point has the same collision surface as hs_extract_token. It delegates --list-reserved reporting directly to hs_extract_token --list-reserved.

mylib_ro_func() {
    eval "$(hs_extract_token mylib_ro_func __mylib_state_token "$@")" || return $?
    local -p list_reserved >/dev/null 2>&1 && { hs_extract_token --list-reserved; return 0; }
    _mylib_ro_func "$@" || return $?
}

_mylib_ro_func() {
    local var1 var2
    eval "$(hs_read_persisted_state -S __mylib_state_token)" || return $?   # implicit form preferred
    # ... read-only work ...
}

Read/modify/write entry point (updates one or more variables inside an existing token without replacing the whole state):

When a function must change variables that are already stored in an opaque token it received from its caller, it must destroy and re-persist those variables — it cannot overwrite them in place. Using hs_extract_token and hs_write_token keeps the operation atomic from the caller’s perspective: the token variable is either fully updated or left unchanged.

mylib_update_func() {
    # No extra locals; hs_extract_token emits __mylib_state_token via eval.
    eval "$(hs_extract_token mylib_update_func __mylib_state_token "$@")" || return $?
    if ! local -p list_reserved >/dev/null 2>&1; then
        _mylib_update_func "$@" || return $?
    fi
    eval "$(hs_write_token mylib_update_func __mylib_state_token "$@")" || return $?
}

_mylib_update_func() {
    local var1 var2
    eval "$(hs_read_persisted_state -S __mylib_state_token)" || return $?   # implicit form preferred
    # ... mutate var1, var2 as needed ...
    # Destroy before re-persisting to avoid HS_ERR_VAR_NAME_COLLISION.
    hs_destroy_state -S __mylib_state_token -- var1 var2 || return $?
    hs_persist_state -S __mylib_state_token -- var1 var2 || return $?
}

Warning

hs_destroy_state modifies the token in the caller’s variable immediately. If hs_persist_state subsequently fails, the destroyed variables are lost from the token. Always call both functions in the same body helper so that any error causes the whole entry point to abort via return $? before hs_write_token propagates an incomplete token back to the caller. Bash’s sequential execution model and the fact that subshells cannot write back to the parent shell’s variables make this pattern safe under normal control flow: there is no concurrent access that could observe a partially-updated token.

Note

Subshells ($(...) command substitutions and explicit ( ) subshell groups) inherit a copy of the parent shell’s environment. Any variable assignment or hs_persist_state call inside a subshell affects only that copy; the parent’s token variable is never updated. This means handle_state.sh state can only be advanced by code that runs directly in the relevant shell process. Functions that run in a subshell (e.g. to capture their output) cannot update the caller’s token even if they call hs_persist_state successfully.

The --list-reserved output differs by entry-point type:

  • Read-only (delegates to hs_extract_token --list-reserved): prints only __hs_processed and __hs_remaining.

  • Read-write (delegates to hs_write_token mylib_func __mylib_state_token --list-reserved): prints __hs_processed, __hs_remaining, and __mylib_state_token.

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
    eval "$(hs_read_persisted_state "$@")" || return $?   # implicit form preferred
    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.

  • State records are separated internally by $'\001' and parsed with IFS=$'\001' read -ra; the state string is never passed to eval.

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

#140

add hs_extract_token and hs_write_token; entry-point pattern (issue #136)

#140

fix –list-reserved merge for read-write entry points

#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