Files
Your Name 35eb90a58b
gates / consistency-and-conventions (push) Successful in 22s
feat: share clients — t=type manual mountpoint (existing path without create)
User report: smb-client/nfs-client mountpoint step could only auto-suggest
candidates, or create a fresh dir behind a hidden 'n=new' key — no way to
type an arbitrary existing path as the mountpoint, so the manual option
was effectively invisible (candidates from /media etc. always populated
the picker, hiding the typing path entirely). Designer framing: capability
gap + discoverability gap; backend already handled arbitrary paths (CLI
cmd_mount + ensure_mountpoint), only the interactive menu blocked it.

Change (identical in bin/pos-share-smb-client and bin/pos-share-nfs-client):
- pick_mountpoint hint 'n=new' -> 't=type'; key arm n -> t
- ask_new_mountpoint generalized to ask_mountpoint: an existing
  directory is now used AS-IS (no create, no confirm); a non-existent
  path keeps the 'Create mountpoint?' confirm + sudo mkdir flow; existing
  non-directory rejected ('has a file there'); shape checks and system-path
  refusal unchanged; stream contract (display->stderr, path->stdout) kept
- menu_ask_mountpoint empty-candidate fall-through now routes through the
  same ask_mountpoint validator (single source of truth)

Docs: DOC/howto/share.md NFS+SMB mountpoint sections updated from n=new to
t=type and describe existing-path-without-create behavior.

Scoped to the two client files + howto doc; persistence/automount units,
unmount/remove flows, cmd_* CLIs, share_folder_candidates, and
lib/menu-lib.sh untouched.

Verified: 9-scenario smoke matrix x2 files (~19 assertions each: existing
dir as-is, new-dir confirm+create, decline, relative/trailing-slash/system/
empty rejections, non-dir reject, mkdir-fail), make gen idempotent, make
check OK, make lint 0 FAIL/0 WARN, make test 17 files / 299 checks green,
bash -n clean, git diff --check clean. Designer ACCEPT framing+spec;
Reviewer ACCEPT after doc fix.
2026-09-07 08:08:02 -04:00

