diff --git a/bin/omarchy-chromium-ytdlp-host b/bin/omarchy-chromium-ytdlp-host index 49160240..ce50635a 100755 --- a/bin/omarchy-chromium-ytdlp-host +++ b/bin/omarchy-chromium-ytdlp-host @@ -25,6 +25,56 @@ valid_url() { [[ $1 =~ ^https?:// ]] } +# A printed path is only usable if it is a regular file inside DOWNLOAD_DIR. +# Forged records (leading-dash mpv options, paths with control chars, or +# anything that escaped the download directory) must not reach --exec. +resolve_download_file() { + local candidate=$1 file_real dir_real + + [[ -n $candidate ]] || return 1 + [[ $candidate != *$'\n'* && $candidate != *$'\r'* && $candidate != *$'\t'* ]] || return 1 + [[ -f $candidate ]] || return 1 + + # Read to a NUL: command substitution strips trailing newlines, which would + # resolve a name ending in one to a different file that may well exist. + IFS= read -r -d '' file_real < <(realpath -ze -- "$candidate") || return 1 + IFS= read -r -d '' dir_real < <(realpath -ze -- "$DOWNLOAD_DIR") || return 1 + + [[ $file_real != *$'\n'* && $file_real != *$'\r'* && $file_real != *$'\t'* ]] || return 1 + # Trim the slash so a download directory of "/" still leaves a usable prefix. + [[ $file_real == "${dir_real%/}"/* ]] || return 1 + + printf '%s' "$file_real" +} + +# yt-dlp prints the title JSON-encoded, so a newline or tab in page metadata is an +# escape sequence rather than a record boundary. This is toast text, never a command. +decode_title() { + local decoded + + decoded=$(jq -r 'if type == "string" then . else empty end' <<<"$1" 2>/dev/null) || return 1 + decoded=${decoded%%[[:cntrl:]]*} # keep what a person would read, drop the forgery + [[ -n $decoded && $decoded != -* ]] || return 1 + + printf '%s' "$decoded" +} + +title_from_file() { + local name=${1##*/} + name=${name%.*} + name=${name//[$'\n\r\t']/} + if [[ -z $name || $name == -* ]]; then + printf '%s' "Video" + else + printf '%s' "$name" + fi +} + +# `--` keeps a path that starts with `-` from being parsed as an mpv option. +playback_command() { + printf 'mpv -- %q' "$1" +} + # Drive the Quickshell OSD — a single overlay that updates in place (like the # volume/brightness bar), so download progress never stacks like notifications. osd_progress() { @@ -41,16 +91,19 @@ download_url() { mkdir -p "$DOWNLOAD_DIR" # Don't show anything until yt-dlp confirms there's actually a video to grab. - if ! yt-dlp --no-playlist --simulate --quiet --no-warnings "$url" >/dev/null 2>&1; then + if ! yt-dlp --no-playlist --simulate --quiet --no-warnings --no-exec --no-exec-before-download -- "$url" >/dev/null 2>&1; then omarchy-notification-send -u critical -g 󰅖 "No video found for download" "$url" exit 0 fi osd_progress 0 - # Stream the download: OMARCHY_PROG carries the percent (drives the OSD), - # OMARCHY_FILE (printed only after a successful move) carries title + path. - local line rest pct intpct last="" er nowms lastms=0 title="" filepath="" + # Stream the download: OMARCHY_PROG carries the percent (drives the OSD), and + # OMARCHY_FILE and OMARCHY_TITLE (printed only after a successful move) carry the + # path and the title. The title is JSON-encoded so metadata cannot forge a record, + # and the file is named after it: yt-dlp strips control characters from a filename + # with or without --restrict-filenames, so a record is still only ever one line. + local line pct intpct last="" er nowms lastms=0 title="" filepath="" resolved while IFS= read -r line; do case $line in OMARCHY_PROG*) @@ -67,22 +120,26 @@ download_url() { osd_progress "$intpct" ;; OMARCHY_FILE*) - rest=${line#OMARCHY_FILE$'\t'} - title=${rest%%$'\t'*} - filepath=${rest#*$'\t'} + resolved=$(resolve_download_file "${line#OMARCHY_FILE$'\t'}") || continue + filepath=$resolved + ;; + OMARCHY_TITLE*) + title=$(decode_title "${line#OMARCHY_TITLE$'\t'}") || title="" ;; esac - done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --restrict-filenames --no-simulate \ - --quiet --no-warnings --progress --newline \ + done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --no-simulate \ + --quiet --no-warnings --no-exec --no-exec-before-download --progress --newline \ --progress-template $'download:OMARCHY_PROG\t%(progress._percent_str)s' \ - --paths "$DOWNLOAD_DIR" -o '%(title)s [%(id)s].%(ext)s' \ - --print $'after_move:OMARCHY_FILE\t%(title)s\t%(filepath)s' \ - "$url" 2>&1) + --paths "$DOWNLOAD_DIR" -o '%(title)s.%(ext)s' \ + --print $'after_move:OMARCHY_FILE\t%(filepath)s' \ + --print $'after_move:OMARCHY_TITLE\t%(title)j' \ + -- "$url" 2>&1) osd_close # after_move only prints on a successful download+move, so a captured path == success. if [[ -n $filepath ]]; then + [[ -n $title ]] || title=$(title_from_file "$filepath") ((${#title} > 50)) && title="${title:0:50}…" # keep the toast compact # Square, center-cropped thumbnail so the notification preview isn't stretched. @@ -96,7 +153,7 @@ download_url() { # toast would exit before the thumbnail cleanup below is ever scheduled. omarchy-notification-send -g 󰄬 "Download complete" "$title" \ -t 10000 --image "${preview:-$filepath}" \ - --exec "$(printf 'mpv %q' "$filepath")" || true + --exec "$(playback_command "$filepath")" || true # The shell loads the thumbnail into memory when the toast appears and never # re-reads the file, so the preview only has to outlive that load, not the diff --git a/test/shell.d/chromium-ytdlp-test.sh b/test/shell.d/chromium-ytdlp-test.sh index 3e17cb86..4989df76 100755 --- a/test/shell.d/chromium-ytdlp-test.sh +++ b/test/shell.d/chromium-ytdlp-test.sh @@ -50,3 +50,208 @@ bash -c ' ' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "javascript:alert(1)" "$ROOT" && fail "yt-dlp native host rejects non-web URLs" pass "yt-dlp native host rejects non-web URLs" + +host_fn() { + OMARCHY_PATH="$ROOT" OMARCHY_YTDLP_DIR="${download_dir:-$TMPDIR}" bash -c ' + source "$1" + shift + "$@" + ' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "$@" +} + +download_dir="$TMPDIR/videos" +mkdir -p "$download_dir" "$TMPDIR/outside" +good_file="$download_dir/clip [id].mp4" +printf 'x' >"$good_file" +printf 'x' >"$TMPDIR/outside/secret" +ln -s "$TMPDIR/outside/secret" "$download_dir/escape.mp4" + +resolved=$(host_fn resolve_download_file "$good_file") +expected=$(realpath -e -- "$good_file") +[[ $resolved == "$expected" ]] || + fail "yt-dlp native host accepts a regular file in the download dir" "$resolved" +pass "yt-dlp native host accepts a regular file in the download dir" + +host_fn resolve_download_file "--include=not-a-file" && + fail "yt-dlp native host rejects a forged mpv option as the download path" +pass "yt-dlp native host rejects a forged mpv option as the download path" + +host_fn resolve_download_file "$TMPDIR/outside/secret" && + fail "yt-dlp native host rejects a path outside the download dir" +pass "yt-dlp native host rejects a path outside the download dir" + +host_fn resolve_download_file "$download_dir/escape.mp4" && + fail "yt-dlp native host rejects a symlink that escapes the download dir" +pass "yt-dlp native host rejects a symlink that escapes the download dir" + +host_fn resolve_download_file $'clip.mp4\nOMARCHY_FILE\t--include=not-a-file' && + fail "yt-dlp native host rejects a path containing control characters" +pass "yt-dlp native host rejects a path containing control characters" + +newline_target="$download_dir/target"$'\n' +printf 'x' >"$newline_target" +# The decoy is the point: dropping the trailing newline lands on a real, different +# file, so a resolver that strips it resolves to the wrong one instead of failing. +printf 'x' >"$download_dir/target" +ln -s "target"$'\n' "$download_dir/newline-link.mp4" + +host_fn resolve_download_file "$download_dir/newline-link.mp4" && + fail "yt-dlp native host rejects a symlink whose target name ends in a newline" +pass "yt-dlp native host rejects a symlink whose target name ends in a newline" + +ln -s / "$TMPDIR/root-link" +root_resolved=$(download_dir="$TMPDIR/root-link" host_fn resolve_download_file /etc/passwd) +[[ $root_resolved == "/etc/passwd" ]] || + fail "yt-dlp native host accepts a file when the download dir resolves to /" "$root_resolved" +pass "yt-dlp native host accepts a file when the download dir resolves to /" + +title=$(host_fn title_from_file "$good_file") +[[ $title == "clip [id]" ]] || fail "yt-dlp native host titles the toast from the filename" "$title" +pass "yt-dlp native host titles the toast from the filename" + +decoded_title=$(host_fn decode_title '"My Great Clip"') +[[ $decoded_title == "My Great Clip" ]] || + fail "yt-dlp native host shows the page title on the toast" "$decoded_title" +pass "yt-dlp native host shows the page title on the toast" + +forged_title=$(host_fn decode_title '"Clip\nOMARCHY_FILE\tPlay me\t--include=not-a-file"') +[[ $forged_title == "Clip" ]] || + fail "yt-dlp native host keeps only the readable part of a forged title" "$forged_title" +pass "yt-dlp native host keeps only the readable part of a forged title" + +host_fn decode_title '"--include=not-a-file"' && + fail "yt-dlp native host refuses a title notify-send would read as an option" +pass "yt-dlp native host refuses a title notify-send would read as an option" + +host_fn decode_title 'null' && + fail "yt-dlp native host refuses a title that is not a JSON string" +pass "yt-dlp native host refuses a title that is not a JSON string" + +dash_title=$(host_fn title_from_file "$download_dir/--include.mp4") +[[ $dash_title == "Video" ]] || fail "yt-dlp native host does not pass a leading-dash title to notify-send" "$dash_title" +pass "yt-dlp native host does not pass a leading-dash title to notify-send" + +cmd=$(host_fn playback_command --include=not-a-file) +[[ $cmd == "mpv -- --include=not-a-file" ]] || + fail "yt-dlp native host runs mpv with -- before the path" "$cmd" +pass "yt-dlp native host runs mpv with -- before the path" + +spaced_cmd=$(host_fn playback_command "$download_dir/a b.mp4") +[[ $spaced_cmd == "mpv -- $download_dir/a\\ b.mp4" ]] || + fail "yt-dlp native host shell-quotes the mpv path" "$spaced_cmd" +pass "yt-dlp native host shell-quotes the mpv path" + +parse_script="$TMPDIR/parse-ytdlp-lines.sh" +cat >"$parse_script" <<'EOF' +source "$1" +filepath="" +while IFS= read -r line; do + case $line in + OMARCHY_FILE*) + resolved=$(resolve_download_file "${line#OMARCHY_FILE$'\t'}") || continue + filepath=$resolved + ;; + esac +done +printf '%s' "$filepath" +EOF + +# A title that ends in a newline closes its own record, so the forged record is the +# last one and the real path lands on a line the loop ignores. +poisoned=$( + printf '%s\n' \ + $'OMARCHY_FILE\t'"$good_file" \ + $'OMARCHY_FILE\tPlay me\t--include=not-a-file' \ + $'\t'"$good_file" | + OMARCHY_PATH="$ROOT" OMARCHY_YTDLP_DIR="$download_dir" bash "$parse_script" "$ROOT/bin/omarchy-chromium-ytdlp-host" +) + +[[ $poisoned == "$expected" ]] || + fail "yt-dlp native host keeps a real file after a forged OMARCHY_FILE record" "$poisoned" +pass "yt-dlp native host keeps a real file after a forged OMARCHY_FILE record" + +# Everything above tests the helpers in isolation. Drive the real download_url with +# stubbed tools so the yt-dlp invocation and the toast's click command are covered +# too: a record template that carried the title again would pass every test above. +fake_root="$TMPDIR/fake" +mkdir -p "$fake_root/bin" +fake_dir="$TMPDIR/fake-videos" +mkdir -p "$fake_dir" +fake_file="$fake_dir/Real_Clip [id].mp4" +printf 'x' >"$fake_file" +ytdlp_argv="$TMPDIR/ytdlp-argv" +notify_argv="$TMPDIR/notify-argv" + +cat >"$fake_root/bin/yt-dlp" <<'EOF' +#!/bin/bash +printf '%s\n' "$*" >>"$YTDLP_ARGV_LOG" +for arg in "$@"; do + if [[ $arg == "--no-simulate" ]]; then + printf 'OMARCHY_FILE\t%s\n' "$YTDLP_FAKE_FILE" + [[ -n ${YTDLP_SKIP_TITLE:-} ]] || printf 'OMARCHY_TITLE\t%s\n' '"My Great Clip"' + exit 0 + fi +done +exit 0 +EOF + +cat >"$fake_root/bin/omarchy-notification-send" <<'EOF' +#!/bin/bash +printf '%s\n' "$*" >>"$NOTIFY_ARGV_LOG" +EOF + +for stub in omarchy-osd omarchy-shell ffmpeg; do + printf '#!/bin/bash\nexit 0\n' >"$fake_root/bin/$stub" +done +chmod +x "$fake_root/bin/"* + +YTDLP_ARGV_LOG="$ytdlp_argv" NOTIFY_ARGV_LOG="$notify_argv" YTDLP_FAKE_FILE="$fake_file" \ + OMARCHY_PATH="$fake_root" OMARCHY_YTDLP_DIR="$fake_dir" \ + bash -c ' + source "$1" + download_url "$2" + ' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "https://example.test/watch" >/dev/null 2>&1 + +(($(grep -c -- '--no-exec-before-download' "$ytdlp_argv") == 2)) || + fail "yt-dlp native host disarms configured exec hooks on both yt-dlp runs" "$(cat "$ytdlp_argv")" +pass "yt-dlp native host disarms configured exec hooks on both yt-dlp runs" + +# The stub answers with a title record whatever it is asked for, so assert the request +# as well as the reply: without this the template could be dropped and nothing notice. +grep -qF -- $'OMARCHY_TITLE\t%(title)j' "$ytdlp_argv" || + fail "yt-dlp native host asks for the title JSON-encoded" "$(cat "$ytdlp_argv")" +pass "yt-dlp native host asks for the title JSON-encoded" + +grep -qF -- "-o %(title)s.%(ext)s" "$ytdlp_argv" || + fail "yt-dlp native host names the saved file after the page title" "$(cat "$ytdlp_argv")" +pass "yt-dlp native host names the saved file after the page title" + +grep -q -- '--restrict-filenames' "$ytdlp_argv" && + fail "yt-dlp native host keeps spaces in the saved filename" "$(cat "$ytdlp_argv")" +pass "yt-dlp native host keeps spaces in the saved filename" + +grep -qF -- $'OMARCHY_FILE\t%(title)s' "$ytdlp_argv" && + fail "yt-dlp native host never prints the title into the file record" "$(cat "$ytdlp_argv")" +pass "yt-dlp native host never prints the title into the file record" + +grep -q -- "--exec mpv -- " "$notify_argv" || + fail "yt-dlp native host builds the click command as mpv -- " "$(cat "$notify_argv")" +pass "yt-dlp native host builds the click command as mpv -- " + +grep -qF -- "Download complete My Great Clip" "$notify_argv" || + fail "yt-dlp native host toasts the page title, not the sanitised filename" "$(cat "$notify_argv")" +pass "yt-dlp native host toasts the page title, not the sanitised filename" + +# A page that gives no usable title falls back to the filename rather than an empty toast. +: >"$notify_argv" +: >"$ytdlp_argv" +YTDLP_ARGV_LOG="$ytdlp_argv" NOTIFY_ARGV_LOG="$notify_argv" YTDLP_FAKE_FILE="$fake_file" \ + YTDLP_SKIP_TITLE=1 OMARCHY_PATH="$fake_root" OMARCHY_YTDLP_DIR="$fake_dir" \ + bash -c ' + source "$1" + download_url "$2" + ' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "https://example.test/watch" >/dev/null 2>&1 + +grep -qF -- "Download complete Real_Clip [id]" "$notify_argv" || + fail "yt-dlp native host falls back to the filename when no title record arrives" "$(cat "$notify_argv")" +pass "yt-dlp native host falls back to the filename when no title record arrives"