Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 74 additions & 3 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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> {
Expand All @@ -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()
Expand Down Expand Up @@ -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}");
}
Expand All @@ -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}");
}
Expand Down
67 changes: 66 additions & 1 deletion src/shared/localMediaPath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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("拒絕網路共用或裝置路徑");
});
Expand All @@ -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" });
Expand All @@ -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);
}
});
});
27 changes: 23 additions & 4 deletions src/shared/localMediaPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
}
Loading