# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables # Explorer file operations (devdesk#54), sourced by bin/devdesk and reached through # `devdesk attach --fs-mkdir|--fs-move|--fs-copy|--fs-delete|--fs-restore|--fs-put|--fs-download`. # Same rules as devdesk-files.sh: every path is relative to the session's folder # and stays inside it. Entries are handled as themselves (a symlink is moved or # deleted as a link, never followed), nothing is ever overwritten unless asked, # and deleting moves to a per-session trash that can be restored for 7 days. # # set -e does not reach into $(...): every helper whose result is captured is # followed by `|| exit 1`, or a failed check would carry on with an empty path. : "${DEVDESK_TRASH_DAYS:=7}" # A name the explorer may create: not empty, no slash, not . or .., no newline. dd_fs_valid_name() { case $1 in '' | . | .. | */* | *$'\n'* | *$'\r'*) return 1 ;; esac return 0 } # dd_fs_inside ROOT ABS: dies unless ABS is ROOT or inside it (a second guard, # whatever the callers checked). dd_fs_inside() { { [ -n "$1" ] && [ -n "$2" ]; } || dd_die "internal error: empty path" case "$2/" in "$1"/*) ;; *) dd_die "outside the session folder: $2" ;; esac } # dd_fs_split REL -> sets DD_PARENT (relative, may be empty) and DD_NAME, or dies. dd_fs_split() { local rel=$1 part case $rel in /* | *$'\n'* | *$'\r'* | '' | */) dd_die "bad path: $rel" ;; esac local IFS=/ for part in $rel; do { [ -n "$part" ] && [ "$part" != . ] && [ "$part" != .. ]; } || dd_die "bad path: $rel" done DD_NAME=${rel##*/} if [[ $rel == */* ]]; then DD_PARENT=${rel%/*}; else DD_PARENT=""; fi } # dd_fs_entry ROOT REL -> the absolute path of the entry itself (its folder # resolved, the last component not followed), which must exist and isn't ROOT. dd_fs_entry() { local root=$1 dir dd_fs_split "$2" dir=$(dd_fs_path "$root" "${DD_PARENT:-.}") || exit 1 [ -d "$dir" ] || dd_die "not a folder: $DD_PARENT" { [ -e "$dir/$DD_NAME" ] || [ -L "$dir/$DD_NAME" ]; } || dd_die "no such file: $2" dd_fs_inside "$root" "$dir" printf '%s/%s\n' "$dir" "$DD_NAME" } # dd_fs_mkdirs ROOT RELDIR -> creates the missing folders of RELDIR one level at # a time, each checked to be a real folder inside ROOT (never through a symlink # out of it), and prints the absolute folder. dd_fs_mkdirs() { local root=$1 rel=$2 cur part cur=$root [ -n "$rel" ] || { printf '%s\n' "$root"; return; } local IFS=/ for part in $rel; do dd_fs_valid_name "$part" || dd_die "bad folder name: $part" if [ ! -e "$cur/$part" ] && [ ! -L "$cur/$part" ]; then mkdir -- "$cur/$part" || dd_die "cannot create folder: $part" fi cur=$(realpath -e -- "$cur/$part" 2>/dev/null) || dd_die "cannot use folder: $part" case "$cur/" in "$root"/*) ;; *) dd_die "outside the session folder: $rel" ;; esac [ -d "$cur" ] || dd_die "not a folder: $part" done printf '%s\n' "$cur" } # dd_fs_dest ROOT REL -> absolute path for a new entry REL (folders above it are # created); dies if something is already there. dd_fs_dest() { local root=$1 dir dd_fs_split "$2" dir=$(dd_fs_mkdirs "$root" "$DD_PARENT") || exit 1 if [ -e "$dir/$DD_NAME" ] || [ -L "$dir/$DD_NAME" ]; then dd_die "already exists: $2"; fi dd_fs_inside "$root" "$dir" printf '%s/%s\n' "$dir" "$DD_NAME" } # --fs-mkdir SESSION REL: a new folder (and any missing ones above it). cmd_fs_mkdir() { [ $# -eq 2 ] || dd_die "usage: devdesk attach --fs-mkdir SESSION PATH" local root dest root=$(dd_session_root "$1") || exit 1 dest=$(dd_fs_dest "$root" "$2") || exit 1 mkdir -- "$dest" || dd_die "cannot create folder: $2" jq -cMn --arg p "${dest#"$root"/}" '{ok: true, path: $p}' } # --fs-move SESSION FROM TO / --fs-copy SESSION FROM TO: TO is the full new path. # Never onto something that exists, never a folder into itself. dd_fs_transfer() { local op=$1 session=$2 from=$3 to=$4 root src dest root=$(dd_session_root "$session") || exit 1 src=$(dd_fs_entry "$root" "$from") || exit 1 dest=$(dd_fs_dest "$root" "$to") || exit 1 case "$dest/" in "$src"/*) dd_die "cannot put a folder inside itself: $to" ;; esac if [ "$op" = move ]; then mv -T -n -- "$src" "$dest" 2>/dev/null || dd_die "cannot move $from" if [ -e "$src" ] || [ -L "$src" ]; then dd_die "cannot move $from"; fi else cp -a -T -n -- "$src" "$dest" 2>/dev/null || { rm -rf -- "$dest"; dd_die "cannot copy $from"; } fi jq -cMn --arg f "${src#"$root"/}" --arg t "${dest#"$root"/}" '{ok: true, from: $f, path: $t}' } cmd_fs_move() { [ $# -eq 3 ] || dd_die "usage: devdesk attach --fs-move SESSION FROM TO" dd_fs_transfer move "$@" } cmd_fs_copy() { [ $# -eq 3 ] || dd_die "usage: devdesk attach --fs-copy SESSION FROM TO" dd_fs_transfer copy "$@" } dd_trash_dir() { printf '%s/trash/%s\n' "$DEVDESK_STATE_DIR" "$1" } # --fs-delete SESSION REL...: moves the entries to the session's trash # ($DEVDESK_STATE_DIR/trash/SESSION/ID, kept DEVDESK_TRASH_DAYS days) and prints # {ok, trash: ID, paths} for --fs-restore. cmd_fs_delete() { [ $# -ge 2 ] || dd_die "usage: devdesk attach --fs-delete SESSION PATH..." local session=$1 root trash id rel src i=0 entries=() shift root=$(dd_session_root "$session") || exit 1 # Check every path before moving any. for rel in "$@"; do src=$(dd_fs_entry "$root" "$rel") || exit 1 entries+=("$src") done trash=$(dd_trash_dir "$session") || exit 1 mkdir -p -- "$trash" && chmod 700 "$DEVDESK_STATE_DIR/trash" "$trash" 2>/dev/null find "$trash" -mindepth 1 -maxdepth 1 -mtime +"$DEVDESK_TRASH_DAYS" -exec rm -rf -- {} + 2>/dev/null || true id=$(date +%Y%m%d-%H%M%S)-$RANDOM mkdir -- "$trash/$id" || dd_die "cannot use the trash" local done_paths=() for src in "${entries[@]}"; do mv -T -- "$src" "$trash/$id/$i" 2>/dev/null || break done_paths+=("${src#"$root"/}") i=$((i + 1)) done printf '%s\0' "${done_paths[@]}" | jq -cMRs --arg root "$root" '{root: $root, paths: (split("\u0000") | map(select(length > 0)))}' >"$trash/$id/manifest.json" [ "${#done_paths[@]}" -eq "$#" ] || dd_die "deleted ${#done_paths[@]} of $#; the rest is still there (trash $id)" jq -cMn --arg id "$id" --slurpfile m "$trash/$id/manifest.json" '{ok: true, trash: $id, paths: $m[0].paths}' } # --fs-restore SESSION ID: puts a deletion back where it was (if nothing has # taken those paths since); prints {ok, paths}. cmd_fs_restore() { [ $# -eq 2 ] || dd_die "usage: devdesk attach --fs-restore SESSION ID" local session=$1 id=$2 root dir rel i=0 restored=() dest [[ $id =~ ^[0-9]{8}-[0-9]{6}-[0-9]+$ ]] || dd_die "bad trash id: $id" root=$(dd_session_root "$session") || exit 1 dir="$(dd_trash_dir "$session")/$id" [ -f "$dir/manifest.json" ] || dd_die "nothing to restore (emptied after $DEVDESK_TRASH_DAYS days?)" [ "$(jq -r .root "$dir/manifest.json")" = "$root" ] || dd_die "that deletion was in another folder" while IFS= read -r -d '' rel; do if [ -e "$dir/$i" ] || [ -L "$dir/$i" ]; then dest=$(dd_fs_dest "$root" "$rel") || exit 1 mv -T -- "$dir/$i" "$dest" || dd_die "cannot restore $rel" restored+=("$rel") fi i=$((i + 1)) done < <(jq -j '.paths[] | ., "\u0000"' "$dir/manifest.json") rm -rf -- "$dir" printf '%s\0' "${restored[@]}" | jq -cMRs '{ok: true, paths: (split("\u0000") | map(select(length > 0)))}' } # --fs-put SESSION REL BYTES new|replace: upload any file (up to # DEVDESK_UPLOAD_MAX) to REL, creating missing folders. "new" refuses to replace # anything; "replace" overwrites a regular file. The content arrives as base64 # after the READY line, into a temp file next to the target, moved into place. cmd_fs_put() { [ $# -eq 4 ] || dd_die "usage: devdesk attach --fs-put SESSION PATH BYTES new|replace" local root rel=$2 size=$3 mode=$4 dir target tmp b64len actual root=$(dd_session_root "$1") || exit 1 { [[ $size =~ ^[0-9]+$ ]] && [ "$size" -le "$DEVDESK_UPLOAD_MAX" ]; } || dd_die "larger than $((DEVDESK_UPLOAD_MAX / 1048576)) MB" case $mode in new | replace) ;; *) dd_die "mode is new or replace" ;; esac dd_fs_split "$rel" dir=$(dd_fs_mkdirs "$root" "$DD_PARENT") || exit 1 dd_fs_inside "$root" "$dir" target="$dir/$DD_NAME" if [ -e "$target" ] || [ -L "$target" ]; then [ "$mode" = replace ] || dd_die "already exists: $rel" { [ -f "$target" ] && [ ! -L "$target" ]; } || dd_die "not a regular file: $rel" fi tmp=$(mktemp --tmpdir="$dir" ".${DD_NAME:0:100}.devdesk-XXXXXX") || dd_die "cannot write in ${dir#"$root"/}" trap 'rm -f "$tmp"' HUP INT TERM # shellcheck disable=SC2017 # integer ceil(size/3)*4: the base64 length, on purpose b64len=$(((size + 2) / 3 * 4)) stty raw -echo 2>/dev/null || true printf 'DEVDESK-PUT-READY\r\n' if [ "$b64len" -gt 0 ]; then head -c "$b64len" | base64 -d >"$tmp" 2>/dev/null || true; fi stty sane 2>/dev/null || true actual=$(stat -c %s "$tmp" 2>/dev/null || echo 0) if [ "$actual" != "$size" ]; then rm -f "$tmp" dd_die "upload incomplete ($actual of $size bytes)" fi if [ -f "$target" ]; then chmod --reference="$target" "$tmp" 2>/dev/null || true chown --reference="$target" "$tmp" 2>/dev/null || true mv -f -- "$tmp" "$target" || { rm -f "$tmp"; dd_die "cannot replace $rel"; } else chmod 0644 "$tmp" # -n: something created meanwhile is not replaced. mv -n -T -- "$tmp" "$target" 2>/dev/null || true if [ -e "$tmp" ]; then rm -f "$tmp" dd_die "already exists: $rel" fi fi trap - HUP INT TERM jq -cMn --arg p "${target#"$root"/}" --argjson s "$size" '{ok: true, path: $p, size: $s}' } # --fs-download SESSION REL: any regular file up to DEVDESK_UPLOAD_MAX, as a blob # with its body whatever the type (the browser saves it). cmd_fs_download() { [ $# -eq 2 ] || dd_die "usage: devdesk attach --fs-download SESSION PATH" local root f size root=$(dd_session_root "$1") || exit 1 f=$(dd_fs_path "$root" "$2") || exit 1 [ -f "$f" ] || dd_die "not a file: $2" size=$(stat -c %s -- "$f") [ "$size" -le "$DEVDESK_UPLOAD_MAX" ] || dd_die "larger than $((DEVDESK_UPLOAD_MAX / 1048576)) MB" jq -cMn --arg p "${f#"$root"/}" --argjson s "$size" '{path: $p, size: $s, body: true, download: true}' printf '%s\n' "$DD_BLOB_BEGIN" base64 -w0 -- "$f" printf '\n%s\n' "$DD_BLOB_END" } # --fs-grep SESSION FLAGS QUERY (devdesk#56): search the session's files. FLAGS # is "-" or letters: i (ignore case), r (regular expression, else literal), # w (whole word). git grep in a repository (tracked + untracked, .gitignore # honoured), else grep without node_modules/.git/build folders. Binary files are # skipped; at most DEVDESK_GREP_MAX (2000) lines, each cut to 400 characters. cmd_fs_grep() { [ $# -eq 3 ] || dd_die "usage: devdesk attach --fs-grep SESSION FLAGS QUERY" local root flags=$2 query=$3 max=${DEVDESK_GREP_MAX:-2000} opts=() root=$(dd_session_root "$1") || exit 1 [[ $flags =~ ^(-|[irw]+)$ ]] || dd_die "flags are i, r and w" { [ -n "$query" ] && [ "${#query}" -le 500 ]; } || dd_die "search for 1 to 500 characters" [[ $flags == *i* ]] && opts+=(-i) [[ $flags == *w* ]] && opts+=(-w) if [[ $flags == *r* ]]; then opts+=(-E); else opts+=(-F); fi { if git --no-optional-locks -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then (cd "$root" && timeout 20 git --no-optional-locks grep -n -I -z --untracked --exclude-standard "${opts[@]}" -e "$query" -- . 2>/dev/null) || true else (cd "$root" && timeout 20 grep -r -n -I -Z "${opts[@]}" \ --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=__pycache__ --exclude-dir=.venv --exclude-dir=venv \ --exclude-dir=dist --exclude-dir=build --exclude-dir=target --exclude-dir=.cache --exclude-dir=.next \ --exclude-dir=coverage --exclude-dir=.worktrees -e "$query" . 2>/dev/null) || true fi } | head -n "$((max + 1))" | jq -cMRs --arg root "$root" --argjson max "$max" ' # git grep -z: path NUL line NUL text; grep -Z: path NUL line:text [split("\n")[] | select(length > 0) | split("\u0000") | select(length >= 2) | if length >= 3 then {p: .[0], l: (.[1] | tonumber? // 0), t: (.[2:] | join(" "))} else (.[1] | index(":")) as $i | select($i != null) | {p: .[0], l: (.[1][:$i] | tonumber? // 0), t: .[1][($i + 1):]} end | {p: (.p | ltrimstr("./")), l, t: (.t | .[:400])}] as $m | {root: $root, truncated: ($m | length > $max), matches: $m[:$max]}' }