frugal-iot-server 2.0.1 → 2.0.2

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.
@@ -10,10 +10,40 @@
10
10
  #
11
11
  # Paste the whole output into a bug report. Passwords are deliberately not printed: the password
12
12
  # file is reported by account name only, and organization configs by name only.
13
-
13
+ #
14
+ # QUIET MODE says nothing at all unless something is wrong, which is what makes it usable as a
15
+ # periodic check rather than only as something to read when you already have a problem:
16
+ # npx --no frugal-iot-diagnostic quiet # positional, see below
17
+ # zsh diagnostic.zsh -q
18
+ #
19
+ # The word "quiet" with no dash is not a typo. npm parses the whole command line itself and passes
20
+ # only the values through, so a flag never reaches a script run by npx - and "-q" is worse than
21
+ # most, because it is npm's OWN abbreviation for --quiet, so npm consumes it and goes quiet itself.
22
+ # "npx --no frugal-iot-diagnostic -- --quiet" also works, if you would rather remember that.
23
+ #
24
+ # Exit status, either mode:
25
+ # 0 nothing found
26
+ # 1 problems found - they are listed, and are the only thing a quiet run prints
27
+ #
14
28
  # No "set -e": a failing check should report and carry on, not stop the script.
15
29
  setopt no_unset 2>/dev/null
16
30
 
31
+ QUIET=0
32
+ for arg in "$@"; do
33
+ case "$arg" in
34
+ -q|--quiet|quiet) QUIET=1 ;;
35
+ -h|--help|help) sed -n '3,28p' "$0" | sed -e 's/^#$//' -e 's/^# //'; exit 0 ;;
36
+ *) print -u2 "Unknown argument: $arg (try \"quiet\", or \"help\")"; exit 2 ;;
37
+ esac
38
+ done
39
+
40
+ # Keep the real stdout on fd 3 for the summary, which prints in both modes. Under -q everything
41
+ # else - including whatever the commands being run write - goes nowhere, so a quiet run that
42
+ # prints anything is a quiet run that found something. stderr goes too: the noise it carries is
43
+ # things like sudo declining, which the checks below already turn into findings of their own.
44
+ exec 3>&1
45
+ (( QUIET )) && exec 1>/dev/null 2>/dev/null
46
+
17
47
  section() { print -r -- ""; print -r -- "===== $* ====="; }
18
48
  item() { print -r -- " $*"; }
19
49
  have() { command -v "$1" >/dev/null 2>&1; }
