現(xiàn)跨操作系統(tǒng)的文件路徑處理)
前言跨平臺(tái)路徑處理的典型事故現(xiàn)場(chǎng)開發(fā)在 Windows 上寫代碼測(cè)試環(huán)境是 Linux打包上線后讀取緩存目錄失敗報(bào)failed to open stream: No such file or directory但路徑打印出來看「明明是對(duì)的」?;蛘叻催^來代碼在 Linux 上跑得好好的同事拉下來在 Windows 上開發(fā)basename()返回了整個(gè)路徑日志分析腳本直接把完整路徑當(dāng)文件名寫進(jìn)了報(bào)告。這類問題的根因只有一個(gè)路徑是字符串而字符串里的分隔符在不同系統(tǒng)上不是同一個(gè)字符。加上 Windows 還多了一堆別人沒有的東西——盤符、UNC 路徑、大小寫不敏感、MAX_PATH長(zhǎng)度限制——用「拼接字符串」的思路寫路徑必然會(huì)在某個(gè)平臺(tái)上翻車。標(biāo)題把這件事掛在 PHP 8.0 上這里要說清楚跨平臺(tái)路徑處理不是 PHP 8.0 引入的特性路徑相關(guān)的常量和函數(shù)DIRECTORY_SEPARATOR、PATH_SEPARATOR、basename()、dirname()、realpath()在更早的版本里就存在。PHP 8.0 對(duì)這件事的實(shí)際貢獻(xiàn)是三個(gè)新的字符串函數(shù)——str_contains()、str_starts_with()、str_ends_with()——它們讓「判斷路徑前綴」「判斷目錄歸屬」這類操作不用再寫substr()加偏移量本文就按 PHP 8.0 及以上的寫法來講。一、兩個(gè)分隔符別用混PHP 里和路徑有關(guān)的常量有兩個(gè)名字很像但用途完全不同常量Windows 上的值Linux/macOS 上的值用在哪DIRECTORY_SEPARATOR\/目錄層級(jí)分隔PATH_SEPARATOR;:include_path、PATH這類「多個(gè)路徑的列表」把PATH_SEPARATOR當(dāng)成目錄分隔符用是一個(gè)很經(jīng)典的錯(cuò)誤——它倆在某些系統(tǒng)上恰好都是單個(gè)字符跑起來不報(bào)錯(cuò)只是行為詭異?php declare(strict_types1); // 需要 PHP 7.2PHP_OS_FAMILY 是 7.2 引入的 echo PHP_OS_FAMILY, PHP_EOL; // Windows / Linux / Darwin / BSD / Solaris / Unknown echo 目錄分隔符 . DIRECTORY_SEPARATOR . PHP_EOL; echo 列表分隔符 . PATH_SEPARATOR . PHP_EOL; // 只有當(dāng)你要拼「多個(gè)目錄組成的搜索路徑」時(shí)才用它 $dirs [/opt/app/src, /opt/app/lib]; $includePath implode(PATH_SEPARATOR, $dirs); set_include_path($includePath . PATH_SEPARATOR . get_include_path());而DIRECTORY_SEPARATOR的使用場(chǎng)景比多數(shù)人想象的要少。原因是Windows 上的 PHP 同時(shí)接受/和\文件系統(tǒng)函數(shù)、include、require都能識(shí)別正斜杠反過來Linux 上的 PHP 完全不認(rèn)\它會(huì)老老實(shí)實(shí)把反斜杠當(dāng)成文件名的合法字符。所以寫進(jìn)代碼里的路徑常量統(tǒng)一用/最省事在所有平臺(tái)上都能被 PHP 解析。DIRECTORY_SEPARATOR只在「要把路徑顯示給人看」或者「要交給外部程序比如在 Windows 上調(diào)用cmd」時(shí)才有必要用。拼接多個(gè)路徑段時(shí)用/拼再交給下面的規(guī)范化函數(shù)統(tǒng)一處理。二、真正的頭號(hào)殺手雙引號(hào)里的反斜杠在講拼接函數(shù)之前必須先講這個(gè)坑因?yàn)樗人锌缙脚_(tái)問題加起來都更常見?php declare(strict_types1); // ? 雙引號(hào)字符串\n、\t、\r 會(huì)被當(dāng)成轉(zhuǎn)義序列 $bad C:\path\to\new\file.txt; // \p → 原樣輸出 \p但 \t 是制表符、\n 是換行、\f 是換頁(yè) echo $bad . PHP_EOL; // 輸出里出現(xiàn)換行和制表符路徑徹底廢掉 // ? 單引號(hào)字符串只有 \\ 和 \ 是轉(zhuǎn)義序列其余原樣保留 $good C:\path\to\new\file.txt; echo $good . PHP_EOL; // ? 或者用正斜杠從根上繞開這個(gè)問題 $better C:/path/to/new/file.txt; echo $better . PHP_EOL;單引號(hào)也不是萬能的結(jié)尾的反斜杠會(huì)轉(zhuǎn)義掉收尾的單引號(hào)。?php declare(strict_types1); // ? 解析錯(cuò)誤末尾的 \ 把 轉(zhuǎn)義掉了 // $dir C:\temp\; // ? 寫成兩個(gè)反斜杠或者干脆用正斜杠 $dir C:\temp\\; $dir C:/temp/;在 PHP 8.0 下正確的做法是代碼里永遠(yuǎn)寫正斜杠。需要接收用戶輸入的 Windows 路徑時(shí)在入口處統(tǒng)一替換掉反斜杠即可。三、寫一個(gè) normalize把任意路徑變成統(tǒng)一形式跨平臺(tái)的本質(zhì)是「內(nèi)部只保留一種表示形式在邊界上轉(zhuǎn)換」。下面這個(gè)規(guī)范化的實(shí)現(xiàn)保持了..語(yǔ)義、保留了盤符與 URI 前綴可以放心用?php declare(strict_types1); // 需要 PHP 8.0用到 str_starts_with final class PathTool { /** * 把路徑規(guī)范化成「統(tǒng)一用 / 分隔、無 .、無冗余 ..、無重復(fù)分隔符」的形式。 * 保留 Windows 盤符、UNC 前綴和 URI 協(xié)議前綴。 */ public static function normalize(string $path): string { // 1) 反斜杠一律變正斜杠Windows 自己也認(rèn)正斜杠反向則不行 $path str_replace(\\, /, $path); // 2) 摘出前綴file://、phar:// 這類協(xié)議或 C: 盤符或 UNC 的 // $prefix ; if (preg_match(#^([a-z][a-z0-9.\-]*://)#i, $path, $m) 1) { $prefix $m[1]; $path substr($path, strlen($prefix)); } elseif (preg_match(#^([A-Za-z]:)#, $path, $m) 1) { $prefix $m[1]; $path substr($path, 2); } elseif (str_starts_with($path, //)) { $prefix //; $path substr($path, 2); } $isAbsolute str_starts_with($path, /); $segments []; foreach (explode(/, $path) as $seg) { if ($seg || $seg .) { continue; // 空段和 . 直接丟棄 } if ($seg ..) { $last $segments [] ? null : end($segments); if ($last ! null $last ! ..) { array_pop($segments); // 能抵消就抵消 } elseif (!$isAbsolute $prefix ) { $segments[] ..; // 相對(duì)路徑的 .. 要保留 } continue; } $segments[] $seg; } return $prefix . ($isAbsolute ? / : ) . implode(/, $segments); } /** 拼接若干路徑段后面的絕對(duì)路徑會(huì)被當(dāng)成相對(duì)段處理不會(huì)覆蓋前面 */ public static function join(string ...$parts): string { $out ; foreach ($parts as $i $p) { if ($p ) { continue; } $out ($i 0 || $out ) ? $p : rtrim($out, /) . / . ltrim($p, /); } return self::normalize($out); } /** 路徑相等比較Windows 下大小寫不敏感Linux 下敏感 */ public static function equals(string $a, string $b): bool { $a self::normalize($a); $b self::normalize($b); return PHP_OS_FAMILY Windows ? strcasecmp($a, $b) 0 : $a $b; } /** * 判斷 $child 是否位于 $parent 目錄之內(nèi)。 * 關(guān)鍵是給前綴補(bǔ)上分隔符否則 /var/www2 會(huì)被誤判為 /var/www 的子路徑。 */ public static function isInside(string $child, string $parent): bool { $parent rtrim(self::normalize($parent), /); if ($parent ) { return false; } $child self::normalize($child); $needle $parent . /; if (PHP_OS_FAMILY Windows) { $child strtolower($child); $needle strtolower($needle); } return str_starts_with($child, $needle); } }幾個(gè)設(shè)計(jì)要點(diǎn)..不能無腦抵消。/../etc/passwd里第一個(gè)..已經(jīng)沒有上一級(jí)可退如果再把它壓掉路徑就變成了/etc/passwd語(yǔ)義完全變了而相對(duì)路徑../a里的..是必須保留的。前綴要單獨(dú)摘出來。phar://后面跟的三個(gè)斜杠不能被壓成兩個(gè)盤符后面的C:也不能被當(dāng)成普通目錄名。isInside()必須補(bǔ)分隔符。直接str_starts_with($child, $parent)會(huì)讓/var/www2通過/var/www的判斷這是一個(gè)經(jīng)常被用來繞過目錄限制的經(jīng)典漏洞。四、完整可運(yùn)行示例?php declare(strict_types1); // 需要 PHP 8.0。把上面的 PathTool 類貼進(jìn)來或單獨(dú)存成 PathTool.php 后 require。 $cases [ C:\\Users\\alice\\..\\bob\\.\\docs//a.txt, /var/www/../www2/logs/./app.log, phar:///tmp/app.phar/src/../lib/Foo.php, //server/share/folder/../other/file.txt, ../../etc/passwd, /../etc/passwd, ]; echo normalize \n; foreach ($cases as $c) { printf(%-46s %s\n, $c, PathTool::normalize($c)); } echo \n join \n; printf(join(/var/www, app, /storage/logs) %s\n, PathTool::join(/var/www, app, /storage/logs)); printf(join(/, a, b) %s\n, PathTool::join(/, a, b)); printf(join(C:\\temp, file.txt) %s\n, PathTool::join(C:\temp, file.txt)); echo \n equals \n; var_dump(PathTool::equals(/var/www/app/, /var/www/app)); var_dump(PathTool::equals(/var/www/App, /var/www/app)); echo \n isInside \n; var_dump(PathTool::isInside(/var/www/app/config.php, /var/www/app)); // true var_dump(PathTool::isInside(/var/www2/app/config.php, /var/www/app)); // false echo \n 真實(shí)文件操作用規(guī)范化的路徑去讀寫 \n; $base sys_get_temp_dir(); // 跨平臺(tái)拿到臨時(shí)目錄 $file PathTool::join($base, p16, demo.txt); if (!is_dir(dirname($file)) !mkdir(dirname($file), 0777, true) !is_dir(dirname($file))) { exit(無法創(chuàng)建目錄 . dirname($file) . PHP_EOL); } file_put_contents($file, hello\n); printf(寫入 %s讀回 %s, $file, file_get_contents($file)); printf(realpath 結(jié)果%s\n, realpath($file)); printf(basename%s, dirname%s\n, basename($file), dirname($file)); unlink($file);輸出的關(guān)鍵幾行在 Linux 上C:\Users\alice\..\bob\.\docs//a.txt C:/Users/bob/docs/a.txt /var/www/../www2/logs/./app.log /var/www2/logs/app.log phar:///tmp/app.phar/src/../lib/Foo.php phar:///tmp/app.phar/lib/Foo.php //server/share/folder/../other/file.txt //server/share/other/file.txt ../../etc/passwd ../../etc/passwd /../etc/passwd /etc/passwd注意最后一行根目錄之上的..被丟棄了這是符合 POSIX 語(yǔ)義的/..就是/。同時(shí)也說明單靠字符串規(guī)范化做不了安全校驗(yàn)真正的邊界判斷要落在realpath()加白名單目錄比較上。常見坑點(diǎn)1. 用PATH_SEPARATOR拼目錄?$dir uploads . PATH_SEPARATOR . 2024;—— 在 Linux 上PATH_SEPARATOR是:得到uploads:2024一個(gè)根本不存在的文件名。 ? 拼目錄用/所有平臺(tái)都認(rèn)多目錄列表才用PATH_SEPARATOR。2.basename()在 Linux 上不認(rèn)反斜杠?basename(C:\logs\app.log)—— 在 Windows 上返回app.log在 Linux 上返回整串C:\logs\app.log。同一個(gè)函數(shù)、同一份代碼兩個(gè)平臺(tái)結(jié)果不同。 ? 先str_replace(\\, /, $path)再交給basename()或者統(tǒng)一用PathTool::normalize()處理。3. 拿DIRECTORY_SEPARATOR做字符串前綴比較?if (str_starts_with($path, $baseDir . DIRECTORY_SEPARATOR))—— 當(dāng)調(diào)用方傳進(jìn)來的$path用的是正斜杠而$baseDir用的是反斜杠時(shí)Windows 上常見判斷直接失效。 ? 兩邊都先規(guī)范化成同一種分隔符再比較。4. 忘記給目錄前綴補(bǔ)分隔符?if (str_starts_with($file, /var/www))——/var/www2/secret.txt也會(huì)通過這是路徑穿越漏洞的經(jīng)典形態(tài)。 ? 比較/var/www/帶尾部分隔符或者用規(guī)范化后的realpath()結(jié)果與白名單目錄逐一比對(duì)。5. Windows 下大小寫敏感誤判? 用$a $b判斷兩個(gè)路徑指向同一個(gè)文件 —— Windows 的 NTFS 默認(rèn)大小寫不敏感C:\Temp和c:\TEMP是同一個(gè)目錄判斷會(huì)漏。 ? 用PHP_OS_FAMILY Windows ? strcasecmp($a, $b) 0 : $a $b或者在關(guān)鍵路徑上統(tǒng)一realpath()。6. 把用戶傳來的路徑直接拼進(jìn)include或readfile()?readfile($base . / . $_GET[file]);——../可以直接跑出基目錄。 ? 拼完后realpath()確認(rèn)結(jié)果仍位于允許的目錄內(nèi)并且文件確實(shí)存在只允許白名單擴(kuò)展名。注意realpath()對(duì)不存在的路徑返回false這本身就是一次有效的存在性校驗(yàn)。7. 忘了 Windows 的路徑長(zhǎng)度限制? 假設(shè)路徑長(zhǎng)度沒有上限用深層嵌套目錄緩存 —— 傳統(tǒng) Windows API 的MAX_PATH是 260 個(gè)字符超出后即使目錄存在也會(huì)報(bào)文件找不到。 ? 控制目錄層級(jí)與文件名長(zhǎng)度確需長(zhǎng)路徑時(shí)用\\?\前綴僅絕對(duì)路徑有效或改用短哈希目錄名。Linux 上雖然限制寬松單段文件名也不應(yīng)超過 255 字節(jié)。8. 在 shell 命令里手拼路徑?exec(tar -czf . $file . . $dir);—— 路徑里有空格、中文或引號(hào)時(shí)命令直接崩還有命令注入風(fēng)險(xiǎn)。 ? 用escapeshellarg()包住每個(gè)參數(shù)并記住它的行為在不同操作系統(tǒng)上不一樣Windows 與 Unix 的轉(zhuǎn)義規(guī)則不同不要跨平臺(tái)復(fù)用同一段拼好的字符串??偨Y(jié)需求做法別做代碼里寫路徑一律用/寫\尤其別寫在雙引號(hào)里拼接目錄用/拼再做一次規(guī)范化用PATH_SEPARATOR展示路徑需要時(shí)再替換成DIRECTORY_SEPARATOR在內(nèi)部數(shù)據(jù)里混用兩種分隔符多目錄列表PATH_SEPARATORinclude_path與目錄分隔符混為一談判前綴規(guī)范化 補(bǔ)尾部分隔符 str_starts_with()裸str_starts_with($path, $base)判同一文件realpath()后比較Windows 下忽略大小寫直接比較原始字符串取臨時(shí)目錄sys_get_temp_dir()硬編碼/tmp或C:\Windows\Temp拼 shell 參數(shù)escapeshellarg()手寫引號(hào)跨平臺(tái)路徑處理沒有捷徑核心就是三條紀(jì)律內(nèi)部只用正斜杠、所有入口先規(guī)范化、比較之前先補(bǔ)齊分隔符與大小寫。把這三條落實(shí)到一個(gè)PathTool里Windows 與 Linux 的差異就只剩下realpath()的輸出格式和大小寫規(guī)則兩處都在你的掌控之中了。