dsh-autorestart 0.7.0 → 0.8.1

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.
@@ -0,0 +1,427 @@
1
+ #!/bin/sh
2
+ # The rescue drill (POSIX): break a profile on purpose, put it back, prove it.
3
+ #
4
+ # Three traps this file avoids (the whole story: scripts/README.md):
5
+ # 1. the wording lives in rescue-strings.zh.json and is NESTED - it is read
6
+ # by path with node, never by a flat grep;
7
+ # 2. the English fallbacks below must NOT contain parentheses: dash counts
8
+ # them even inside single quotes, and one stray ")" breaks the script;
9
+ # 3. the sed expressions stay single-quoted: the shell eats the backslashes
10
+ # in a double-quoted sed program and truncates it.
11
+ #
12
+ # The same assertions as drill-rescue.ps1, in a directory of its own under the
13
+ # temp directory. Nothing here touches the profile of a running DSH.
14
+ #
15
+ # The wording comes from rescue-strings.zh.json; the English fallbacks keep
16
+ # the script working without it.
17
+ set -eu
18
+
19
+ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
20
+ RESTORE="$SCRIPT_DIR/restore-profile.sh"
21
+ DISABLE="$SCRIPT_DIR/emergency-disable.sh"
22
+ STRINGS_FILE="$SCRIPT_DIR/rescue-strings.zh.json"
23
+ PACKAGE_NAME='dsh-autorestart'
24
+
25
+ DRILL_DIR="${TMPDIR:-/tmp}/dsh-rescue-drill-$$"
26
+ FAILED=0
27
+
28
+ usage() {
29
+ printf 'Usage: %s [drill-directory]\n' "$0"
30
+ }
31
+
32
+ while [ $# -gt 0 ]; do
33
+ case "$1" in
34
+ --help|-h) usage; exit 0 ;;
35
+ *) DRILL_DIR="$1" ;;
36
+ esac
37
+ shift
38
+ done
39
+
40
+ NODE_BIN=''
41
+ if command -v node >/dev/null 2>&1; then
42
+ NODE_BIN=$(command -v node)
43
+ fi
44
+
45
+ # t <key> <english fallback>
46
+ #
47
+ # The key is a PATH into the strings file ("drill.passed"), because that file
48
+ # is nested. Node reads it (it is on every machine that runs DSH); without
49
+ # node every message falls back to English.
50
+ t() {
51
+ key="$1"
52
+ fallback="$2"
53
+ if [ -n "$NODE_BIN" ] && [ -f "$STRINGS_FILE" ]; then
54
+ value=$("$NODE_BIN" -e '
55
+ const fs = require("fs");
56
+ try {
57
+ const doc = JSON.parse(fs.readFileSync(process.argv[1], "utf8").replace(/^\uFEFF/, ""));
58
+ let value = doc;
59
+ for (const part of process.argv[2].split(".")) value = value == null ? undefined : value[part];
60
+ if (typeof value !== "string" || value === "") process.exit(1);
61
+ process.stdout.write(value);
62
+ } catch (error) { process.exit(1); }
63
+ ' "$STRINGS_FILE" "$key" 2>/dev/null) || value=''
64
+ if [ -n "$value" ]; then
65
+ printf '%s' "$value"
66
+ return 0
67
+ fi
68
+ fi
69
+ printf '%s' "$fallback"
70
+ }
71
+
72
+ # fmt <text> <value> ...
73
+ #
74
+ # Node does the replacement when it is there: the Chinese wording is UTF-8 and
75
+ # a byte-wise sed would mangle it. Without node only the English fallbacks are
76
+ # used, and those are plain ASCII.
77
+ fmt() {
78
+ text="$1"
79
+ shift
80
+ if [ -n "$NODE_BIN" ]; then
81
+ if "$NODE_BIN" -e '
82
+ let text = process.argv[1];
83
+ process.argv.slice(2).forEach((value, index) => { text = text.split("{" + index + "}").join(value); });
84
+ process.stdout.write(text);
85
+ ' "$text" "$@" 2>/dev/null; then
86
+ return 0
87
+ fi
88
+ fi
89
+ index=0
90
+ for value in "$@"; do
91
+ safe=$(printf '%s' "$value" | sed 's/[&|\\]/\\&/g')
92
+ text=$(printf '%s' "$text" | sed "s|{$index}|$safe|g")
93
+ index=$((index + 1))
94
+ done
95
+ printf '%s' "$text"
96
+ }
97
+
98
+ say() {
99
+ printf '%s\n' "$1"
100
+ }
101
+
102
+ assert_pass() {
103
+ say "$(fmt "$(t 'drill.assertPass' ' [pass] {0}')" "$1")"
104
+ }
105
+
106
+ assert_fail() {
107
+ FAILED=$((FAILED + 1))
108
+ say "$(fmt "$(t 'drill.assertFail' ' [FAIL] {0} -- {1}')" "$1" "$2")"
109
+ }
110
+
111
+ # The keywords a refusal has to carry (the same file the Windows drill uses).
112
+ keyword_list() {
113
+ key="$1"
114
+ if [ -n "$NODE_BIN" ] && [ -f "$STRINGS_FILE" ]; then
115
+ "$NODE_BIN" -e '
116
+ const fs = require("fs");
117
+ try {
118
+ const doc = JSON.parse(fs.readFileSync(process.argv[1], "utf8").replace(/^\uFEFF/, ""));
119
+ let value = doc;
120
+ for (const part of process.argv[2].split(".")) value = value == null ? undefined : value[part];
121
+ if (!Array.isArray(value)) process.exit(1);
122
+ process.stdout.write(value.join("\n"));
123
+ } catch (error) { process.exit(1); }
124
+ ' "$STRINGS_FILE" "$key" 2>/dev/null
125
+ return $?
126
+ fi
127
+ printf ''
128
+ }
129
+
130
+ # refused <name> <output> <exit code> <keyword key>
131
+ refused() {
132
+ name="$1"
133
+ output="$2"
134
+ code="$3"
135
+ key="$4"
136
+ if [ "$code" -eq 0 ]; then
137
+ assert_fail "$name" "$(fmt "$(t 'drill.refusedNotRefused' ' [FAIL] {0} -- the exit code is 0, the script did not refuse')" "$name")"
138
+ return
139
+ fi
140
+ found=''
141
+ for word in $(keyword_list "$key"); do
142
+ [ -n "$word" ] || continue
143
+ if printf '%s' "$output" | grep -q "$word"; then
144
+ found="$word"
145
+ break
146
+ fi
147
+ done
148
+ if [ -z "$found" ]; then
149
+ assert_fail "$name" "$(fmt "$(t 'drill.refusedNoReason' ' [FAIL] {0} -- the exit code is {1}, but the output has no reason')" "$name" "$code")"
150
+ return
151
+ fi
152
+ detail=$(fmt "$(t 'drill.refusedOk' ' [pass] {0} -- exit code {1}, the output says why')" "$name" "$code")
153
+ assert_pass "$name ($detail)"
154
+ }
155
+
156
+ if [ ! -x "$RESTORE" ] && [ ! -f "$RESTORE" ]; then
157
+ say "$(fmt "$(t 'drill.noScript' 'the drill failed: {0} not found')" "$RESTORE")"
158
+ exit 1
159
+ fi
160
+ if [ -e "$DRILL_DIR" ]; then
161
+ say "$(fmt "$(t 'drill.dirOccupied' 'the drill failed: this drill directory is already there: {0}')" "$DRILL_DIR")"
162
+ exit 1
163
+ fi
164
+
165
+ say "$(fmt "$(t 'drill.dir' 'drill directory: {0}')" "$DRILL_DIR")"
166
+ mkdir -p -- "$DRILL_DIR"
167
+
168
+ PROFILE="$DRILL_DIR/profile"
169
+ STATE="$DRILL_DIR/state"
170
+ BACKUPS="$STATE/backups"
171
+ SNAPSHOT="$BACKUPS/20260101-000000"
172
+ mkdir -p -- "$PROFILE/node_modules/$PACKAGE_NAME" "$SNAPSHOT/config" "$SNAPSHOT/node_modules/$PACKAGE_NAME"
173
+
174
+ # --- the profile and the snapshot ---
175
+ cat > "$PROFILE/package.json" <<'JSON'
176
+ {
177
+ "name": "dsh-profile",
178
+ "dsh": {
179
+ "profile": {
180
+ "bundles": [
181
+ "dsh-autorestart"
182
+ ]
183
+ }
184
+ }
185
+ }
186
+ JSON
187
+ cat > "$PROFILE/cordis.patch.yml" <<'YML'
188
+ # the profile configuration
189
+ YML
190
+ cat > "$PROFILE/node_modules/$PACKAGE_NAME/package.json" <<'JSON'
191
+ {
192
+ "name": "dsh-autorestart",
193
+ "version": "1.2.3"
194
+ }
195
+ JSON
196
+ cp "$PROFILE/package.json" "$SNAPSHOT/config/package.json"
197
+ cp "$PROFILE/cordis.patch.yml" "$SNAPSHOT/config/cordis.patch.yml"
198
+ cp "$PROFILE/node_modules/$PACKAGE_NAME/package.json" "$SNAPSHOT/node_modules/$PACKAGE_NAME/package.json"
199
+ cat > "$SNAPSHOT/manifest.json" <<'JSON'
200
+ {
201
+ "at": 1767225600000,
202
+ "atLocal": "2026-01-01T00:00:00",
203
+ "includePlugins": true
204
+ }
205
+ JSON
206
+
207
+ # A NEWER decoy without a manifest: it must be skipped.
208
+ DECOY="$BACKUPS/20260202-000000"
209
+ mkdir -p -- "$DECOY/config"
210
+ cp "$PROFILE/package.json" "$DECOY/config/package.json"
211
+
212
+ # --- break it on purpose ---
213
+ printf '{ this is not json' > "$PROFILE/package.json"
214
+ rm -rf -- "$PROFILE/node_modules/$PACKAGE_NAME"
215
+
216
+ say "$(t 'drill.broke' 'broken on purpose: package.json is invalid JSON, the plugin directory is gone.')"
217
+
218
+ # --- 1) the dry run changes nothing ---
219
+ set +e
220
+ DRY_OUT=$("$RESTORE" --dry-run --profile "$PROFILE" --state-dir "$STATE" 2>&1)
221
+ DRY_CODE=$?
222
+ set -e
223
+ DRY_MARK=$(keyword_list 'drill.keywords.dryRun' | head -n 1)
224
+ if [ "$DRY_CODE" -eq 0 ] && { [ -z "$DRY_MARK" ] || printf '%s' "$DRY_OUT" | grep -q "$DRY_MARK"; }; then
225
+ assert_pass "$(t 'drill.assertDryRunExit' 'the dry run exits with 0')"
226
+ else
227
+ assert_fail "$(t 'drill.assertDryRunExit' 'the dry run exits with 0')" "$DRY_OUT"
228
+ fi
229
+ if [ -z "$(find "$DRILL_DIR" -maxdepth 1 -name 'profile.broken-*' 2>/dev/null)" ]; then
230
+ assert_pass "$(t 'drill.assertDryRunNoBroken' 'the dry run leaves no broken directory')"
231
+ else
232
+ assert_fail "$(t 'drill.assertDryRunNoBroken' 'the dry run leaves no broken directory')" 'there is one'
233
+ fi
234
+
235
+ # --- 2) the real restore ---
236
+ set +e
237
+ RESTORE_OUT=$("$RESTORE" --profile "$PROFILE" --state-dir "$STATE" 2>&1)
238
+ RESTORE_CODE=$?
239
+ set -e
240
+ if [ "$RESTORE_CODE" -eq 0 ]; then
241
+ assert_pass "$(t 'drill.assertRestoreExit' 'the restore exits with 0')"
242
+ else
243
+ assert_fail "$(t 'drill.assertRestoreExit' 'the restore exits with 0')" "$RESTORE_OUT"
244
+ fi
245
+
246
+ # --- the assertions about the result ---
247
+ if printf '%s' "$RESTORE_OUT" | LC_ALL=C grep -q '[^ -~]'; then
248
+ assert_pass "$(t 'drill.assertChineseOutput' 'the output of the restore script carries Chinese (the wording comes from the JSON)')"
249
+ else
250
+ assert_fail "$(t 'drill.assertChineseOutput' 'the output of the restore script carries Chinese (the wording comes from the JSON)')" 'no non-ASCII byte in the output'
251
+ fi
252
+
253
+ if [ -n "$NODE_BIN" ] && "$NODE_BIN" -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$PROFILE/package.json" >/dev/null 2>&1; then
254
+ assert_pass "$(t 'drill.assertParses' 'after the restore package.json can be parsed')"
255
+ else
256
+ assert_fail "$(t 'drill.assertParses' 'after the restore package.json can be parsed')" 'it does not parse'
257
+ fi
258
+
259
+ if grep -q "$PACKAGE_NAME" "$PROFILE/package.json" 2>/dev/null; then
260
+ assert_pass "$(t 'drill.assertUsedSnapshot' 'the snapshot with the manifest was used (the newer decoy was skipped)')"
261
+ else
262
+ assert_fail "$(t 'drill.assertUsedSnapshot' 'the snapshot with the manifest was used (the newer decoy was skipped)')" 'the plugin is not in the bundles'
263
+ fi
264
+
265
+ if grep -q '"bundles"' "$PROFILE/package.json" 2>/dev/null; then
266
+ assert_pass "$(t 'drill.assertBundles' 'after the restore dsh.profile.bundles has content')"
267
+ else
268
+ assert_fail "$(t 'drill.assertBundles' 'after the restore dsh.profile.bundles has content')" 'there is no bundles'
269
+ fi
270
+
271
+ VERSION=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PROFILE/node_modules/$PACKAGE_NAME/package.json" 2>/dev/null | head -n 1)
272
+ if [ -n "$VERSION" ]; then
273
+ assert_pass "$(t 'drill.assertVersionReadable' 'after the restore the version of the plugin can be read')"
274
+ else
275
+ assert_fail "$(t 'drill.assertVersionReadable' 'after the restore the version of the plugin can be read')" 'there is no version'
276
+ fi
277
+ if [ "$VERSION" = '1.2.3' ]; then
278
+ assert_pass "$(t 'drill.assertVersionSame' 'the version is the one of the snapshot')"
279
+ else
280
+ assert_fail "$(t 'drill.assertVersionSame' 'the version is the one of the snapshot')" "got '$VERSION'"
281
+ fi
282
+
283
+ BROKEN=$(find "$DRILL_DIR" -maxdepth 1 -name 'profile.broken-*' 2>/dev/null | head -n 1)
284
+ if [ -n "$BROKEN" ]; then
285
+ assert_pass "$(t 'drill.assertBrokenExists' 'the broken evidence directory is there')"
286
+ if grep -q 'this is not json' "$BROKEN/package.json" 2>/dev/null; then
287
+ assert_pass "$(t 'drill.assertBrokenKeeps' 'the broken directory keeps the broken version')"
288
+ else
289
+ assert_fail "$(t 'drill.assertBrokenKeeps' 'the broken directory keeps the broken version')" 'it does not'
290
+ fi
291
+ else
292
+ assert_fail "$(t 'drill.assertBrokenExists' 'the broken evidence directory is there')" 'there is none'
293
+ fi
294
+
295
+ # --- the refusals: the exit code AND the reason in the output ---
296
+ NOT_A_PROFILE="$DRILL_DIR/not-a-profile"
297
+ mkdir -p -- "$NOT_A_PROFILE"
298
+ printf 'hello' > "$NOT_A_PROFILE/readme.txt"
299
+ set +e
300
+ OUT=$("$RESTORE" --profile "$NOT_A_PROFILE" --state-dir "$STATE" 2>&1)
301
+ CODE=$?
302
+ set -e
303
+ refused "$(t 'drill.assertNotProfileRefused' 'a directory that is not a profile is refused')" "$OUT" "$CODE" 'drill.keywords.notProfile'
304
+ if [ "$(cat "$NOT_A_PROFILE/readme.txt")" = 'hello' ]; then
305
+ assert_pass "$(t 'drill.assertNotProfileUntouched' 'the refused directory was not touched')"
306
+ else
307
+ assert_fail "$(t 'drill.assertNotProfileUntouched' 'the refused directory was not touched')" 'it changed'
308
+ fi
309
+
310
+ set +e
311
+ OUT=$("$RESTORE" --profile / --state-dir "$STATE" 2>&1)
312
+ CODE=$?
313
+ set -e
314
+ refused "$(t 'drill.assertDriveRootRefused' 'the filesystem root is refused')" "$OUT" "$CODE" 'drill.keywords.driveRoot'
315
+
316
+ SYSTEM_DIR=/etc
317
+ set +e
318
+ OUT=$("$RESTORE" --profile "$SYSTEM_DIR" --state-dir "$STATE" 2>&1)
319
+ CODE=$?
320
+ set -e
321
+ refused "$(fmt "$(t 'drill.assertSystemDirRefused' 'the system directory {0} is refused')" "$SYSTEM_DIR")" "$OUT" "$CODE" 'drill.keywords.systemDir'
322
+ if [ -d "$SYSTEM_DIR" ]; then
323
+ assert_pass "$(fmt "$(t 'drill.assertSystemDirIntact' 'the system directory {0} is untouched')" "$SYSTEM_DIR")"
324
+ else
325
+ assert_fail "$(fmt "$(t 'drill.assertSystemDirIntact' 'the system directory {0} is untouched')" "$SYSTEM_DIR")" 'it is gone'
326
+ fi
327
+
328
+ MISSING="$DRILL_DIR/does-not-exist"
329
+ set +e
330
+ OUT=$("$RESTORE" --profile "$MISSING" --state-dir "$STATE" 2>&1)
331
+ CODE=$?
332
+ set -e
333
+ refused "$(t 'drill.assertMissingDirRefused' 'a target profile directory that does not exist is refused')" "$OUT" "$CODE" 'drill.keywords.missingDir'
334
+ if [ ! -e "$MISSING" ]; then
335
+ assert_pass "$(t 'drill.assertMissingDirNotCreated' 'the missing directory was not created')"
336
+ else
337
+ assert_fail "$(t 'drill.assertMissingDirNotCreated' 'the missing directory was not created')" 'it is there'
338
+ fi
339
+
340
+ # --- "the profile says one thing, the disk another": the restore must fail ---
341
+ LYING_PROFILE="$DRILL_DIR/lying"
342
+ LYING_SNAPSHOT="$BACKUPS/20260303-000000"
343
+ mkdir -p -- "$LYING_PROFILE" "$LYING_SNAPSHOT/config"
344
+ cat > "$LYING_PROFILE/package.json" <<'JSON'
345
+ {
346
+ "name": "dsh-profile",
347
+ "dsh": {
348
+ "profile": {
349
+ "bundles": [
350
+ "dsh-autorestart"
351
+ ]
352
+ }
353
+ }
354
+ }
355
+ JSON
356
+ cp "$LYING_PROFILE/package.json" "$LYING_SNAPSHOT/config/package.json"
357
+ cat > "$LYING_SNAPSHOT/manifest.json" <<'JSON'
358
+ {
359
+ "at": 1772668800000,
360
+ "atLocal": "2026-03-03T00:00:00",
361
+ "includePlugins": false
362
+ }
363
+ JSON
364
+ set +e
365
+ OUT=$("$RESTORE" --profile "$LYING_PROFILE" --snapshot "$LYING_SNAPSHOT" --state-dir "$STATE" 2>&1)
366
+ CODE=$?
367
+ set -e
368
+ if [ "$CODE" -ne 0 ]; then
369
+ assert_pass "$(t 'drill.assertLyingRefused' 'the profile declares the plugin but does not have it -> the restore must fail')"
370
+ else
371
+ assert_fail "$(t 'drill.assertLyingRefused' 'the profile declares the plugin but does not have it -> the restore must fail')" 'it exited with 0'
372
+ fi
373
+ LYING_BROKEN=$(find "$DRILL_DIR" -maxdepth 1 -name 'lying.broken-*' 2>/dev/null | head -n 1)
374
+ if [ -n "$LYING_BROKEN" ]; then
375
+ assert_pass "$(t 'drill.assertLyingBrokenHint' 'the failing restore says the broken directory is still there')"
376
+ else
377
+ assert_fail "$(t 'drill.assertLyingBrokenHint' 'the failing restore says the broken directory is still there')" 'there is none'
378
+ fi
379
+
380
+ # --- the scripts are pure ASCII ---
381
+ for script in "$RESTORE" "$DISABLE"; do
382
+ if [ ! -f "$script" ]; then
383
+ continue
384
+ fi
385
+ if LC_ALL=C grep -q '[^ -~]' "$script"; then
386
+ assert_fail "$(fmt 'the script {0} is pure ASCII' "$(basename "$script")")" 'it holds a non-ASCII byte'
387
+ else
388
+ assert_pass "$(fmt 'the script {0} is pure ASCII' "$(basename "$script")")"
389
+ fi
390
+ done
391
+
392
+ # --- the emergency switch ---
393
+ if [ -f "$DISABLE" ]; then
394
+ EM_OUT=$("$DISABLE" --package "$PROFILE/package.json" --action disable 2>&1) || true
395
+ if grep -q "$PACKAGE_NAME" "$PROFILE/package.json" 2>/dev/null; then
396
+ assert_fail 'the emergency switch takes the plugin out of the bundles' 'it is still there'
397
+ else
398
+ assert_pass 'the emergency switch takes the plugin out of the bundles'
399
+ fi
400
+ if ls "$PROFILE"/package.json.bak-emergency-* >/dev/null 2>&1; then
401
+ assert_pass 'the emergency switch leaves a backup behind'
402
+ else
403
+ assert_fail 'the emergency switch leaves a backup behind' 'there is none'
404
+ fi
405
+ fi
406
+
407
+ # --- only the temp directory was touched ---
408
+ if [ -n "$(find "$DRILL_DIR" -maxdepth 1 -name 'profile.broken-*' 2>/dev/null)" ]; then
409
+ assert_pass "$(t 'drill.assertOnlyTemp' 'the drill only touched its own temporary directory')"
410
+ else
411
+ assert_fail "$(t 'drill.assertOnlyTemp' 'the drill only touched its own temporary directory')" 'nothing was touched'
412
+ fi
413
+
414
+ # --- cleanup ---
415
+ rm -rf -- "$DRILL_DIR"
416
+ if [ -e "$DRILL_DIR" ]; then
417
+ assert_fail "$(t 'drill.notCleaned' 'the temporary directory was not cleaned up, please delete it yourself: {0}')" "$DRILL_DIR"
418
+ else
419
+ assert_pass "$(t 'drill.cleaned' 'the temporary directory is clean.')"
420
+ fi
421
+
422
+ if [ "$FAILED" -eq 0 ]; then
423
+ say "$(t 'drill.passed' 'the drill passed: broke it on purpose -> restored -> checked -> cleaned up.')"
424
+ exit 0
425
+ fi
426
+ say "$(fmt "$(t 'drill.failed' 'the drill failed: {0} assertions did not pass.')" "$FAILED")"
427
+ exit 1