774 lines
29 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# POS: share smb-client — Mount SMB/CIFS shares (ephemeral or persistent systemd mount units)
# POS_SUBCMDS: mount unmount list persist unpersist menu
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
source "$(dirname "$0")/../lib/share-lib.sh" 2>/dev/null || source "$(dirname "$0")/share-lib.sh"
SMB_CREDS_DIR="${SMB_CREDS_DIR:-/etc/samba/credentials}"
UNIT_DIR="${UNIT_DIR:-/etc/systemd/system}"
SMB_PORT="${SMB_PORT:-445}"
SMB_CONF="${SMB_CONF:-/etc/samba/smb.conf}"
command -v mount.cifs &>/dev/null || err "mount.cifs not found (install cifs-utils)"
command -v systemd-escape &>/dev/null || err "systemd-escape not found"
usage() {
cat <<EOF
Usage: pos share smb-client <command> [args]
Mount and manage SMB/CIFS shares from remote servers (cifs-utils).
Commands:
mount <//server/share> <local-dir> [user] One-shot mount (creates local-dir if needed)
unmount <local-dir> Unmount the share (idempotent: rc 0 when
nothing is mounted)
list Show active + persistent SMB mounts
persist <//server/share> <local-dir> [user] Persistent mount via systemd .mount + .automount
units (mounts on first access — never blocks boot)
unpersist <local-dir> Stop, disable and remove the mount/automount units
Safety net before anything is mounted:
- server reachability on port 445 is probed first (fast fail with hints,
before any password is asked)
- an already-mounted target refuses a second mount instead of stacking
- mounting over a non-empty directory asks for confirmation first
- every successful mount is read-tested immediately; a mount that cannot
be listed is rolled back automatically
- mount failures are translated into targeted fixes (password, chmod o+x
traversal, wrong share name, firewall/down server) instead of raw
kernel errors
With no user, guest access is attempted. With a user, you are prompted for
the Samba password — one-shot mounts use a throwaway chmod-600 credentials
file; persistent mounts keep one at /etc/samba/credentials/.
Examples:
pos share smb-client mount //100.100.100.1/media /mnt/smb/media
pos share smb-client persist //100.100.100.1/media /mnt/smb/media bob
pos share smb-client list
pos share smb-client unmount /mnt/smb/media
EOF
exit 0
}
cmd="${1:-}"
case "$cmd" in
-h|--help) usage ;;
""|menu|mount|unmount|list|persist|unpersist) ;;
*) err "Unknown command '$cmd' (see --help)" ;;
esac
validate_share() {
case "$1" in
//*/*) ;;
//*) err "Invalid share '$1' — missing share name: expected <//server/share> (e.g. //10.0.0.5/media)" ;;
*) err "Invalid share '$1' — expected <//server/share> (e.g. //10.0.0.5/media)" ;;
esac
}
validate_dir() {
case "$1" in
/*) ;;
*) err "Mount point must be an absolute path: $1" ;;
esac
}
# Split //server/share into server + share; normalizes trailing slashes.
split_share() {
local rest="$1"
rest="${rest#//}"
SHARE_PATH="/${rest#*/}"
SERVER="${rest%%/*}"
SHARE_PATH="${SHARE_PATH%/}"
[ -n "$SERVER" ] || err "No server in share '$1'"
[ -n "${SHARE_PATH#/}" ] || err "No share name in '$1'"
}
# TCP connect probe against the server before touching passwords or mounts.
probe_server() {
local host="$1"
# host/port are positional args, never interpolated into the command
# source — a hostile share/address string stays a literal argument.
if timeout 3 bash -c 'exec 3<>/dev/tcp/$1/$2' _ "$host" "$SMB_PORT" 2>/dev/null; then
return 0
fi
err "Server ${host} not reachable on port ${SMB_PORT} (SMB) — nothing was mounted.
Checks:
- is the address right? ping ${host}
- is the server up and sharing? on the server: pos share smb-server status
- port filtered? from here: pos network checkport ${host}:${SMB_PORT}
- Tailscale host? make sure 'tailscale status' lists it (up + recent)"
}
# Source currently cifs-mounted at $1 (empty string when none).
mounted_src() {
findmnt -rnf -t cifs -o SOURCE,TARGET 2>/dev/null | awk -v t="$1" '$2 == t {print $1; exit}'
}
# Persisted Type=cifs unit declaring Where=<path>? An idle automount never
# appears in findmnt, so this is the only way to tell "idle automount" apart
# from "nothing configured". Sets PERSISTED_UNIT to the unit file basename.
persisted_smb_at() { # <path> — rc 0 persisted · rc 1 not persisted
local uf
PERSISTED_UNIT=""
for uf in "${UNIT_DIR}"/*.mount; do
[ -f "$uf" ] || continue
grep -q '^Type=cifs$' "$uf" || continue
if [ "$(sed -n 's/^Where=//p' "$uf")" = "$1" ]; then
PERSISTED_UNIT="$(basename "$uf")"
return 0
fi
done
return 1
}
# Create the mountpoint if needed; refuse to silently shadow a non-empty dir.
ensure_mountpoint() {
local where="$1"
if [ -e "$where" ] && [ ! -d "$where" ]; then
err "$where exists and is not a directory — pick another mount point"
fi
if [ -d "$where" ] && [ -n "$(ls -A "$where" 2>/dev/null)" ]; then
warn "$where is not empty — its contents are hidden while the share is mounted."
confirm "Mount over it anyway?" n || err "Aborted — nothing was mounted"
elif [ ! -d "$where" ]; then
run sudo mkdir -p "$where"
log "Created mount point $where"
fi
}
# Prompt for the Samba password (no echo) and write a fresh chmod-600
# credentials file; prints its path, caller removes it. Test seam:
# SMB_PW_FILE redirects the password read (never advertised).
make_creds() {
local user="$1" pw tmp pw_in="${SMB_PW_FILE:-/dev/tty}"
read -rsp "Samba password for $user: " pw <"$pw_in" || true
echo >&2
[ -n "$pw" ] || err "empty password"
tmp="$(mktemp)"
chmod 600 "$tmp"
printf 'username=%s\npassword=%s\n' "$user" "$pw" > "$tmp"
printf '%s' "$tmp"
}
mount_opts() {
printf 'uid=%s,gid=%s' "$(id -u)" "$(id -g)"
}
# Translate a failed mount attempt into the fix the human actually needs.
diagnose_mount_failure() {
local msg="$1"
case "$msg" in
*LOGON_FAILURE*|*WRONG_PASSWORD*)
err "Server rejected the login (wrong user/password).
- retry with the correct Samba user: pos share smb-client mount $what $where <user>
- does the account exist at all? on the server: pos share smb-server status" ;;
*ACCESS_DENIED*|*"error(13)"*|*"Permission denied"*|*STATUS_CANNOT_DELETE*)
err "Access denied by the server (login worked, share said no).
On the server side, in order:
1. traversal: every parent dir of the share needs o+x — the server tool
prints the exact dirs when you re-share; quick fix:
chmod o+x <parent-dir>
2. user missing from the Samba passdb: pos share smb-server adduser <user>
3. share restricted: 'valid users' must contain your user (pos share smb-server list)" ;;
*BAD_NETWORK_NAME*)
err "The server has no share named '${SHARE_PATH#/}'.
- typo in the share name? on the server: pos share smb-server list
- correct syntax: //$SERVER/<share>" ;;
*CONNECTION_REFUSED*|*"error(111)"*)
err "${SERVER} refused the connection — smbd is down or a firewall rejects port ${SMB_PORT}.
on the server: pos share smb-server enable / sudo ufw allow Samba" ;;
*HOST_UNREACHABLE*|*"error(101)"*|*"error(113)"*|*"No route to host"*|*"Network is unreachable"*)
err "${SERVER} is unreachable (routing/host down).
- Tailscale host? 'tailscale status' must list it as online
- probe from here: pos network checkport ${SERVER}:${SMB_PORT}" ;;
*IO_TIMEOUT*|*"Host is down"*|*"error(112)"*|*"timed out"*)
err "${SERVER} accepted TCP but never answered the SMB handshake (usually a dropping firewall).
on the server: sudo ufw allow Samba / pos share smb-server status" ;;
*)
err "Mount failed:
$msg
More causes + fixes: DOC/howto/share.md (SMB troubleshooting)" ;;
esac
}
# Mount attempt with captured stderr (so failures can be diagnosed).
try_mount() {
local what="$1" where="$2" opts="$3"
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) mount -t cifs $what $where -o $opts"
return 0
fi
MOUNT_ERR="$(sudo mount -t cifs "$what" "$where" -o "$opts" 2>&1)" || return 1
}
# Post-mount proof: the mount must actually list. A mount that cannot be
# read is worse than no mount — roll it back.
read_test_or_rollback() {
local where="$1" what="$2"
if [ "${DRY_RUN:-0}" -eq 1 ]; then return 0; fi
if timeout 5 ls -A "$where" >/dev/null 2>&1; then
return 0
fi
warn "Mounted, but $where cannot be listed — rolling back."
sudo umount "$where" 2>/dev/null || sudo umount -l "$where" 2>/dev/null || true
err "Share '${what}' mounted but reading it failed, so it was unmounted again.
Login works but file permissions don't — on the server:
- ls -la <shared-path> as root: do others/the owner have r-x?
- the connecting user must have unix-level access to the folder itself
Details: DOC/howto/share.md (SMB troubleshooting)"
}
cmd_mount() { # <//server/share> <local-dir> [user]
local what="$1" where="$2" user="${3:-}"
local src creds opts
validate_share "$what"
validate_dir "$where"
split_share "$what"
probe_server "$SERVER"
if src="$(mounted_src "$where")"; [ -n "$src" ]; then
err "$where is already mounted (source: ${src:-unknown}) — nothing done.
Unmount it first: pos share smb-client unmount $where"
fi
ensure_mountpoint "$where"
if [ -n "$user" ]; then
creds="$(make_creds "$user")"
trap 'rm -f "$creds"' EXIT
opts="credentials=$creds,$(mount_opts)"
try_mount "$what" "$where" "$opts" || {
rm -f "$creds"
trap - EXIT
diagnose_mount_failure "${MOUNT_ERR:-}"
}
rm -f "$creds"
trap - EXIT
else
warn "No user — attempting guest mount (works only if the server allows guest access)"
try_mount "$what" "$where" "guest,$(mount_opts)" || diagnose_mount_failure "${MOUNT_ERR:-}"
fi
read_test_or_rollback "$where" "$what"
log "Mounted $what at $where (read test passed)"
notify_send "SMB mounted: $what$where"
}
cmd_unmount() { # <local-dir>
local where="$1" src out
validate_dir "$where"
src="$(mounted_src "$where")"
if [ -z "$src" ]; then
# Idempotent no-op — rc 0 whether or not anything is configured.
if persisted_smb_at "$where"; then
warn "$where is a persisted automount — not currently mounted."
log "Access it once (e.g.: ls $where) to auto-mount it, or remove the persistence first: menu option 5 (pos share smb-client unpersist $where)"
else
log "Nothing mounted at $where"
fi
return 0
fi
if ! out="$(sudo umount "$where" 2>&1)"; then
if grep -qE "busy|in use" <<<"$out"; then
warn "$where is busy (${src})"
confirm "Force a lazy unmount now?" y && { run sudo umount -l "$where"; log "Lazy-unmounted $where"; return 0; }
err "Still mounted. Find the blocker: sudo lsof +D $where (or fuser -vm $where)"
fi
err "Unmount failed: $out"
fi
log "Unmounted $where"
notify_send "SMB unmounted: $where"
}
cmd_list() {
local found=0 active_mounts unit what where
local -a persistent=()
active_mounts="$(findmnt -t cifs 2>/dev/null || true)"
if [ -n "$active_mounts" ]; then
printf 'Active mounts:\n'
printf '%s\n' "$active_mounts"
found=1
fi
for unit in "${UNIT_DIR}"/*.mount; do
[ -e "$unit" ] || continue
grep -q '^Type=cifs$' "$unit" || continue
what="$(sed -n 's/^What=//p' "$unit")"
where="$(sed -n 's/^Where=//p' "$unit")"
[ -n "$what" ] && [ -n "$where" ] || continue
persistent+=("$where|$what")
done
if [ "${#persistent[@]}" -gt 0 ]; then
found=1
printf 'Persistent (automount):\n'
for entry in "${persistent[@]}"; do
printf ' %-44s %s\n' "${entry%%|*}" "${entry#*|}"
done
fi
[ "$found" -eq 1 ] || echo "No SMB mounts"
}
cmd_persist() { # <//server/share> <local-dir> [user]
local what="$1" where="$2" user="${3:-}"
local src unit auto_unit unit_file auto_file opts creds_file tmp out verified
validate_share "$what"
validate_dir "$where"
split_share "$what"
probe_server "$SERVER"
if src="$(mounted_src "$where")"; [ -n "$src" ]; then
err "$where is already mounted (source: ${src:-unknown}) — nothing done.
Active mount + automount units conflict; unmount first:
pos share smb-client unmount $where"
fi
unit="$(systemd-escape --path --suffix=mount "$where")"
auto_unit="${unit%.mount}.automount"
unit_file="${UNIT_DIR}/${unit}"
auto_file="${UNIT_DIR}/${auto_unit}"
if [ -e "$unit_file" ] || [ -e "$auto_file" ]; then
warn "Units for $where already exist — they will be REPLACED:"
if [ -e "$unit_file" ]; then warn " ${unit_file}"; fi
if [ -e "$auto_file" ]; then warn " ${auto_file}"; fi
confirm "Replace them?" n || err "Aborted — units left untouched"
fi
opts="$(mount_opts),_netdev,noexec"
creds_file=""
if [ -n "$user" ]; then
creds_file="$SMB_CREDS_DIR/$(basename "$where")"
sudo mkdir -p "$SMB_CREDS_DIR"
tmp="$(make_creds "$user")"
sudo install -m 600 "$tmp" "$creds_file"
rm -f "$tmp"
opts="credentials=$creds_file,$opts"
else
warn "No user — persisting a guest mount (works only if the server allows guest access)"
opts="guest,$opts"
fi
rollback_persist() {
warn "Rolling back everything this command created…"
sudo systemctl disable "$auto_unit" 2>/dev/null || true
sudo systemctl stop "$auto_unit" 2>/dev/null || true
sudo systemctl disable "$unit" 2>/dev/null || true
sudo systemctl stop "$unit" 2>/dev/null || true
sudo rm -f "$unit_file" "$auto_file"
if [ -n "$creds_file" ]; then sudo rm -f "$creds_file"; fi
sudo systemctl daemon-reload 2>/dev/null || true
}
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) write $unit_file + $auto_file (Type=cifs, Options=$opts)"
log "(dry-run) daemon-reload + enable --now $auto_unit"
exit 0
fi
cat <<UNIT | sudo tee "$unit_file" >/dev/null
[Unit]
Description=SMB mount of ${what} at ${where}
After=network-online.target
Wants=network-online.target
[Mount]
What=${what}
Where=${where}
Type=cifs
Options=${opts}
UNIT
cat <<UNIT | sudo tee "$auto_file" >/dev/null
[Unit]
Description=Automount of SMB share ${what} at ${where}
[Automount]
Where=${where}
[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
# Enable, then prove the automount actually serves the share before
# declaring victory — a broken unit here would bite months later.
if ! out="$(sudo systemctl enable --now "$auto_unit" 2>&1)"; then
rollback_persist
err "Could not enable $auto_unit: $out"
fi
ls "$where" >/dev/null 2>&1 || true # poke the automount
verified=0
for _ in 1 2 3 4 5 6 7 8 9 10; do
if [ -n "$(mounted_src "$where")" ]; then verified=1; break; fi
sleep 0.5
done
if [ "$verified" -ne 1 ]; then
rollback_persist
err "Automount did not trigger for $where — units removed again.
Check: systemctl status $auto_unit"
fi
# Same read-proof as one-shot mounts: catch permission problems now.
if ! timeout 5 ls -A "$where" >/dev/null 2>&1; then
rollback_persist
err "Automount triggered but $where is not readable — units + credentials removed.
Login works but file permissions don't — fix unix perms on the server
(the shared folder itself needs r-x for the connecting user).
Details: DOC/howto/share.md (SMB troubleshooting)"
fi
log "Persistent SMB mount (automount): ${what}${where} (${auto_unit})"
log "Verified: automount triggers and the share is readable"
notify_send "SMB mount persisted: ${what}${where}"
}
cmd_unpersist() { # <local-dir>
local where="$1" unit auto_unit unit_file auto_file
validate_dir "$where"
unit="$(systemd-escape --path --suffix=mount "$where")"
auto_unit="${unit%.mount}.automount"
unit_file="${UNIT_DIR}/${unit}"
auto_file="${UNIT_DIR}/${auto_unit}"
if [ ! -f "$unit_file" ] && [ ! -f "$auto_file" ]; then
# Idempotent no-op — rc 0 whether or not anything is configured.
# Return, not exit: a bogus typed path reached from the menu must
# not kill the whole session. A persisted unit can also live under
# a non-escape-derived filename (Where= still matches) — point at
# it instead of a bare nothing-to-do.
if persisted_smb_at "$where"; then
warn "$where has a persistent SMB mount unit (${PERSISTED_UNIT}) under a non-standard unit name."
log "Check it: systemctl status ${PERSISTED_UNIT%.mount} — or find it in the list: pos share smb-client list"
else
log "No persistent SMB mount for $where — nothing to do"
fi
return 0
fi
sudo systemctl disable "$auto_unit" 2>/dev/null || true
sudo systemctl stop "$auto_unit" 2>/dev/null || true
sudo systemctl disable "$unit" 2>/dev/null || true
sudo systemctl stop "$unit" 2>/dev/null || true
sudo rm -f "$unit_file" "$auto_file"
sudo rm -f "$SMB_CREDS_DIR/$(basename "$where")"
sudo rmdir "$SMB_CREDS_DIR" 2>/dev/null || true
sudo systemctl daemon-reload
if [ -n "$(mounted_src "$where")" ]; then
warn "$where is still mounted (something holds it open)"
if confirm "Force a lazy unmount now?" y; then run sudo umount -l "$where"; fi
fi
log "Removed persistent SMB mount: $where"
notify_send "SMB persistent mount removed: $where"
}
# ── Interactive menu flows ─────────────────────────────────────
# Best-effort resolution of the directory BEHIND share <$2> on host <$1>.
# The underlying path of a remote SMB share is not remotely discoverable;
# it is only locally knowable when THIS machine is the server (testparm
# answers from the local config). Anything else stays unresolved (rc 1)
# and the mountpoint picker silently skips the "(as on server)" suggestion.
smb_server_path() { # <host> <share> — stdout: server-side dir · rc 1 = unresolved
local host="${1,,}" share="$2" name p ips=""
local -a names=("localhost" "127.0.0.1" "::1" "$(hostname)")
names+=("$(hostname -f 2>/dev/null || true)")
ips="$(hostname -I 2>/dev/null)" || true
# shellcheck disable=SC2086 — $ips is an intentional space-split IP list
for name in "${names[@]}" ${ips}; do
if [ "$name" = "$host" ]; then
share_require_bin testparm "" || return 1
[ -f "$SMB_CONF" ] || return 1
p="$(testparm -s --parameter-name=path --section-name="$share" "$SMB_CONF" 2>/dev/null)" || return 1
[ -n "$p" ] || return 1
printf '%s\n' "$p"
return 0
fi
done
return 1
}
# Mountpoint picker — local port of the share_pick primitive with exactly
# two deltas: the hint line offers `t=type`, and typing t runs the
# manual-entry flow below. share_pick cannot intercept `t` (it filters on
# it) and lib/menu-lib.sh is shared, so the fork lives here. Rendering of
# numbered picks / text filter / 0=back is byte-identical to menu_pick.
# stdout: chosen item text (or the freshly created dir) · rc 1 = back/cancel.
pick_mountpoint() {
local prompt="$1"; shift
local -a items=("$@")
if [ "${#items[@]}" -eq 0 ]; then
return 1
fi
if ! [ -t 0 ]; then
printf '[!] Interactive picker needs a terminal.\n' >&2
return 1
fi
local filter="" ans i n total=${#items[@]} made
local -a shown=()
while true; do
shown=()
for ((i = 0; i < total; i++)); do
if [ -z "$filter" ] || [[ "${items[$i],,}" == *"${filter,,}"* ]]; then
shown+=("${items[$i]}")
fi
done
n=${#shown[@]}
{
echo
if [ -n "$filter" ]; then
printf -- "-- %d of %d match '%s' --\n" "$n" "$total" "$filter"
else
printf -- "-- %d available --\n" "$total"
fi
if [ "$n" -eq 0 ]; then
printf '[!] no matches — enter nothing or / to clear the filter\n' >&2
else
for ((i = 0; i < n; i++)); do
printf ' %2d) %s\n' $((i + 1)) "${shown[$i]}"
done
fi
} >&2
if ! read -rp "${prompt} [1-${n}], t=type, text=filter, 0=back " ans; then
return 1 # EOF — cancel
fi
case "$ans" in
"") [ -z "$filter" ] || filter="" ; continue ;;
"/") filter="" ; continue ;;
0 | q | Q | b | B) return 1 ;;
t | T)
made="$(ask_mountpoint)" && { echo "$made"; return 0; }
continue # cancelled/invalid/mkdir-failed → redraw
;;
*[!0-9]*)
filter="$ans"
continue
;;
*)
if (( ans >= 1 && ans <= n )); then
echo "${shown[$((ans - 1))]}"
return 0
fi
echo "Unknown choice." >&2
;;
esac
done
}
# Manual entry flow behind the picker's `t` key. Validates the shape
# (absolute, no trailing slash, system-path refusal), then branches:
# existing dir → use as-is; new dir → confirm-gate the creation + mkdir -p.
# Any decline, invalid input, EOF or mkdir failure is a warning + rc 1 —
# the picker redraws, the tool never aborts.
ask_mountpoint() { # stdout: absolute path (existing or newly created) · rc 1 = cancelled/failed
# NOTE: runs inside $( ) from the picker — every display line MUST go to
# stderr (menu-lib contract: display → stderr, result → stdout).
local dir
dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1
case "$dir" in
/*) ;;
*) warn "'$dir' is not an absolute path — must start with /" >&2; return 1 ;;
esac
case "$dir" in
*/) warn "'$dir' must not end with a slash" >&2; return 1 ;;
esac
case "$dir" in
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
warn "Refusing system path as mountpoint" >&2
return 1
;;
esac
if [ -d "$dir" ]; then
echo "$dir"
return 0
fi
if [ -e "$dir" ]; then
warn "$dir exists and is not a directory — pick another mount point" >&2
return 1
fi
confirm "Create mountpoint ${dir}?" n || return 1
if ! run sudo mkdir -p "$dir"; then
warn "Could not create ${dir}" >&2
return 1
fi
log "Created mount point $dir" >&2
echo "$dir"
}
menu_ask_mountpoint() { # [server_path] — stdout: absolute path · rc 1 cancelled
local srv="${1:-}" idx res cand dir known=0
local -a cands=() dirs=()
mapfile -t cands < <(share_folder_candidates)
for cand in "${cands[@]}"; do
dirs+=("${cand%% (*}") # bare path (strip "(mounted fstype)" note)
done
# Same-as-server suggestion: unless the server-side path already exists
# among the local candidates, append it as a synthetic pick so mounting
# at a mirrored path is a normal selection.
if [ -n "$srv" ] && [ "${#dirs[@]}" -gt 0 ] &&
printf '%s\n' "${dirs[@]}" | grep -qxF -- "$srv"; then
known=1
fi
if [ -n "$srv" ] && [ "$known" -eq 0 ]; then
cands+=("${srv} (as on server)")
fi
if [ "${#cands[@]}" -gt 0 ]; then
if res="$(pick_mountpoint "Mountpoint" "${cands[@]}")"; then
dir="${res%% (*}" # strip "(as on server)"/mount note
case "$dir" in
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
warn "Refusing system path as mountpoint"
return 1
;;
*)
echo "$dir"
return 0
;;
esac
fi
fi
ask_mountpoint
}
menu_mount() { # ephemeral|persist
local mode="$1" host idx share what where
host="$(share_ask_value "SMB server (host or IP)")" || return 1
[ -n "$host" ] || { warn "No server given"; return 1; }
# Empty answer = guest enumeration (with an interactive auth retry inside
# the helper); a named user authenticates right away. SMB_AUTH_USER tells
# us which account ended up being used so the mount reuses it.
local user=""
read -rp "Samba user (empty = try guest): " user || return 1
SMB_AUTH_USER=""
local -a shares=()
if mapfile -t shares < <(share_smb_shares "$host" "$user") && [ "${#shares[@]}" -gt 0 ]; then
idx="$(share_pick "Pick share on ${host}" "${shares[@]}")" || return 1
share="${shares[$((idx - 1))]}"
else
share="$(share_ask_value "Share name on ${host} (e.g. media)")" || return 1
[ -n "$share" ] || { warn "No share name given"; return 1; }
fi
user="${SMB_AUTH_USER:-$user}"
what="//${host}/${share}"
# Underlying server-side dir, when knowable (this machine is the server);
# empty → picker silently skips the "(as on server)" suggestion.
local srv_path=""
srv_path="$(smb_server_path "$host" "$share")" || srv_path=""
where="$(menu_ask_mountpoint "$srv_path")" || return 1
if [ "$mode" = "persist" ]; then
cmd_persist "$what" "$where" "$user"
else
cmd_mount "$what" "$where" "$user"
fi
}
menu_unmount() {
local idx row src where i
local -a tgts=() srcs=() items=()
# Same enumeration source as the `list` view's active section
# (findmnt -t cifs), reduced to TARGET|SOURCE rows.
while IFS= read -r row; do
[ -n "$row" ] || continue
tgts+=("${row%%|*}")
srcs+=("${row#*|}")
done < <(findmnt -rn -o TARGET,SOURCE -t cifs 2>/dev/null |
awk '{ src=$NF; $NF=""; sub(/[ \t]+$/, ""); print $0 "|" src }')
if [ "${#tgts[@]}" -eq 0 ]; then
log "No active SMB mounts"
where="$(share_ask_value "Local mountpoint to unmount")" || return 1
[ -n "$where" ] || return 1
cmd_unmount "$where"
return 0
fi
for ((i = 0; i < ${#tgts[@]}; i++)); do
items+=("${tgts[$i]}${srcs[$i]}")
done
idx="$(share_pick "Unmount which SMB mount?" "${items[@]}")" || return 1
where="${tgts[$((idx - 1))]}"
src="${srcs[$((idx - 1))]}"
confirm "Unmount ${where} (from ${src})?" n || { log "Cancelled"; return 1; }
cmd_unmount "$where"
}
menu_unpersist() {
local idx uf where unit_w
local -a paths=()
for uf in "${UNIT_DIR}"/*.mount; do
grep -q '^Type=cifs$' "$uf" 2>/dev/null || continue
unit_w="$(sed -n 's/^Where=//p' "$uf")"
[ -n "$unit_w" ] || continue
paths+=("$unit_w")
done
if [ "${#paths[@]}" -gt 0 ]; then
idx="$(share_pick "Remove which persistent SMB mount?" "${paths[@]}")" || return 1
where="${paths[$((idx - 1))]}"
else
where="$(share_ask_value "Local mountpoint whose units to remove")" || return 1
[ -n "$where" ] || return 1
fi
cmd_unpersist "$where"
}
run_menu() {
share_menu_guard || exit 1
while true; do
local choice
choice="$(share_menu_run "SMB client" \
"Mount a share (one-shot)" \
"Persist a share (automount units)" \
"List active + persistent mounts" \
"Unmount a mounted share" \
"Remove a persistent mount")" || return 0
case "$choice" in
# Handlers return nonzero on cancel/back — normalized here so a
# cancel can never reach set -e and kill the whole session.
1) menu_mount ephemeral || true ;;
2) menu_mount persist || true ;;
3) cmd_list || true ;;
4) menu_unmount || true ;;
5) menu_unpersist || true ;;
esac
done
}
case "$cmd" in
""|menu)
run_menu
exit 0
;;
mount)
[ $# -ge 3 ] || err "Usage: pos share smb-client mount <//server/share> <local-dir> [user]"
cmd_mount "$2" "$3" "${4:-}"
;;
unmount)
[ $# -ge 2 ] || err "Usage: pos share smb-client unmount <local-dir>"
cmd_unmount "$2"
;;
list)
cmd_list
;;
persist)
[ $# -ge 3 ] || err "Usage: pos share smb-client persist <//server/share> <local-dir> [user]"
cmd_persist "$2" "$3" "${4:-}"
;;
unpersist)
[ $# -ge 2 ] || err "Usage: pos share smb-client unpersist <local-dir>"
cmd_unpersist "$2"
;;
esac