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