@@ -115,9 +145,33 @@ for f in /etc/mosquitto/conf.d/*(N); do
115
145
  done
116
146
  [[ -z "$(print -r -- /etc/mosquitto/conf.d/*(N))" ]] && problem "Nothing in /etc/mosquitto/conf.d - the Frugal IoT config was never copied there (step 5)"
117
147
 
148
+ # The files the broker actually reads. mosquitto's include_dir only loads names ending in .conf,
149
+ # so look at exactly those - and the last setting wins, hence "tail -1" everywhere below.
150
+ BROKER_CONF_FILES=(/etc/mosquitto/mosquitto.conf(N) /etc/mosquitto/conf.d/*.conf(N))
151
+
152
+ # Everything the broker reads, as one stream - with sudo if reading it plainly gives nothing. A
153
+ # file under /etc can be readable only by root, and an ordinary user then gets silence rather than
154
+ # an error, which is indistinguishable from "that setting is not configured". Left unguarded, the
155
+ # comparison below reported a broker as missing EVERY setting this release ships, which as a
156
+ # periodic check is worse than no check at all.
157
+ broker_conf_text() {
158
+ local t
159
+ (( ${#BROKER_CONF_FILES} )) || return 0
160
+ t=$(cat "${BROKER_CONF_FILES[@]}" 2>/dev/null) || true
161
+ if [[ -z "$t" ]] && have sudo; then
162
+ t=$(sudo -n cat "${BROKER_CONF_FILES[@]}" 2>/dev/null) || true
163
+ fi
164
+ print -r -- "$t"
165
+ }
166
+
167
+ # Where the packaged copy of a shipped file is. What is in ./extras is NOT it: frugal-iot-init
168
+ # copies extras/ in and then never overwrites it, so once a release changes one of these files the
169
+ # local copy is a fossil. Checks below read the package.
170
+ PKG_EXTRAS="node_modules/frugal-iot-server/extras"
171
+ [[ -d "$PKG_EXTRAS" ]] || PKG_EXTRAS="${0:A:h:h}/extras"
172
+
118
173
  # Which password file does the running configuration actually name?
119
- # mosquitto's include_dir only loads files ending in .conf, so look at exactly those, last one wins
120
- CONFIGURED_PWFILE=$(grep -hE '^[[:space:]]*password_file[[:space:]]' /etc/mosquitto/mosquitto.conf /etc/mosquitto/conf.d/*.conf(N) 2>/dev/null | tail -1 | awk '{print $2}')
174
+ CONFIGURED_PWFILE=$(broker_conf_text | grep -hE '^[[:space:]]*password_file[[:space:]]' | tail -1 | awk '{print $2}')
121
175
  section "Mosquitto password file"
122
176
  if [[ -n "$CONFIGURED_PWFILE" ]]; then
123
177
  item "configuration names: $CONFIGURED_PWFILE"
@@ -133,12 +187,35 @@ else
133
187
  item "no password_file line found in any mosquitto configuration"
134
188
  problem "No password_file configured - the broker would allow anonymous access or reject everything"
135
189
  fi
136
- # The copy in this installation is what step 5 tells you to put into /etc - if the two disagree,
137
- # either the copy never happened or the installed package is older than the instructions.
138
- if [[ -f extras/mosquitto.conf ]]; then
139
- item "this installation's extras/mosquitto.conf names: $(grep -hE '^[[:space:]]*password_file' extras/mosquitto.conf 2>/dev/null | awk '{print $2}')"
140
- if [[ -n "$CONFIGURED_PWFILE" ]] && ! diff -q extras/mosquitto.conf /etc/mosquitto/conf.d/frugal-iot.conf >/dev/null 2>&1; then
141
- problem "extras/mosquitto.conf here differs from /etc/mosquitto/conf.d/frugal-iot.conf - if you followed step 5 with an older installed package, copy it again after upgrading"
190
+ # What this release ships, against what the broker is actually running.
191
+ #
192
+ # This used to be "diff ./extras/mosquitto.conf /etc/mosquitto/conf.d/frugal-iot.conf", which
193
+ # reported a problem on every machine there has ever been, for three separate reasons: ./extras is
194
+ # a fossil frugal-iot-init will not overwrite, so it differs from the release for ever; the
195
+ # installed copy has the dynamic-security plugin's path substituted, so it differs from the shipped
196
+ # one by design; and "diff -q" against a frugal-iot.conf that does not exist fails too, which is
197
+ # the normal state of a broker whose configuration was assembled by hand as several small files in
198
+ # conf.d. A check that is always wrong is one you stop reading, so this asks the question that
199
+ # actually matters instead: is the broker missing a setting this release ships?
200
+ if [[ -f "${PKG_EXTRAS}/mosquitto.conf" ]]; then
201
+ item "this release's mosquitto.conf names: $(grep -hE '^[[:space:]]*password_file' "${PKG_EXTRAS}/mosquitto.conf" 2>/dev/null | awk '{print $2}')"
202
+ if (( ${#BROKER_CONF_FILES} )); then
203
+ # The plugin line is normalised: the installer substitutes a path that varies by distribution.
204
+ # Comparing settings rather than files means this works whether the configuration arrived as one
205
+ # frugal-iot.conf or as several hand-written pieces.
206
+ MISSING_CONF=$(comm -23 \
207
+ <(sed -E 's#^plugin .*#plugin -#' "${PKG_EXTRAS}/mosquitto.conf" | grep -vE '^[[:space:]]*(#|$)' | sort -u) \
208
+ <(broker_conf_text | sed -E 's#^plugin .*#plugin -#' | grep -vE '^[[:space:]]*(#|$)' | sort -u) 2>/dev/null)
209
+ # password_file and the listeners are allowed to be sited differently on an older installation,
210
+ # so a difference in those is reported without being called a fault.
211
+ MISSING_REAL=$(print -r -- "$MISSING_CONF" | grep -vE '^[[:space:]]*(password_file|listener|protocol)[[:space:]]' || true)
212
+ if [[ -n "$MISSING_CONF" ]]; then
213
+ item "settings this release ships that the broker does not have:"
214
+ print -r -- "$MISSING_CONF" | while IFS= read -r l; do [[ -n "$l" ]] && item " $l"; done
215
+ else
216
+ item "the broker has every setting this release ships"
217
+ fi
218
+ [[ -n "$MISSING_REAL" ]] && problem "The broker is missing $(print -r -- "$MISSING_REAL" | grep -c .) setting(s) this release ships - see \"Mosquitto password file\" above. Re-run install-pi.sh, or add them by hand if this broker's config.d was written by hand."
142
219
  fi
143
220
  fi
144
221
  item "candidate locations, whether or not configured:"
@@ -270,15 +347,10 @@ section "Broker access control"
270
347
  # An acl_file confines each account to its own organization's topics; without one, every account
271
348
  # that can log in reaches every topic on the broker. Reported here because a broker installed before
272
349
  # the ACL shipped will not have one, and nothing else says so.
273
- BROKER_CONF_FILES=(/etc/mosquitto/mosquitto.conf /etc/mosquitto/conf.d/*.conf(N))
274
350
  BROKER_SETTING=""
275
351
  broker_setting() { # name -> value from the broker's own configuration, sudo only if needed
276
- local v
277
- v=$(grep -hE "^[[:space:]]*$1[[:space:]]" "${BROKER_CONF_FILES[@]}" 2>/dev/null | tail -1 | awk '{print $2}') || true
278
- if [[ -z "$v" ]] && have sudo; then
279
- v=$(sudo -n grep -hE "^[[:space:]]*$1[[:space:]]" "${BROKER_CONF_FILES[@]}" 2>/dev/null | tail -1 | awk '{print $2}') || true
280
- fi
281
- BROKER_SETTING="$v"
352
+ # broker_conf_text carries the sudo fallback this used to spell out for itself
353
+ BROKER_SETTING=$(broker_conf_text | grep -hE "^[[:space:]]*$1[[:space:]]" | tail -1 | awk '{print $2}') || true
282
354
  }
283
355
  broker_setting acl_file; ACLFILE="$BROKER_SETTING"
284
356
  if [[ -z "$ACLFILE" ]]; then
@@ -317,11 +389,45 @@ else
317
389
  fi
318
390
  fi
319
391
  # Can the server actually drive it? That needs the admin credential frugal-iot-init recorded.
320
- DYNUSER=$(sed -n 's/^dynsec_admin_user:[[:space:]]*//p' config.d/secrets.yaml 2>/dev/null | tr -d '"'"'"'' | head -1)
321
- DYNPW=$(sed -n 's/^dynsec_admin_password:[[:space:]]*//p' config.d/secrets.yaml 2>/dev/null | tr -d '"'"'"'' | head -1)
392
+ #
393
+ # Read with a sudo fallback, and report the file's ownership, because the two ways this goes wrong
394
+ # look identical from here: the setting really is absent, or the file belongs to somebody else and
395
+ # an ordinary reader gets silence. The second is the one that matters, since the SERVER is an
396
+ # ordinary reader too - it runs as the account in the unit file, and a secrets.yaml it cannot read
397
+ # leaves it with no credential to hand a browser, whatever is written inside.
398
+ item " secrets file: config.d/secrets.yaml $(fileinfo config.d/secrets.yaml)"
399
+ SERVICE_USER=$(sed -n 's/^User=//p' /etc/systemd/system/frugaliot.service 2>/dev/null | head -1)
400
+ [[ -z "$SERVICE_USER" ]] && SERVICE_USER=$(sudo -n sed -n 's/^User=//p' /etc/systemd/system/frugaliot.service 2>/dev/null | head -1)
401
+ secret_value() { # name -> its value, with sudo if reading it plainly gives nothing
402
+ local v
403
+ v=$(sed -n "s/^$1:[[:space:]]*//p" config.d/secrets.yaml 2>/dev/null | tr -d "\"'" | head -1)
404
+ if [[ -z "$v" ]] && have sudo; then
405
+ v=$(sudo -n sed -n "s/^$1:[[:space:]]*//p" config.d/secrets.yaml 2>/dev/null | tr -d "\"'" | head -1)
406
+ fi
407
+ print -r -- "$v"
408
+ }
409
+ if [[ -f config.d/secrets.yaml && -n "$SERVICE_USER" ]] && have stat; then
410
+ SECRETS_OWNER=$(stat -c '%U' config.d/secrets.yaml 2>/dev/null || true)
411
+ if [[ -n "$SECRETS_OWNER" && "$SECRETS_OWNER" != "$SERVICE_USER" && "$SECRETS_OWNER" != root ]]; then
412
+ problem "config.d/secrets.yaml belongs to ${SECRETS_OWNER} but the server runs as ${SERVICE_USER}, so the server cannot read it. Fix with: sudo chown ${SERVICE_USER} config.d/secrets.yaml"
413
+ elif [[ "$SECRETS_OWNER" == root && "$SERVICE_USER" != root ]]; then
414
+ problem "config.d/secrets.yaml belongs to root but the server runs as ${SERVICE_USER}, so the server reads nothing from it - every login is told live data is unavailable. Fix with: sudo chown ${SERVICE_USER} config.d/secrets.yaml && sudo systemctl restart frugaliot"
415
+ fi
416
+ fi
417
+ # Without this a login gets no broker credential at all, whatever else is right - which shows up
418
+ # in the dashboard as "No broker credential for this login".
419
+ USERSECRET=$(secret_value user_secret)
420
+ if [[ -z "$USERSECRET" ]]; then
421
+ item " user_secret: MISSING - no login can be given a broker credential"
422
+ problem "config.d/secrets.yaml has no user_secret, so every login is told \"No broker credential for this login\". The server writes one at startup; if it cannot, it says so in its log."
423
+ else
424
+ item " user_secret: present (each login's broker password is derived from it)"
425
+ fi
426
+ DYNUSER=$(secret_value dynsec_admin_user)
427
+ DYNPW=$(secret_value dynsec_admin_password)
322
428
  if [[ -z "$DYNUSER" || -z "$DYNPW" ]]; then
