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:-Swas 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_. Runhs_persist_state --list-reservedfor 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. Seehs_persist_state --list-reservedfor 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:-Swas 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. Seehs_persist_state --list-reservedfor 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 callerevals. Because the snippet runslocal -pdirectly 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;
unsetthe 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.
-qsuppresses 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 -pin 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:-Swas 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);unsetthe 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> "$@")".$1is the calling API function name (used in error messages).$2is 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 — readreserved_namesback out of the token, merge it withhs_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 andreturn 0. The token local$2is included unless the marker ends in-ro(read-only; seehs_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-Sname.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 andreturn 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:$1is an option other than--list-reserved(typically a mistyped one),$1is not a usable API function name, or--list-reservedwas given extra arguments.HS_ERR_MISSING_ARGUMENT=8: fewer than two positional arguments.HS_ERR_INVALID_VAR_NAME=5:$2is 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$@(emitset -- …) sohs_finalize_tokenperforms no write-back.``mode=…`` marker (any mode): append
-roto the marker (idempotently — never a double-ro), leaving the payload untouched, sohs_finalize_tokenexcludes the token local$2from the reported surface.
Because the rule keys only on the mode= prefix, every present and future
mode gains a read-only variant (mode=X → mode=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:
hs_extract_tokenpopulates the token local — either the caller’s state (normal mode) or a mode token (--list-reserved).hs_is_list_reserved_modeskips the body helper whenever the token is a mode token, so no business logic runs during a--list-reservedquery (any read or persist attempted there would hit the mode token and fail withHS_ERR_LIST_RESERVED_TOKEN).hs_finalize_tokenwrites 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; markermode=list-reserved): prints__hs_processed,__hs_remaining, and__mylib_state_token.Read-only (
hs_read_onlyuncommented; markermode=list-reserved-ro): prints only__hs_processedand__hs_remaining— the token local is excluded because no write-back can shadow the caller’s-Sname.
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-Squiet:trueorfalsevars: explicit variable-name list, serialized as a space-separated stringseparator: 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=1HS_ERR_VAR_NAME_COLLISION=2HS_ERR_MULTIPLE_STATE_INPUTS=3HS_ERR_CORRUPT_STATE=4HS_ERR_INVALID_VAR_NAME=5HS_ERR_VAR_NAME_NOT_IN_STATE=6HS_ERR_STATE_VAR_UNINITIALIZED=7HS_ERR_MISSING_ARGUMENT=8HS_ERR_INVALID_ARGUMENT_TYPE=9HS_ERR_UNKNOWN_VAR_NAME=10HS_ERR_VAR_ALREADY_SET=11HS_ERR_NAMEREF_TARGET_NOT_PERSISTED=12HS_ERR_LIST_RESERVED_TOKEN=13: a list-reserved mode token (checksum field starting withmode=) 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 fromHS_ERR_CORRUPT_STATEso 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 viahs_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 withIFS=$'\001' read -ra; the state string is never passed toeval.
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 |
#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 |