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