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. -S is mandatory: omitting it is a structural error (HS_ERR_STATE_VAR_UNINITIALIZED, printed to stderr).

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). Instead of extracting a state value, it emits eval-code that declares the token local and assigns it a mode token — a real HS2 object whose checksum field is replaced by a non-numeric marker only hs_extract_token can emit:

HS2:mode=list-reserved:declare -a reserved_names=([0]="__hs_processed" [1]="__hs_remaining" ...)

The payload (a reserved_names array) is built with hs_persist_state and carries the merged collision surface: hs_extract_token’s own names plus a capture of the entry-point frame taken at eval time (so locals the entry point declared before the eval are included). The token local ($2) is declared last, immediately before assignment, so it never appears in its own capture; whether $2 belongs in the reported surface is decided later by hs_finalize_token (see below), not here. No further arguments are valid in this mode.

Because the marker replaces the numeric checksum, a normal token can never equal a mode token: every non-token-utility consumer rejects it with the discriminable HS_ERR_LIST_RESERVED_TOKEN (see Error Codes), and hs_is_list_reserved_mode / hs_finalize_token recognise it structurally.

Errors: same set as the shared option parser; -S is mandatory in normal mode.

hs_finalize_token

hs_finalize_token terminates every entry point. It is always called , and its behaviour is driven entirely by the token it is handed — never by re-sniffing $@.

  • Usage: eval "$(hs_finalize_token <API_function> <token_local> "$@")".

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

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

  • Runs in a $(...) subshell, inheriting the calling frame read-only for the write path; the list-reserved report path emits code that runs in the entry-point frame so it can capture that frame afresh.

Behaviour, selected by the token’s checksum field:

  • Mode token (field starts with mode=): emit the collision-surface report — read reserved_names back out of the token, merge it with hs_finalize_token’s own surface and a fresh capture of the entry-point frame (catching locals declared between the two evals), then print one name per line and return 0. The token local $2 is included unless the marker ends in -ro (read-only; see hs_read_only), in which case it is excluded. The reported conflicting token name is always $2 — the fixed internal state-token name — independent of any external -S name.

  • Normal token (numeric checksum) with -S <statevar> present: emit <statevar>='<token_value>' — a plain assignment writing the (possibly updated) token back. Idempotent when the body left the token unchanged.

  • Normal token with no -S: emit nothing and return 0 — the entry point is read-only with respect to external state.

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

Structural errors — those where the shape of the call is wrong, as opposed to a well-formed call whose request fails — are checked before the token is read, and print both a diagnostic and the synopsis on stderr. No option is consulted on these paths: -q belongs to the functional domain, and a malformed argument list is precisely what must not be trusted to carry an option.

  • HS_ERR_INVALID_ARGUMENT_TYPE=9: $1 is an option other than --list-reserved (typically a mistyped one), $1 is not a usable API function name, or --list-reserved was given extra arguments.

  • HS_ERR_MISSING_ARGUMENT=8: fewer than two positional arguments.

  • HS_ERR_INVALID_VAR_NAME=5: $2 is not a valid Bash identifier.

$1 and $2 are validated against different rules. $2 names a variable and must be a plain identifier. $1 names a function, so dotted and colon-separated forms (obj.method, a.b.c, ns::func) are accepted — they are legal Bash function names, and they are the shape a dispatch layer built on top of tokens would use. The accepted set is narrower than Bash’s own, which also admits foo*, foo[1] and foo#bar: glob and expansion metacharacters are excluded because the name is interpolated into diagnostics. First character a letter or underscore, then also digits and . : + @ -.

See Entry-Point Pattern for canonical usage examples.

hs_is_list_reserved_mode

hs_is_list_reserved_mode -S <token_local> returns 0 iff the named token is a list-reserved mode token (checksum field starts with mode=list-reserved, matching both the read-write baseline and the -ro variant), non-zero otherwise. It reads the token through dynamic scope and never accesses external state, so it carries no collision surface of its own. It is the body-skip guard in the canonical skeleton: the entry-point body runs only when the token is a real state token.

hs_read_only

hs_read_only <API_function> <token_local> "$@" marks an entry point as read-only with respect to external state. It is an optional line in the canonical skeleton; include it only in entry points that never write state back. Its single, mode-agnostic rule inspects the token’s checksum field:

  • numeric checksum (a normal state token, or an empty / non-HS2 token): strip -S <var> from $@ (emit set -- ) so hs_finalize_token performs no write-back.

  • ``mode=…`` marker (any mode): append -ro to the marker (idempotently — never a double -ro), leaving the payload untouched, so hs_finalize_token excludes the token local $2 from the reported surface.

Because the rule keys only on the mode= prefix, every present and future mode gains a read-only variant (mode=Xmode=X-ro) for free.

hs_read_only performs the same structural checks as hs_finalize_token, with the same codes and the same rules for $1 and $2; see above. They matter more here: unchecked, a malformed call falls through to the normal-token path and emits a bare set --, silently discarding the entry point’s positional parameters. Its skeleton line therefore carries || return $? like the other two, so the emitted exit stub reaches the caller.

Entry-Point Pattern

Libraries that expose -S <statevar> and use handle_state.sh internally should structure every stateful entry point with the same three-line skeleton, regardless of whether it reads, writes, or both:

mylib_func() {
    eval "$(hs_extract_token  mylib_func __mylib_state_token "$@")" || return $?
    # eval "$(hs_read_only    mylib_func __mylib_state_token "$@")" || return $?   # <-- uncomment iff this entry point never writes state back
    hs_is_list_reserved_mode -S __mylib_state_token || { _mylib_func "$@" || return $?; }
    eval "$(hs_finalize_token mylib_func __mylib_state_token "$@")" || return $?
}

The three steps are always extract → body-unless-listing → finalize:

  1. hs_extract_token populates the token local — either the caller’s state (normal mode) or a mode token (--list-reserved).

  2. hs_is_list_reserved_mode skips the body helper whenever the token is a mode token, so no business logic runs during a --list-reserved query (any read or persist attempted there would hit the mode token and fail with HS_ERR_LIST_RESERVED_TOKEN).

  3. hs_finalize_token writes the token back (normal mode) or emits the collision report (list-reserved mode).

Read-write and read/modify/write entry points use the skeleton as shown. The body helper reads from and persists to the token local via dynamic scoping:

_mylib_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 $?
}

Read-only entry points uncomment the hs_read_only line. In normal mode it strips -S so hs_finalize_token performs no write-back; in list-reserved mode it appends -ro to the token marker so the report excludes the token local. The body helper simply omits the destroy/persist calls:

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

