valise-sh 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +152 -2
  2. package/README.md +99 -146
  3. package/bin/valise +57 -5376
  4. package/docs/architecture.md +77 -121
  5. package/docs/contrib-modules.md +172 -218
  6. package/docs/installation.md +137 -83
  7. package/docs/module-abi.md +125 -126
  8. package/docs/projects.md +249 -0
  9. package/docs/release-policy.md +68 -93
  10. package/docs/shared-modules.md +40 -39
  11. package/examples/README.md +12 -11
  12. package/examples/e2e/README.md +59 -66
  13. package/examples/e2e/bundles.tsv +6 -0
  14. package/examples/e2e/module.d/e2e_aliases.meta +1 -2
  15. package/examples/e2e/module.d/e2e_completions_bash.meta +0 -1
  16. package/examples/e2e/module.d/e2e_completions_zsh.meta +0 -1
  17. package/examples/e2e/module.d/e2e_env_setup.meta +2 -3
  18. package/examples/e2e/module.d/e2e_fish_features.meta +0 -1
  19. package/examples/e2e/module.d/e2e_generated_config.meta +1 -2
  20. package/examples/e2e/module.d/e2e_ksh_features.meta +0 -1
  21. package/examples/e2e/module.d/e2e_layer_probe.meta +1 -2
  22. package/examples/e2e/module.d/e2e_optional_network.meta +1 -2
  23. package/examples/e2e/module.d/e2e_platform_tools.meta +8 -0
  24. package/examples/e2e/module.d/e2e_platform_tools_bsd.meta +2 -3
  25. package/examples/e2e/module.d/e2e_platform_tools_darwin.meta +2 -3
  26. package/examples/e2e/module.d/e2e_platform_tools_linux.meta +2 -3
  27. package/examples/e2e/valise.conf +4 -0
  28. package/examples/e2e/variants.tsv +3 -0
  29. package/examples/local/module.d/hello_valise.meta +1 -2
  30. package/examples/profile-project/README.md +78 -108
  31. package/examples/profile-project/bundles.tsv +8 -0
  32. package/examples/profile-project/{shell/layers → layers}/demo/module.d/contrib_demo_greeting.meta +1 -2
  33. package/examples/profile-project/{shell/module.d → module.d}/greeting.meta +1 -2
  34. package/examples/profile-project/{shell/module.d → module.d}/profile_main.meta +0 -1
  35. package/examples/profile-project/{shell/modules → modules}/greeting.sh +1 -1
  36. package/examples/profile-project/valise.conf +4 -0
  37. package/examples/profile-project/variants.tsv +3 -0
  38. package/examples/shared/README.md +13 -34
  39. package/examples/shared/module.d/contrib_shared_paths.meta +2 -3
  40. package/examples/shared/module.d/contrib_shared_umask.meta +1 -2
  41. package/examples/shared/modules/shared/contrib_shared_paths.sh +2 -2
  42. package/lib/capabilities.sh +40 -13
  43. package/lib/cli/commands.sh +278 -0
  44. package/lib/cli/common.sh +326 -0
  45. package/lib/cli/devtools.sh +539 -0
  46. package/lib/cli/fish-hook.sh +986 -0
  47. package/lib/cli/integration.sh +337 -0
  48. package/lib/cli/lifecycle.sh +578 -0
  49. package/lib/cli/project.sh +1807 -0
  50. package/lib/cli/store.sh +1023 -0
  51. package/lib/cli/usage.sh +308 -0
  52. package/lib/compiler.awk +11 -4
  53. package/lib/fish/loader.fish +69 -9
  54. package/lib/fish/metadata.fish +49 -5
  55. package/lib/fish/profile.fish +170 -0
  56. package/lib/loader.sh +6 -8
  57. package/lib/metadata.sh +111 -5
  58. package/manifests/parser-support.tsv +2 -2
  59. package/module.d/valise_editor.meta +9 -0
  60. package/module.d/valise_history.meta +9 -0
  61. package/module.d/valise_pager.meta +9 -0
  62. package/module.d/valise_path.meta +9 -0
  63. package/module.d/{platform_bsd.meta → valise_platform_bsd.meta} +3 -4
  64. package/module.d/{platform_linux.meta → valise_platform_linux.meta} +3 -4
  65. package/module.d/{platform_macos.meta → valise_platform_macos.meta} +3 -4
  66. package/module.d/{platform_posix.meta → valise_platform_posix.meta} +2 -3
  67. package/module.d/valise_xdg.meta +9 -0
  68. package/modules/system/{contrib_editor.sh → valise_editor.sh} +2 -2
  69. package/modules/system/{contrib_history.sh → valise_history.sh} +2 -2
  70. package/modules/system/{contrib_pager.sh → valise_pager.sh} +2 -2
  71. package/modules/system/{contrib_path.sh → valise_path.sh} +3 -3
  72. package/modules/system/{contrib_xdg.sh → valise_xdg.sh} +2 -2
  73. package/package.json +91 -10
  74. package/docs/variants.md +0 -327
  75. package/entrypoints/.gitkeep +0 -0
  76. package/entrypoints/fish/profile.fish +0 -99
  77. package/examples/e2e/build-manifest.sh +0 -138
  78. package/examples/local/.gitkeep +0 -0
  79. package/examples/profile-project/shell/release/bundle-roots +0 -9
  80. package/examples/profile-project/shell/release/install-variants +0 -3
  81. package/examples/profile-project/shell/release/module-registry.tsv +0 -4
  82. package/examples/profile-project/shell/release/project.conf +0 -3
  83. package/lib/.gitkeep +0 -0
  84. package/manifests/.gitkeep +0 -0
  85. package/module.d/.gitkeep +0 -0
  86. package/module.d/contrib_editor.meta +0 -10
  87. package/module.d/contrib_history.meta +0 -10
  88. package/module.d/contrib_pager.meta +0 -10
  89. package/module.d/contrib_path.meta +0 -10
  90. package/module.d/contrib_xdg.meta +0 -10
  91. package/modules/.gitkeep +0 -0
  92. /package/examples/profile-project/{shell/layers → layers}/demo/modules/shared/contrib_demo_greeting.sh +0 -0
