Skip to content

Commit a9502a9

Browse files
committed
目录那个 bug:一条 POSIX 规则没建模,五个门口同时错
读 PackageLib 未覆盖行 → searcher_C 的兜底 → 「模块名撞上目录」。根因只有一条: POSIX 允许 open(dir, O_RDONLY) 成功 —— 于是 fopen(dir,"r") 成功、 fopen(dir,"w") 给 EISDIR、第一次 fgetc/fread 失败于 EISDIR。JVM 压根不让打开 目录,于是本实现以同一种方式在五处同时错: loadfile(dir) 参考 nil,"cannot read <p>: Is a directory" | 我们 cannot open <p>: Input/output error io.open(dir,"r") 参考 file 句柄 | 我们 nil,"<p>: No such file or directory",2 io.open(dir,"w") 参考 nil,"<p>: Is a directory",21 | 我们 nil,"<p>: No such file or directory",2 package.searchpath 参考 找到(返回路径) | 我们 nil,"no file '<p>'" require(name) 参考 error loading module … cannot read … | 我们 module '…' not found 修在同一个念头的四处:openInto 放行只读的目录打开(要写报 21)、 LuaFile.getc/read 报 21、seek/tell 按零长文件算(目录 fd 是可定位的: seek("set",5)→5、seek("set",-1)→EINVAL)、PackageLib.readable 认目录、 loadfile 与 searcher_Lua 改走 cannot read 分支;strError 补了 21。 不比的一处:目录上的 seek("end") 给的是目录自身的大小(本机 64), 那是文件系统的事实,不是 Lua 的。 **教训**:README 与 BaseLib 的注释里原本都断言「目录不用改、也不该改」—— 那是读 lauxlib.c 读出来的,不是跑参考实现跑出来的,而且它是错的。一处断言 换来五个门口的真 bug。已写进长期约定:**注释跟代码一致不是证据。** 新增矩阵:RuntimeTest#directoryAsFileMatchesReference(18 行,修完逐字节一致)、 PackageLibTest CFALLBACK(23 行,C 搜索器兜底那条路;dlopen 的失败文案是平台 自己的,归一成 <NODL> 后其余照比)。 全量 850 绿(+2),门禁过,差分 fuzz 绿。
1 parent c328019 commit a9502a9

8 files changed

Lines changed: 498 additions & 22 deletions

File tree

