diff --git a/docs/changelog/2026-06-02-unix-ls-longiso-parse-fix.md b/docs/changelog/2026-06-02-unix-ls-longiso-parse-fix.md new file mode 100644 index 00000000..b2ac8860 --- /dev/null +++ b/docs/changelog/2026-06-02-unix-ls-longiso-parse-fix.md @@ -0,0 +1,53 @@ +# 2026-06-02 文件管理器目录列表全空 Bug 修复(unix_ls long-iso 解析) + +## 日期 +2026-06-02 + +## 变更摘要 +修复 `server/utils/unix_ls.py` 中 `parse_ls_la_line` 函数对 GNU `ls -la --time-style=long-iso` +输出格式的解析错误,导致所有普通文件和目录(非软链接)被静默丢弃。 + +## 根本原因 + +`ls -la --time-style=long-iso` 输出每行 **8 个字段**(日期和时间各占 1 个字段): +``` +drwxr-xr-x 2 root root 4096 2024-05-22 10:30 dirname +``` +而原始代码要求 `len(parts) >= 9`,导致所有普通文件/目录行被 `return None` 丢弃。 + +软链接能幸存,是因为它们的文件名包含 ` -> target`,在 **标准格式**(3 个日期字段)下 +使字段数达到 9,但在 long-iso 格式下仍只有 8 个字段,实际上也应该被修复。 + +**表现**: +- root 用户(最高权限)浏览任意目录,返回条目数为 0,且 `error` 为空 +- 只有软链接偶尔出现(取决于目标系统的 ls 实现) +- 浏览 `/` 只显示 `bin, lib, lib64, sbin` 这 4 个软链接,而非全部目录 + +## 修复方案 + +在 `parse_ls_la_line` 中检测字段 `parts[5]` 是否匹配 `YYYY-MM-DD`: +- **long-iso** → 文件名在 `parts[7]`,修改时间为 `parts[5] + parts[6]` +- **standard** → 文件名在 `parts[8]`(同原逻辑) + +两种格式均正确提取文件名、软链接目标、修改时间。 + +## 涉及文件 + +| 文件 | 变更 | +|------|------| +| `server/utils/unix_ls.py` | 修复 `parse_ls_la_line`,兼容 long-iso 格式(8 字段) | + +## 重要说明 + +- 此 Bug 与 SSH 用户权限(root / 非 root)**无关** +- 使用 GNU `ls`(支持 `--time-style`)的服务器均受影响 +- 使用 BusyBox `ls`(不支持 `--time-style`,会 fallback 到标准格式)的服务器不受影响 +- **修复后无需重新配置 sudoers**,纯后端解析逻辑修复 + +## 需要迁移/重启 +- 后端重启生效(`supervisorctl restart nexus`) + +## 验证方式 +1. 运行 `python scripts/_test_unix_ls.py` — 全部 OK +2. 部署后访问 `/files?server_id=8`,应能看到 `/` 下的完整目录列表 +3. 对比修复前后:`/etc`, `/usr`, `/var`, `/tmp` 等目录应正常显示 diff --git a/server/utils/unix_ls.py b/server/utils/unix_ls.py index be1f67cf..6b1a14b2 100644 --- a/server/utils/unix_ls.py +++ b/server/utils/unix_ls.py @@ -20,18 +20,46 @@ def modified_from_ls_parts(parts: list[str]) -> str: def parse_ls_la_line(line: str) -> dict | None: - """Parse one ``ls -la`` line; returns None for total/skip lines.""" + """Parse one ``ls -la`` line; returns None for total/skip lines. + + Handles two timestamp formats produced by different ``ls`` implementations: + - Standard (3-token date): ``perms nlinks owner group size Mon DD HH:MM name`` → 9+ fields + - GNU long-iso (2-token): ``perms nlinks owner group size YYYY-MM-DD HH:MM name`` → 8+ fields + + With ``--time-style=long-iso`` the date collapses from 3 tokens to 2, so + the total token count drops from 9 to 8. Directories and regular files were + silently dropped (only symlinks survived because their names contain + ``" -> target"`` which pushed the count back to 9). + """ stripped = line.strip() if not stripped or stripped.startswith("total "): return None + + # Split into at most 9 pieces so filenames with spaces are kept intact. parts = stripped.split(None, 8) - if len(parts) < 9: + if len(parts) < 8: return None perms = parts[0] is_dir = perms.startswith("d") is_link = perms.startswith("l") - name = parts[8] + + # Detect format by checking whether field[5] is an ISO date (YYYY-MM-DD). + # long-iso: [perms nlinks owner group size DATE TIME name] → name at index 7 + # standard: [perms nlinks owner group size MON DAY TIME name] → name at index 8 + if _ISO_DATE.match(parts[5]): + # long-iso format — name is at index 7 (or rest of string) + if len(parts) < 8: + return None + name = parts[7] + modified = f"{parts[5]} {parts[6].split('.', 1)[0]}" + else: + # standard format — need at least 9 tokens for the name at index 8 + if len(parts) < 9: + return None + name = parts[8] + modified = modified_from_ls_parts(parts) + link_target = None if is_link and " -> " in name: name, link_target = name.split(" -> ", 1) @@ -47,7 +75,7 @@ def parse_ls_la_line(line: str) -> dict | None: "perms": perms, "owner": parts[2], "group": parts[3] if len(parts) > 3 else "", - "modified": modified_from_ls_parts(parts), + "modified": modified, }