diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1996e05..7cd6e9a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1485,7 +1485,9 @@ fn string_field<'a>(value: &'a Value, field: &str) -> Result<&'a str, String> { /// UNC (`\\host\share`), `\\?\UNC\` and device (`\\.\`, `\\?\Volume{..}`) paths /// are absolute on Windows, but touching one opens an SMB connection (and may /// send NTLM credentials) to a host named by an untrusted project file. Only a -/// verbatim drive path (`\\?\C:\`) stays local. +/// verbatim drive path (`\\?\C:\`) stays local. `\??\` hands the rest of the +/// path to the NT object manager unparsed (`\??\UNC\host\share`), so no path +/// with that prefix is local media. Mirrors `src/shared/localMediaPath.ts`. fn is_windows_network_path(path: &str) -> bool { let normalized = path.replace('/', "\\"); let bytes = normalized.as_bytes(); @@ -1494,7 +1496,36 @@ fn is_windows_network_path(path: &str) -> bool { && bytes[4].is_ascii_alphabetic() && bytes[5] == b':' && bytes.get(6).is_none_or(|byte| *byte == b'\\'); - normalized.starts_with("\\\\") && !verbatim_drive + (normalized.starts_with("\\\\") && !verbatim_drive) + || normalized.starts_with("\\??\\") + || normalized + .split(['\\', ':']) + .any(is_windows_reserved_device_name) +} + +/// Win32 opens a DOS device instead of a file for these names in any letter +/// case, also with trailing spaces (`NUL `) and, before Windows 11, with an +/// extension (`nul.txt`, `NUL .txt`); `:` also ends a name (`C:NUL`). Classic +/// Win32 only maps the final component to a device; every component is checked +/// anyway, a conservative choice that also covers a folder later opened on its +/// own. COM0 and LPT0 are not on Microsoft's current list but are rejected too. +fn is_windows_reserved_device_name(name: &str) -> bool { + let base = name + .split('.') + .next() + .unwrap_or_default() + .trim_end_matches(' ') + .to_uppercase(); + match base.as_str() { + "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" => true, + _ => base + .strip_prefix("COM") + .or_else(|| base.strip_prefix("LPT")) + .is_some_and(|port| { + let mut chars = port.chars(); + matches!(chars.next(), Some('0'..='9' | '¹' | '²' | '³')) && chars.next().is_none() + }), + } } fn allow_path(app: &AppHandle, path: &str) -> Result<(), String> { @@ -1504,7 +1535,7 @@ fn allow_path(app: &AppHandle, path: &str) -> Result<(), String> { } if cfg!(windows) && is_windows_network_path(path) { return Err(format!( - "拒絕網路共用或裝置路徑作為媒體來源,請先複製到本機磁碟:{path}" + "拒絕網路共用或裝置路徑作為媒體來源,請先以一般檔名複製到本機磁碟:{path}" )); } app.asset_protocol_scope() @@ -10739,6 +10770,39 @@ mod tests { r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\clip.mp4", r"\\?\GLOBALROOT\Device\HarddiskVolume1\clip.mp4", r"\\?\", + r"\??\UNC\host\share\clip.mp4", + r"\??\C:\media\clip.mp4", + "/??/UNC/host/share/clip.mp4", + r"C:\media\NUL", + r"C:\media\nul.txt", + r"C:\x\CON", + r"C:\media\aux.mp4", + r"C:\media\PRN", + r"C:\media\COM1", + r"C:\media\com9.mp4", + r"C:\media\LPT1", + r"C:\media\lpt9.wav", + r"C:\media\COM¹", + r"C:\media\com².mp4", + r"C:\media\COM³", + r"C:\media\LPT¹.mp4", + r"C:\media\lpt²", + r"C:\media\LPT³", + r"C:\media\CONIN$", + r"C:\media\conout$.mp4", + r"C:\media\nul.", + r"C:\media\NUL .txt", + r"C:\media\NUL ", + r"C:\media\Nul.tar.gz", + "C:/media/nul.mp4", + "C:NUL", + r"C:\media\NUL:stream", + "nul.mp4", + // Conservative choices: a non-final component, a verbatim path, COM0 and LPT0. + r"C:\media\NUL\clip.mp4", + r"\\?\C:\media\NUL", + r"C:\media\COM0.mp4", + r"C:\media\LPT0.mp4", ] { assert!(is_windows_network_path(path), "{path}"); } @@ -10749,6 +10813,13 @@ mod tests { r"\\?\c:", "/Users/someone/clip.mp4", "media/clip.mp4", + r"C:\media\null.mp4", + r"C:\media\console.mp4", + r"C:\media\COM10.mp4", + r"C:\media\LPT10.mp4", + r"C:\media\clip.nul.mp4", + r"C:\media\auxiliary\clip.mp4", + r"C:\media\nul-cut\clip.mp4", ] { assert!(!is_windows_network_path(path), "{path}"); } diff --git a/src/shared/localMediaPath.test.ts b/src/shared/localMediaPath.test.ts index 23c3cca..1385a4e 100644 --- a/src/shared/localMediaPath.test.ts +++ b/src/shared/localMediaPath.test.ts @@ -13,6 +13,42 @@ const NETWORK_PATHS = [ String.raw`\\.\pipe\name`, String.raw`\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\clip.mp4`, String.raw`\\?\GLOBALROOT\Device\HarddiskVolume1\clip.mp4`, + String.raw`\??\UNC\host\share\clip.mp4`, + String.raw`\??\C:\media\clip.mp4`, + "/??/UNC/host/share/clip.mp4", +]; + +const DEVICE_NAME_PATHS = [ + String.raw`C:\media\NUL`, + String.raw`C:\media\nul.txt`, + String.raw`C:\x\CON`, + String.raw`C:\media\aux.mp4`, + String.raw`C:\media\PRN`, + String.raw`C:\media\COM1`, + String.raw`C:\media\com9.mp4`, + String.raw`C:\media\LPT1`, + String.raw`C:\media\lpt9.wav`, + String.raw`C:\media\COM¹`, + String.raw`C:\media\com².mp4`, + String.raw`C:\media\COM³`, + String.raw`C:\media\LPT¹.mp4`, + String.raw`C:\media\lpt²`, + String.raw`C:\media\LPT³`, + String.raw`C:\media\CONIN$`, + String.raw`C:\media\conout$.mp4`, + String.raw`C:\media\nul.`, + String.raw`C:\media\NUL .txt`, + String.raw`C:\media\NUL `, + String.raw`C:\media\Nul.tar.gz`, + "C:/media/nul.mp4", + "C:NUL", + String.raw`C:\media\NUL:stream`, + "nul.mp4", + // Conservative choices: a non-final component, a verbatim path, COM0 and LPT0. + String.raw`C:\media\NUL\clip.mp4`, + String.raw`\\?\C:\media\NUL`, + String.raw`C:\media\COM0.mp4`, + String.raw`C:\media\LPT0.mp4`, ]; const LOCAL_PATHS = [ @@ -22,10 +58,17 @@ const LOCAL_PATHS = [ String.raw`\\?\c:`, "/Users/someone/clip.mp4", "media/clip.mp4", + String.raw`C:\media\null.mp4`, + String.raw`C:\media\console.mp4`, + String.raw`C:\media\COM10.mp4`, + String.raw`C:\media\LPT10.mp4`, + String.raw`C:\media\clip.nul.mp4`, + String.raw`C:\media\auxiliary\clip.mp4`, + String.raw`C:\media\nul-cut\clip.mp4`, ]; describe("local media path guard", () => { - it.each(NETWORK_PATHS)("classifies %s as a network or device path", (path) => { + it.each([...NETWORK_PATHS, ...DEVICE_NAME_PATHS])("classifies %s as a network or device path", (path) => { expect(isWindowsNetworkPath(path)).toBe(true); expect(() => assertLocalMediaPath(path, "win32")).toThrow("拒絕網路共用或裝置路徑"); }); @@ -40,6 +83,11 @@ describe("local media path guard", () => { expect(() => assertLocalMediaPath("//host/share/clip.mp4", "darwin")).not.toThrow(); }); + it.each(["/Users/someone/NUL", "/media/con.mp4", "/media/COM1", String.raw`\??\C:\media\clip.mp4`])("keeps %s usable on POSIX", (path) => { + expect(() => assertLocalMediaPath(path, "linux")).not.toThrow(); + expect(() => assertLocalMediaPath(path, "darwin")).not.toThrow(); + }); + it("refuses a UNC source before any process or file access on Windows", async () => { const platform = Object.getOwnPropertyDescriptor(process, "platform")!; Object.defineProperty(process, "platform", { value: "win32" }); @@ -56,4 +104,21 @@ describe("local media path guard", () => { Object.defineProperty(process, "platform", platform); } }); + + it("refuses a DOS device name or NT object path before any process or file access on Windows", async () => { + const platform = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { value: "win32" }); + try { + for (const source of [String.raw`C:\media\CON`, String.raw`C:\media\com1.mp4`, String.raw`\??\UNC\attacker\share\clip.mp4`]) { + await expect(inspectMedia(source, "/nonexistent/ffprobe")).rejects.toThrow("拒絕網路共用或裝置路徑"); + await expect(analyzeSceneCuts({ sourcePath: source } as never, { ffmpegPath: "/nonexistent/ffmpeg" })).rejects.toThrow("拒絕網路共用或裝置路徑"); + await expect(probeMedia(source, "/nonexistent/ffprobe")).rejects.toThrow("拒絕網路共用或裝置路徑"); + await expect(analyzeAutomaticCaptionTranscript({ sourcePath: source } as never, {} as never)).rejects.toThrow("拒絕網路共用或裝置路徑"); + expect(() => resolveMediaPath(source)).toThrow("拒絕網路共用或裝置路徑"); + expect(() => resolveMediaPath("clip.mp4", source)).toThrow("拒絕網路共用或裝置路徑"); + } + } finally { + Object.defineProperty(process, "platform", platform); + } + }); }); diff --git a/src/shared/localMediaPath.ts b/src/shared/localMediaPath.ts index 4bfd099..7b21516 100644 --- a/src/shared/localMediaPath.ts +++ b/src/shared/localMediaPath.ts @@ -3,20 +3,39 @@ * paths are absolute, but touching them makes Windows open an SMB connection * (and may send NTLM credentials) to a host named by an untrusted project file. * The only `\\` form that stays local is a verbatim drive path (`\\?\C:\…`). + * `\??\` hands the rest of the path to the NT object manager unparsed + * (`\??\UNC\host\share\…`), so no path with that prefix is local media. */ const VERBATIM_DRIVE_PATH = /^\\\\\?\\[A-Za-z]:(?:\\|$)/; +/** + * Win32 opens a DOS device instead of a file for these names in any letter case, + * also with trailing spaces (`NUL `) and, before Windows 11, with an extension + * (`nul.txt`, `NUL .txt`); `:` also ends a name (`C:NUL`). Classic Win32 only + * maps the final component to a device; every component is checked anyway, a + * conservative choice that also covers a folder later opened on its own. + * COM0 and LPT0 are not on Microsoft's current list but are rejected too. + */ +const RESERVED_DEVICE_NAME = /^(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|(?:COM|LPT)[0-9¹²³])$/; + +function hasReservedDeviceName(normalized: string): boolean { + return normalized.split(/[\\:]/).some((name) => + RESERVED_DEVICE_NAME.test(name.split(".", 1)[0].replace(/ +$/, "").toUpperCase())); +} + export function isWindowsNetworkPath(path: string): boolean { const normalized = path.replaceAll("/", "\\"); - return normalized.startsWith("\\\\") && !VERBATIM_DRIVE_PATH.test(normalized); + return (normalized.startsWith("\\\\") && !VERBATIM_DRIVE_PATH.test(normalized)) + || normalized.startsWith("\\??\\") + || hasReservedDeviceName(normalized); } /** - * Rejects network and device paths on Windows. Elsewhere `//x` is an ordinary - * POSIX path, so nothing is rejected. + * Rejects network, NT object and DOS device paths on Windows. Elsewhere `//x` + * and `NUL` are ordinary POSIX paths, so nothing is rejected. */ export function assertLocalMediaPath(path: string, platform: NodeJS.Platform = process.platform): void { if (platform === "win32" && isWindowsNetworkPath(path)) { - throw new Error(`拒絕網路共用或裝置路徑作為媒體來源,請先複製到本機磁碟:${path}`); + throw new Error(`拒絕網路共用或裝置路徑作為媒體來源,請先以一般檔名複製到本機磁碟:${path}`); } }