@@ -0,0 +1,1023 @@
1
+ # Copyright (c) 2026 Joseph Cumines
2
+ # SPDX-License-Identifier: MIT
3
+ # valise CLI: release store: locking, manifests, verification, install, release management.
4
+ # Sourced by bin/valise; not executable on its own.
5
+
6
+ # Acquire atomic lock via mkdir (POSIX atomic operation). Every utility that
7
+ # participates in lock integrity is resolved by absolute path so PATH cannot
8
+ # forge ownership or suppress stale-lock cleanup.
9
+ _lock_owned=0
10
+ _publish_tmp=""
11
+ _manifest_rows=""
12
+ _manifest_parse=""
13
+ _stage=""
14
+ _hook_tmp=""
15
+ _mm_tmp=""
16
+ _rr_tmp=""
17
+ _mv_path=""
18
+ _cleanup_failed=0
19
+ acquire_lock() {
20
+ _lock_cat_path=$(trusted_utility_path cat) || die "no trusted cat utility available"
21
+ _lock_mkdir_path=$(trusted_utility_path mkdir) || die "no trusted mkdir utility available"
22
+ _lock_rm_path=$(trusted_utility_path rm) || die "no trusted rm utility available"
23
+ _lock_kill_path=$(trusted_utility_path kill) || die "no trusted kill utility available"
24
+
25
+ # Reentrant ownership is valid only for a real lock directory and a real,
26
+ # non-symlink pid file containing this process's exact PID.
27
+ if [ "$_lock_owned" -eq 1 ] && [ -d "$VALISE_LOCK_DIR" ] && [ ! -L "$VALISE_LOCK_DIR" ] &&
28
+ [ -f "$VALISE_LOCK_DIR/pid" ] && [ ! -L "$VALISE_LOCK_DIR/pid" ]; then
29
+ _existing_pid=$("$_lock_cat_path" "$VALISE_LOCK_DIR/pid" 2>/dev/null || :)
30
+ case "$_existing_pid" in
31
+ ''|*[!0-9]*) : ;;
32
+ "$$") _lock_owned=1; return 0 ;;
33
+ esac
34
+ fi
35
+
36
+ _lock_parent=${VALISE_LOCK_DIR%/*}
37
+ [ -n "$_lock_parent" ] || _lock_parent=/
38
+ if [ ! -d "$_lock_parent" ]; then
39
+ "$_lock_mkdir_path" -p "$_lock_parent" 2>/dev/null || die "cannot create lock parent: $_lock_parent"
40
+ fi
41
+ if ! "$_lock_mkdir_path" -m 700 "$VALISE_LOCK_DIR" 2>/dev/null; then
42
+ # Check for stale lock using only the trusted utility paths. An invalid or
43
+ # unreadable lock is fail-closed rather than treated as stale.
44
+ if [ -d "$VALISE_LOCK_DIR" ] && [ ! -L "$VALISE_LOCK_DIR" ] &&
45
+ [ -f "$VALISE_LOCK_DIR/pid" ] && [ ! -L "$VALISE_LOCK_DIR/pid" ]; then
46
+ _lock_pid=$("$_lock_cat_path" "$VALISE_LOCK_DIR/pid" 2>/dev/null || :)
47
+ case "$_lock_pid" in
48
+ ''|*[!0-9]*) die "another valise process is running (unreadable lock)" ;;
49
+ esac
50
+ if ! "$_lock_kill_path" -0 "$_lock_pid" 2>/dev/null; then
51
+ info "removing stale lock (pid $_lock_pid)"
52
+ "$_lock_rm_path" -rf "$VALISE_LOCK_DIR" 2>/dev/null || die "failed to remove stale lock"
53
+ if [ -e "$VALISE_LOCK_DIR" ] || [ -L "$VALISE_LOCK_DIR" ]; then
54
+ die "failed to remove stale lock"
55
+ fi
56
+ "$_lock_mkdir_path" -m 700 "$VALISE_LOCK_DIR" 2>/dev/null || die "failed to acquire lock after stale cleanup"
57
+ else
58
+ die "another valise process is running (pid $_lock_pid)"
59
+ fi
60
+ else
61
+ die "failed to acquire lock: $VALISE_LOCK_DIR"
62
+ fi
63
+ fi
64
+ [ -d "$VALISE_LOCK_DIR" ] && [ ! -L "$VALISE_LOCK_DIR" ] || die "lock directory is not safe"
65
+ printf '%s\n' "$$" > "$VALISE_LOCK_DIR/pid" || die "failed to write lock ownership"
66
+ [ -f "$VALISE_LOCK_DIR/pid" ] && [ ! -L "$VALISE_LOCK_DIR/pid" ] || die "lock ownership file is not safe"
67
+ _lock_pid=$("$_lock_cat_path" "$VALISE_LOCK_DIR/pid" 2>/dev/null || :)
68
+ [ "$_lock_pid" = "$$" ] || die "failed to verify lock ownership"
69
+ _lock_owned=1
70
+ }
71
+
72
+ remove_tree() {
73
+ _remove_tree_path=$1
74
+ _remove_chmod_path=$(trusted_utility_path chmod) || return 1
75
+ _remove_rm_path=$(trusted_utility_path rm) || return 1
76
+ if [ -d "$_remove_tree_path" ] && [ ! -L "$_remove_tree_path" ]; then
77
+ "$_remove_chmod_path" -R u+w "$_remove_tree_path" 2>/dev/null || return 1
78
+ fi
79
+ "$_remove_rm_path" -rf "$_remove_tree_path" 2>/dev/null || return 1
80
+ [ ! -e "$_remove_tree_path" ] && [ ! -L "$_remove_tree_path" ]
81
+ }
82
+
83
+ seal_tree() {
84
+ _seal_tree_root=$1
85
+ _find_path=$(trusted_utility_path find) || return 1
86
+ _seal_chmod_path=$(trusted_utility_path chmod) || return 1
87
+ "$_find_path" "$_seal_tree_root" -type d -exec "$_seal_chmod_path" 0555 {} + 2>/dev/null || return 1
88
+ _seal_tree_dirs=$("$_find_path" "$_seal_tree_root" -type d -print) || return 1
89
+ while IFS= read -r _seal_tree_dir; do
90
+ [ -n "$_seal_tree_dir" ] || continue
91
+ [ "$(file_mode "$_seal_tree_dir")" = "0555" ] || return 1
92
+ done <<EOF
93
+ $_seal_tree_dirs
94
+ EOF
95
+ return 0
96
+ }
97
+
98
+ release_lock() {
99
+ _release_rm_path=$(trusted_utility_path rm 2>/dev/null || :)
100
+ _release_rmdir_path=$(trusted_utility_path rmdir 2>/dev/null || :)
101
+ if [ -n "$_publish_tmp" ]; then
102
+ remove_tree "$_publish_tmp" || _cleanup_failed=1
103
+ _publish_tmp=""
104
+ fi
105
+ if [ -n "$_manifest_rows" ]; then
106
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_manifest_rows" 2>/dev/null || :
107
+ if [ -e "$_manifest_rows" ] || [ -L "$_manifest_rows" ]; then _cleanup_failed=1; fi
108
+ _manifest_rows=""
109
+ fi
110
+ if [ -n "$_manifest_parse" ]; then
111
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_manifest_parse" 2>/dev/null || :
112
+ if [ -e "$_manifest_parse" ] || [ -L "$_manifest_parse" ]; then _cleanup_failed=1; fi
113
+ _manifest_parse=""
114
+ fi
115
+ if [ -n "${_pm_tmp:-}" ]; then
116
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_pm_tmp" 2>/dev/null || :
117
+ if [ -e "$_pm_tmp" ] || [ -L "$_pm_tmp" ]; then _cleanup_failed=1; fi
118
+ _pm_tmp=""
119
+ fi
120
+ if [ -n "${_rr_tmp:-}" ]; then
121
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_rr_tmp" 2>/dev/null || :
122
+ if [ -e "$_rr_tmp" ] || [ -L "$_rr_tmp" ]; then _cleanup_failed=1; fi
123
+ _rr_tmp=""
124
+ fi
125
+ if [ -n "${_pi_tmp:-}" ]; then
126
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_pi_tmp" 2>/dev/null || :
127
+ if [ -e "$_pi_tmp" ] || [ -L "$_pi_tmp" ]; then _cleanup_failed=1; fi
128
+ _pi_tmp=""
129
+ fi
130
+ if [ -n "${_eb_registry_tmp:-}" ]; then
131
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_eb_registry_tmp" 2>/dev/null || :
132
+ if [ -e "$_eb_registry_tmp" ] || [ -L "$_eb_registry_tmp" ]; then _cleanup_failed=1; fi
133
+ _eb_registry_tmp=""
134
+ fi
135
+ if [ -n "${_doctor_cand_file:-}" ]; then
136
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_doctor_cand_file" 2>/dev/null || :
137
+ if [ -e "$_doctor_cand_file" ] || [ -L "$_doctor_cand_file" ]; then _cleanup_failed=1; fi
138
+ _doctor_cand_file=""
139
+ fi
140
+ if [ -n "${_module_list_tmp:-}" ]; then
141
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_module_list_tmp" 2>/dev/null || :
142
+ if [ -e "$_module_list_tmp" ] || [ -L "$_module_list_tmp" ]; then _cleanup_failed=1; fi
143
+ _module_list_tmp=""
144
+ fi
145
+ if [ -n "${_rg_tmp:-}" ]; then
146
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_rg_tmp" 2>/dev/null || :
147
+ if [ -e "$_rg_tmp" ] || [ -L "$_rg_tmp" ]; then _cleanup_failed=1; fi
148
+ _rg_tmp=""
149
+ fi
150
+ if [ -n "${_publish_previous:-}" ]; then
151
+ # A publication that failed after moving the previous output aside left
152
+ # it here; the error already named the output, so keep the tree for the
153
+ # user rather than deleting the only copy.
154
+ printf 'valise: previous output kept at %s\n' "$_publish_previous/tree" >&2
155
+ _publish_previous=""
156
+ fi
157
+ if [ -n "${_dev_tmp:-}" ]; then
158
+ remove_tree "$_dev_tmp" || _cleanup_failed=1
159
+ _dev_tmp=""
160
+ fi
161
+ if [ -n "$_stage" ]; then
162
+ remove_tree "$_stage" || _cleanup_failed=1
163
+ _stage=""
164
+ fi
165
+ if [ -n "$_hook_tmp" ]; then
166
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_hook_tmp" 2>/dev/null || :
167
+ if [ -e "$_hook_tmp" ] || [ -L "$_hook_tmp" ]; then _cleanup_failed=1; fi
168
+ _hook_tmp=""
169
+ fi
170
+ for _release_tmp in "${_rc_tmp:-}" "${_rc_stub:-}" "${_rc_pub:-}" "${_prev_tmp:-}" "${_dry_tmp:-}" "${_un_tmp:-}"; do
171
+ [ -n "$_release_tmp" ] || continue
172
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_release_tmp" 2>/dev/null || :
173
+ if [ -e "$_release_tmp" ] || [ -L "$_release_tmp" ]; then _cleanup_failed=1; fi
174
+ done
175
+ _rc_tmp=""
176
+ _rc_stub=""
177
+ _rc_pub=""
178
+ _prev_tmp=""
179
+ _dry_tmp=""
180
+ _un_tmp=""
181
+ if [ -n "$_mm_tmp" ]; then
182
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$_mm_tmp" 2>/dev/null || :
183
+ if [ -e "$_mm_tmp" ] || [ -L "$_mm_tmp" ]; then _cleanup_failed=1; fi
184
+ _mm_tmp=""
185
+ fi
186
+ [ "$_lock_owned" -eq 1 ] || { [ "$_cleanup_failed" -eq 0 ]; return; }
187
+ [ -n "$_release_rm_path" ] && "$_release_rm_path" -f "$VALISE_LOCK_DIR/pid" 2>/dev/null || :
188
+ if [ -e "$VALISE_LOCK_DIR/pid" ] || [ -L "$VALISE_LOCK_DIR/pid" ]; then _cleanup_failed=1; fi
189
+ [ -n "$_release_rmdir_path" ] && "$_release_rmdir_path" "$VALISE_LOCK_DIR" 2>/dev/null || :
190
+ if [ -e "$VALISE_LOCK_DIR" ] || [ -L "$VALISE_LOCK_DIR" ]; then _cleanup_failed=1; fi
191
+ _lock_owned=0
192
+ [ "$_cleanup_failed" -eq 0 ]
193
+ }
194
+
195
+ _handle_int() {
196
+ trap - INT
197
+ [ "${_install_txn:-0}" -ne 1 ] || install_txn_abort
198
+ release_lock
199
+ exit 130
200
+ }
201
+
202
+ _handle_term() {
203
+ trap - TERM
204
+ [ "${_install_txn:-0}" -ne 1 ] || install_txn_abort
205
+ release_lock
206
+ exit 143
207
+ }
208
+
209
+ trap '_exit_status=$?; [ "${_install_txn:-0}" -ne 1 ] || install_txn_abort; release_lock; if [ "$_exit_status" -eq 0 ] && [ "$_cleanup_failed" -ne 0 ]; then _exit_status=1; fi; exit "$_exit_status"' EXIT
210
+ trap '_handle_int' INT
211
+ trap '_handle_term' TERM
212
+
213
+ switch_active_link() {
214
+ _ln_path=$(trusted_utility_path ln) || die "no trusted ln utility available"
215
+ _readlink_path=$(trusted_utility_path readlink) || die "no trusted readlink utility available"
216
+ if [ -e "$VALISE_ACTIVE_LINK" ] && [ ! -L "$VALISE_ACTIVE_LINK" ]; then
217
+ die "active path exists and is not a symlink: $VALISE_ACTIVE_LINK"
218
+ fi
219
+ # ln -sfn replaces the destination symlink. The -n flag prevents following a
220
+ # destination that is a symlink to a directory. If the swap fails after the
221
+ # old link was removed, restore the prior target so the pointer is never
222
+ # left missing.
223
+ _switch_old_target=
224
+ if [ -L "$VALISE_ACTIVE_LINK" ]; then
225
+ _switch_old_target=$(readlink_target "$VALISE_ACTIVE_LINK" 2>/dev/null || :)
226
+ fi
227
+ if ! "$_ln_path" -sfn "$1" "$VALISE_ACTIVE_LINK"; then
228
+ if [ -n "$_switch_old_target" ]; then
229
+ "$_ln_path" -sfn "$_switch_old_target" "$VALISE_ACTIVE_LINK" 2>/dev/null || :
230
+ fi
231
+ die "failed to update active symlink"
232
+ fi
233
+ [ -L "$VALISE_ACTIVE_LINK" ] || die "active symlink was not created"
234
+ [ "$($_readlink_path "$VALISE_ACTIVE_LINK" 2>/dev/null)" = "$1" ] || die "active symlink target mismatch"
235
+ note_switch "$_switch_old_target" "$1"
236
+ unset _switch_old_target
237
+ }
238
+
239
+ source_path_is_physical() {
240
+ _source_root=$1
241
+ _source_relative=$2
242
+ _source_path=$_source_root
243
+ _source_remaining=$_source_relative
244
+ while [ -n "$_source_remaining" ]; do
245
+ case "$_source_remaining" in
246
+ */*)
247
+ _source_component=${_source_remaining%%/*}
248
+ _source_remaining=${_source_remaining#*/}
249
+ ;;
250
+ *)
251
+ _source_component=$_source_remaining
252
+ _source_remaining=
253
+ ;;
254
+ esac
255
+ [ -n "$_source_component" ] || return 1
256
+ _source_path=$_source_path/$_source_component
257
+ [ ! -L "$_source_path" ] || return 1
258
+ done
259
+ return 0
260
+ }
261
+
262
+ validate_manifest() {
263
+ _awk_path=$(trusted_utility_path awk) || return 1
264
+ "$_awk_path" -F '\t' '
265
+ NR == 1 { if ($0 != "valise-manifest-v2") exit 10; next }
266
+ NR == 2 { if ($1 != "destination" || $2 != "source" || $3 != "source_mode" || $4 != "install_mode" || $5 != "bytes" || $6 != "sha256" || NF != 6) exit 11; next }
267
+ NF == 0 { next }
268
+ NF != 6 || $1 == "" || $2 == "" || $1 !~ /^[A-Za-z0-9._\/-]+$/ || $2 !~ /^[A-Za-z0-9._\/-]+$/ || $1 ~ /^\// || $2 ~ /^\// || $1 ~ /\/$/ || $2 ~ /\/$/ || $1 ~ /^#/ || $1 == "destination" || $1 == "valise-manifest-v2" || $3 !~ /^0(644|755)$/ || $4 != (($3 == "0644") ? "0444" : "0555") || $5 !~ /^[0-9]+$/ || length($6) != 64 || $6 !~ /^[0-9a-fA-F]+$/ || $1 ~ /(^|\/)\.($|\/)/ || index($1, "//") || $1 ~ /(^|\/)\.\.($|\/)/ || index($2, "//") || $2 ~ /(^|\/)\.($|\/)/ || $2 ~ /(^|\/)\.\.($|\/)/ || seen[$1]++ || seen_source[$2]++ { exit 12 }
269
+ { data_rows = 1 }
270
+ END { if (NR < 3 || data_rows != 1) exit 13 }
271
+ ' "$1"
272
+ }
273
+
274
+ temp_path_is_safe() {
275
+ _temp_candidate=$1
276
+ _temp_kind=$2
277
+ _temp_root=${3:-${TMPDIR:-/tmp}}
278
+ case "$_temp_root" in /*) ;; *) return 1 ;; esac
279
+ case "$_temp_candidate" in /*) ;; *) return 1 ;; esac
280
+ [ -e "$_temp_candidate" ] || return 1
281
+ [ ! -L "$_temp_candidate" ] || return 1
282
+ case "$_temp_kind" in
283
+ file) [ -f "$_temp_candidate" ] || return 1 ;;
284
+ dir) [ -d "$_temp_candidate" ] || return 1 ;;
285
+ *) return 1 ;;
286
+ esac
287
+ _temp_root_canonical=$(cd "$_temp_root" 2>/dev/null && pwd -P) || return 1
288
+ _temp_parent=${_temp_candidate%/*}
289
+ [ "$_temp_parent" != "$_temp_candidate" ] || _temp_parent=/
290
+ _temp_parent_canonical=$(cd "$_temp_parent" 2>/dev/null && pwd -P) || return 1
291
+ case "$_temp_parent_canonical/" in
292
+ "$_temp_root_canonical/"*) ;;
293
+ *) return 1 ;;
294
+ esac
295
+ return 0
296
+ }
297
+
298
+ trusted_mktemp_at() {
299
+ _temp_root=$1
300
+ _temp_kind=$2
301
+ _temp_template=$3
302
+ _mktemp_path=$(trusted_utility_path mktemp) || return 1
303
+ case "$_temp_kind" in
304
+ file) _temp_candidate=$("$_mktemp_path" "$_temp_template") || return 1 ;;
305
+ dir) _temp_candidate=$("$_mktemp_path" -d "$_temp_template") || return 1 ;;
306
+ *) return 1 ;;
307
+ esac
308
+ temp_path_is_safe "$_temp_candidate" "$_temp_kind" "$_temp_root" || return 1
309
+ printf '%s\n' "$_temp_candidate"
310
+ }
311
+
312
+ trusted_mktemp() {
313
+ trusted_mktemp_at "${TMPDIR:-/tmp}" "$@"
314
+ }
315
+
316
+ prepare_manifest_rows() {
317
+ _cleanup_rm_path=$(trusted_utility_path rm) || return 1
318
+ if [ -n "$_manifest_rows" ]; then
319
+ "$_cleanup_rm_path" -f "$_manifest_rows" 2>/dev/null || :
320
+ _manifest_rows=""
321
+ fi
322
+ if [ -n "$_manifest_parse" ]; then
323
+ "$_cleanup_rm_path" -f "$_manifest_parse" 2>/dev/null || :
324
+ _manifest_parse=""
325
+ fi
326
+ # _manifest_rows is the exact private snapshot used for identity and
327
+ # publication. Parsing is performed from a second file that only adds a
328
+ # final newline, so an unterminated source row is still consumed by POSIX
329
+ # read without changing the bytes whose digest defines the release.
330
+ _manifest_rows=$(trusted_mktemp file "${TMPDIR:-/tmp}/valise-manifest.XXXXXX") || return 1
331
+ _cat_path=$(trusted_utility_path cat) || {
332
+ "$_cleanup_rm_path" -f "$_manifest_rows" 2>/dev/null || :
333
+ _manifest_rows=""
334
+ return 1
335
+ }
336
+ "$_cat_path" "$1" >"$_manifest_rows" || {
337
+ "$_cleanup_rm_path" -f "$_manifest_rows" 2>/dev/null || :
338
+ _manifest_rows=""
339
+ return 1
340
+ }
341
+ _manifest_parse=$(trusted_mktemp file "${TMPDIR:-/tmp}/valise-manifest-parse.XXXXXX") || {
342
+ "$_cleanup_rm_path" -f "$_manifest_rows" 2>/dev/null || :
343
+ _manifest_rows=""
344
+ return 1
345
+ }
346
+ _awk_path=$(trusted_utility_path awk) || {
347
+ "$_cleanup_rm_path" -f "$_manifest_rows" "$_manifest_parse" 2>/dev/null || :
348
+ _manifest_rows=""
349
+ _manifest_parse=""
350
+ return 1
351
+ }
352
+ "$_awk_path" '{ print }' "$_manifest_rows" >"$_manifest_parse" || {
353
+ "$_cleanup_rm_path" -f "$_manifest_rows" "$_manifest_parse" 2>/dev/null || :
354
+ _manifest_rows=""
355
+ _manifest_parse=""
356
+ return 1
357
+ }
358
+ }
359
+
360
+ locate_manifest() {
361
+ if [ -f "./manifest.tsv" ] && [ ! -L "./manifest.tsv" ]; then
362
+ printf '%s\n' "./manifest.tsv"
363
+ return 0
364
+ fi
365
+ return 1
366
+ }
367
+
368
+ _manifest_has_file() {
369
+ _awk_path=$(trusted_utility_path awk) || return 1
370
+ "$_awk_path" -F '\t' -v destination="$2" '
371
+ NR > 2 && NF && $1 == destination { found = 1 }
372
+ END { exit found ? 0 : 1 }
373
+ ' "$1"
374
+ }
375
+
376
+ _manifest_has_descendant() {
377
+ _awk_path=$(trusted_utility_path awk) || return 1
378
+ "$_awk_path" -F '\t' -v prefix="$2/" '
379
+ NR > 2 && NF && index($1, prefix) == 1 { found = 1 }
380
+ END { exit found ? 0 : 1 }
381
+ ' "$1"
382
+ }
383
+
384
+ verify_release_dir() {
385
+ _verify_dir=$1
386
+ _verify_manifest=$2
387
+ _verify_canonical=$(cd "$_verify_dir" 2>/dev/null && pwd -P) || _verify_fail "cannot resolve release root" || return 1
388
+ [ "$(file_mode "$_verify_dir")" = "0555" ] || _verify_fail "release root mode mismatch" || return 1
389
+ _verify_snapshot="$_verify_dir/.valise-manifest"
390
+ if [ -e "$_verify_snapshot" ] || [ -L "$_verify_snapshot" ]; then
391
+ [ -f "$_verify_snapshot" ] || _verify_fail "release manifest snapshot is not a regular file" || return 1
392
+ [ ! -L "$_verify_snapshot" ] || _verify_fail "release manifest snapshot must not be a symlink" || return 1
393
+ [ "$(file_mode "$_verify_snapshot")" = "0444" ] || _verify_fail "release manifest snapshot mode mismatch" || return 1
394
+ [ "$(file_bytes "$_verify_snapshot")" = "$(file_bytes "$_verify_manifest")" ] || _verify_fail "release manifest snapshot byte count mismatch" || return 1
395
+ _verify_snapshot_digest=$(file_sha256 "$_verify_snapshot") || _verify_fail "no SHA-256 utility available" || return 1
396
+ _verify_manifest_digest=$(file_sha256 "$_verify_manifest") || _verify_fail "no SHA-256 utility available" || return 1
397
+ [ "$_verify_snapshot_digest" = "$_verify_manifest_digest" ] || _verify_fail "release manifest snapshot digest mismatch" || return 1
398
+ _verify_expected_id="${3:-}"
399
+ if [ -z "$_verify_expected_id" ]; then
400
+ _verify_expected_id=$(basename "$_verify_dir")
401
+ fi
402
+ [ "$_verify_snapshot_digest" = "$_verify_expected_id" ] || _verify_fail "release manifest snapshot identity mismatch" || return 1
403
+ fi
404
+ while IFS="$(printf '\t')" read -r _dest _src _mode _imode _size _sha; do
405
+ [ -n "$_dest" ] || continue
406
+ case "$_dest" in valise-manifest-v2|destination) continue ;; esac
407
+ _verify_file="$_verify_dir/$_dest"
408
+ [ -f "$_verify_file" ] || _verify_fail "release file missing: $_dest" || return 1
409
+ [ ! -L "$_verify_file" ] || _verify_fail "release file must not be a symlink: $_dest" || return 1
410
+ _verify_parent=$(cd "$(path_parent "$_verify_file")" 2>/dev/null && pwd -P) || _verify_fail "release parent missing: $_dest" || return 1
411
+ case "$_verify_parent/" in "$_verify_canonical/"*) : ;; *) _verify_fail "release path escapes target: $_dest" || return 1 ;; esac
412
+ [ "$(file_mode "$_verify_file")" = "$_imode" ] || _verify_fail "release mode mismatch for $_dest" || return 1
413
+ [ "$(file_bytes "$_verify_file")" = "$_size" ] || _verify_fail "release byte count mismatch for $_dest" || return 1
414
+ case "$_sha" in *[!0123456789abcdefABCDEF]*) _verify_fail "invalid release digest for $_dest" || return 1 ;; esac
415
+ [ "${#_sha}" -eq 64 ] || _verify_fail "invalid release digest for $_dest" || return 1
416
+ _verify_actual=$(file_sha256 "$_verify_file") || _verify_fail "no trusted SHA-256 utility available" || return 1
417
+ _verify_expected=$(printf '%s' "$_sha" | lower_hex) || _verify_fail "no trusted tr utility available" || return 1
418
+ [ "$_verify_actual" = "$_verify_expected" ] || _verify_fail "release digest mismatch for $_dest" || return 1
419
+ done < "${_manifest_parse:-$_manifest_rows}"
420
+ _find_path=$(trusted_utility_path find) || _verify_fail "no trusted find utility available" || return 1
421
+ _verify_bad=$("$_find_path" "$_verify_dir" -print | while IFS= read -r _verify_path; do
422
+ [ "$_verify_path" = "$_verify_dir" ] && continue
423
+ if [ -L "$_verify_path" ]; then
424
+ printf '%s\n' "$_verify_path"
425
+ break
426
+ fi
427
+ _verify_relative=${_verify_path#"$_verify_dir"/}
428
+ [ "$_verify_relative" = ".valise-manifest" ] && continue
429
+ if [ -f "$_verify_path" ]; then
430
+ _manifest_has_file "$_verify_manifest" "$_verify_relative" || { printf '%s\n' "$_verify_path"; break; }
431
+ elif [ -d "$_verify_path" ]; then
432
+ if [ "$(file_mode "$_verify_path")" != "0555" ] || ! _manifest_has_descendant "$_verify_manifest" "$_verify_relative"; then
433
+ printf '%s\n' "$_verify_path"
434
+ break
435
+ fi
436
+ else
437
+ printf '%s\n' "$_verify_path"
438
+ break
439
+ fi
440
+ done)
441
+ [ -z "$_verify_bad" ] || _verify_fail "release contains unmanifested or mutable path: $_verify_bad" || return 1
442
+ }
443
+
444
+
445
+ cmd_verify_release_root() {
446
+ [ "$#" -eq 1 ] || die "usage: valise _verify-release-root <release-root>"
447
+ _verify_release_arg=$1
448
+ case "$_verify_release_arg" in
449
+ /*) : ;;
450
+ *) die "release root must be absolute" ;;
451
+ esac
452
+ [ -d "$_verify_release_arg" ] || die "release root is not a directory"
453
+ [ ! -L "$_verify_release_arg" ] || die "release root must not be a symlink"
454
+ _verify_release_canonical=$(cd "$_verify_release_arg" 2>/dev/null && pwd -P) || die "cannot resolve release root"
455
+ _verify_releases_canonical=$(cd "$VALISE_RELEASES_DIR" 2>/dev/null && pwd -P) || die "release storage is unavailable"
456
+ _verify_release_parent=$(cd "$(path_parent "$_verify_release_canonical")" 2>/dev/null && pwd -P) || die "release parent unavailable"
457
+ [ "$_verify_release_parent" = "$_verify_releases_canonical" ] || die "release root escapes release storage"
458
+ _verify_release_id=${_verify_release_canonical##*/}
459
+ case "$_verify_release_id" in
460
+ *[!0123456789abcdefABCDEF]*|'') die "release root identity is not a SHA-256 digest" ;;
461
+ esac
462
+ [ "${#_verify_release_id}" -eq 64 ] || die "release root identity is not a SHA-256 digest"
463
+ _verify_release_snapshot="$_verify_release_canonical/.valise-manifest"
464
+ [ -f "$_verify_release_snapshot" ] || die "release manifest snapshot is missing"
465
+ [ ! -L "$_verify_release_snapshot" ] || die "release manifest snapshot must not be a symlink"
466
+ prepare_manifest_rows "$_verify_release_snapshot" || die "cannot read release manifest snapshot"
467
+ _verify_release_rows=$_manifest_rows
468
+ validate_manifest "$_verify_release_rows" || die "invalid release manifest snapshot"
469
+ verify_release_dir "$_verify_release_canonical" "$_verify_release_rows" "$_verify_release_id"
470
+ printf '%s\n' "$_verify_release_canonical"
471
+ }
472
+
473
+ cmd_verify_active() {
474
+ _verify_print_root=0
475
+ for _verify_arg do
476
+ case "$_verify_arg" in
477
+ --print-root) _verify_print_root=1 ;;
478
+ --help|-h) usage_release; return 0 ;;
479
+ *) die "unknown release verify option: $_verify_arg" ;;
480
+ esac
481
+ done
482
+ [ -L "$VALISE_ACTIVE_LINK" ] || die "active pointer is not a symlink"
483
+ _verify_active_target=$(readlink_target "$VALISE_ACTIVE_LINK" 2>/dev/null) || die "cannot read active pointer"
484
+ case "$_verify_active_target" in
485
+ /*) : ;;
486
+ *)
487
+ _verify_active_link_parent=$(cd "$(path_parent "$VALISE_ACTIVE_LINK")" 2>/dev/null && pwd -P) || die "active pointer parent unavailable"
488
+ _verify_active_target="$_verify_active_link_parent/$_verify_active_target"
489
+ ;;
490
+ esac
491
+ [ -d "$_verify_active_target" ] || die "active release target is not a directory"
492
+ _verify_active_root=$(cd "$_verify_active_target" 2>/dev/null && pwd -P) || die "cannot resolve active release root"
493
+ _verify_releases_root=$(cd "$VALISE_RELEASES_DIR" 2>/dev/null && pwd -P) || die "release root unavailable"
494
+ _verify_active_parent=$(cd "$(path_parent "$_verify_active_root")" 2>/dev/null && pwd -P) || die "active release parent unavailable"
495
+ [ "$_verify_active_parent" = "$_verify_releases_root" ] || die "active release escapes release root"
496
+ _verify_active_id=${_verify_active_root##*/}
497
+ case "$_verify_active_id" in
498
+ *[!0123456789abcdefABCDEF]*|'') die "active release identity is not a SHA-256 digest" ;;
499
+ esac
500
+ [ "${#_verify_active_id}" -eq 64 ] || die "active release identity is not a SHA-256 digest"
501
+ _verify_active_manifest="$_verify_active_root/.valise-manifest"
502
+ [ -f "$_verify_active_manifest" ] || die "active release manifest is missing"
503
+ [ ! -L "$_verify_active_manifest" ] || die "active release manifest must not be a symlink"
504
+ prepare_manifest_rows "$_verify_active_manifest" || die "cannot read active release manifest"
505
+ _verify_active_manifest_rows="$_manifest_rows"
506
+ validate_manifest "$_verify_active_manifest_rows" || die "invalid active release manifest"
507
+ verify_release_dir "$_verify_active_root" "$_verify_active_manifest_rows" "$_verify_active_id"
508
+ if [ "$_verify_print_root" -eq 1 ]; then
509
+ printf '%s\n' "$_verify_active_root"
510
+ fi
511
+ return 0
512
+ }
513
+ cmd_release() {
514
+ _subcmd="${1:-list}"
515
+ case "$_subcmd" in
516
+ --help|-h) usage_release; return 0 ;;
517
+ list)
518
+ case "${2:-}" in
519
+ --help|-h) usage_release; return 0 ;;
520
+ esac
521
+ if [ -d "$VALISE_RELEASES_DIR" ]; then
522
+ _list_active_id=$(active_release_id 2>/dev/null || :)
523
+ for _rel in "$VALISE_RELEASES_DIR"/*/; do
524
+ [ -d "$_rel" ] || continue
525
+ _name=$(basename "$_rel")
526
+ if [ -n "$_list_active_id" ] && [ "$_name" = "$_list_active_id" ]; then
527
+ printf '* %s (active)\n' "$_name"
528
+ else
529
+ printf ' %s\n' "$_name"
530
+ fi
531
+ done
532
+ unset _list_active_id
533
+ else
534
+ info "no releases installed"
535
+ fi
536
+ ;;
537
+ use)
538
+ case "${2:-}" in
539
+ --help|-h) usage_release; return 0 ;;
540
+ esac
541
+ _target="${2:-}"
542
+ [ -n "$_target" ] || die "usage: valise release use <sha256>"
543
+ [ "$#" -le 2 ] || die "usage: valise release use <sha256>"
544
+ release_activate "$_target"
545
+ info "switched to release $_target"
546
+ ;;
547
+ prune)
548
+ shift
549
+ _prune_keep=3
550
+ _prune_keep_set=0
551
+ for _prune_arg do
552
+ case "$_prune_arg" in
553
+ --keep=*)
554
+ _prune_keep_set=1
555
+ _prune_keep=${_prune_arg#--keep=}
556
+ case "$_prune_keep" in
557
+ '' | *[!0-9]*) die "--keep requires a non-negative integer: $_prune_keep" ;;
558
+ esac
559
+ # Strip leading zeros so the shell does not read the value as
560
+ # octal in the arithmetic comparison below, and so the digit
561
+ # count measures the value rather than its spelling.
562
+ while [ "$_prune_keep" != "0" ] && [ "${_prune_keep#0}" != "$_prune_keep" ]; do
563
+ _prune_keep=${_prune_keep#0}
564
+ done
565
+ # A value beyond the shell's integer range makes the comparison
566
+ # below fail, which would read as "keep nothing" and remove every
567
+ # release. Reject it instead. Nine digits is the bound: every
568
+ # nine-digit value fits in a signed 32-bit integer, so no
569
+ # lexicographic comparison against the maximum is needed.
570
+ if [ "${#_prune_keep}" -gt 9 ]; then
571
+ die "--keep is too large: $_prune_keep (maximum is 999999999)"
572
+ fi
573
+ ;;
574
+ --help|-h) usage_release; return 0 ;;
575
+ *) die "unknown release prune option: $_prune_arg" ;;
576
+ esac
577
+ done
578
+ [ -d "$VALISE_RELEASES_DIR" ] || die "no releases installed"
579
+ # The active release is identified by its canonical directory name, not
580
+ # by a path spelling. A trailing slash, a relative target, or an alias
581
+ # symlink must never make prune delete the release the pointer names.
582
+ _prune_active_id=$(active_release_id 2>/dev/null || :)
583
+ _prune_active=$_prune_active_id
584
+ # Collect the candidate releases with their modification times. The
585
+ # active release is excluded here and kept unconditionally below.
586
+ _prune_sort=$(trusted_mktemp file "${TMPDIR:-/tmp}/valise-prune.XXXXXX") ||
587
+ die "cannot create prune staging file"
588
+ for _prune_rel in "$VALISE_RELEASES_DIR"/*/; do
589
+ [ -d "$_prune_rel" ] || continue
590
+ [ ! -L "$_prune_rel" ] || continue
591
+ _prune_dir=${_prune_rel%/}
592
+ _prune_name=${_prune_dir##*/}
593
+ [ "$_prune_name" = "$_prune_active_id" ] && continue
594
+ _prune_mtime=$(file_mtime "$_prune_dir") || _prune_mtime=0
595
+ printf '%s\t%s\n' "$_prune_mtime" "$_prune_name" >>"$_prune_sort"
596
+ done
597
+ _prune_sort_path=$(trusted_utility_path sort) || die "no trusted sort utility available"
598
+ _prune_sorted=$(trusted_mktemp file "${TMPDIR:-/tmp}/valise-prune-sorted.XXXXXX") ||
599
+ die "cannot create prune ordering file"
600
+ # Newest first; equal timestamps fall back to the release name so the
601
+ # ordering is deterministic.
602
+ "$_prune_sort_path" -k1,1nr -k2,2 "$_prune_sort" >"$_prune_sorted" ||
603
+ die "cannot order releases for pruning"
604
+ _prune_index=0
605
+ _prune_removed=0
606
+ _prune_kept=0
607
+ while IFS="$(printf '\t')" read -r _prune_mtime _prune_name; do
608
+ [ -n "$_prune_name" ] || continue
609
+ _prune_index=$((_prune_index + 1))
610
+ if [ "$_prune_index" -le "$_prune_keep" ]; then
611
+ _prune_kept=$((_prune_kept + 1))
612
+ continue
613
+ fi
614
+ _prune_target="$VALISE_RELEASES_DIR/$_prune_name"
615
+ # A release is only removed after it verifies against its own
616
+ # manifest. A corrupted release is kept, never deleted, so a damaged
617
+ # installation can still be inspected and repaired.
618
+ _prune_manifest="$_prune_target/.valise-manifest"
619
+ if [ ! -f "$_prune_manifest" ] || [ -L "$_prune_manifest" ]; then
620
+ info "keeping unverifiable release $_prune_name (no manifest snapshot)"
621
+ _prune_kept=$((_prune_kept + 1))
622
+ continue
623
+ fi
624
+ if ! prepare_manifest_rows "$_prune_manifest" ||
625
+ ! validate_manifest "$_manifest_rows"; then
626
+ info "keeping unverifiable release $_prune_name (invalid manifest)"
627
+ _prune_kept=$((_prune_kept + 1))
628
+ continue
629
+ fi
630
+ _verify_quiet=1
631
+ if ! verify_release_dir "$_prune_target" "$_manifest_rows" "$_prune_name"; then
632
+ _verify_quiet=0
633
+ info "keeping unverifiable release $_prune_name (failed verification)"
634
+ _prune_kept=$((_prune_kept + 1))
635
+ continue
636
+ fi
637
+ _verify_quiet=0
638
+ acquire_lock
639
+ if remove_tree "$_prune_target"; then
640
+ _prune_removed=$((_prune_removed + 1))
641
+ info "removed release $_prune_name"
642
+ else
643
+ info "keeping release $_prune_name (could not be removed)"
644
+ _prune_kept=$((_prune_kept + 1))
645
+ fi
646
+ release_lock
647
+ done <"$_prune_sorted"
648
+ _prune_rm=$(trusted_utility_path rm) || die "no trusted rm utility available"
649
+ "$_prune_rm" -f "$_prune_sort" "$_prune_sorted" || die "cannot remove prune staging files"
650
+ if [ -n "$_prune_active" ]; then
651
+ info "prune kept the active release plus $_prune_kept other release(s); removed $_prune_removed"
652
+ else
653
+ info "prune kept $_prune_kept release(s); removed $_prune_removed (no active release)"
654
+ fi
655
+ ;;
656
+ rollback)
657
+ shift
658
+ cmd_release_rollback "$@"
659
+ ;;
660
+ verify)
661
+ if [ "$#" -gt 0 ]; then
662
+ shift
663
+ fi
664
+ cmd_verify_active "$@"
665
+ ;;
666
+ *)
667
+ die "unknown release subcommand: $_subcmd (use list, use, rollback, prune, or verify)"
668
+ ;;
669
+ esac
670
+ }
671
+
672
+ cmd_install() {
673
+ _install_integration=1
674
+ for _arg do
675
+ case "$_arg" in
676
+ --help|-h) usage_install; return 0 ;;
677
+ --no-integration) _install_integration=0 ;;
678
+ *) die "unknown install option: $_arg (usage: valise install [--no-integration])" ;;
679
+ esac
680
+ done
681
+
682
+ _manifest_source=$(locate_manifest) ||
683
+ die "cannot find manifest.tsv in the current directory (run valise install from a release tree built by valise build)"
684
+ _manifest_root=$(cd "$(path_parent "$_manifest_source")" && pwd -P) || die "cannot resolve manifest source root"
685
+ prepare_manifest_rows "$_manifest_source" || die "cannot normalize manifest.tsv"
686
+ _manifest="$_manifest_rows"
687
+ validate_manifest "$_manifest" || die "invalid manifest.tsv schema or row"
688
+
689
+ # Only a tree built by valise build is installable. The integration loads
690
+ # plans/<shell>.sh and runtime/fish/profile.fish, which only that shape
691
+ # carries; installing anything else would wire startup files to nothing.
692
+ _install_awk=$(trusted_utility_path awk) || die "no trusted awk utility available"
693
+ "$_install_awk" -F '\t' '$1 == "valise-release.conf" { found = 1 } END { exit found ? 0 : 1 }' "$_manifest" ||
694
+ die "manifest.tsv does not list valise-release.conf: only a release tree built by valise build can be installed"
695
+ [ "$(release_conf_value "$_manifest_root" format 2>/dev/null)" = 2 ] ||
696
+ die "valise-release.conf does not declare format=2: rebuild the release with this valise"
697
+ release_commands "$_manifest_root" >/dev/null ||
698
+ die "valise-release.conf lists an invalid command name: rebuild the release with valise build"
699
+
700
+ # Everything install will write must be writable before anything changes.
701
+ if [ "$_install_integration" -eq 1 ]; then
702
+ integration_preflight "$_manifest_root"
703
+ if ! _install_unmanaged=$(integration_unmanaged_report); then
704
+ info "warning: these lines load valise outside a managed block, so it may load twice; remove them:"
705
+ printf '%s\n' "$_install_unmanaged"
706
+ fi
707
+ fi
708
+
709
+ # Release identity is content-derived; the schema header is not an ID.
710
+ _release_id=$(file_sha256 "$_manifest") || die "no trusted SHA-256 utility available"
711
+ case "$_release_id" in
712
+ *[!0123456789abcdefABCDEF]*|'') die "invalid release ID derived from manifest" ;;
713
+ esac
714
+ [ "${#_release_id}" -eq 64 ] || die "invalid release ID derived from manifest"
715
+
716
+ _target_dir="$VALISE_RELEASES_DIR/$_release_id"
717
+
718
+ acquire_lock
719
+ _mkdir_path=$(trusted_utility_path mkdir) || die "no trusted mkdir utility available"
720
+ _chmod_path=$(trusted_utility_path chmod) || die "no trusted chmod utility available"
721
+ "$_mkdir_path" -p "$VALISE_XDG_CONFIG" "$VALISE_XDG_STATE" || die "cannot create valise configuration directories"
722
+ [ ! -L "$_target_dir" ] || die "release target must not be a symlink: $_release_id"
723
+
724
+ if [ -e "$_target_dir" ] && [ ! -d "$_target_dir" ]; then
725
+ die "release path is not a directory: $_target_dir"
726
+ fi
727
+ if [ -d "$_target_dir" ]; then
728
+ _target_manifest="$_target_dir/.valise-manifest"
729
+ if [ -e "$_target_manifest" ] || [ -L "$_target_manifest" ]; then
730
+ [ -f "$_target_manifest" ] || die "release manifest snapshot is not a regular file"
731
+ [ ! -L "$_target_manifest" ] || die "release manifest snapshot must not be a symlink"
732
+ prepare_manifest_rows "$_target_manifest" || die "cannot snapshot release manifest"
733
+ _target_manifest="$_manifest_rows"
734
+ validate_manifest "$_target_manifest" || die "invalid release manifest snapshot"
735
+ verify_release_dir "$_target_dir" "$_target_manifest" "$_release_id"
736
+ else
737
+ verify_release_dir "$_target_dir" "$_manifest" "$_release_id"
738
+ fi
739
+ info "release $_release_id already installed and verified"
740
+ else
741
+ info "installing release $_release_id..."
742
+
743
+ # Create target directories
744
+ "$_mkdir_path" -p "$VALISE_RELEASES_DIR" "$VALISE_XDG_CONFIG" "$VALISE_XDG_STATE"
745
+
746
+ # Stage into temp directory first
747
+ _stage=$(trusted_mktemp dir "${TMPDIR:-/tmp}/valise-install.XXXXXX") || die "cannot create staging directory"
748
+ _cp_path=$(trusted_utility_path cp) || die "no trusted cp utility available"
749
+ "$_cp_path" -p "$_manifest" "$_stage/.valise-manifest" || die "failed to stage release manifest"
750
+ "$_chmod_path" 0444 "$_stage/.valise-manifest" || die "failed to seal release manifest"
751
+
752
+ # Copy release files from manifest
753
+ # The manifest format is: destination source mode install_mode size sha256
754
+ while IFS="$(printf '\t')" read -r _dest _src _mode _imode _size _sha; do
755
+ [ -n "$_dest" ] || continue
756
+ case "$_dest" in \#*|valise-manifest-v2|destination) continue ;; esac
757
+ case "$_src" in
758
+ ""|.|./*|*/.|*/./*|*//*|/*|*/../*|../*|*/..|..*) die "invalid manifest source path: $_src" ;;
759
+ esac
760
+ case "$_dest" in
761
+ ""|.|./*|*/.|*/./*|*//*|/*|*/../*|../*|*/..|..*) die "invalid manifest destination path: $_dest" ;;
762
+ esac
763
+ _src_path="$_manifest_root/$_src"
764
+ _dest_path="$_stage/$_dest"
765
+ [ -f "$_src_path" ] || die "manifest source missing: $_src"
766
+ source_path_is_physical "$_manifest_root" "$_src" || die "manifest source path contains a symlink: $_src"
767
+ [ ! -L "$_src_path" ] || die "manifest source must not be a symlink: $_src"
768
+ _src_parent=$(cd "$(path_parent "$_src_path")" 2>/dev/null && pwd -P) || die "manifest source parent missing: $_src"
769
+ case "$_src_parent/" in
770
+ "$_manifest_root/"*) : ;;
771
+ *) die "manifest source escapes source root: $_src" ;;
772
+ esac
773
+ [ "$(file_mode "$_src_path")" = "$_mode" ] || die "manifest source mode mismatch: $_src"
774
+ [ "$(file_bytes "$_src_path")" = "$_size" ] || die "manifest source byte count mismatch: $_src"
775
+ case "$_mode:$_imode" in
776
+ 0644:0444|0755:0555) : ;;
777
+ *) die "invalid immutable mode pair for $_dest" ;;
778
+ esac
779
+ [ -n "$_sha" ] || die "manifest digest missing: $_dest"
780
+ case "$_sha" in
781
+ *[!0123456789abcdefABCDEF]*) die "invalid manifest digest for $_dest" ;;
782
+ esac
783
+ [ "${#_sha}" -eq 64 ] || die "invalid manifest digest for $_dest"
784
+ _dest_parent=$(path_parent "$_dest_path")
785
+ "$_mkdir_path" -p "$_dest_parent"
786
+ "$_cp_path" -p "$_src_path" "$_dest_path" || die "failed to copy $_src"
787
+ "$_chmod_path" "$_imode" "$_dest_path" || die "failed to seal $_dest"
788
+ done < "${_manifest_parse:-$_manifest_rows}"
789
+
790
+ # Verify staged files against manifest digests
791
+ _verify_ok=1
792
+ while IFS="$(printf '\t')" read -r _dest _src _mode _imode _size _sha; do
793
+ [ -n "$_dest" ] || continue
794
+ case "$_dest" in \#*|valise-manifest-v2|destination) continue ;; esac
795
+ case "$_dest" in
796
+ ""|.|./*|*/.|*/./*|*//*|/*|*/../*|../*|*/..|..*) die "invalid manifest destination path: $_dest" ;;
797
+ esac
798
+ _staged_file="$_stage/$_dest"
799
+ [ -f "$_staged_file" ] || die "staged manifest file missing: $_dest"
800
+ [ "$(file_mode "$_staged_file")" = "$_imode" ] || die "staged install mode mismatch: $_dest"
801
+ [ "$(file_bytes "$_staged_file")" = "$_size" ] || die "staged byte count mismatch: $_dest"
802
+ [ -n "$_sha" ] || die "manifest digest missing: $_dest"
803
+ case "$_sha" in
804
+ *[!0123456789abcdefABCDEF]*) die "invalid manifest digest for $_dest" ;;
805
+ esac
806
+ [ "${#_sha}" -eq 64 ] || die "invalid manifest digest for $_dest"
807
+ _actual=$(file_sha256 "$_staged_file") || die "no trusted SHA-256 utility available"
808
+ _expected=$(printf '%s' "$_sha" | lower_hex) || die "no trusted tr utility available"
809
+ if [ "$_actual" != "$_expected" ]; then
810
+ die "digest mismatch for $_dest: expected $_sha got $_actual"
811
+ fi
812
+ done < "${_manifest_parse:-$_manifest_rows}"
813
+
814
+ # Seal all release directories; files already received their declared
815
+ # immutable mode while being staged. Never publish an incompletely sealed
816
+ # tree.
817
+ seal_tree "$_stage" || die "failed to seal release directories"
818
+
819
+ # Publish through a temporary sibling, then atomically rename the
820
+ # complete immutable tree into place. The EXIT/signal cleanup removes any
821
+ # incomplete sibling before it can poison this release ID.
822
+ _publish_tmp="$VALISE_RELEASES_DIR/.valise-release.$_release_id.$$"
823
+ [ ! -e "$_publish_tmp" ] && [ ! -L "$_publish_tmp" ] || die "release publication temporary path already exists"
824
+ "$_cp_path" -rp "$_stage" "$_publish_tmp" || die "failed to publish release"
825
+ seal_tree "$_publish_tmp" || die "failed to seal published release"
826
+ verify_release_dir "$_publish_tmp" "$_manifest" "$_release_id"
827
+ [ ! -e "$_target_dir" ] && [ ! -L "$_target_dir" ] || die "release target appeared during publication"
828
+ _mv_path=$(trusted_utility_path mv) || die "no trusted mv utility available"
829
+ "$_mv_path" "$_publish_tmp" "$_target_dir" || die "failed to publish release"
830
+ _publish_tmp=""
831
+ [ -d "$_target_dir" ] || die "published release directory is missing"
832
+ verify_release_dir "$_target_dir" "$_manifest" "$_release_id"
833
+ remove_tree "$_stage" || die "failed to remove staging directory"
834
+ _stage=""
835
+ info "release $_release_id installed"
836
+ fi
837
+
838
+ # Update active symlink atomically without clobbering a non-symlink path.
839
+ if [ ! -L "$VALISE_ACTIVE_LINK" ] || [ "$(readlink_target "$VALISE_ACTIVE_LINK" 2>/dev/null)" != "$_target_dir" ]; then
840
+ if [ -e "$VALISE_ACTIVE_LINK" ] && [ ! -L "$VALISE_ACTIVE_LINK" ]; then
841
+ die "active path exists and is not a symlink: $VALISE_ACTIVE_LINK"
842
+ fi
843
+ # From here a failure puts the previous active release back.
844
+ install_txn_begin
845
+ _install_txn=1
846
+ switch_active_link "$_target_dir"
847
+ info "active release set to $_release_id"
848
+ fi
849
+
850
+ # Deploy the Fish conf.d hook when Fish is available. The hook loads the
851
+ # release's Fish runtime when it has one and does nothing otherwise.
852
+ _fish_path=$(trusted_utility_path fish 2>/dev/null || :)
853
+ if [ "$_install_integration" -eq 1 ] && [ -n "$_fish_path" ]; then
854
+ _fish_conf_dir="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d"
855
+ _fish_hook="$_fish_conf_dir/valise.fish"
856
+ _mkdir_path=$(trusted_utility_path mkdir) || die "no trusted mkdir utility available"
857
+ "$_mkdir_path" -p "$_fish_conf_dir" || die "cannot create Fish configuration directory"
858
+ [ ! -L "$_fish_hook" ] || die "Fish hook path must not be a symlink: $_fish_hook"
859
+ if [ -d "$_fish_hook" ]; then
860
+ die "Fish hook path is a directory: $_fish_hook"
861
+ fi
862
+ # Use the platform mktemp in the destination directory. Its exclusive
863
+ # creation makes a pre-created predictable symlink unable to redirect the
864
+ # shell redirection below into an attacker-selected victim.
865
+ _hook_tmp=$(trusted_mktemp_at "$_fish_conf_dir" file "$_fish_conf_dir/.valise-hook.XXXXXX") || die "cannot create Fish hook staging file"
866
+ [ -f "$_hook_tmp" ] && [ ! -L "$_hook_tmp" ] || die "Fish hook staging path is unsafe"
867
+ print_fish_integration >"$_hook_tmp" || die "cannot write Fish hook"
868
+ _chmod_path=$(trusted_utility_path chmod) || die "no trusted chmod utility available"
869
+ "$_chmod_path" 0644 "$_hook_tmp" || die "cannot set Fish hook mode"
870
+ _cat_path=$(trusted_utility_path cat) || die "no trusted cat utility available"
871
+ _hook_expected=$("$_cat_path" "$_hook_tmp") || die "cannot read Fish hook staging file"
872
+ _hook_cmp=$(trusted_utility_path cmp) || die "no trusted cmp utility available"
873
+ if [ -f "$_fish_hook" ] && [ "$(file_mode "$_fish_hook")" = 0644 ] &&
874
+ "$_hook_cmp" -s "$_hook_tmp" "$_fish_hook"; then
875
+ # An identical hook stays in place, so its inode and mtime change only
876
+ # when its content does.
877
+ _rm_path=$(trusted_utility_path rm) || die "no trusted rm utility available"
878
+ "$_rm_path" -f "$_hook_tmp" || die "cannot remove Fish hook staging file"
879
+ _hook_tmp=""
880
+ info "Fish hook current: $_fish_hook"
881
+ else
882
+ _mv_path=$(trusted_utility_path mv) || die "no trusted mv utility available"
883
+ # mv -f uses rename(2) which atomically replaces a regular file
884
+ # destination on both BSD and GNU systems. The hook path was verified
885
+ # non-symlink above, so no explicit removal is needed — mv -f alone
886
+ # preserves atomicity.
887
+ "$_mv_path" -f "$_hook_tmp" "$_fish_hook" || die "cannot publish Fish hook"
888
+ _hook_tmp=""
889
+ [ -f "$_fish_hook" ] || die "Fish hook was not published"
890
+ [ "$(file_mode "$_fish_hook")" = 0644 ] || die "Fish hook mode mismatch"
891
+ _hook_actual=$("$_cat_path" "$_fish_hook") || die "cannot read published Fish hook"
892
+ [ "$_hook_actual" = "$_hook_expected" ] || die "published Fish hook contents mismatch"
893
+ _txn_changed="$_txn_changed $_fish_hook"
894
+ info "Fish hook deployed: $_fish_hook"
895
+ fi
896
+ fi
897
+
898
+ if [ "$_install_integration" -eq 1 ]; then
899
+ write_posix_integration
900
+ reconcile_commands "$_target_dir"
901
+ fi
902
+
903
+ _install_txn=0
904
+ release_lock
905
+ info "installation complete"
906
+ if [ "$_install_integration" -eq 1 ]; then
907
+ info "run 'valise uninstall' to remove the integration, the commands, and the releases"
908
+ else
909
+ info "run 'valise init' to print the shell integration snippet for manual setup"
910
+ fi
911
+ }
912
+
913
+ cmd_make_manifest() {
914
+ _mm_tree=""
915
+ _mm_out=""
916
+ _mm_want_out=0
917
+ for _mm_arg do
918
+ case "$_mm_arg" in
919
+ --output) _mm_want_out=1 ;;
920
+ --output=*)
921
+ _mm_out=${_mm_arg#--output=}
922
+ [ -n "$_mm_out" ] || die "usage: valise make-manifest <tree> [--output <file>]"
923
+ ;;
924
+ --help|-h) printf '%s\n' "usage: valise make-manifest <tree> [--output <file>]"; return 0 ;;
925
+ --*) die "unknown make-manifest option: $_mm_arg" ;;
926
+ *)
927
+ if [ "$_mm_want_out" -eq 1 ]; then
928
+ _mm_out=$_mm_arg
929
+ _mm_want_out=0
930
+ elif [ -n "$_mm_tree" ]; then
931
+ die "usage: valise make-manifest <tree> [--output <file>]"
932
+ else
933
+ _mm_tree=$_mm_arg
934
+ fi
935
+ ;;
936
+ esac
937
+ done
938
+ [ "$_mm_want_out" -eq 0 ] || die "usage: valise make-manifest <tree> [--output <file>]"
939
+ [ -n "$_mm_tree" ] || die "usage: valise make-manifest <tree> [--output <file>]"
940
+ [ -d "$_mm_tree" ] || die "manifest tree is not a directory: $_mm_tree"
941
+ _mm_root=$(cd "$_mm_tree" && pwd -P) || die "cannot resolve manifest tree: $_mm_tree"
942
+ _mm_default_out="$_mm_root/manifest.tsv"
943
+ if [ -z "$_mm_out" ]; then
944
+ _mm_out="$_mm_default_out"
945
+ fi
946
+ case "$_mm_out" in
947
+ /*) : ;;
948
+ *)
949
+ while :; do
950
+ case "$_mm_out" in
951
+ ./*) _mm_out=${_mm_out#./} ;;
952
+ *) break ;;
953
+ esac
954
+ done
955
+ _mm_cwd=$(pwd -P) || die "cannot resolve working directory"
956
+ _mm_out="$_mm_cwd/$_mm_out"
957
+ ;;
958
+ esac
959
+ _mm_out=$(path_normalize "$_mm_out") || die "cannot normalize manifest output path"
960
+ _mm_out_dir=$(path_parent "$_mm_out") || die "cannot resolve manifest parent"
961
+ _mm_mkdir_path=$(trusted_utility_path mkdir) || die "no trusted mkdir utility available"
962
+ "$_mm_mkdir_path" -p "$_mm_out_dir" || die "cannot create manifest directory: $_mm_out_dir"
963
+ # Resolve the parent physically: find reports physical paths, and /tmp is a
964
+ # symlink to /private/tmp on macOS. A lexical comparison would include the
965
+ # manifest in itself on the second run.
966
+ _mm_out_dir=$(cd "$_mm_out_dir" && pwd -P) || die "cannot resolve manifest parent: $_mm_out_dir"
967
+ _mm_out="$_mm_out_dir/${_mm_out##*/}"
968
+ if [ -e "$_mm_out" ] || [ -L "$_mm_out" ]; then
969
+ [ -f "$_mm_out" ] && [ ! -L "$_mm_out" ] ||
970
+ die "manifest output is not a regular file: $_mm_out"
971
+ fi
972
+ _mm_find_path=$(trusted_utility_path find) || die "no trusted find utility available"
973
+ _mm_sort_path=$(trusted_utility_path sort) || die "no trusted sort utility available"
974
+ # Symlinks never enter a sealed release: find without -L reports them as
975
+ # -type l, so any symlink anywhere under the tree fails closed here.
976
+ _mm_links=$("$_mm_find_path" "$_mm_root" -type l -print) || die "cannot scan manifest tree: $_mm_root"
977
+ [ -z "$_mm_links" ] || die "manifest tree must not contain symlinks: $_mm_root"
978
+ _mm_list=$("$_mm_find_path" "$_mm_root" -type f -print) || die "cannot list manifest tree: $_mm_root"
979
+ _mm_tmp=$(trusted_mktemp_at "$_mm_out_dir" file "$_mm_out_dir/.valise-manifest.XXXXXX") || die "cannot stage manifest"
980
+ _mm_tab=$(printf '\t')
981
+ _mm_nl='
982
+ '
983
+ _mm_count=0
984
+ printf '%s\n' "valise-manifest-v2" >"$_mm_tmp" || die "cannot write manifest"
985
+ printf 'destination\tsource\tsource_mode\tinstall_mode\tbytes\tsha256\n' >>"$_mm_tmp" || die "cannot write manifest"
986
+ _mm_sorted=$(printf '%s\n' "$_mm_list" | LC_ALL=C "$_mm_sort_path") || die "cannot sort manifest tree listing"
987
+ while IFS= read -r _mm_full || [ -n "$_mm_full" ]; do
988
+ [ -n "$_mm_full" ] || continue
989
+ # The manifest describes the tree payload, never itself: both the
990
+ # requested output and the default output path stay out of the rows so
991
+ # consecutive runs over an unchanged tree are byte-identical.
992
+ [ "$_mm_full" != "$_mm_out" ] || continue
993
+ [ "$_mm_full" != "$_mm_default_out" ] || continue
994
+ [ "$_mm_full" != "$_mm_tmp" ] || continue
995
+ _mm_rel=${_mm_full#"$_mm_root"/}
996
+ # Same name rules as validate_manifest: the schema charset plus no
997
+ # tab/newline (row delimiters) and no dot segments. Anything else
998
+ # fails here naming the file instead of at validation time.
999
+ case "$_mm_rel" in
1000
+ *"$_mm_nl"*|*"$_mm_tab"*|*[!A-Za-z0-9._/-]*|"#"*|.|..|./*|../*|*/.|*/..|*/./*|*/../*) die "manifest tree file has an unrepresentable name: $_mm_full" ;;
1001
+ esac
1002
+ _mm_mode=$(file_mode "$_mm_full") || die "cannot read file mode: $_mm_full"
1003
+ case "$_mm_mode" in
1004
+ 0644) _mm_imode=0444 ;;
1005
+ 0755) _mm_imode=0555 ;;
1006
+ *) die "manifest tree file has unsupported mode $_mm_mode (normalize to 0644 or 0755): $_mm_full" ;;
1007
+ esac
1008
+ _mm_bytes=$(file_bytes "$_mm_full") || die "cannot read file size: $_mm_full"
1009
+ _mm_sha=$(file_sha256 "$_mm_full") || die "no trusted SHA-256 utility available"
1010
+ printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$_mm_rel" "$_mm_rel" "$_mm_mode" "$_mm_imode" "$_mm_bytes" "$_mm_sha" >>"$_mm_tmp" || die "cannot write manifest"
1011
+ _mm_count=$((_mm_count + 1))
1012
+ done <<_MM_SORTED_EOF
1013
+ $_mm_sorted
1014
+ _MM_SORTED_EOF
1015
+ [ "$_mm_count" -gt 0 ] || die "manifest tree contains no files: $_mm_root"
1016
+ validate_manifest "$_mm_tmp" || die "generated manifest failed validation"
1017
+ _mm_mv_path=$(trusted_utility_path mv) || die "no trusted mv utility available"
1018
+ "$_mm_mv_path" -f "$_mm_tmp" "$_mm_out" || die "cannot publish manifest: $_mm_out"
1019
+ _mm_chmod_path=$(trusted_utility_path chmod) || die "no trusted chmod utility available"
1020
+ "$_mm_chmod_path" 0644 "$_mm_out" || die "cannot set manifest mode: $_mm_out"
1021
+ _mm_tmp=""
1022
+ info "manifest written: $_mm_out ($_mm_count files)"
1023
+ }