323
- item " admin credential: not in config.d/secrets.yaml - the server cannot create accounts"
324
- problem "dynamic security is enabled but config.d/secrets.yaml has no dynsec_admin_user/password"
429
+ item " admin credential: not readable in config.d/secrets.yaml - the server cannot create accounts"
430
+ problem "dynamic security is enabled but config.d/secrets.yaml has no readable dynsec_admin_user/password, so no login gets a broker credential"
325
431
  elif have mosquitto_ctrl; then
326
432
  if DYNOUT=$(mosquitto_ctrl -h localhost -u "$DYNUSER" -P "$DYNPW" dynsec listClients 2>&1 | grep -viE 'without encryption|visible on the network'); then
327
433
  item " admin credential works ($(print -r -- $DYNOUT | tr '\n' ' ' | cut -c1-60))"
@@ -515,8 +621,11 @@ if [[ -f config.d/server.yaml ]]; then
515
621
  MORGANSET=$(sed -n 's/^morgan:[[:space:]]*//p' config.d/server.yaml 2>/dev/null | head -1)
516
622
  item "server morgan: ${MORGANSET:-not set, so on - a line logged per HTTP request}"
517
623
  fi
518
- if [[ -f /etc/mosquitto/conf.d/frugal-iot.conf ]]; then
519
- if grep -qE '^[[:space:]]*connection_messages[[:space:]]+false' /etc/mosquitto/conf.d/frugal-iot.conf 2>/dev/null; then
624
+ # Across every file the broker reads, not just frugal-iot.conf: a configuration assembled by hand
625
+ # puts this wherever it likes, and looking in one file meant a broker that logs every connect and
626
+ # disconnect was never reported as doing so.
627
+ if (( ${#BROKER_CONF_FILES} )); then
628
+ if broker_conf_text | grep -qhE '^[[:space:]]*connection_messages[[:space:]]+false'; then
520
629
  item "mosquitto connections: not logged"
521
630
  else
522
631
  item "mosquitto connections: logged - every connect and disconnect is a write"
@@ -539,10 +648,23 @@ if [[ -d data ]]; then
539
648
  [[ -n "$OLDEST" ]] && item " oldest day: ${OLDEST}"
540
649
  fi
541
650
 
542
- section "Summary"
651
+ # The summary prints to fd 3, which is the real stdout whether or not the rest was silenced. A
652
+ # quiet run with nothing to report prints nothing at all, which is the point of it.
543
653
  if (( ${#PROBLEMS} == 0 )); then
544
- item "No problems detected by these checks."
545
- else
546
- for p in $PROBLEMS; do item "PROBLEM: $p"; done
654
+ if (( ! QUIET )); then
655
+ print -r -- "" >&3
656
+ print -r -- "===== Summary =====" >&3
657
+ print -r -- " No problems detected by these checks." >&3
658
+ print -r -- "" >&3
659
+ fi
660
+ exit 0
661
+ fi
662
+ print -r -- "" >&3
663
+ print -r -- "===== Summary: ${#PROBLEMS} problem(s) =====" >&3
664
+ for p in $PROBLEMS; do print -r -- " PROBLEM: $p" >&3; done
665
+ if (( QUIET )); then
666
+ print -r -- "" >&3
667
+ print -r -- " Run it again without \"quiet\" for the whole report." >&3
547
668
  fi
548
- print -r -- ""
669
+ print -r -- "" >&3
670
+ exit 1
package/scripts/init.zsh CHANGED
@@ -63,10 +63,16 @@ done
63
63
  for f in "${PKG}"/config.d/schema/*.yaml(N); do
64
64
  copy_if_missing "$f" "./config.d/schema/${f:t}" compare
65
65
  done
66
- # Copied so that mosquitto.conf and frugaliot.service can be edited and installed from here. Worth
67
- # comparing: these get copied on somewhere else (/etc/...), where an old version lingers unnoticed.
66
+ # Copied so that mosquitto.conf and frugaliot.service can be there to read and install from.
67
+ #
68
+ # NOT compared against the packaged copy. This copy is never overwritten - that is the whole point
69
+ # of copy_if_missing - so once a release changes one of these it differs for ever, and says so at
70
+ # every upgrade whether or not anything is actually out of date. A warning that is always wrong is
71
+ # one you learn to skip past, and then miss on the day it is right. What matters is not whether
72
+ # this copy is current but whether the file actually in use is, so that is what is checked, below,
73
+ # where it really lives.
68
74
  for f in "${PKG}"/extras/*(N); do
69
- copy_if_missing "$f" "./extras/${f:t}" compare
75
+ copy_if_missing "$f" "./extras/${f:t}"
70
76
  done
71
77
 
72
78
  # ---- 1b. Secrets this server generates for itself ----
@@ -182,6 +188,74 @@ else
182
188
  echo " kept ${DB} permissions.project (already present)"
183
189
  fi
184
190
 
191
+ # ---- 4. Is what was installed FROM extras/ still current? ----
192
+ # The files in extras/ are templates for files that live somewhere else - the broker's
193
+ # configuration, its ACL, the systemd unit. Whether this directory's copy matches the release says
194
+ # nothing about any of them, so the question is asked where the file actually is.
195
+ #
196
+ # Each of these is checked in the way that file is allowed to differ, because two of them are
197
+ # *supposed* to differ and a plain cmp would cry wolf at every upgrade:
198
+ # - mosquitto.conf carries a placeholder for the dynamic security plugin, which the installer
199
+ # replaces with a path that varies by distribution.
200
+ # - aclfile gets a stanza appended per bridge by addbridge-prod, so the installed file is
201
+ # expected to be a superset - only a line this release ships and it lacks is a finding.
202
+ STALE=() # installed, and older than this release
203
+ UNINSTALLED=() # nothing at the usual place, so there was nothing to compare
204
+ UNREADABLE=() # there, but not readable without sudo
205
+
206
+ # check_installed <packaged file> <installed path> <exact|plugin|subset>
207
+ check_installed() {
208
+ local from=$1 to=$2 how=$3 a b missing
209
+ [[ -e "$from" ]] || return 0
210
+ if [[ ! -e "$to" ]]; then
211
+ UNINSTALLED+=("${to}|${from}")
212
+ return 0
213
+ fi
214
+ if [[ ! -r "$to" ]]; then
215
+ UNREADABLE+=("$to")
216
+ return 0
217
+ fi
218
+ # Both sides through the same filter, so only a real difference survives. Note the "#"
219
+ # delimiter: an alternation inside an "s|...|" would end the expression at its first branch, and
220
+ # the "|| true" below would then hide the error and report the two files as identical.
221
+ local norm
222
+ case "$how" in
223
+ plugin) norm='s#^plugin .*#plugin -#' ;;
224
+ # The installer rewrites User, WorkingDirectory and ExecStart for whoever runs it and wherever
225
+ # the server went, so those three are normalised - comparing them exactly would report every
226
+ # installation that is not user "pi" in /home/pi/frugal-iot as out of date at every upgrade.
227
+ service) norm='s#^(User|WorkingDirectory|ExecStart)=.*#\1=-#' ;;
228
+ *) norm='' ;;
229
+ esac
230
+
231
+ case "$how" in
232
+ plugin|service)
233
+ a=$(sed -E "$norm" "$from" | grep -vE '^[[:space:]]*(#|$)' | sort || true)
234
+ b=$(sed -E "$norm" "$to" | grep -vE '^[[:space:]]*(#|$)' | sort || true)
235
+ # Neither of these files is ever empty once the comments are gone. Both blank means the
236
+ # filter failed rather than that they agree, and reporting "current" on that would be the
237
+ # worst answer of the three.
238
+ if [[ -z "$a" || -z "$b" ]]; then
239
+ UNREADABLE+=("$to")
240
+ elif [[ "$a" != "$b" ]]; then
241
+ STALE+=("${to}|${from}")
242
+ fi
243
+ ;;
244
+ subset)
245
+ missing=$(comm -23 <(grep -vE '^[[:space:]]*(#|$)' "$from" | sort -u || true) \
246
+ <(grep -vE '^[[:space:]]*(#|$)' "$to" | sort -u || true) || true)
247
+ [[ -z "$missing" ]] || STALE+=("${to}|${from}")
248
+ ;;
249
+ *)
250
+ cmp -s "$from" "$to" || STALE+=("${to}|${from}")
251
+ ;;
252
+ esac
253
+ }
254
+
255
+ check_installed "${PKG}/extras/mosquitto.conf" /etc/mosquitto/conf.d/frugal-iot.conf plugin
256
+ check_installed "${PKG}/extras/aclfile" /etc/mosquitto/aclfile subset
257
+ check_installed "${PKG}/extras/frugaliot.service" /etc/systemd/system/frugaliot.service service
258
+
185
259
  if (( ${#DIFFER_TO} )); then
186
260
  echo
187
261
  echo "These files were left as you have them, but this release ships a different version:"
@@ -189,9 +263,35 @@ if (( ${#DIFFER_TO} )); then
189
263
  echo " ${DIFFER_TO[$i]}"
190
264
  echo " compare with: diff ${DIFFER_TO[$i]} ${DIFFER_FROM[$i]}"
191
265
  done
192
- echo "Usually that just means you edited it, and there is nothing to do. But a release can also"
193
- echo "change one of these files - and anything installed elsewhere from it, such as"
194
- echo "/etc/mosquitto/conf.d/frugal-iot.conf, keeps the old content until you copy it again."
266
+ echo "The schema describes the sensor types the software understands. If you added one of your"
267
+ echo "own that is exactly as it should be; if you did not, this release changed it and the"
268
+ echo "difference is worth reading."
269
+ fi
270
+
271
+ if (( ${#STALE} )); then
272
+ echo
273
+ echo "Installed elsewhere from extras/, and older than this release:"
274
+ for e in $STALE; do
275
+ echo " ${e%%|*}"
276
+ echo " compare with: sudo diff ${e%%|*} ${e#*|}"
277
+ done
278
+ echo "Re-running the installer brings these up to date and is the safe way to do it - it"
279
+ echo "substitutes the plugin path and will not discard ACL rules added per bridge:"
280
+ echo " bash node_modules/frugal-iot-server/scripts/install-pi.sh"
281
+ fi
282
+
283
+ if (( ${#UNINSTALLED} )); then
284
+ echo
285
+ echo "Nothing at the usual place, so these were not compared:"
286
+ for e in $UNINSTALLED; do echo " ${e%%|*} (would come from extras/${${e#*|}:t})"; done
287
+ echo "Either install-pi.sh has not been run here, or this machine's broker configuration was put"
288
+ echo "together by hand under another name - several small files in /etc/mosquitto/conf.d is a"
289
+ echo "normal way to have done it. Nothing is out of date; there is simply nothing to compare."
290
+ fi
291
+
292
+ if (( ${#UNREADABLE} )); then
293
+ echo
294
+ echo "There, but not readable as $(id -un), so not compared: ${(j:, :)UNREADABLE}"
195
295
  fi
196
296
 
197
297
  echo