tv-anarchy/Sources/TVAnarchyCore/DurationProbe.swift
Natalie 3c67b547c6 fix(adult): 🔒 terminate ffprobe options with -- in duration probe
Defense-in-depth against option injection: a library path beginning with '-'
could be parsed as an ffprobe flag. Paths are always absolute today so it isn't
reachable, but '--' makes it safe regardless. Not command injection: $p is a
double-quoted expansion (contents not re-evaluated) and paths arrive as stdin
data, never on a command line — documented inline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:00:02 -04:00

71 lines
3.5 KiB
Swift

import Foundation
/// Probes video durations (seconds) for black-side library paths via a single
/// SSH connection that runs `ffprobe` per file on the storage host. Paths are
/// sent NUL-delimited on stdin, so arbitrary filenames (the eporner scrapes have
/// spaces, quotes, and `[brackets]`) pass verbatim with zero shell quoting.
///
/// Used to show clip length in the adult collection detail list. Best-effort:
/// an unreachable host or a per-file probe failure just omits that path from the
/// result. Blocking always call off the main actor.
public enum DurationProbe {
/// Reads NUL-delimited paths from stdin; for each, emits `<seconds>\t<path>\0`.
///
/// Injection note: `$p` is a double-quoted variable expansion, so the shell
/// does not re-evaluate the path's contents a filename containing `$()` or
/// backticks is passed verbatim, not executed (and the path arrives as stdin
/// *data*, never on a command line). `--` terminates ffprobe's option parsing
/// so a path beginning with `-` can't be read as a flag.
private static let remoteScript =
"while IFS= read -r -d '' p; do "
+ "d=$(ffprobe -v error -show_entries format=duration -of csv=p=0 -- \"$p\" 2>/dev/null); "
+ "printf '%s\\t%s\\0' \"${d:-}\" \"$p\"; done"
/// Returns `[path: seconds]` for paths that resolved to a positive duration.
/// `timeout` caps the whole batch (the remote walk can spin up sleeping disks).
public static func probe(paths: [String], timeout: TimeInterval = 90) -> [String: Double] {
guard !paths.isEmpty, let host = DevicesConfig.storageSSHEndpoints().first else { return [:] }
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
p.arguments = ["-o", "ConnectTimeout=6", "-o", "BatchMode=yes", host, remoteScript]
let inPipe = Pipe(), outPipe = Pipe()
p.standardInput = inPipe
p.standardOutput = outPipe
p.standardError = Pipe()
do { try p.run() } catch { return [:] }
// Feed NUL-joined paths, then close stdin so the remote read loop ends.
let payload = paths.map { $0 + "\0" }.joined()
inPipe.fileHandleForWriting.write(Data(payload.utf8))
try? inPipe.fileHandleForWriting.close()
// Watchdog: terminate an overrunning batch (closes the pipe read returns).
let killer = DispatchWorkItem { if p.isRunning { p.terminate() } }
DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: killer)
let data = outPipe.fileHandleForReading.readDataToEndOfFile()
p.waitUntilExit()
killer.cancel()
var out: [String: Double] = [:]
let text = String(decoding: data, as: UTF8.self)
for record in text.split(separator: "\0", omittingEmptySubsequences: true) {
guard let tab = record.firstIndex(of: "\t") else { continue }
let durStr = record[..<tab]
let path = String(record[record.index(after: tab)...])
if let secs = Double(durStr.trimmingCharacters(in: .whitespacesAndNewlines)), secs > 0 {
out[path] = secs
}
}
return out
}
/// `1:23:45` / `12:34` / `0:09` from a duration in seconds.
public static func format(_ seconds: Double) -> String {
let s = Int(seconds.rounded())
let h = s / 3600, m = (s % 3600) / 60, sec = s % 60
return h > 0 ? String(format: "%d:%02d:%02d", h, m, sec)
: String(format: "%d:%02d", m, sec)
}
}