Fix SFTP root navigation on Windows

Use portable POSIX parent paths instead of platform-dependent dirname() for remote navigation, previews, uploads, file operations and shared URLs.

Normalize browser paths, avoid doubled root separators and preserve top-level directory names in ZIP downloads.

Add regression coverage for root navigation, generated links, relative paths and ZIP members, and document the new test command. Verified with PHP syntax checks and all three regression suites.
This commit is contained in:
Michal Sedlák
2026-10-16 10:36:57 +02:00
parent 574662c1cc
commit dc452059f3
14 changed files with 94 additions and 42 deletions
+3 -1
View File
@@ -105,7 +105,8 @@ USB-RAID-Array/
│ └── ... # původní technická dokumentace a podklady │ └── ... # původní technická dokumentace a podklady
├── tests/ ├── tests/
│ ├── workspace_test.php │ ├── workspace_test.php
│ └── serverstats_test.php │ ├── serverstats_test.php
│ └── remotepaths_test.php
└── Web/ └── Web/
├── index.php # přihlášení ├── index.php # přihlášení
├── config/config.php # databáze, SFTP a SSH ├── config/config.php # databáze, SFTP a SSH
@@ -127,6 +128,7 @@ Z kořene repozitáře:
```sh ```sh
php tests/workspace_test.php php tests/workspace_test.php
php tests/serverstats_test.php php tests/serverstats_test.php
php tests/remotepaths_test.php
node --check Web/js/theme.js node --check Web/js/theme.js
node --check Web/js/ui.js node --check Web/js/ui.js
node --check Web/content/ftp/js/index.js node --check Web/content/ftp/js/index.js
+1 -13
View File
@@ -3,6 +3,7 @@
// Autoload dependencies (phpseclib) // Autoload dependencies (phpseclib)
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../includes/remotepaths.php';
use phpseclib3\Net\SFTP; use phpseclib3\Net\SFTP;
use phpseclib3\Net\SSH2; use phpseclib3\Net\SSH2;
@@ -56,17 +57,4 @@ function getSSHConnection(): SSH2 {
return $ssh; return $ssh;
} }
/** Utility: Normalise a path */
function normalizePath(string $path): string {
$parts = array_filter(explode('/', $path), fn($part) => $part !== '' && $part !== '.');
$stack = [];
foreach ($parts as $part) {
if ($part === '..') {
array_pop($stack);
} else {
$stack[] = $part;
}
}
return '/' . implode('/', $stack);
}
?> ?>
+1 -1
View File
@@ -5,7 +5,7 @@ $sftp = initializeSFTP();
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete'])) {
$itemToDelete = $_POST['delete']; $itemToDelete = $_POST['delete'];
$parentDir = dirname($itemToDelete); $parentDir = remoteDirname($itemToDelete);
if ($parentDir === '/' || $parentDir === '.') { if ($parentDir === '/' || $parentDir === '.') {
$parentDir = $defaultPath; $parentDir = $defaultPath;
+1 -1
View File
@@ -41,7 +41,7 @@ try {
$fullRemotePath = rtrim($currentPath, '/') . '/' . $file; $fullRemotePath = rtrim($currentPath, '/') . '/' . $file;
$baseDirName = basename($remoteBasePath); $baseDirName = basename($remoteBasePath);
$relPathFromBase = substr($fullRemotePath, strlen(dirname($remoteBasePath)) + 1); $relPathFromBase = substr($fullRemotePath, strlen(rtrim(remoteDirname($remoteBasePath), '/')) + 1);
file_put_contents($logFile, "Processing: $fullRemotePath (relative: $relPathFromBase)\n", FILE_APPEND); file_put_contents($logFile, "Processing: $fullRemotePath (relative: $relPathFromBase)\n", FILE_APPEND);
$isDir = $sftp->is_dir($fullRemotePath); $isDir = $sftp->is_dir($fullRemotePath);
+1 -1
View File
@@ -24,7 +24,7 @@ $sftp = initializeSFTP();
// Process extraction request // Process extraction request
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['archive'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['archive'])) {
$archivePath = $_POST['archive']; $archivePath = $_POST['archive'];
$dirPath = dirname($archivePath); $dirPath = remoteDirname($archivePath);
$archiveName = basename($archivePath); $archiveName = basename($archivePath);
$extension = pathinfo($archiveName, PATHINFO_EXTENSION); $extension = pathinfo($archiveName, PATHINFO_EXTENSION);
+16 -15
View File
@@ -13,11 +13,12 @@
error_reporting(E_ALL); error_reporting(E_ALL);
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../includes/workspace.php'; require_once __DIR__ . '/../../includes/workspace.php';
$sftp = initializeSFTP(); $sftp = initializeSFTP();
$defaultPath = normalizePath($defaultPath);
$currentPath = isset($_GET['path']) ? normalizePath($_GET['path']) : $defaultPath; $currentPath = isset($_GET['path']) ? normalizePath($_GET['path']) : $defaultPath;
if (strpos($currentPath, $defaultPath) !== 0) { if ($currentPath !== $defaultPath && strpos($currentPath, rtrim($defaultPath, '/') . '/') !== 0) {
$currentPath = $defaultPath; $currentPath = $defaultPath;
} }
@@ -188,30 +189,30 @@ require_once __DIR__ . '/../../includes/workspace.php';
if ($currentPath !== $defaultPath) : ?> if ($currentPath !== $defaultPath) : ?>
<tr> <tr>
<td colspan="3"><a class="text-danger" href="?path=<?= urlencode(dirname($currentPath)) ?>"><b>↑ Parent folder</b></a></td> <td colspan="3"><a class="text-danger" href="?path=<?= urlencode(remoteDirname($currentPath)) ?>"><b>↑ Parent folder</b></a></td>
</tr> </tr>
<?php endif; <?php endif;
foreach ($directories as $directory) : ?> foreach ($directories as $directory) : ?>
<tr data-file-name="<?= htmlspecialchars($directory) ?>" data-kind="folder"> <tr data-file-name="<?= htmlspecialchars($directory) ?>" data-kind="folder">
<td><span class="file-icon folder-icon" aria-hidden="true">▰</span> <td><span class="file-icon folder-icon" aria-hidden="true">▰</span>
<a href="?path=<?= urlencode($currentPath . '/' . $directory) ?>"><?= htmlspecialchars($directory) ?></a></td><td class="size-cell">—</td> <a href="?path=<?= urlencode(rtrim($currentPath, '/') . '/' . $directory) ?>"><?= htmlspecialchars($directory) ?></a></td><td class="size-cell">—</td>
<td> <td>
<?php if (isset($_SESSION["delPer"]) && $_SESSION["delPer"] == true) : ?> <?php if (isset($_SESSION["delPer"]) && $_SESSION["delPer"] == true) : ?>
<form method="POST" action="delete.php" style="display:inline;"> <form method="POST" action="delete.php" style="display:inline;">
<input type="hidden" name="delete" value="<?= htmlspecialchars($currentPath . '/' . $directory) ?>"> <input type="hidden" name="delete" value="<?= htmlspecialchars(rtrim($currentPath, '/') . '/' . $directory) ?>">
<button type="submit" class="btn btn-danger mt-1 mb-1" onclick="confirmDelete(event)">Delete</button> <button type="submit" class="btn btn-danger mt-1 mb-1" onclick="confirmDelete(event)">Delete</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php if (isset($_SESSION["downPer"]) && $_SESSION["downPer"] == true) : ?> <?php if (isset($_SESSION["downPer"]) && $_SESSION["downPer"] == true) : ?>
<form method="POST" action="download.php" style="display:inline;"> <form method="POST" action="download.php" style="display:inline;">
<input type="hidden" name="file" value="<?= htmlspecialchars($currentPath . '/' . $directory) ?>"> <input type="hidden" name="file" value="<?= htmlspecialchars(rtrim($currentPath, '/') . '/' . $directory) ?>">
<button type="submit" class="btn btn-success mt-1 mb-1">Download</button> <button type="submit" class="btn btn-success mt-1 mb-1">Download</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) : ?> <?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) : ?>
<button type="button" class="btn btn-warning mt-1 mb-1" onclick="openRenameModal('<?= htmlspecialchars(addslashes($directory)) ?>', '<?= htmlspecialchars(addslashes($currentPath . '/' . $directory)) ?>', true)">Rename</button> <button type="button" class="btn btn-warning mt-1 mb-1" onclick="openRenameModal('<?= htmlspecialchars(addslashes($directory)) ?>', '<?= htmlspecialchars(addslashes(rtrim($currentPath, '/') . '/' . $directory)) ?>', true)">Rename</button>
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
@@ -235,7 +236,7 @@ require_once __DIR__ . '/../../includes/workspace.php';
<tr data-file-name="<?= htmlspecialchars($file) ?>" data-kind="file"> <tr data-file-name="<?= htmlspecialchars($file) ?>" data-kind="file">
<td><span class="file-icon" aria-hidden="true">▤</span> <td><span class="file-icon" aria-hidden="true">▤</span>
<?php if ($isMedia): ?> <?php if ($isMedia): ?>
<a class="text-info-emphasis" href="view.php?file=<?= urlencode($currentPath . '/' . $file) ?>"> <a class="text-info-emphasis" href="view.php?file=<?= urlencode(rtrim($currentPath, '/') . '/' . $file) ?>">
<?= htmlspecialchars($file) ?> <?= htmlspecialchars($file) ?>
</a> </a>
<?php if ($isImage): ?> <?php if ($isImage): ?>
@@ -246,12 +247,12 @@ require_once __DIR__ . '/../../includes/workspace.php';
<span class="badge bg-info rounded-pill">Audio</span> <span class="badge bg-info rounded-pill">Audio</span>
<?php endif; ?> <?php endif; ?>
<?php elseif ($isEditable): ?> <?php elseif ($isEditable): ?>
<a class="text-warning-emphasis" href="open.php?file=<?= urlencode($currentPath . '/' . $file) ?>"> <a class="text-warning-emphasis" href="open.php?file=<?= urlencode(rtrim($currentPath, '/') . '/' . $file) ?>">
<?= htmlspecialchars($file) ?> <?= htmlspecialchars($file) ?>
</a> </a>
<span class="badge bg-secondary rounded-pill"><?= htmlspecialchars($fileExtension) ?></span> <span class="badge bg-secondary rounded-pill"><?= htmlspecialchars($fileExtension) ?></span>
<?php elseif ($isPDF): ?> <?php elseif ($isPDF): ?>
<a class="text-info-emphasis" href="pdf.php?file=<?= urlencode($currentPath . '/' . $file) ?>&type=pdf"> <a class="text-info-emphasis" href="pdf.php?file=<?= urlencode(rtrim($currentPath, '/') . '/' . $file) ?>&type=pdf">
<?= htmlspecialchars($file) ?> <?= htmlspecialchars($file) ?>
</a> </a>
<span class="badge bg-danger rounded-pill">PDF</span> <span class="badge bg-danger rounded-pill">PDF</span>
@@ -261,28 +262,28 @@ require_once __DIR__ . '/../../includes/workspace.php';
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
<span class="text"><?= formatBytes($sftp->stat($currentPath . '/' . $file)['size']) ?></span> <span class="text"><?= formatBytes($sftp->stat(rtrim($currentPath, '/') . '/' . $file)['size']) ?></span>
</td> </td>
<td> <td>
<?php if (isset($_SESSION["delPer"]) && $_SESSION["delPer"] == true) : ?> <?php if (isset($_SESSION["delPer"]) && $_SESSION["delPer"] == true) : ?>
<form method="POST" action="delete.php" style="display:inline;"> <form method="POST" action="delete.php" style="display:inline;">
<input type="hidden" name="delete" value="<?= htmlspecialchars($currentPath . '/' . $file) ?>"> <input type="hidden" name="delete" value="<?= htmlspecialchars(rtrim($currentPath, '/') . '/' . $file) ?>">
<button type="submit" class="btn btn-danger mt-1 mb-1" onclick="confirmDelete(event)">Delete</button> <button type="submit" class="btn btn-danger mt-1 mb-1" onclick="confirmDelete(event)">Delete</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php if (isset($_SESSION["downPer"]) && $_SESSION["downPer"] == true) : ?> <?php if (isset($_SESSION["downPer"]) && $_SESSION["downPer"] == true) : ?>
<form method="POST" action="download.php" style="display:inline;"> <form method="POST" action="download.php" style="display:inline;">
<input type="hidden" name="file" value="<?= htmlspecialchars($currentPath . '/' . $file) ?>"> <input type="hidden" name="file" value="<?= htmlspecialchars(rtrim($currentPath, '/') . '/' . $file) ?>">
<button type="submit" class="btn btn-success mt-1 mb-1">Download</button> <button type="submit" class="btn btn-success mt-1 mb-1">Download</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) : ?> <?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) : ?>
<button type="button" class="btn btn-warning mt-1 mb-1" onclick="openRenameModal('<?= htmlspecialchars(addslashes($file)) ?>', '<?= htmlspecialchars(addslashes($currentPath . '/' . $file)) ?>')">Rename</button> <button type="button" class="btn btn-warning mt-1 mb-1" onclick="openRenameModal('<?= htmlspecialchars(addslashes($file)) ?>', '<?= htmlspecialchars(addslashes(rtrim($currentPath, '/') . '/' . $file)) ?>')">Rename</button>
<?php endif; ?> <?php endif; ?>
<?php if ($isArchive && isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) : ?> <?php if ($isArchive && isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) : ?>
<form method="POST" action="extract.php" style="display:inline;"> <form method="POST" action="extract.php" style="display:inline;">
<input type="hidden" name="archive" value="<?= htmlspecialchars($currentPath . '/' . $file) ?>"> <input type="hidden" name="archive" value="<?= htmlspecialchars(rtrim($currentPath, '/') . '/' . $file) ?>">
<button type="submit" class="btn btn-info mt-1 mb-1">Extract</button> <button type="submit" class="btn btn-info mt-1 mb-1">Extract</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
+1 -1
View File
@@ -52,7 +52,7 @@ if (!empty($filePath) && $sftp->file_exists($filePath) && !$sftp->is_dir($filePa
$showLineNumbers = in_array(strtolower($extension), ['php', 'js', 'html', 'css', 'py', 'java', 'c', 'cpp', 'h', 'hpp', 'rb', 'sh', 'xml', 'json']); $showLineNumbers = in_array(strtolower($extension), ['php', 'js', 'html', 'css', 'py', 'java', 'c', 'cpp', 'h', 'hpp', 'rb', 'sh', 'xml', 'json']);
$parentDir = dirname($filePath); $parentDir = remoteDirname($filePath);
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
+1 -1
View File
@@ -134,7 +134,7 @@ if (isset($_GET['stream'])) {
'title' => $fileName, 'title' => $fileName,
'description' => 'Browse your document without leaving the workspace.', 'description' => 'Browse your document without leaving the workspace.',
'connected' => true, 'connected' => true,
'back' => 'index.php?path=' . urlencode(dirname($filePath)), 'back' => 'index.php?path=' . urlencode(remoteDirname($filePath)),
'class' => 'pdf-page', 'class' => 'pdf-page',
]); ?> ]); ?>
+1 -1
View File
@@ -26,7 +26,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
die("Missing required information for renaming."); die("Missing required information for renaming.");
} }
$dirPath = dirname($oldPath); $dirPath = remoteDirname($oldPath);
$oldName = basename($oldPath); $oldName = basename($oldPath);
$newPath = $dirPath . '/' . $newName; $newPath = $dirPath . '/' . $newName;
+2 -2
View File
@@ -20,7 +20,7 @@ function createDirectoryRecursive($sftp, $path) {
return true; return true;
} }
$parent = dirname($path); $parent = remoteDirname($path);
if ($parent != '/' && !$sftp->is_dir($parent)) { if ($parent != '/' && !$sftp->is_dir($parent)) {
createDirectoryRecursive($sftp, $parent); createDirectoryRecursive($sftp, $parent);
} }
@@ -91,7 +91,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!empty($relativePath)) { if (!empty($relativePath)) {
$fileName = basename($relativePath); $fileName = basename($relativePath);
$dirPart = dirname($relativePath); $dirPart = remoteDirname($relativePath);
if ($dirPart !== '.' && $dirPart !== '') { if ($dirPart !== '.' && $dirPart !== '') {
$remoteDirPath = $currentPath . '/' . $dirPart; $remoteDirPath = $currentPath . '/' . $dirPart;
+2 -2
View File
@@ -38,7 +38,7 @@ if (isset($_POST['createShare'])) {
if ($stmt->execute()) { if ($stmt->execute()) {
$shareUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]" . $shareUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]" .
dirname($_SERVER['PHP_SELF']) . "/shared.php?token=$token"; remoteDirname($_SERVER['PHP_SELF']) . "/shared.php?token=$token";
$_SESSION['shareCreated'] = true; $_SESSION['shareCreated'] = true;
$_SESSION['shareUrl'] = $shareUrl; $_SESSION['shareUrl'] = $shareUrl;
@@ -274,7 +274,7 @@ if (isset($_GET['stream'])) {
'title' => $fileName, 'title' => $fileName,
'description' => 'Preview your media, download a copy or create a shared link.', 'description' => 'Preview your media, download a copy or create a shared link.',
'connected' => true, 'connected' => true,
'back' => 'index.php?path=' . urlencode(dirname($filePath)), 'back' => 'index.php?path=' . urlencode(remoteDirname($filePath)),
'class' => 'media-page', 'class' => 'media-page',
]); ?> ]); ?>
+24
View File
@@ -0,0 +1,24 @@
<?php
/** SFTP paths use POSIX separators, regardless of the OS running PHP. */
function normalizePath(string $path): string {
$parts = array_filter(explode('/', $path), fn($part) => $part !== '' && $part !== '.');
$stack = [];
foreach ($parts as $part) {
if ($part === '..') {
array_pop($stack);
} else {
$stack[] = $part;
}
}
return '/' . implode('/', $stack);
}
/** Like dirname(), but only '/' is a separator and the root stays '/'. */
function remoteDirname(string $path): string {
$path = rtrim($path, '/');
if ($path === '') return '/';
$separator = strrpos($path, '/');
if ($separator === false) return '.';
$parent = rtrim(substr($path, 0, $separator), '/');
return $parent === '' ? '/' : $parent;
}
+31
View File
@@ -0,0 +1,31 @@
<?php
require_once __DIR__ . '/../Web/includes/remotepaths.php';
$parents = [
'/' => '/',
'/etc' => '/',
'/etc/' => '/',
'/etc/file.txt' => '/etc',
'//etc//file.txt' => '//etc',
'/folder with spaces/item.txt' => '/folder with spaces',
'folder/file.txt' => 'folder',
'file.txt' => '.',
'/a\\b/file.txt' => '/a\\b', // Backslash is a valid POSIX filename character.
];
foreach ($parents as $path => $expected) {
if (remoteDirname($path) !== $expected) throw new RuntimeException('Incorrect parent for ' . $path);
}
foreach (['/' => '/', '//etc/../' => '/', '/etc/../../' => '/', '/etc/./ssh/' => '/etc/ssh'] as $path => $expected) {
if (normalizePath($path) !== $expected) throw new RuntimeException('Incorrect normalized path for ' . $path);
}
// Round trips must remain correct on both Windows/XAMPP and Linux.
foreach (['/etc', '/mnt/raid', '/folder with spaces'] as $directory) {
$parent = normalizePath(remoteDirname($directory));
if ($parent !== ($directory === '/mnt/raid' ? '/mnt' : '/')) throw new RuntimeException('Invalid navigation round trip');
}
// ZIP paths keep the top-level directory name when it is directly under root.
foreach (['/etc' => '/etc/config.txt', '/mnt/raid' => '/mnt/raid/config.txt'] as $base => $file) {
$relative = substr($file, strlen(rtrim(remoteDirname($base), '/')) + 1);
if ($relative !== ($base === '/etc' ? 'etc/config.txt' : 'raid/config.txt')) throw new RuntimeException('Incorrect ZIP member path');
}
echo "Remote paths: POSIX parents, normalization, root navigation and ZIP paths passed.\n";
+8 -2
View File
@@ -1,5 +1,6 @@
<?php <?php
require_once __DIR__ . '/../Web/includes/workspace.php'; require_once __DIR__ . '/../Web/includes/workspace.php';
require_once __DIR__ . '/../Web/includes/remotepaths.php';
require_once __DIR__ . '/../Web/content/ftp/lib/serverstats.php'; require_once __DIR__ . '/../Web/content/ftp/lib/serverstats.php';
set_error_handler(function ($severity, $message, $file, $line) { set_error_handler(function ($severity, $message, $file, $line) {
if (error_reporting() & $severity) throw new ErrorException($message, 0, $severity, $file, $line); if (error_reporting() & $severity) throw new ErrorException($message, 0, $severity, $file, $line);
@@ -8,9 +9,8 @@ function expectWorkspace($condition, $message) {
if (!$condition) throw new RuntimeException($message); if (!$condition) throw new RuntimeException($message);
} }
function renderWorkspaceFixture($name, $admin = true) { function renderWorkspaceFixture($name, $admin = true, $defaultPath = '/mnt/raid', $currentPath = '/mnt/raid/Workspace') {
$_SESSION = ['uname' => 'michal', 'admin' => $admin, 'upPer' => true, 'downPer' => true, 'delPer' => true]; $_SESSION = ['uname' => 'michal', 'admin' => $admin, 'upPer' => true, 'downPer' => true, 'delPer' => true];
$defaultPath = '/mnt/raid'; $currentPath = '/mnt/raid/Workspace';
$filePath = $currentPath . '/Example.pdf'; $fileName = basename($filePath); $parentDir = $currentPath; $filePath = $currentPath . '/Example.pdf'; $fileName = basename($filePath); $parentDir = $currentPath;
$fileExtension = 'png'; $fileSize = 245760; $mimeType = 'image/png'; $fileExtension = 'png'; $fileSize = 245760; $mimeType = 'image/png';
$isImage = true; $isVideo = false; $isAudio = false; $isImage = true; $isVideo = false; $isAudio = false;
@@ -75,6 +75,12 @@ foreach ($pages as $name) {
$member = renderWorkspaceFixture('index', false); $member = renderWorkspaceFixture('index', false);
expectWorkspace(strpos($member, 'User management') === false && strpos($member, '> Server status</a>') === false, 'Member navigation omits admin pages'); expectWorkspace(strpos($member, 'User management') === false && strpos($member, '> Server status</a>') === false, 'Member navigation omits admin pages');
expectWorkspace(strpos($member, 'Account settings') !== false, 'Members can access account settings'); expectWorkspace(strpos($member, 'Account settings') !== false, 'Members can access account settings');
$rootChild = renderWorkspaceFixture('index', true, '/', '/etc');
expectWorkspace(strpos($rootChild, 'href="?path=%2F"') !== false, 'Parent link from a root child uses a POSIX slash');
expectWorkspace(strpos($rootChild, '%5C') === false, 'No Windows separator in remote links');
$root = renderWorkspaceFixture('index', true, '/', '/');
expectWorkspace(strpos($root, 'Parent folder') === false, 'Root has no parent-folder link');
expectWorkspace(strpos($root, 'href="?path=%2FDocuments"') !== false, 'Root children use a single leading slash');
if ($preview) { if ($preview) {
// One-page PDF fixture for viewer navigation and fullscreen checks. // One-page PDF fixture for viewer navigation and fullscreen checks.
$stream = "BT /F1 24 Tf 60 700 Td (USB RAID workspace preview) Tj ET"; $stream = "BT /F1 24 Tf 60 700 Td (USB RAID workspace preview) Tj ET";