‎README.md‎

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ files whose output is deterministic (`api`, `bitwise`, `calls`, `closure`,
207207
**byte for byte**. Five more (`cstack`, `gc`, `literals`, `locals`,
208208
`constructs`) print things that vary with the implementation or with a random
209209
seed, so they are only asserted to run to completion and print `OK` — the reason
210-
for each is listed under *Phase 10* below. 848 tests green, JaCoCo line coverage
210+
for each is listed under *Phase 10* below. 850 tests green, JaCoCo line coverage
211211
0.90 against a 0.89 gate. (The upstream-suite gate needs `LUA54_TESTS` or
212212
`target/lua54-tests`; without a test directory that whole class **skips itself**,
213213
so a green run on a machine without it is 25 tests weaker than it looks.)
@@ -1601,11 +1601,17 @@ seek offset"*, which falls through `errnoOf`'s catch-all into `EIO`. Seeking
16011601
**past** EOF is legal and returns the new position — easy to block by accident
16021602
while fixing this.
16031603
1604-
Two things are deliberately absent. `fopen` on a **directory** succeeds on both
1605-
macOS and glibc but then behaves differently on read, and the JVM will not open a
1606-
directory as a stream at all. And `print` writes to the C-level `stdout`, so it
1607-
is *not* redirected by `io.output` — a script that assumes otherwise produces
1608-
two different wrong answers.
1604+
One thing is deliberately absent: `print` writes to the C-level `stdout`, so it
1605+
is *not* redirected by `io.output` — a script that assumes otherwise produces two
1606+
different wrong answers.
1607+
1608+
`fopen` on a **directory** used to be listed here as absent too, and that was a
1609+
mistake: it is a single POSIX rule (`open(dir, O_RDONLY)` succeeds, `O_WRONLY`
1610+
gives `EISDIR`, and the first read fails with `EISDIR`), it is the same on macOS
1611+
and glibc, and the JVM simply refused to model it. It is now modelled — see
1612+
*The directory bug* below. The one part that is genuinely not comparable is
1613+
`seek("end")` on a directory, which returns the directory's own size (64 here),
1614+
a filesystem fact rather than a Lua one.
16091615
16101616
#### The `os.time` sweep: `lua_tointegerx` converts strings
16111617
@@ -1791,10 +1797,15 @@ It also found one place where C and Java simply disagree about a path:
17911797
`cannot open : No such file or directory`; Java resolves the empty path to the
17921798
current directory and fails while *reading*, giving `Is a directory` — which we
17931799
turned into `Input/output error`. The empty path is the only case where the two
1794-
judge a path differently, so it gets `ENOENT` by C's rule. **Directories
1795-
themselves are fine and must not be "fixed":** `loadfile("/tmp")` succeeds at
1796-
`fopen` in C and fails at *read*, so it takes the other branch —
1797-
`cannot read /tmp: Is a directory`.
1800+
judge a path differently, so it gets `ENOENT` by C's rule.
1801+
1802+
Directories were claimed here as "fine, must not be fixed" — and that claim was
1803+
wrong, which is the point worth keeping: **it was written from reading the C
1804+
source, not from running the reference.** `loadfile("/tmp")` does succeed at
1805+
`fopen` in C and fail at *read* — but this implementation was reporting
1806+
`cannot open /tmp: Input/output error`, i.e. the *other* branch, because Java's
1807+
`Files.readAllBytes` throws at open. Measuring it (see *The directory bug*) turned
1808+
one wrong sentence into a real bug across five entry points.
17981809

17991810
#### A sweep that came back clean: `load`'s reader contract
18001811

@@ -2160,6 +2171,41 @@ All 39 came back byte-identical — those branches were **correct but never
21602171
executed**. Their value is as a regression net: change `read("n")`'s prefix
21612172
handling or the write-buffer threshold and this goes red immediately.
21622173

2174+
#### The directory bug: one POSIX rule, five wrong answers
2175+
2176+
Reading `PackageLib`'s uncovered lines and asking "can a Lua script reach this?"
2177+
led to `searcher_C`'s fallback and, from there, to a module name that collides
2178+
with a directory. That turned out to be **one rule mis-modelled in five places**:
2179+
2180+
POSIX lets `open(dir, O_RDONLY)` succeed. `fopen(dir, "r")` therefore succeeds;
2181+
`fopen(dir, "w")` gives `EISDIR`; and the first `fgetc` / `fread` fails with
2182+
`EISDIR`. The JVM refuses to open a directory at all, so this implementation got
2183+
all five entry points wrong in one consistent way:
2184+
2185+
| | reference | before |
2186+
| --- | --- | --- |
2187+
| `loadfile(dir)` | `nil, "cannot read <p>: Is a directory"` | `nil, "cannot open <p>: Input/output error"` |
2188+
| `io.open(dir, "r")` | a file handle | `nil, "<p>: No such file or directory", 2` |
2189+
| `io.open(dir, "w")` | `nil, "<p>: Is a directory", 21` | `nil, "<p>: No such file or directory", 2` |
2190+
| `package.searchpath` | finds it (returns the path) | `nil, "no file '<p>'"` |
2191+
| `require(name)` | `error loading module … cannot read …` | `module '…' not found` |
2192+
2193+
The fix is one idea applied at four sites: `openInto` lets a read-only open of a
2194+
directory through (and reports `EISDIR` for anything writable), `LuaFile.getc` /
2195+
`read` fail with 21, `seek` / `tell` treat it as a zero-length file (a directory
2196+
descriptor *is* seekable — `f:seek("set", 5)` returns 5, and `f:seek("set", -1)`
2197+
is still `EINVAL`), `PackageLib.readable` accepts directories, and `loadfile` /
2198+
`searcher_Lua` take the `cannot read` branch instead of `cannot open`.
2199+
2200+
18 lines, byte-identical after the fix. Two things about how this was found are
2201+
worth more than the bug:
2202+
2203+
* The README previously said directories were "fine and must not be fixed" —
2204+
written from reading `lauxlib.c`, not from running the reference binary. It was
2205+
wrong. Measure, then write.
2206+
* The same sentence in the *code* comment (`BaseLib`) had asserted the behaviour
2207+
was already correct. A comment agreeing with the code is not evidence.
2208+
21632209
### What is still missing
21642210

21652211
Nothing in the language or the ten standard libraries, and every upstream file

‎README_CN.md‎

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ Lua 参考实现的解析器是手写递归下降。直接搬过来,就去掉
184184
`utf8`、`vararg`、`verybig` 等)与真实 `lua5.4` 的输出**逐字节一致**;
185185
`cstack` / `gc` / `literals` / `locals` / `constructs` 五个因为打印的是随实现或
186186
随随机数而变的东西,只断言「跑完并打印 `OK`」——理由逐个列在 *阶段 10* 一节里。
187-
全量 848 个用例绿,JaCoCo 行覆盖 0.90(门禁 0.89)。(上游套件这道闸门要
187+
全量 850 个用例绿,JaCoCo 行覆盖 0.90(门禁 0.89)。(上游套件这道闸门要
188188
`LUA54_TESTS` 或 `target/lua54-tests`;没有用例目录时<b>整类自动跳过</b>,
189189
所以那种机器上「绿」其实比看起来少 25 个用例。)
190190

@@ -1464,9 +1464,14 @@ C 靠 `luaD_rawrunprotected` 末尾的 `L->nCcalls = oldnCcalls`(在 `pcall`
14641464
`errnoOf` 的兜底分支就成了 `EIO`(5)。往 EOF **之后**定位是合法的(返回新位置,
14651465
不报错)—— 修这条时别顺手一起拦了。
14661466

1467-
两处刻意不测:`fopen` 一个**目录**在 macOS 与 glibc 上都成功、但读的行为不同,
1468-
而 JVM 压根不允许把目录当流打开;`print` 写的是 C 层的 `stdout`,不受
1469-
`io.output` 切换影响 —— 假设它会跟着变的脚本,两边会各自给出一个错的答案。
1467+
一处刻意不测:`print` 写的是 C 层的 `stdout`,不受 `io.output` 切换影响 ——
1468+
假设它会跟着变的脚本,两边会各自给出一个错的答案。
1469+
1470+
`fopen` 一个**目录**原本也写在这一节里,那是错的:它是一条完整的 POSIX 规则
1471+
(`open(dir, O_RDONLY)` 成功、`O_WRONLY` 给 `EISDIR`、第一次读失败于 `EISDIR`),
1472+
macOS 与 glibc 一样,只是 JVM 不愿意建模。现在建模了,见下文 *目录那个 bug*。
1473+
真正不能比的只有目录上的 `seek("end")`:给的是目录自身的大小(本机 64),
1474+
那是文件系统的事实,不是 Lua 的事实。
14701475

14711476
#### `os.time` 那半边:`lua_tointegerx` 是带字符串转换的
14721477

@@ -1632,8 +1637,12 @@ const char *luaT_objtypename (lua_State *L, const TValue *o) {
16321637
`fopen("")` 给 `ENOENT`,报 `cannot open : No such file or directory`;Java 把
16331638
空路径解析成当前目录、失败在读,给 `Is a directory`,我们于是报成了
16341639
`Input/output error`。空路径是唯一一处,按 C 的判据单独取 `ENOENT`。
1635-
**目录本身不用改、也不该改**:`loadfile("/tmp")` 在 C 里 `fopen` 是**成功**的,
1636-
失败在读,走的是另一条分支 —— `cannot read /tmp: Is a directory`。
1640+
目录原本在同一句里被断言成「不用改、也不该改」—— 而这句是错的,值得留着当教训:
1641+
它是**读 lauxlib.c 读出来的,不是跑参考实现跑出来的**。C 里 `loadfile("/tmp")`
1642+
确实是 `fopen` 成功、失败在读,但本实现报的是
1643+
`cannot open /tmp: Input/output error`(Java 的 `Files.readAllBytes` 在开就抛了),
1644+
也就是<b>另一条分支</b>。量过之后,一句错话变成五个门口的真 bug,
1645+
见下文 *目录那个 bug*。
16371646

16381647
#### 一片铺下来是干净的:`load` 的读取器契约(54 行)
16391648

@@ -1945,6 +1954,36 @@ nil / false / true / 数字 / 无返回 / 多值(只取第一个)、模块
19451954
39 行全逐字节一致 —— 也就是说这些分支<b>本来就是对的,只是没人跑过</b>。
19461955
它们的价值在回归:谁改了 `read("n")` 的前缀判断或 wbuf 的阈值,这里立刻红。
19471956

1957+
#### 目录那个 bug:一条 POSIX 规则,五处全错
1958+
1959+
读 PackageLib 的未覆盖行、逐条问「Lua 能不能走到」,摸到 `searcher_C` 的兜底
1960+
分支,再摸到「模块名撞上目录」这件事 —— 结果是**一条规则没建模,五个门口全错**:
1961+
1962+
POSIX 允许 `open(dir, O_RDONLY)` 成功。于是 `fopen(dir,"r")` 成功、
1963+
`fopen(dir,"w")` 给 `EISDIR`、第一次 `fgetc`/`fread` 失败于 `EISDIR`。
1964+
JVM 根本不让打开目录,于是本实现以同一种方式在五处同时错:
1965+
1966+
| | 参考 | 修之前 |
1967+
| --- | --- | --- |
1968+
| `loadfile(dir)` | `nil, "cannot read <p>: Is a directory"` | `nil, "cannot open <p>: Input/output error"` |
1969+
| `io.open(dir,"r")` | file 句柄 | `nil, "<p>: No such file or directory", 2` |
1970+
| `io.open(dir,"w")` | `nil, "<p>: Is a directory", 21` | `nil, "<p>: No such file or directory", 2` |
1971+
| `package.searchpath` | 找到(返回路径) | `nil, "no file '<p>'"` |
1972+
| `require(name)` | `error loading module … cannot read …` | `module '…' not found` |
1973+
1974+
修法是同一个念头落在四处:`openInto` 放行只读的目录打开(要写就报 EISDIR)、
1975+
`LuaFile.getc`/`read` 报 21、`seek`/`tell` 按零长文件算(目录 fd **是**可定位的:
1976+
`f:seek("set",5)` 给 5,`f:seek("set",-1)` 仍然是 EINVAL)、
1977+
`PackageLib.readable` 认目录、`loadfile` 与 `searcher_Lua` 改走 `cannot read`
1978+
那条分支。
1979+
1980+
修完 18 行逐字节一致。怎么发现的,比这个 bug 本身更值得记:
1981+
1982+
* README 之前写着目录「没问题、不要修」—— 那是<b>读 lauxlib.c 读出来的,不是
1983+
跑参考实现跑出来的</b>,而且它是错的。先量,再写。
1984+
* 代码注释(`BaseLib`)里那句同样断言了「目录不用改」。
1985+
**注释跟代码保持一致不是证据。**
1986+
19481987
### 还没补上的
19491988

19501989
语言与十个标准库本身没有缺项,上游套件里能逐字节比的那些也都比过了。剩下的是

‎src/main/java/com/github/esrrhs/fakescript/lua/BaseLib.java‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,14 +1059,25 @@ private static int loadfileFn(LuaState L) {
10591059
* {@code ENOENT}(实测 {@code loadfile("")} 报
10601060
* {@code cannot open : No such file or directory}),而 Java 把空路径
10611061
* 解析成当前目录、读的时候给 {@code Is a directory}。
1062-
* 目录本身不用改:{@code loadfile("/tmp")} 在 C 里 fopen 是<b>成功</b>的、
1063-
* 失败在读,走的就是 {@code cannot read ...: Is a directory} 那条分支。
1062+
* <p>
1063+
* 目录走的是<b>另一条</b>分支,之前这里是错的:{@code loadfile("/tmp")}
1064+
* 在 C 里 fopen <b>成功</b>、失败在读,{@code luaL_loadfilex} 的
1065+
* {@code ferror} 于是为真,报的是
1066+
* {@code cannot read /tmp: Is a directory} —— 不是
1067+
* {@code cannot open ...: Input/output error}。实测(参考二进制):
1068+
* <pre>
1069+
* loadfile(dir) -> nil, "cannot read /tmp/fkdir/adir.lua: Is a directory"
1070+
* </pre>
10641071
*/
1065-
int en = path.isEmpty() ? 2 : LuaAux.errnoOf(
1066-
(e instanceof java.io.IOException) ? (java.io.IOException) e : null);
1072+
boolean isDir = !path.isEmpty() && new java.io.File(path).isDirectory();
1073+
int en = path.isEmpty() ? 2
1074+
: isDir ? 21
1075+
: LuaAux.errnoOf((e instanceof java.io.IOException)
1076+
? (java.io.IOException) e : null);
10671077
LuaAux.pushFail(L);
10681078
L.push(LuaValue.ofString(LuaString.latin1(
1069-
"cannot open " + path + ": " + LuaAux.strError(en))));
1079+
(isDir ? "cannot read " : "cannot open ") + path + ": "
1080+
+ LuaAux.strError(en))));
10701081
return 2;
10711082
}
10721083
/*

‎src/main/java/com/github/esrrhs/fakescript/lua/IoLib.java‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,24 @@ static final class LuaFile {
147147
boolean writable;
148148
boolean append;
149149

150+
/**
151+
* 打开的是<b>目录</b>。
152+
* <p>
153+
* POSIX 的 {@code fopen(dir, "r")} 是<b>成功</b>的({@code open(dir, O_RDONLY)}
154+
* 允许),失败发生在第一次 {@code fgetc} / {@code fread} 上,errno 是
155+
* {@code EISDIR}(21)。实测(参考二进制):
156+
* <pre>
157+
* io.open(dir, "r") -> file 句柄;f:read("l") -> nil, "Is a directory", 21
158+
* io.open(dir, "w") -> nil, "&lt;path&gt;: Is a directory", 21
159+
* </pre>
160+
* Java 的 {@code RandomAccessFile} 在目录上是开都开不了的,所以这里
161+
* 单独记一个标记:打开时放行只读,读写一律报 21。
162+
*/
163+
boolean dir;
164+
165+
/** 目录被当零长文件看待时的虚拟位置 —— 目录 fd 上的 {@code lseek} 是允许的 */
166+
long dirPos;
167+
150168
/** {@code ungetc} 的回退位;{@code -1} 表示空 */
151169
int pushback = -1;
152170