Whether a call ultimately modifies the state is a runtime property of the body — the finalizer simply serialises whatever the token holds — so a helper that conditionally mutates state needs no special flag and no branch in the entry point.

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_finalize_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, because hs_finalize_token includes the token local only when the mode marker lacks the -ro suffix:

  • Read-write / read-modify-write (no hs_read_only; marker mode=list-reserved): prints __hs_processed, __hs_remaining, and __mylib_state_token.

  • Read-only (hs_read_only uncommented; marker mode=list-reserved-ro): prints only __hs_processed and __hs_remaining — the token local is excluded because no write-back can shadow the caller’s -S name.

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

  • HS_ERR_LIST_RESERVED_TOKEN=13: a list-reserved mode token (checksum field starting with mode=) was handed to a normal state consumer (hs_persist_state, hs_destroy_state, hs_read_persisted_state). A mode token carries only the reserved-name surface and is not usable as state; this code is distinct from HS_ERR_CORRUPT_STATE so callers can tell the two apart. It signals programmer misuse and is printed to stderr.

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_LIST_RESERVED_TOKEN=13
  23readonly HS_ERR_DEPENDENCY_MISSING=19
  24
  25# --- Internal constants --------------------------------------------------------
  26# A list-reserved "mode token" replaces the numeric checksum field of an HS2
  27# object with this marker, which cksum output (digits only) can never equal.
  28# hs_read_only appends "-ro" to it; hs_is_list_reserved_mode matches the prefix.
  29readonly _HS_LIST_RESERVED_MARK='mode=list-reserved'
  30
  31# Source command guard for secure external command usage
  32# shellcheck disable=SC2317  # Linter complains that the error handler is unreachable.
  33# shellcheck source=command_guard.sh
  34if ! source "${BASH_SOURCE%/*}/command_guard.sh"; then
  35    echo "[ERROR] handle_state.sh: Unable to load required library 'command_guard.sh'" >&2
  36    return "$HS_ERR_DEPENDENCY_MISSING"
  37fi
  38
  39# Library usage — see docs/libraries/handle_state.rst for the full API.
  40
  41cg_guard cksum || return $?
  42
  43# --- hs_persist_state ----------------------------------------------------------
  44# Function:
  45#   hs_persist_state [options] [--] [state_variable ...]
  46# Description:
  47#   Appends the current values of the specified local variables to an HS2-format
  48#   opaque state object held in the variable named by -S. Supports scalars,
  49#   indexed arrays, associative arrays, and namerefs (nameref target must also
  50#   be persisted in the same call or already present in the prior state).
  51# Options:
  52#   -S <state> - pass the state object by name, mandatory.
  53#   Other options are ignored up to the last --, so this function is usually able
  54#   to directly process its caller's argument list, future-proofing it against
  55#   new hs_persist_state options.
  56#   -- - marks the end of options and the beginning of the list of variable names.
  57#   --list-reserved - prints the reserved internal variable names to stdout, one
  58#     per line, and returns 0. Incompatible with all other options. Intended for
  59#     testing only. The reported names are also reported by hs_read_persisted_state
  60#     and hs_destroy_state --list-reserved (identical output across all three).
  61# Arguments:
  62#   $@ - names of local variables to persist. Without `--`, the trailing
  63#        arguments that are valid Bash identifiers are treated as the variable
  64#        list. Note that the value associated with the last given option will be
  65#        mistaken for a variable unless `--` is used.
  66# Errors:
  67#   - `HS_ERR_MISSING_ARGUMENT` if no state variable name is supplied at all.
  68#   - `HS_ERR_MULTIPLE_STATE_INPUTS` if `-S` is given more than once, even with
  69#     the same variable name.
  70#   - `HS_ERR_INVALID_VAR_NAME` if the state variable name or a requested
  71#     persist variable name is not a valid Bash identifier.
  72#   - `HS_ERR_STATE_VAR_UNINITIALIZED` if `-S <statevar>` is missing.
  73#   - `HS_ERR_CORRUPT_STATE` if the existing state is not in HS2 format or
  74#     the rebuilt state cannot be verified.
  75#   - `HS_ERR_RESERVED_VAR_NAME` if a requested name starts with `__hs_`,
  76#     which is the reserved internal name prefix used by this library.
  77#   - `HS_ERR_VAR_NAME_COLLISION` if a requested name is already present in
  78#     the existing state object.
  79#   - `HS_ERR_UNKNOWN_VAR_NAME` if a requested name is not declared in scope,
  80#     or is a function name rather than a variable.
  81#   - `HS_ERR_NAMEREF_TARGET_NOT_PERSISTED` if a nameref's target is not being
  82#     persisted in the same call and is not already present in the prior state.
  83# Usage examples:
  84#   init_function() {
  85#       local token="abc" count=3
  86#       hs_persist_state "$@" -- token count || return $?
  87#   }
  88#   init_with_array() {
  89#       local -a items=(one two three)
  90#       hs_persist_state -S "$1" -- items || return $?
  91#   }
  92hs_persist_state() {
  93    local -a __hs_remaining=()
  94    local -A __hs_processed=()
  95    if [[ "${1-}" == "--list-reserved" ]]; then
  96        local list_reserved=1
  97        shift
  98        if [ $# -gt 0 ]; then
  99            echo "[ERROR] hs_persist_state: --list-reserved takes no other arguments." >&2
 100            return "$HS_ERR_INVALID_ARGUMENT_TYPE"
 101        fi
 102    else
 103        _hs_resolve_state_inputs hs_persist_state S: "$@" || return $?
 104        # $() absorbs the helper's exit status; embedding "return N" in the output
 105        # lets the surrounding eval propagate the failure to the caller.
 106        eval "$(_hs_ps_body "${__hs_processed[state]}" "${__hs_processed[vars]-}" \
 107            || printf 'return %d' "$?")" || return $?
 108    fi
 109    # List reserved
 110    if _hs_local_exists "$(local -p)" list_reserved; then
 111        # Snapshot taken after all processing locals are declared. local -p runs
 112        # in a subshell before the assignment completes, so lp_snapshot itself
 113        # is absent from the output. Splitting declare+assign would cause
 114        # lp_snapshot to appear in its own snapshot; the combined form is
 115        # intentional here.
 116        : "${list_reserved}"
 117        # shellcheck disable=SC2155
 118        local lp_snapshot="$(local -p)"
 119        _hs_print_reserved_names "$lp_snapshot" list_reserved
 120    fi
 121}
 122
 123# _hs_ps_body <out_var> <vars_str>
 124# Contains the validation and build logic for hs_persist_state. Runs in its
 125# own frame so its locals do not appear in the entry point's collision section.
 126_hs_ps_body() {
 127    local existing="${!1-}"
 128    local out_var="$1"
 129    local -a vars=()
 130    read -r -a vars <<< "${2-}"
 131
 132    # Parse existing state (must be empty or HS2).
 133    local existing_payload=""
 134    local -a existing_recs=()
 135    local -A existing_names=()
 136    if [[ -n "$existing" ]]; then
 137        if [[ "$existing" != HS2:* ]]; then
 138            echo "[ERROR] hs_persist_state: existing state is not in HS2 format." >&2
 139            return "$HS_ERR_CORRUPT_STATE"
 140        fi
 141        _hs_hs2_parse hs_persist_state "$existing" existing_recs || return $?
 142        existing_payload="${existing#HS2:}"
 143        existing_payload="${existing_payload#*:}"
 144        local existing_rec
 145        for existing_rec in "${existing_recs[@]}"; do
 146            existing_names["$(_hs_hs2_record_name "$existing_rec")"]=1
 147        done
 148    fi
 149
 150    # Phase 1: validate all names; separate non-namerefs from namerefs.
 151    local -a non_namerefs=()
 152    local -a namerefs=()
 153    local -A this_call=()
 154    local var decl flags
 155    for var in "${vars[@]}"; do
 156        if [[ -n "${existing_names[$var]-}" ]]; then
 157            echo "[ERROR] hs_persist_state: variable '$var' already exists in the state." >&2
 158            return "$HS_ERR_VAR_NAME_COLLISION"
 159        fi
 160        if ! decl=$(declare -p "$var" 2>/dev/null); then
 161            if declare -f "$var" >/dev/null 2>&1; then
 162                echo "[ERROR] hs_persist_state: '$var' is a function, not a variable." >&2
 163            else
 164                echo "[ERROR] hs_persist_state: '$var' is not declared in scope." >&2
 165            fi
 166            return "$HS_ERR_UNKNOWN_VAR_NAME"
 167        fi
 168        flags="${decl#declare }"
 169        flags="${flags%% *}"
 170        if [[ "$flags" == *n* ]]; then
 171            this_call["$var"]=nameref
 172        else
 173            non_namerefs+=("$(_hs_strip_export "$decl")")
 174            this_call["$var"]=1
 175        fi
 176    done
 177
 178    # Phase 2: validate nameref targets and build nameref records (after targets).
 179    local target
 180    for var in "${vars[@]}"; do
 181        [[ "${this_call[$var]-}" == nameref ]] || continue
 182        decl=$(declare -p "$var" 2>/dev/null)
 183        target="${decl#*\"}"
 184        target="${target%\"}"
 185        if [[ -z "${existing_names[$target]-}" && \
 186              -z "${this_call[$target]-}" ]]; then
 187            echo "[ERROR] hs_persist_state: nameref '$var' target '$target' is not being persisted." >&2
 188            return "$HS_ERR_NAMEREF_TARGET_NOT_PERSISTED"
 189        fi
 190        namerefs+=("$(_hs_strip_export "$decl")")
 191    done
 192
 193    # Build HS2 state: existing payload + non-nameref records + nameref records.
 194    # Print the assignment statement; the entry point evals it so no helper
 195    # ever writes directly into a caller's variable.
 196    local new_state
 197    new_state=$(_hs_hs2_build "$existing_payload" \
 198        "${non_namerefs[@]}" "${namerefs[@]}") || return $?
 199    printf '%s=%s\n' "$out_var" "$(printf '%q' "$new_state")"
 200}
 201
 202# --- hs_destroy_state ---------------------------------------------------------------
 203# Function:
 204#   hs_destroy_state [options] [--] [state_variable ...]
 205# Description:
 206#   Removes the specified local variables from an opaque state object.
 207#   In a cleanup function, this allows the same state variable to be reused by
 208#   a later init call without triggering name-collision errors.
 209# Options:
 210#   -S <state> - pass the state object by name, mandatory.
 211#   Other options are ignored up to the last --, so this function is usually able
 212#   to directly process its caller's argument list, future-proofing it against
 213#   new hs_destroy_state options.
 214#   -- - marks the end of options and the beginning of the list of variable names.
 215#   --list-reserved - prints the reserved internal variable names to stdout, one
 216#     per line, and returns 0. Incompatible with all other options. Intended for
 217#     testing only. See hs_persist_state --list-reserved for the authoritative list.
 218# Arguments:
 219#   $@ - names of local variables to destroy. Without `--`, the trailing
 220#        arguments that are valid Bash identifiers are treated as the variable list.
 221#        Note that the value associated with the last given option will be mistaken
 222#        for a variable unless `--` is used.
 223# Errors:
 224#   - `HS_ERR_STATE_VAR_UNINITIALIZED` if `-S <statevar>` is missing.
 225#   - `HS_ERR_MULTIPLE_STATE_INPUTS` if `-S` is given more than once, even with
 226#     the same variable name.
 227#   - `HS_ERR_INVALID_VAR_NAME` if the state variable name or a requested
 228#     destroy variable name is not a valid Bash identifier.
 229#   - `HS_ERR_VAR_NAME_NOT_IN_STATE` if a requested destroy variable is not
 230#     present in the input state object.
 231#   - `HS_ERR_CORRUPT_STATE` if the input state object cannot be parsed or
 232#     rebuilt safely.
 233# Usage examples:
 234#   cleanup_function() {
 235#       hs_destroy_state "$@" -- mylib_statevar1 mylib_statevar2
 236#   }
 237hs_destroy_state() {
 238    local -a __hs_remaining=()
 239    local -A __hs_processed=()
 240    if [[ "${1-}" == "--list-reserved" ]]; then
 241        local list_reserved=1
 242        shift
 243        if [ $# -gt 0 ]; then
 244            echo "[ERROR] hs_destroy_state: --list-reserved takes no other arguments." >&2
 245            return "$HS_ERR_INVALID_ARGUMENT_TYPE"
 246        fi
 247    else
 248        _hs_resolve_state_inputs hs_destroy_state S: "$@" || return $?
 249        # $() absorbs the helper's exit status; embedding "return N" in the output
 250        # lets the surrounding eval propagate the failure to the caller.
 251        eval "$(_hs_ds_body "${__hs_processed[state]}" "${__hs_processed[vars]-}" \
 252            || printf 'return %d' "$?")" || return $?
 253    fi
 254    if _hs_local_exists "$(local -p)" list_reserved; then
 255        # Snapshot taken after all processing locals are declared. local -p runs
 256        # in a subshell before the assignment completes, so lp_snapshot itself
 257        # is absent from the output. Splitting declare+assign would cause
 258        # lp_snapshot to appear in its own snapshot; the combined form is
 259        # intentional here.
 260        : "${list_reserved}"
 261        # shellcheck disable=SC2155
 262        local lp_snapshot="$(local -p)"
 263        _hs_print_reserved_names "$lp_snapshot" list_reserved
 264    fi
 265}
 266
 267# _hs_ds_body <out_var> <vars_str>
 268# Contains the validation and rebuild logic for hs_destroy_state. Runs in its
 269# own frame so its locals do not appear in the entry point's collision section.
 270_hs_ds_body() {
 271    local out_var="$1"
 272    local -a vars=()
 273    read -r -a vars <<< "${2-}"
 274    local existing="${!out_var-}"
 275
 276    if [[ "$existing" != HS2:* ]]; then
 277        echo "[ERROR] hs_destroy_state: state is not in HS2 format." >&2
 278        return "$HS_ERR_CORRUPT_STATE"
 279    fi
 280
 281    local -a recs=()
 282    _hs_hs2_parse hs_destroy_state "$existing" recs || return $?
 283    local -A present=()
 284    local rec
 285    for rec in "${recs[@]}"; do
 286        present["$(_hs_hs2_record_name "$rec")"]=1
 287    done
 288
 289    local var
 290    for var in "${vars[@]}"; do
 291        if [[ -z "${present[$var]-}" ]]; then
 292            echo "[ERROR] hs_destroy_state: variable '$var' is not defined in the state." >&2
 293            return "$HS_ERR_VAR_NAME_NOT_IN_STATE"
 294        fi
 295    done
 296
 297    local -A destroy_set=()
 298    for var in "${vars[@]}"; do
 299        destroy_set["$var"]=1
 300    done
 301    local -a survivors=()
 302    local record_name
 303    for rec in "${recs[@]}"; do
 304        record_name=$(_hs_hs2_record_name "$rec")
 305        [[ -z "${destroy_set[$record_name]-}" ]] && survivors+=("$rec")
 306    done
 307
 308    # Print the assignment statement; the entry point evals it so no helper
 309    # ever writes directly into a caller's variable.
 310    local new_state
 311    if (( ${#survivors[@]} > 0 )); then
 312        new_state=$(_hs_hs2_build "" "${survivors[@]}") || return $?
 313        printf '%s=%s\n' "$out_var" "$(printf '%q' "$new_state")"
 314    else
 315        printf '%s=\n' "$out_var"
 316    fi
 317}
 318# --- hs_read_persisted_state --------------------------------------------------------
 319# Function:
 320#   hs_read_persisted_state [options] [--] [state_variable ...]
 321# Description:
 322#   Restores the values of the specified local variables from the opaque state
 323#   object held in the variable named by -S.
 324#   Preferred (implicit) form — no -- and no variable names: emits a restore
 325#   snippet to stdout that the caller must eval; the snippet uses local -p in
 326#   the caller's scope so it can only target unset scalar locals of the
 327#   immediate caller, making it provably free of global scope pollution.
 328#   Explicit form — variable names supplied after --: restores each name by
 329#   traversing the full dynamic scope (caller chain and globals). Use when
 330#   targeting a variable in a higher-level caller or a declared global.
 331#   With -- and no variable names: returns 0 without restoring anything,
 332#   disabling the implicit-probe path.
 333# Options:
 334#   -q - suppresses the warning that is normally emitted when a requested
 335#        state variable is not present in the state object. Does not suppress
 336#        errors.
 337#   -S <state> - pass the state object by name, mandatory.
 338#   Other options are ignored up to the last --, so this function is usually able
 339#   to directly process its caller's argument list, future-proofing it against
 340#   new hs_read_persisted_state options.
 341#   --list-reserved - prints the reserved internal variable names to stdout, one
 342#     per line, and returns 0. Incompatible with all other options. Intended for
 343#     testing only. See hs_persist_state --list-reserved for the authoritative list.
 344#   -- - marks the end of options and the beginning of the list of variable names.
 345# Arguments:
 346#   $@ - names of variables to restore (explicit form). Without `--`, the
 347#        trailing arguments that are valid Bash identifiers are treated as the
 348#        variable list. Note that the value associated with the last given
 349#        option will be mistaken for a variable unless that option is known or
 350#        `--` is used.
 351# Errors:
 352#   - `HS_ERR_MISSING_ARGUMENT` if no state variable name is supplied at all.
 353#   - `HS_ERR_MULTIPLE_STATE_INPUTS` if `-S` is given more than once, even with
 354#     the same variable name.
 355#   - `HS_ERR_INVALID_VAR_NAME` if the state variable name or a requested
 356#     restore variable name is not a valid Bash identifier.
 357#   - `HS_ERR_STATE_VAR_UNINITIALIZED` if `-S <statevar>` is missing, or if
 358#     the named state variable is unset or empty.
 359#   - `HS_ERR_CORRUPT_STATE` if the state object cannot be evaluated safely
 360#     while restoring requested variables.
 361#   - `HS_ERR_UNKNOWN_VAR_NAME` if a requested variable name (explicit form)
 362#     is not declared anywhere in the dynamic scope.
 363#   - `HS_ERR_VAR_ALREADY_SET` if a requested variable name (explicit form)
 364#     is set (including empty string); unset it first if an overwrite is intended.
 365#   - Missing requested variables are warnings, one per variable, unless `-q`
 366#     is supplied.
 367# Usage examples:
 368#   # Preferred: implicit form, targets only the caller's own unset locals.
 369#   cleanup() {
 370#       local temp_file resource_id
 371#       eval "$(hs_read_persisted_state "$@")" || return $?
 372#       rm -f "$temp_file"
 373#       printf 'Cleaned up resource: %s\n' "$resource_id"
 374#   }
 375#   # Explicit form: use when targeting a specific subset or higher-scope vars.
 376#   cleanup() {
 377#       local temp_file resource_id
 378#       hs_read_persisted_state "$@" -- temp_file resource_id || return $?
 379#       rm -f "$temp_file"
 380#       printf 'Cleaned up resource: %s\n' "$resource_id"
 381#   }
 382hs_read_persisted_state() {
 383    local -a __hs_remaining=()
 384    local -A __hs_processed=()
 385    if [[ "${1-}" == "--list-reserved" ]]; then
 386        local list_reserved=1
 387        shift
 388        if [ $# -gt 0 ]; then
 389            echo "[ERROR] hs_read_persisted_state: --list-reserved takes no other arguments." >&2
 390            return "$HS_ERR_INVALID_ARGUMENT_TYPE"
 391        fi
 392    else
 393        if [[ "${1-}" != -* ]]; then
 394            set -- -S "$@"
 395        fi
 396        _hs_resolve_state_inputs hs_read_persisted_state qS: "$@" || return $?
 397        if [[ -n "${__hs_processed[vars]-}" ]]; then
 398            # $() absorbs the helper's exit status; embedding "return N" in the
 399            # output lets the surrounding eval propagate the failure to the caller.
 400            eval "$(_hs_rr_explicit_stmts "${__hs_processed[state]}" \
 401                "${__hs_processed[quiet]}" "${__hs_processed[vars]}" \
 402                || printf 'return %d' "$?")" || return $?
 403            return 0
 404        fi
 405        [[ -n "${__hs_processed[separator]-}" ]] && return 0
 406        _hs_rr_implicit_snippet "${__hs_processed[state]}" || return $?
 407    fi
 408    if _hs_local_exists "$(local -p)" list_reserved; then
 409        # Snapshot taken after all processing locals are declared. local -p runs
 410        # in a subshell before the assignment completes, so lp_snapshot itself
 411        # is absent from the output. Splitting declare+assign would cause
 412        # lp_snapshot to appear in its own snapshot; the combined form is
 413        # intentional here.
 414        : "${list_reserved}"
 415        # shellcheck disable=SC2155
 416        local lp_snapshot="$(local -p)"
 417        _hs_print_reserved_names "$lp_snapshot" list_reserved
 418    fi
 419}
 420
 421# --- hs_extract_token ---------------------------------------------------------
 422# Function:
 423#   hs_extract_token --list-reserved
 424#   eval "$(hs_extract_token <API_function> <local_name> --list-reserved)" || return $?
 425#   eval "$(hs_extract_token <API_function> <local_name> "$@")" || return $?
 426# Description:
 427#   Direct query form ($1 == --list-reserved, no further args):
 428#     Prints every local in this function's own frame, one per line; identical
 429#     output to hs_persist_state --list-reserved. Names are derived from local -p
 430#     so future edits are automatically reflected.
 431#
 432#   Eval-code --list-reserved form ($1 is API function name, $2 is local_name, $3 == --list-reserved, no $4):
 433#     eval "$(hs_extract_token mod_entry_point __mod_state_token --list-reserved)"
 434#     Declares the token local (late, so it is absent from its own capture) and
 435#     assigns it a mode token: HS2:mode=list-reserved:<reserved_names payload>,
 436#     minted by _hs_mint_list_reserved_token from this function's own surface plus
 437#     a capture of the entry-point frame. No list_reserved local is declared.
 438#     Returns HS_ERR_INVALID_ARGUMENT_TYPE if any $4.. are present.
 439#
 440#   Normal eval form ($1 is API function name, $2 is local_name, $3 is not --list-reserved):
 441#     eval "$(hs_extract_token mod_entry_point __mod_state_token "$@")"
 442#     Parses -S <statevar> from forwarded opts ($3..) and prints either:
 443#       local <local_name>='<token_value>'   on success
 444#       bash -c 'exit N'                     on error (causes eval to return N)
 445#     Runs in a subshell: no caller local visible at fork, collision space = 0.
 446# Arguments:
 447#   $1  - --list-reserved (direct query) OR name of the calling API function
 448#   $2  - name of the local to declare (eval forms only)
 449#   $3  - --list-reserved (eval-code mode, no further args) OR first forwarded opt
 450#   $4..- forwarded parameter list (normal eval form only)
 451hs_extract_token() {
 452    local -a __hs_remaining=()
 453    local -A __hs_processed=()
 454    if [[ "${1-}" == "--list-reserved" ]]; then
 455        if [[ $# -gt 1 ]]; then
 456            echo "[ERROR] hs_extract_token: --list-reserved takes no other arguments." >&2
 457            return "$HS_ERR_INVALID_ARGUMENT_TYPE"
 458        fi
 459        # Direct query form: print own collision surface, one name per line.
 460        # shellcheck disable=SC2155
 461        local lp_snapshot="$(local -p)"
 462        _hs_print_reserved_names "$lp_snapshot"
 463        return 0
 464    fi
 465    if [[ "${3-}" == "--list-reserved" ]]; then
 466        if [[ $# -gt 3 ]]; then
 467            echo "[ERROR] $1: --list-reserved takes no other arguments." >&2
 468            printf 'bash -c '\''exit %d'\''\n' "$HS_ERR_INVALID_ARGUMENT_TYPE"
 469            return 0
 470        fi
 471        # Eval-code --list-reserved form: emit code that declares the token local
 472        # (late — so it is absent from its own capture) and assigns it a mode token
 473        # minted from this function's own surface plus a fresh capture of the
 474        # entry-point frame. hs_finalize_token later merges another capture and
 475        # decides whether the token local itself belongs in the report.
 476        # shellcheck disable=SC2155
 477        local lp_snapshot="$(local -p)"
 478        printf 'local %s=%s\n' "$2" "''"
 479        printf '%s="$(_hs_mint_list_reserved_token %s "$(local -p)"' \
 480            "$2" "$(printf '%q' "$2")"
 481        local __hs_et_name
 482        while IFS= read -r __hs_et_name; do
 483            [[ -n "$__hs_et_name" ]] && printf ' %s' "$(printf '%q' "$__hs_et_name")"
 484        done < <(_hs_print_reserved_names "$lp_snapshot")
 485        printf ')"\n'
 486        return 0
 487    fi
 488    _hs_resolve_state_inputs "$1" S: "${@:3}" \
 489        || { printf 'bash -c '\''exit %d'\''\n' "$?"; return 0; }
 490    # shellcheck disable=SC2155  # value read from inherited frame; name checked above
 491    local __hs_et_value="${!__hs_processed[state]}"
 492    printf 'local %s=%s\n' "$2" "$(printf '%q' "$__hs_et_value")"
 493}
 494
 495# --- hs_finalize_token --------------------------------------------------------
 496# Function:
 497#   hs_finalize_token --list-reserved
 498#   eval "$(hs_finalize_token <API_function> <token_local> "$@")" || return $?
 499# Description:
 500#   The terminal step of every entry point, always called. Its behaviour is
 501#   driven by the token in <token_local>, never by re-parsing $@.
 502#
 503#   Direct query form ($1 == --list-reserved, no further args):
 504#     Prints every local in this function's own frame, one per line; identical
 505#     output to hs_persist_state --list-reserved.
 506#     Returns HS_ERR_INVALID_ARGUMENT_TYPE if any extra arguments are present.
 507#
 508#   Eval form ($1 is API function name, $2 is token_local):
 509#     eval "$(hs_finalize_token mod_entry_point __mod_state_token "$@")"
 510#     Mode token (checksum field starts with "mode="): emits code that captures
 511#       the entry-point frame afresh and prints the merged collision surface —
 512#       reserved_names read from the token, the fresh capture, and $2 unless the
 513#       marker ends in "-ro" — then returns 0.
 514#     Normal token with -S <statevar> in the forwarded args: prints
 515#       <statevar>='<value>' (plain assignment) to write the token back.
 516#     Normal token with no -S: emits nothing and returns 0 (read-only).
 517#     On error while parsing -S: prints bash -c 'exit N'.
 518#     Runs in a subshell: no caller local visible at fork, collision space = 0.
 519#
 520#   Structural errors (the shape of the call is wrong) print a diagnostic and the
 521#   synopsis on stderr, then emit the exit stub so the code reaches the caller:
 522#     HS_ERR_INVALID_ARGUMENT_TYPE - $1 is an option other than --list-reserved
 523#       (typically a mistyped one), $1 is not a usable API function name, or
 524#       --list-reserved was given extra arguments.
 525#     HS_ERR_MISSING_ARGUMENT      - fewer than two positional arguments.
 526#     HS_ERR_INVALID_VAR_NAME      - $2 is not a valid Bash identifier.
 527hs_finalize_token() {
 528    local -a __hs_remaining=()
 529    local -A __hs_processed=()
 530    if [[ "${1-}" == "--list-reserved" ]]; then
 531        if [[ $# -gt 1 ]]; then
 532            echo "[ERROR] hs_finalize_token: --list-reserved takes no other arguments." >&2
 533            return "$HS_ERR_INVALID_ARGUMENT_TYPE"
 534        fi
 535        # Direct query form: print own collision surface, one name per line.
 536        # shellcheck disable=SC2155
 537        local lp_snapshot="$(local -p)"
 538        _hs_print_reserved_names "$lp_snapshot"
 539        return 0
 540    fi
 541    # Eval form: <API_function> and <token_local> are both mandatory. The -S
 542    # relaxation means the token-driven paths below can no longer rely on
 543    # _hs_resolve_state_inputs to reject a malformed call: with $2 absent, "${!2}"
 544    # is the empty string, which is indistinguishable from a legitimate read-only
 545    # call and would be silently accepted. Check the call shape here instead.
 546    # Errors are emitted as an exit stub, never as a return status: this function
 547    # runs inside $( ), so eval discards its status and only emitted code reaches
 548    # the caller.
 549    if [[ "${1-}" == -* ]]; then
 550        echo "[ERROR] hs_finalize_token: unknown option '$1'." >&2
 551        _hs_usage hs_finalize_token
 552        printf 'bash -c '\''exit %d'\''\n' "$HS_ERR_INVALID_ARGUMENT_TYPE"
 553        return 0
 554    fi
 555    if [[ $# -lt 2 ]]; then
 556        echo "[ERROR] hs_finalize_token: eval form requires <API_function> <token_local>." >&2
 557        _hs_usage hs_finalize_token
 558        printf 'bash -c '\''exit %d'\''\n' "$HS_ERR_MISSING_ARGUMENT"
 559        return 0
 560    fi
 561    if ! _hs_is_valid_function_name "$1"; then
 562        echo "[ERROR] hs_finalize_token: '$1' is not a usable API function name." >&2
 563        _hs_usage hs_finalize_token
 564        printf 'bash -c '\''exit %d'\''\n' "$HS_ERR_INVALID_ARGUMENT_TYPE"
 565        return 0
 566    fi
 567    if ! _hs_is_valid_variable_name "$2"; then
 568        echo "[ERROR] hs_finalize_token: '$2' is not a valid variable name." >&2
 569        _hs_usage hs_finalize_token
 570        printf 'bash -c '\''exit %d'\''\n' "$HS_ERR_INVALID_VAR_NAME"
 571        return 0
 572    fi
 573    # shellcheck disable=SC2155  # value read from inherited frame by position
 574    local __hs_ft_tok="${!2}"
 575    if [[ "$__hs_ft_tok" == HS2:* ]]; then
 576        local __hs_ft_field="${__hs_ft_tok#HS2:}"
 577        __hs_ft_field="${__hs_ft_field%%:*}"
 578        if [[ "$__hs_ft_field" == mode=* ]]; then
 579            # Report path: recover reserved_names from the payload, then emit code
 580            # that merges it with a fresh entry-point-frame capture, adding the
 581            # token local ($2) unless the marker carries the read-only suffix.
 582            local __hs_ft_payload="${__hs_ft_tok#HS2:*:}"
 583            local -a reserved_names=()
 584            eval "$__hs_ft_payload"
 585            local __hs_ft_excl=''
 586            [[ "$__hs_ft_field" == *-ro ]] && __hs_ft_excl="$2"
 587            printf '_hs_emit_reserved %s "$(local -p)"' "$(printf '%q' "$__hs_ft_excl")"
 588            local __hs_ft_name
 589            for __hs_ft_name in "${reserved_names[@]}"; do
 590                printf ' %s' "$(printf '%q' "$__hs_ft_name")"
 591            done
 592            printf '\nreturn 0\n'
 593            return 0
 594        fi
 595    fi
 596    # Normal token: write back only when the caller passed -S; a missing -S means
 597    # the entry point is read-only w.r.t. external state (silent no-op).
 598    local __hs_ft_has_s=0 __hs_ft_arg
 599    for __hs_ft_arg in "${@:3}"; do
 600        [[ "$__hs_ft_arg" == "--" ]] && break
 601        [[ "$__hs_ft_arg" == "-S" || "$__hs_ft_arg" == "-S"?* ]] && { __hs_ft_has_s=1; break; }
 602    done
 603    (( __hs_ft_has_s )) || return 0
 604    _hs_resolve_state_inputs "$1" S: "${@:3}" \
 605        || { printf 'bash -c '\''exit %d'\''\n' "$?"; return 0; }
 606    printf '%s=%s\n' "${__hs_processed[state]}" "$(printf '%q' "${!2}")"
 607}
 608
 609# --- hs_is_list_reserved_mode -------------------------------------------------
 610# Function:
 611#   hs_is_list_reserved_mode --list-reserved
 612#   hs_is_list_reserved_mode -S <token_local>
 613# Description:
 614#   Body-skip guard for the entry-point skeleton. Returns 0 iff the token named
 615#   by -S is a list-reserved mode token (checksum field starts with
 616#   "mode=list-reserved", matching both the baseline and the -ro variant), and
 617#   non-zero otherwise. Reads the token through dynamic scope; never accesses
 618#   external state, so it carries no collision surface of its own.
 619#   The direct query form ($1 == --list-reserved) prints its own reserved names.
 620hs_is_list_reserved_mode() {
 621    local -a __hs_remaining=()
 622    local -A __hs_processed=()
 623    if [[ "${1-}" == "--list-reserved" ]]; then
 624        if [[ $# -gt 1 ]]; then
 625            echo "[ERROR] hs_is_list_reserved_mode: --list-reserved takes no other arguments." >&2
 626            return "$HS_ERR_INVALID_ARGUMENT_TYPE"
 627        fi
 628        # shellcheck disable=SC2155
 629        local lp_snapshot="$(local -p)"
 630        _hs_print_reserved_names "$lp_snapshot"
 631        return 0
 632    fi
 633    _hs_resolve_state_inputs hs_is_list_reserved_mode S: "$@" || return $?
 634    local __hs_ilrm_tok="${!__hs_processed[state]}"
 635    [[ "$__hs_ilrm_tok" == HS2:* ]] || return 1
 636    local __hs_ilrm_field="${__hs_ilrm_tok#HS2:}"
 637    __hs_ilrm_field="${__hs_ilrm_field%%:*}"
 638    [[ "$__hs_ilrm_field" == "$_HS_LIST_RESERVED_MARK"* ]]
 639}
 640
 641# --- hs_read_only -------------------------------------------------------------
 642# Function:
 643#   hs_read_only --list-reserved
 644#   eval "$(hs_read_only <API_function> <token_local> "$@")" || return $?
 645# Description:
 646#   Optional entry-point line marking the function read-only w.r.t. external
 647#   state. Mode-agnostic, keyed on the token's checksum field:
 648#     "mode=..." marker  -> emits <token_local>=<token with -ro appended>
 649#                           (idempotent), so hs_finalize_token excludes the token
 650#                           local from the collision report.
 651#     numeric / empty    -> emits `set -- ...` with `-S <var>` stripped from the
 652#                           forwarded args, so hs_finalize_token writes nothing.
 653#   Runs in a subshell; the entry point evals its output, which must therefore be
 654#   followed by `|| return $?` so a structural error reaches the caller.
 655#
 656#   Structural errors (the shape of the call is wrong) print a diagnostic and the
 657#   synopsis on stderr, then emit the exit stub:
 658#     HS_ERR_INVALID_ARGUMENT_TYPE - $1 is an option other than --list-reserved
 659#       (typically a mistyped one), $1 is not a usable API function name, or
 660#       --list-reserved was given extra arguments.
 661#     HS_ERR_MISSING_ARGUMENT      - fewer than two positional arguments.
 662#     HS_ERR_INVALID_VAR_NAME      - $2 is not a valid Bash identifier.
 663hs_read_only() {
 664    if [[ "${1-}" == "--list-reserved" ]]; then
 665        if [[ $# -gt 1 ]]; then
 666            echo "[ERROR] hs_read_only: --list-reserved takes no other arguments." >&2
 667            return "$HS_ERR_INVALID_ARGUMENT_TYPE"
 668        fi
 669        # shellcheck disable=SC2155
 670        local lp_snapshot="$(local -p)"
 671        _hs_print_reserved_names "$lp_snapshot"
 672        return 0
 673    fi
 674    # Eval form: same structural checks as hs_finalize_token, and for the same
 675    # reason. Unchecked, hs_read_only "$1" alone falls through to the normal-token
 676    # path and emits a bare `set --`, which wipes the entry point's positional
 677    # parameters silently -- a mistyped --list-reserved would destroy "$@".
 678    if [[ "${1-}" == -* ]]; then
 679        echo "[ERROR] hs_read_only: unknown option '$1'." >&2
 680        _hs_usage hs_read_only
 681        printf 'bash -c '\''exit %d'\''\n' "$HS_ERR_INVALID_ARGUMENT_TYPE"
 682        return 0
 683    fi
 684    if [[ $# -lt 2 ]]; then
 685        echo "[ERROR] hs_read_only: eval form requires <API_function> <token_local>." >&2
 686        _hs_usage hs_read_only
 687        printf 'bash -c '\''exit %d'\''\n' "$HS_ERR_MISSING_ARGUMENT"
 688        return 0
 689    fi
 690    if ! _hs_is_valid_function_name "$1"; then
 691        echo "[ERROR] hs_read_only: '$1' is not a usable API function name." >&2
 692        _hs_usage hs_read_only
 693        printf 'bash -c '\''exit %d'\''\n' "$HS_ERR_INVALID_ARGUMENT_TYPE"
 694        return 0
 695    fi
 696    if ! _hs_is_valid_variable_name "$2"; then
 697        echo "[ERROR] hs_read_only: '$2' is not a valid variable name." >&2
 698        _hs_usage hs_read_only
 699        printf 'bash -c '\''exit %d'\''\n' "$HS_ERR_INVALID_VAR_NAME"
 700        return 0
 701    fi
 702    # shellcheck disable=SC2155
 703    local __hs_ro_tok="${!2}"
 704    if [[ "$__hs_ro_tok" == HS2:* ]]; then
 705        local __hs_ro_field="${__hs_ro_tok#HS2:}"
 706        __hs_ro_field="${__hs_ro_field%%:*}"
 707        if [[ "$__hs_ro_field" == mode=* ]]; then
 708            if [[ "$__hs_ro_field" != *-ro ]]; then
 709                local __hs_ro_payload="${__hs_ro_tok#HS2:*:}"
 710                printf '%s=%s\n' "$2" \
 711                    "$(printf '%q' "HS2:${__hs_ro_field}-ro:${__hs_ro_payload}")"
 712            fi
 713            return 0
 714        fi
 715    fi
 716    # Normal token: strip -S <var> from the entry point's positional parameters.
 717    local -a __hs_ro_out=()
 718    local __hs_ro_skip=0 __hs_ro_arg
 719    for __hs_ro_arg in "${@:3}"; do
 720        if (( __hs_ro_skip )); then __hs_ro_skip=0; continue; fi
 721        if [[ "$__hs_ro_arg" == "-S" ]]; then __hs_ro_skip=1; continue; fi
 722        if [[ "$__hs_ro_arg" == "-S"?* ]]; then continue; fi
 723        __hs_ro_out+=("$__hs_ro_arg")
 724    done
 725    printf 'set --'
 726    for __hs_ro_arg in "${__hs_ro_out[@]}"; do
 727        printf ' %s' "$(printf '%q' "$__hs_ro_arg")"
 728    done
 729    printf '\n'
 730}
 731
 732# _hs_rr_explicit_stmts <state_var> <quiet> <vars_str>
 733# Validates all requested variables (declared and unset in dynamic scope), then
 734# prints one assignment statement per variable to stdout. The caller evals the
 735# output in the entry point's frame so assignments traverse dynamic scope and
 736# none of this helper's locals are in the collision section.
 737_hs_rr_explicit_stmts() {
 738    local existing="${!1-}"
 739    local state_var="$1"
 740    local quiet="$2"
 741    local -a requested=()
 742    read -r -a requested <<< "${3-}"
 743 
 744    if [ -z "$existing" ]; then
 745        echo "[ERROR] hs_read_persisted_state: state variable '$state_var' is not set or is empty." >&2
 746        return "$HS_ERR_STATE_VAR_UNINITIALIZED"
 747    fi
 748    if [[ "$existing" != HS2:* ]]; then
 749        echo "[ERROR] hs_read_persisted_state: state is not in HS2 format." >&2
 750        return "$HS_ERR_CORRUPT_STATE"
 751    fi
 752
 753    local -a recs=()
 754    _hs_hs2_parse hs_read_persisted_state "$existing" recs || return $?
 755    local -A record_map=()
 756    local rec
 757    for rec in "${recs[@]}"; do
 758        record_map["$(_hs_hs2_record_name "$rec")"]="$rec"
 759    done
 760
 761    # Phase 1: all-or-nothing guard check.
 762    local var caller_decl
 763    for var in "${requested[@]}"; do
 764        [[ -z "${record_map[$var]+x}" ]] && continue
 765        if ! caller_decl=$(declare -p "$var" 2>/dev/null); then
 766            echo "[ERROR] hs_read_persisted_state: '$var' is not declared in scope." >&2
 767            return "$HS_ERR_UNKNOWN_VAR_NAME"
 768        fi
 769        if [[ "$caller_decl" == *=* ]]; then
 770            echo "[ERROR] hs_read_persisted_state: '$var' is already set; refusing to overwrite." >&2
 771            return "$HS_ERR_VAR_ALREADY_SET"
 772        fi
 773    done
 774
 775    # Phase 2: generate assignment statements (eval'd by the entry point).
 776    local record value_part
 777    for var in "${requested[@]}"; do
 778        if [[ -z "${record_map[$var]+x}" ]]; then
 779            [[ "$quiet" == "false" ]] && \
 780                echo "[WARNING] hs_read_persisted_state: variable '$var' is not defined in the state." >&2
 781            continue
 782        fi
 783        record="${record_map[$var]}"
 784        if [[ "$record" == *=* ]]; then
 785            value_part="${record#*=}"
 786            printf '%s=%s\n' "$var" "$value_part"
 787        fi
 788    done
 789}
 790
 791# _hs_rr_implicit_snippet <state_var>
 792# Emits the eval-able restore snippet for the implicit (no-variable-names) form
 793# of hs_read_persisted_state. Runs in its own frame; the snippet is eval'd by
 794# the caller of hs_read_persisted_state, not by the entry point.
 795_hs_rr_implicit_snippet() {
 796    local existing="${!1-}"
 797    local state_var="$1"
 798
 799    if [ -z "$existing" ]; then
 800        echo "[ERROR] hs_read_persisted_state: state variable '$state_var' is not set or is empty." >&2
 801        return "$HS_ERR_STATE_VAR_UNINITIALIZED"
 802    fi
 803    if [[ "$existing" != HS2:* ]]; then
 804        echo "[ERROR] hs_read_persisted_state: state is not in HS2 format." >&2
 805        return "$HS_ERR_CORRUPT_STATE"
 806    fi
 807
 808    local -a recs=()
 809    _hs_hs2_parse hs_read_persisted_state "$existing" recs || return $?
 810    local -A nameref_targets=()
 811    local rec rec_flags rec_name rec_target
 812    for rec in "${recs[@]}"; do
 813        rec_flags="${rec#declare }"
 814        rec_flags="${rec_flags%% *}"
 815        if [[ "$rec_flags" == *n* && "$rec" == *=* ]]; then
 816            rec_name=$(_hs_hs2_record_name "$rec")
 817            rec_target="${rec#*\"}"
 818            rec_target="${rec_target%\"}"
 819            nameref_targets["$rec_name"]="$rec_target"
 820        fi
 821    done
 822
 823    local escaped_state_var
 824    escaped_state_var=$(printf '%q' "$state_var")
 825    local snippet=""
 826    IFS= read -r -d '' snippet <<EOF || true
 827hs_read_persisted_state -q -S ${escaped_state_var} -- \$(
 828  local -p | while IFS= read -r __hs_local_decl; do
 829    [[ "\$__hs_local_decl" == *=* ]] && continue
 830    [[ "\$__hs_local_decl" =~ ^declare\ -[^[:space:]]*n ]] && continue
 831    __hs_local_name=\${__hs_local_decl##* }
 832    printf '%s ' "\$__hs_local_name"
 833  done
 834) >/dev/null
 835EOF
 836
 837    local nameref_name nameref_target quoted_name quoted_target
 838    for nameref_name in "${!nameref_targets[@]}"; do
 839        nameref_target="${nameref_targets[$nameref_name]}"
 840        quoted_name=$(printf '%q' "$nameref_name")
 841        quoted_target=$(printf '%q' "$nameref_target")
 842        snippet+="[[ \"\$(declare -p ${quoted_name} 2>/dev/null)\" == 'declare -'*n*' ${quoted_name}' ]] && declare -n ${quoted_name}=${quoted_target}"$'\n'
 843    done
 844    printf '%s' "$snippet"
 845}
 846
 847# --- Utility functions --------------------------------------------------------
 848
 849# Function:
 850#   _hs_is_valid_variable_name
 851# Description:
 852#   Returns success if the argument is a syntactically valid Bash variable name.
 853# Arguments:
 854#   $1 - candidate variable name
 855# Returns:
 856#   0 if the name is valid, 1 otherwise.
 857# _hs_local_exists <lp_snapshot> <name>
 858# Returns 0 if <name> appears as a declared local in the local -p snapshot,
 859# 1 otherwise. The snapshot must be captured with local -p in the caller's
 860# own frame so that only that frame's locals are visible — not ancestor frames.
 861# This avoids the dynamic-scope false-positive that [[ -v name ]] produces when
 862# an ancestor frame happens to declare a local with the same name.
 863_hs_local_exists() {
 864    local __hs_le_name="$2"
 865    local __hs_le_line __hs_le_n
 866    while IFS= read -r __hs_le_line; do
 867        [[ "$__hs_le_line" != declare\ * ]] && continue
 868        __hs_le_n="${__hs_le_line#* }"; __hs_le_n="${__hs_le_n#* }"; __hs_le_n="${__hs_le_n%%=*}"
 869        [[ "$__hs_le_n" == "$__hs_le_name" ]] && return 0
 870    done <<< "$1"
 871    return 1
 872}
 873
 874_hs_is_valid_variable_name() {
 875    [[ "${1-}" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]
 876}
 877
 878# _hs_is_valid_function_name <name>
 879# True when <name> is usable as an API function name. Deliberately distinct from
 880# _hs_is_valid_variable_name in both directions.
 881#
 882# More permissive: dotted and colon-separated forms (obj.method, a.b.c, ns::func)
 883# are accepted. Once state lives in a token, a dispatch layer becomes possible --
 884# a command-not-found handler decodes <object>.<method>, reads the class out of
 885# <object>.__token, and forwards to <class>.<method> -S <object>.__token "$@".
 886# Requiring a plain identifier here would foreclose that design for no benefit:
 887# $1 is a label used in diagnostics, never a variable name and never eval'd.
 888#
 889# Narrower than Bash itself, which rejects only a leading '-' and an embedded '='
 890# -- `foo*`, `foo[1]`, `foo#bar` and `foo!bar` are all legal function names.
 891# Glob and expansion metacharacters are excluded because the name is interpolated
 892# into diagnostics; admitting them would require quoting discipline at every use
 893# site for a gain nobody wants.
 894#
 895# First character: letter or underscore. Thereafter also digits and . : + @ -
 896_hs_is_valid_function_name() {
 897    [[ "${1-}" =~ ^[a-zA-Z_][a-zA-Z0-9_.:+@-]*$ ]]
 898}
 899
 900# _hs_usage <function_name>
 901# Prints the synopsis of <function_name> on stderr, one call form per line.
 902#
 903# Called from structural error paths only -- those where the shape of the call is
 904# wrong (missing or malformed positional, unknown option, extra arguments) -- and
 905# never from functional ones, where the call is well formed and the request itself
 906# fails. A synopsis on a functional error would be noise on every legitimate
 907# runtime failure.
 908#
 909# Unconditional -- but that is a consequence of "structural", not a second rule.
 910# -q belongs to the functional domain: it suppresses warnings about variables
 911# absent from the state. It has no jurisdiction over a structural error, and
 912# cannot have any: a malformed argument list is precisely what must not be trusted
 913# to carry an option. Nothing in this helper or its callers reads -q.
 914#
 915# Additive: callers print this *and* return their discriminable error code.
 916#
 917# Works from an eval-form function: command substitution captures stdout only, so
 918# this reaches the terminal even from inside $(hs_finalize_token ...).
 919#
 920# The text must stay identical to the "# Function:" header block of the named
 921# function; test-hs_persist_state.bats asserts that it does. Only the entry points
 922# whose call-syntax checks exist today are covered; issue #146 generalises this
 923# helper to every public entry point of the three libraries.
 924_hs_usage() {
 925    case "${1-}" in
 926        hs_finalize_token)
 927            echo 'Usage: eval "$(hs_finalize_token <API_function> <token_local> "$@")" || return $?' >&2
 928            ;;
 929        hs_read_only)
 930            echo 'Usage: eval "$(hs_read_only <API_function> <token_local> "$@")" || return $?' >&2
 931            ;;
 932        *)
 933            echo "[ERROR] _hs_usage: no synopsis recorded for '${1-}'." >&2
 934            return "$HS_ERR_INVALID_ARGUMENT_TYPE"
 935            ;;
 936    esac
 937}
 938
 939# _hs_print_reserved_names <lp_snapshot> [exclude]
 940# Prints every variable name found in the local -p snapshot, one per line,
 941# skipping the single name given by the optional <exclude> argument. Called by
 942# the --list-reserved end block of each entry point; <exclude> is the mode-flag
 943# local (e.g. list_reserved) that is present only in --list-reserved mode and
 944# must not be reported as part of the collision section.
 945_hs_print_reserved_names() {
 946    local __hs_prn_snapshot="$1"
 947    local __hs_prn_exclude="${2-}"
 948    local declaration name
 949    while IFS= read -r declaration; do
 950        [[ "$declaration" != declare\ * ]] && continue
 951        name="${declaration#* }"; name="${name#* }"; name="${name%%=*}"
 952        [[ -n "$__hs_prn_exclude" && "$name" == "$__hs_prn_exclude" ]] && continue
 953        printf '%s\n' "$name"
 954    done <<< "$__hs_prn_snapshot"
 955}
 956
 957# _hs_mint_list_reserved_token <exclude_name> <lp_snapshot> [surface_name ...]
 958# Builds a list-reserved mode token and prints it to stdout. The reserved-name
 959# set is the union of the names in <lp_snapshot> (the entry-point frame, minus
 960# <exclude_name> — the token local) and the trailing <surface_name> arguments
 961# (the caller's own collision surface). Runs in a subshell of the emitted
 962# extract code; never touches the external state, so it carries no collision
 963# surface. Designed to be unfailable: any name that cannot be an associative
 964# array key is ignored, and a failed persist yields an empty (non-mode) token.
 965_hs_mint_list_reserved_token() {
 966    local __hs_mlrt_exclude="$1" __hs_mlrt_snapshot="$2"
 967    shift 2
 968    local -A __hs_mlrt_seen=()
 969    local __hs_mlrt_n
 970    for __hs_mlrt_n in "$@"; do
 971        [[ -n "$__hs_mlrt_n" ]] && __hs_mlrt_seen["$__hs_mlrt_n"]=1
 972    done
 973    while IFS= read -r __hs_mlrt_n; do
 974        [[ "$__hs_mlrt_n" != declare\ * ]] && continue
 975        __hs_mlrt_n="${__hs_mlrt_n#* }"; __hs_mlrt_n="${__hs_mlrt_n#* }"; __hs_mlrt_n="${__hs_mlrt_n%%=*}"
 976        [[ -z "$__hs_mlrt_n" || "$__hs_mlrt_n" == "$__hs_mlrt_exclude" ]] && continue
 977        __hs_mlrt_seen["$__hs_mlrt_n"]=1
 978    done <<< "$__hs_mlrt_snapshot"
 979    local reserved_names_state=''
 980    local -a reserved_names=("${!__hs_mlrt_seen[@]}")
 981    hs_persist_state -S reserved_names_state reserved_names || return 0
 982    # Swap the numeric checksum field for the mode marker; the payload is a valid
 983    # HS2 payload, so hs_finalize_token can read reserved_names straight back out.
 984    printf 'HS2:%s:%s' "$_HS_LIST_RESERVED_MARK" "${reserved_names_state#HS2:*:}"
 985}
 986
 987# _hs_emit_reserved <exclude_name> <lp_snapshot> [static_name ...]
 988# Prints, one per line, the deduplicated union of the <static_name> arguments and
 989# the names found in <lp_snapshot>, skipping <exclude_name>. Called by the code
 990# hs_finalize_token emits into the entry-point frame so the snapshot is a fresh
 991# capture of that frame (catching locals declared after hs_extract_token ran).
 992_hs_emit_reserved() {
 993    local __hs_er_exclude="$1" __hs_er_snapshot="$2"
 994    shift 2
 995    local -A __hs_er_seen=()
 996    local __hs_er_n
 997    for __hs_er_n in "$@"; do
 998        [[ -n "$__hs_er_n" && "$__hs_er_n" != "$__hs_er_exclude" ]] && __hs_er_seen["$__hs_er_n"]=1
 999    done
1000    while IFS= read -r __hs_er_n; do
1001        [[ "$__hs_er_n" != declare\ * ]] && continue
1002        __hs_er_n="${__hs_er_n#* }"; __hs_er_n="${__hs_er_n#* }"; __hs_er_n="${__hs_er_n%%=*}"
1003        [[ -z "$__hs_er_n" || "$__hs_er_n" == "$__hs_er_exclude" ]] && continue
1004        __hs_er_seen["$__hs_er_n"]=1
1005    done <<< "$__hs_er_snapshot"
1006    for __hs_er_n in "${!__hs_er_seen[@]}"; do
1007        printf '%s\n' "$__hs_er_n"
1008    done
1009}
1010
1011# Function:
1012#   _hs_resolve_state_inputs
1013# Description:
1014#   Parses helper options for state-oriented functions. Parsed results are
1015#   written directly into the caller's `__hs_remaining` (indexed array) and
1016#   `__hs_processed` (associative array) variables via Bash dynamic scoping.
1017#   The helper recognizes `-S <statevar>` when requested by `$2`, optional
1018#   helper flags such as `-q`, unknown forwarded options, and an optional
1019#   final `--` separator before an explicit variable-name list.
1020# Caller contract:
1021#   The caller MUST declare the following variables before calling this helper:
1022#     local -a __hs_remaining=()
1023#     local -A __hs_processed=()
1024#   The helper writes its output into those exact names through dynamic scoping.
1025#   Passing any other names is a programming error.
1026# Arguments:
1027#   $1 - caller function name, used in error messages; must be a valid Bash name
1028#   $2 - `getopts` format string of accepted helper options; e.g. `qS:`
1029#   $3... - forwarded arguments from the public helper caller; if `--` is
1030#           present, its last occurrence marks the start of the explicit
1031#           variable-name list
1032# Returns:
1033#   0 on success.
1034#   On success, `__hs_processed` may contain:
1035#     - `state`: the validated state variable name from `-S`
1036#     - `quiet`: `true` or `false`
1037#     - `vars`: the validated explicit variable-name list as a space-separated string
1038#     - `separator`: set when an explicit `--` was seen
1039#   `HS_ERR_MISSING_ARGUMENT` if a required option parameter such as the value
1040#   for `-S` is missing.
1041#   `HS_ERR_INVALID_VAR_NAME` if the state variable name or an explicit
1042#   variable-name token is not a valid Bash identifier.
1043#   `HS_ERR_RESERVED_VAR_NAME` if the state variable name or a variable-name
1044#   token matches a name in the caller's --list-reserved output.
1045#   `HS_ERR_STATE_VAR_UNINITIALIZED` if no `-S <statevar>` option is provided.
1046# Usage:
1047#   local -a __hs_remaining=()
1048#   local -A __hs_processed=()
1049#   _hs_resolve_state_inputs my_helper qS: "$@" || return $?
1050_hs_resolve_state_inputs() {
1051    if [ $# -lt 2 ]; then
1052        echo "[ERROR] ${1-_hs_resolve_state_inputs}: missing required arguments." >&2
1053        return "$HS_ERR_MISSING_ARGUMENT"
1054    fi
1055    local __hs_ri_caller="$1"
1056    local __hs_ri_opts="$2"
1057    local __hs_ri_opt
1058    local OPTARG
1059    local -i OPTIND=1
1060    local -i __hs_ri_sep_idx=0
1061    local -i __hs_ri_scan=0
1062    local -i __hs_ri_last_opt_sz=0
1063    local -a __hs_ri_trailing=()
1064    shift 2
1065
1066    # Verify caller declared the required output variables with correct types.
1067    if [[ "${__hs_remaining@a}" != *a* ]]; then
1068        echo "[ERROR] ${__hs_ri_caller}: caller must declare 'local -a __hs_remaining=()' before calling _hs_resolve_state_inputs." >&2
1069        return "$HS_ERR_INVALID_ARGUMENT_TYPE"
1070    fi
1071    if [[ "${__hs_processed@a}" != *A* ]]; then
1072        echo "[ERROR] ${__hs_ri_caller}: caller must declare 'local -A __hs_processed=()' before calling _hs_resolve_state_inputs." >&2
1073        return "$HS_ERR_INVALID_ARGUMENT_TYPE"
1074    fi
1075
1076    __hs_processed=(["quiet"]=false)
1077    __hs_remaining=()
1078
1079    local __hs_ri_reserved_list
1080    __hs_ri_reserved_list=$("$__hs_ri_caller" --list-reserved 2>/dev/null) || true
1081
1082    while (( "$#" >= "$OPTIND" )); do
1083        __hs_ri_scan=${OPTIND}
1084        if getopts ":$__hs_ri_opts" __hs_ri_opt; then
1085            case "$__hs_ri_opt" in
1086                \?)
1087                    __hs_remaining+=("-$OPTARG")
1088                    ;;
1089                S)
1090                    if ! _hs_is_valid_variable_name "$OPTARG"; then
1091                        echo "[ERROR] ${__hs_ri_caller}: invalid variable name '${OPTARG}'." >&2
1092                        return "$HS_ERR_INVALID_VAR_NAME"
1093                    fi
1094                    if [[ -n "$__hs_ri_reserved_list" && \
1095                          $'\n'"$__hs_ri_reserved_list"$'\n' == *$'\n'"$OPTARG"$'\n'* ]]; then
1096                        echo "[ERROR] ${__hs_ri_caller}: state variable name '$OPTARG' is reserved; choose a different variable name." >&2
1097                        return "$HS_ERR_RESERVED_VAR_NAME"
1098                    fi
1099                    if [[ -n "${__hs_processed[state]-}" ]]; then
1100                        echo "[ERROR] ${__hs_ri_caller}: option -S may only be given once." >&2
1101                        return "$HS_ERR_MULTIPLE_STATE_INPUTS"
1102                    fi
1103                    __hs_processed["state"]="$OPTARG"
1104                    __hs_ri_last_opt_sz=${#__hs_remaining[@]}
1105                    ;;
1106                q)
1107                    __hs_processed["quiet"]=true
1108                    __hs_ri_last_opt_sz=${#__hs_remaining[@]}
1109                    ;;
1110                :)
1111                    echo "[ERROR] ${__hs_ri_caller}: missing required parameter to option -${OPTARG}." >&2
1112                    return "$HS_ERR_MISSING_ARGUMENT"
1113                    ;;
1114            esac
1115        elif (( __hs_ri_scan == OPTIND )); then
1116            __hs_remaining+=("${!OPTIND}")
1117            OPTIND=$(( OPTIND + 1 ))
1118        else
1119            # Hit --. Find the last occurrence to handle multiple separators.
1120            __hs_processed["separator"]=true
1121            __hs_ri_sep_idx=$(( OPTIND - 1 ))
1122            for (( __hs_ri_scan = OPTIND; __hs_ri_scan <= $#; __hs_ri_scan++ )); do
1123                [[ "${!__hs_ri_scan}" == "--" ]] && __hs_ri_sep_idx=$__hs_ri_scan
1124            done
1125            if (( __hs_ri_sep_idx > OPTIND - 1 )); then
1126                __hs_remaining+=("--")
1127            fi
1128            while (( OPTIND < __hs_ri_sep_idx )); do
1129                __hs_remaining+=("${!OPTIND}")
1130                OPTIND=$(( OPTIND + 1 ))
1131            done
1132            OPTIND=$(( __hs_ri_sep_idx + 1 ))
1133            break
1134        fi
1135    done
1136
1137    : "${__hs_processed["vars"]:=}"
1138    if [[ -n "${__hs_processed[separator]-}" ]]; then
1139        while (( "$#" >= "$OPTIND" )); do
1140            if ! _hs_is_valid_variable_name "${!OPTIND}"; then
1141                echo "[ERROR] ${__hs_ri_caller}: invalid variable name '${!OPTIND}'." >&2
1142                return "$HS_ERR_INVALID_VAR_NAME"
1143            fi
1144            if [[ -n "$__hs_ri_reserved_list" && \
1145                  $'\n'"$__hs_ri_reserved_list"$'\n' == *$'\n'"${!OPTIND}"$'\n'* ]]; then
1146                echo "[ERROR] ${__hs_ri_caller}: variable name '${!OPTIND}' is reserved." >&2
1147                return "$HS_ERR_RESERVED_VAR_NAME"
1148            fi
1149            printf -v '__hs_processed[vars]' "%s%s " "${__hs_processed[vars]}" "${!OPTIND}"
1150            OPTIND=$(( OPTIND + 1 ))
1151        done
1152    else
1153        while (( ${#__hs_remaining[@]} > __hs_ri_last_opt_sz )) && \
1154              _hs_is_valid_variable_name "${__hs_remaining[-1]}"; do
1155            if [[ -n "$__hs_ri_reserved_list" && \
1156                  $'\n'"$__hs_ri_reserved_list"$'\n' == *$'\n'"${__hs_remaining[-1]}"$'\n'* ]]; then
1157                echo "[ERROR] ${__hs_ri_caller}: variable name '${__hs_remaining[-1]}' is reserved." >&2
1158                return "$HS_ERR_RESERVED_VAR_NAME"
1159            fi
1160            __hs_ri_trailing=("${__hs_remaining[-1]}" "${__hs_ri_trailing[@]}")
1161            unset '__hs_remaining[-1]'
1162        done
1163        local IFS=' '
1164        __hs_processed["vars"]="${__hs_ri_trailing[*]}"
1165        if [[ "${__hs_processed[quiet]}" == false ]] && \
1166           (( ${#__hs_remaining[@]} > 0 )); then
1167            echo "[WARNING] ${__hs_ri_caller}: forwarded arguments remain after implicit variable-list parsing; use -- before the variable names." >&2
1168        fi
1169    fi
1170
1171    if [[ -z "${__hs_processed[state]-}" ]]; then
1172        echo "[ERROR] ${__hs_ri_caller}: state variable is uninitialized; missing required -S <statevar> option." >&2
1173        return "$HS_ERR_STATE_VAR_UNINITIALIZED"
1174    fi
1175}
1176
1177# --- HS2 helper functions -------------------------------------------------------
1178
1179# _hs_strip_export <decl>
1180# Prints a declare -p record with the export flag (-x) removed.
1181_hs_strip_export() {
1182    local __decl="$1"
1183    if [[ "$__decl" != "declare -"*x* ]]; then
1184        printf '%s' "$__decl"
1185        return 0
1186    fi
1187    local __rest="${__decl#declare }"
1188    local __attrs="${__rest%% *}"
1189    local __nameandval="${__rest#* }"
1190    __attrs="${__attrs//x/}"
1191    [[ "$__attrs" == "-" ]] && __attrs="--"
1192    printf 'declare %s %s' "$__attrs" "$__nameandval"
1193}
1194
1195# _hs_hs2_record_name <record>
1196# Prints the variable name from a declare -p record.
1197_hs_hs2_record_name() {
1198    local __rest="${1#declare }"
1199    __rest="${__rest#* }"
1200    printf '%s' "${__rest%%=*}"
1201}
1202
1203# _hs_hs2_build <existing_payload> [record ...]
1204# Builds an HS2 state string from existing payload and new records and prints
1205# it to stdout. Callers are responsible for assigning the result.
1206_hs_hs2_build() {
1207    local __hs2b_payload="$1"
1208    shift 1
1209    local __hs2b_rec
1210    for __hs2b_rec in "$@"; do
1211        if [[ -n "$__hs2b_payload" ]]; then
1212            __hs2b_payload+=$'\001'
1213        fi
1214        __hs2b_payload+="$__hs2b_rec"
1215    done
1216    local __hs2b_cksum
1217    __hs2b_cksum=$(printf '%s' "$__hs2b_payload" | cksum)
1218    __hs2b_cksum="${__hs2b_cksum%% *}"
1219    printf 'HS2:%s:%s' "$__hs2b_cksum" "$__hs2b_payload"
1220}
1221
1222# _hs_hs2_parse <caller> <state> <out_array>
1223# Verifies an HS2 state string and splits its records (SOH-delimited) into the
1224# indexed array named by <out_array>.
1225_hs_hs2_parse() {
1226    local __hs2p_caller="$1"
1227    local __hs2p_state="$2"
1228    local -n __hs2p_out="$3"
1229
1230    if [[ "$__hs2p_state" != HS2:* ]]; then
1231        echo "[ERROR] ${__hs2p_caller}: state is not in HS2 format." >&2
1232        return "$HS_ERR_CORRUPT_STATE"
1233    fi
1234    local __hs2p_rest="${__hs2p_state#HS2:}"
1235    local __hs2p_stored="${__hs2p_rest%%:*}"
1236    local __hs2p_payload="${__hs2p_rest#*:}"
1237
1238    # A mode token (checksum field replaced by a "mode=" marker) is not ordinary
1239    # state; reject it with a discriminable code so callers can tell it apart from
1240    # generic corruption. Structural (programmer) error, so it prints to stderr.
1241    if [[ "$__hs2p_stored" == mode=* ]]; then
1242        echo "[ERROR] ${__hs2p_caller}: '${__hs2p_stored}' token is not usable as ordinary state." >&2
1243        return "$HS_ERR_LIST_RESERVED_TOKEN"
1244    fi
1245
1246    local __hs2p_computed
1247    __hs2p_computed=$(printf '%s' "$__hs2p_payload" | cksum)
1248    __hs2p_computed="${__hs2p_computed%% *}"
1249    if [[ "$__hs2p_stored" != "$__hs2p_computed" ]]; then
1250        echo "[ERROR] ${__hs2p_caller}: HS2 state checksum mismatch." >&2
1251        return "$HS_ERR_CORRUPT_STATE"
1252    fi
1253
1254    __hs2p_out=()
1255    [[ -z "$__hs2p_payload" ]] && return 0
1256    local __hs2p_old_ifs="$IFS"
1257    IFS=$'\001' read -ra __hs2p_out <<< "$__hs2p_payload"
1258    IFS="$__hs2p_old_ifs"
1259}
1260
1261# --- Change History -------------------------------------------------------
1262# | PR    | Summary                                                        |
1263# |-------|----------------------------------------------------------------|
1264# | #32   | batch security fixes: guard commands [closes #7]               |
1265# | #38   | do not return state via stdout                                 |
1266# | #60   | use ${BASH:-bash} for collision-check subprocess [closes #59]  |
1267# | #63   | refactor safer handle-state restoration flow [closes #62]      |
1268# | #83   | fix hs_destroy_state rebuild subprocess helper [closes #82]    |
1269# | #87   | fix top-of-file usage example, IFS-safe join [closes #64]      |
1270# | #88   | clarify hs_persist_state_as_code as opaque token [closes #65]  |
1271# | #89   | describe sandboxed eval and nameref restore [closes #68]       |
1272# | #92   | replace non-standard hs_read_persisted_state example [cls #75] |
1273# | #93   | rename probe-snippet to implicit local restore [closes #76]    |
1274# | #99   | error on undeclared variable names [closes #1]                 |
1275# | #102  | guard nameref restore against undeclared variables [cls #100]  |
1276# | #103  | reject function names with HS_ERR_UNKNOWN_VAR_NAME             |
1277# | #105  | fix hs_persist_state dropping indexed array elements [cls #3]  |
1278# | #109  | reduce nameref collision surface [closes #104]                 |
1279# | #110  | document HS_ERR_MULTIPLE_STATE_INPUTS for all entry points     |
1280# | #134  | remove top-level return 0 — fixes SC2317 in sourcing files [closes #133] |
1281# | #140  | add hs_extract_token and hs_write_token; API_function name as $1 [closes #136] |
1282# | #140  | fix --list-reserved merge for read-write entry points             |
1283# | #145  | token-borne --list-reserved; hs_write_token->hs_finalize_token [closes #143] |
1284# | #145  | _hs_usage + structural call checks in hs_finalize_token/hs_read_only |
1285# | #145  | _hs_is_valid_function_name: API function names allow obj.method forms |

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

#145

token-borne –list-reserved mode; hs_finalize_token, hs_is_list_reserved_mode, hs_read_only (issue #143)

#145

usage on structural call errors; hs_read_only skeleton line gains || return $? (issue #146)

#145

API function names validated as function names, not identifiers (obj.method)

#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