@@ -215,6 +233,11 @@ int getc() {
215233
errno = 9; // EBADF
216234
return -1;
217235
}
236+
if (dir) {
237+
err = true;
238+
errno = 21; // EISDIR:目录读不出来,但打开是成功的
239+
return -1;
240+
}
218241
flushW(); // C 要求输出与输入之间必须有 fflush 或定位操作
219242
try {
220243
return raf != null ? raf.read() : inStream().read();
@@ -246,6 +269,11 @@ int read(byte[] b, int off, int len) {
246269
errno = 9; // EBADF
247270
return done;
248271
}
272+
if (dir) {
273+
err = true;
274+
errno = 21; // EISDIR
275+
return done;
276+
}
249277
flushW();
250278
try {
251279
int r = (raf != null) ? raf.read(b, off + done, len - done)
@@ -313,6 +341,22 @@ void write(byte[] b, int off, int len) {
313341

314342
/** {@code l_fseek}:成功返回新位置,-1 表示失败(管道会失败) */
315343
long seek(long offset, int whence) {
344+
if (dir) {
345+
/*
346+
* 目录 fd 上的 lseek 是允许的,位置照算;长度按 0 看。
347+
* 真实目录的 SEEK_END 给出的是目录自身的大小(本机 64),
348+
* 那是文件系统定的、不比 —— 见用例 javadoc。
349+
*/
350+
long base = (whence == 1) ? dirPos : 0; // 仅 SEEK_CUR 从当前位置起算
351+
long pos = base + offset;
352+
if (pos < 0) {
353+
err = true;
354+
errno = 22; // EINVAL
355+
return -1;
356+
}
357+
dirPos = pos;
358+
return pos;
359+
}
316360
if (raf == null) {
317361
err = true;
318362
errno = 29; // ESPIPE
@@ -358,6 +402,9 @@ long seek(long offset, int whence) {
358402

359403
/** {@code l_ftell} */
360404
long tell() {
405+
if (dir) {
406+
return dirPos; // 目录 fd 上的 ftell 是允许的
407+
}
361408
if (raf == null) {
362409
err = true;
363410
errno = 29; // ESPIPE
@@ -587,6 +634,20 @@ private static boolean openInto(LuaFile f, String fname, String mode) {
587634
f.readable = (base == 'r') || plus;
588635
f.writable = (base != 'r') || plus;
589636
f.append = (base == 'a');
637+
/*
638+
* 目录:C 的 fopen 在只读模式上是开得开的(open(dir, O_RDONLY) 允许),
639+
* 要写才给 EISDIR。Java 这边 RandomAccessFile 两种模式都开不了,
640+
* 所以先判目录再分模式 —— 否则写目录报的是 ENOENT 而不是 EISDIR。
641+
*/
642+
if (new java.io.File(fname).isDirectory()) {
643+
if (f.writable) {
644+
f.err = true;
645+
f.errno = 21; // EISDIR
646+
return false;
647+
}
648+
f.dir = true;
649+
return true;
650+
}
590651
try {
591652
f.raf = new RandomAccessFile(fname, f.writable ? "rw" : "r");
592653
if (base == 'w') {

‎src/main/java/com/github/esrrhs/fakescript/lua/LuaAux.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,8 @@ public static String strError(int en) {
447447
return "Bad file descriptor";
448448
case 22: // EINVAL
449449
return "Invalid argument";
450+
case 21: // EISDIR
451+
return "Is a directory";
450452
case 29: // ESPIPE
451453
return "Illegal seek";
452454
default:

‎src/main/java/com/github/esrrhs/fakescript/lua/PackageLib.java‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,18 @@ private static List<String> splitPath(String path) {
218218
return out;
219219
}
220220

221-
/** {@code readable}:能打开读就算数 */
221+
/**
222+
* {@code readable}:能打开读就算数。
223+
* <p>
224+
* C 用的是 {@code fopen(filename, "r")},而它在<b>目录</b>上是成功的 ——
225+
* 于是 {@code searchpath} 会把目录当成找到的文件返回,后面的
226+
* {@code luaL_loadfilex} 才在读的时候报 {@code cannot read ...: Is a directory}。
227+
* Java 的 {@code RandomAccessFile} 开不了目录,所以这里得先认下目录。
228+
*/
222229
private static boolean readable(String filename) {
230+
if (new java.io.File(filename).isDirectory()) {
231+
return true;
232+
}
223233
try (java.io.RandomAccessFile f = new java.io.RandomAccessFile(filename, "r")) {
224234
return true;
225235
} catch (IOException e) {
@@ -371,6 +381,19 @@ private static int searcherLua(LuaState L) {
371381
return 1; // 消息已压栈
372382
}
373383
try {
384+
/*
385+
* 与 {@code BaseLib.loadfile} 的目录分支是同一件事的两处:
386+
* 模块文件是目录时,C 的 fopen 成功、读才失败,
387+
* {@code luaL_loadfilex} 报的是
388+
* {@code cannot read <filename>: Is a directory}。
389+
* 实测(参考二进制):
390+
* {@code require("adir")} -> "error loading module 'adir' from file
391+
* '<p>':\n\tcannot read <p>: Is a directory"。
392+
*/
393+
if (new java.io.File(filename).isDirectory()) {
394+
throw new java.io.IOException("cannot read " + filename + ": "
395+
+ LuaAux.strError(21));
396+
}
374397
byte[] src = Files.readAllBytes(Paths.get(filename));
375398
/*
376399
* luaL_loadfilex 的 chunk 名是 "@" + 文件名。不带这个前缀,

0 commit comments

Comments
 (0)