Skip to content

Commit 38e57a3

Browse files
authored
Lua FFI fixes (#657)
* Fix clipboard.get leaking its buffer on every call clipboard_get returns strndup'd memory but typed it const char *, and aegisub.internal.ffi's string helper deliberately only frees non-const char * (const means the native side retains ownership). Type it char * so the buffer is actually freed. * Fix leaking the error message when re.compile fails regex_compile strdups the exception text into the error out-param, but the moon side read it with ffi.string, which does not free. Use ffi_util.string, which does. * Don't let C++ exceptions escape through the FFI boundary in re boost::regex can throw -- most notably on invalid UTF-8 via the UTF-8-to-UTF-32 iterators (the crash backtrace in Aegisub#99), and on regex complexity/backtracking limits -- but only regex_compile caught anything. An exception unwinding through the LuaJIT interpreter or JIT frames which called these functions is undefined behavior and can take down the process. Catch exceptions in regex_search/regex_match/regex_replace and report them through a char **err out-param as lfs already does; the moon side raises them as proper Lua errors. * Return an error from unicode case conversion on invalid UTF-8 On a bogus conversion the wrapper set the error out-param but then ran the conversion anyway and returned a result, which the moon side's error branch never freed. Return null once the error is set; the moon side already handles a null result plus an error message correctly. * Check for malloc failure in strndup Previously OOM meant memcpy through a null pointer. The Lua side already maps a null return to nil. * Fix lfs.chdir/mkdir/rmdir/touch always returning nil The impl functions return a C bool, which LuaJIT's ffi converts to a Lua boolean, and tonumber(true) is nil -- so these four functions returned nil, nil on success, indistinguishable from failure to any caller checking the standard lfs true-or-nil,err convention. Found because busted's own chdir call refuses to run the automation test suite against the aegisub lfs. * Fix swapped error() arguments in lfs.dir error 2, err raises the number 2 with err as the level; failing to open a directory should raise the error message. * Remove the uuid dependency from the lfs tests It was only used to generate unique names under /tmp, which os.tmpname does without an external rock (whose API has also drifted: uuid 1.0 refuses to run until an rng is explicitly configured). os.tmpname creates the file on POSIX, so unlink it and use just the name. * meson: build aegisub-lua and wire up the automation tests Builds the standalone Lua host from automation/tests and registers the busted suite as a meson test when busted is installed through luarocks for Lua 5.1; nothing has been running these tests since Travis went away. The suite's fixtures are POSIX-only, so it is only registered on non-Windows hosts. Run with: meson test --suite automation * Document that Windows builds require the dynamic CRT This has been institutional knowledge since the /MT era of the old Visual Studio build broke automation (2016-2018); write it down where the next person flipping b_vscrt will find it. * ci: install busted and run the full test suite The test step only ran "gtest main" by name, so luajit-52 (and now the automation suite) never ran in CI. Install busted through luarocks on Ubuntu and macOS so the automation tests are registered; Windows keeps skipping them since luarocks has no painless setup there and the meson gate handles the absence. * Add test coverage for lfs.dir and invalid UTF-8 handling lfs.dir had no tests at all; the re specs pin that invalid UTF-8 is reported as a Lua error, which is the path that previously crashed (Aegisub#99); the unicode spec pins ICU's substitution behavior for invalid input. Notably lfs.dir's error branch turns out to be unreachable: DirectoryIterator swallows construction failures on every platform and yields an empty iteration instead. * Fix re.compile raising the module table instead of the error message real_compile passed the FFI impl module to error() rather than the message returned from compile, so failed compiles raised a useless table. The existing invalid-regex test only checked that an error was raised, so extend it to check the error is actually a string. * Consolidate the re exception guards into a catch-all wrap helper Mirror the wrap() idiom lfs.cpp already uses: a single helper with a catch-all arm, so no exception type can unwind through LuaJIT frames even if boost throws something not derived from std::exception. Also check the malloc in regex_search like strndup already does. * Remove test asserting lfs.dir's silent behavior on unopenable paths Vanilla lfs errors out from lfs.dir when the directory can't be opened, and require 'lfs' is transparently redirected to aegisub.lfs, so the silent empty iteration is a compatibility bug rather than behavior to enshrine in a test. To be fixed separately. * ci: pin meson test to our own suites and require the automation suite Bare meson test also runs the test suites of subproject dependencies (the uchardet wrap alone adds 111 tests on macOS), so limit it to the Aegisub project's tests. Since the automation suite is only registered when the busted probe succeeds at configure time, fail the build if it silently drops out rather than reporting green without it.
1 parent 00a0537 commit 38e57a3

12 files changed

Lines changed: 188 additions & 60 deletions

File tree

.github/workflows/ci.yml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,20 @@ jobs:
132132
brew install libass zlib ffms2 fftw hunspell
133133
brew install pulseaudio # NO OpenAL in github CI
134134
135+
# busted for the automation test suite (LuaJIT is Lua 5.1 to luarocks)
136+
brew install luajit luarocks
137+
luarocks --lua-dir="$(brew --prefix luajit)" --lua-version=5.1 install busted
138+
135139
- name: Install dependencies (Linux)
136140
if: startsWith(matrix.config.os, 'ubuntu-')
137141
run: |
138142
sudo apt-get update
139143
sudo apt-get install ninja-build build-essential libx11-dev libfreetype6-dev pkg-config libfontconfig1-dev libass-dev libasound2-dev libffms2-dev intltool libboost-all-dev libhunspell-dev libcurl4-openssl-dev libuchardet-dev libgtest-dev libgmock-dev libwxgtk3.2-dev libportal-gtk3-dev
140144
145+
# busted for the automation test suite (LuaJIT is Lua 5.1 to luarocks)
146+
sudo apt-get install -y lua5.1 liblua5.1-0-dev luarocks
147+
sudo luarocks --lua-version=5.1 install busted
148+
141149
- name: Configure
142150
run: |
143151
${{ matrix.config.devenv }}
@@ -148,10 +156,17 @@ jobs:
148156
${{ matrix.config.devenv }}
149157
meson compile -C build
150158
159+
# The automation suite is registered at configure time only if the
160+
# busted probe succeeds, so make sure it can't silently drop out of CI
161+
- name: Check automation tests are registered
162+
if: ${{ !startsWith(matrix.config.os, 'windows-') }}
163+
run: meson test -C build --list | grep automation
164+
165+
# --suite limits this to our own tests, excluding those of subproject deps
151166
- name: Run test
152167
run: |
153168
${{ matrix.config.devenv }}
154-
meson test -C build --verbose "gtest main"
169+
meson test -C build --verbose --suite Aegisub
155170
156171
# Windows artifacts
157172
- name: Generate Windows installer

automation/include/aegisub/lfs.moon

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ string_ret = (f) -> (...) ->
2727
res, err = f ...
2828
ffi_util.string(res), err
2929

30-
number_ret = (f) -> (...) ->
30+
-- Convert the C bool returns to lfs's true-or-nil,err convention
31+
bool_ret = (f) -> (...) ->
3132
res, err = f ...
32-
tonumber(res), err
33+
if res then true else nil, err
3334

3435
attributes = check'string ?string' (path, field) ->
3536
switch field
@@ -67,16 +68,16 @@ class dir_iter
6768
dir = check'string' (path) ->
6869
obj, err = impl.dir_new path
6970
if err
70-
error 2, err
71+
error err, 2
7172
iter = dir_iter obj
7273
iter.next, iter
7374

7475
return {
7576
:attributes
76-
chdir: check'string' number_ret impl.chdir
77+
chdir: check'string' bool_ret impl.chdir
7778
currentdir: check'' string_ret impl.currentdir
7879
:dir
79-
mkdir: check'string' number_ret impl.mkdir
80-
rmdir: check'string'number_ret impl.rmdir
81-
touch: check'string'number_ret impl.touch
80+
mkdir: check'string' bool_ret impl.mkdir
81+
rmdir: check'string'bool_ret impl.rmdir
82+
touch: check'string'bool_ret impl.touch
8283
}

automation/include/aegisub/re.moon

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,35 @@ regex_flag = ffi.typeof 'agi_re_flag'
3333
-- Get the boost::eegex binding
3434
regex = require 'aegisub.__re_impl'
3535

36+
err_buff = ffi.new 'char *[1]'
37+
38+
-- Raise a Lua error if the last native call reported one
39+
check_err = ->
40+
if err_buff[0] != nil
41+
error ffi_util.string(err_buff[0]), 3
42+
3643
-- Wrappers to convert returned values from C types to Lua types
3744
search = (re, str, start) ->
3845
return unless start <= str\len()
39-
res = regex.search re, str, str\len(), start
46+
err_buff[0] = nil
47+
res = regex.search re, str, str\len(), start, err_buff
48+
check_err!
4049
return unless res != nil
4150
first, last = res[0], res[1]
4251
ffi.gc(res, ffi.C.free)
4352
first, last
4453

4554
replace = (re, replacement, str, max_count) ->
46-
ffi_util.string regex.replace re, replacement, str, str\len(), max_count
55+
err_buff[0] = nil
56+
res = regex.replace re, replacement, str, str\len(), max_count, err_buff
57+
check_err!
58+
ffi_util.string res
4759

4860
match = (re, str, start) ->
4961
assert start <= str\len()
50-
m = regex.match re, str, str\len(), start
62+
err_buff[0] = nil
63+
m = regex.match re, str, str\len(), start, err_buff
64+
check_err!
5165
return unless m != nil
5266
ffi.gc m, regex.match_free
5367

@@ -56,12 +70,11 @@ get_match = (m, idx) ->
5670
return unless res != nil
5771
res[0], res[1] -- Result buffer is owned by match so no need to free
5872

59-
err_buff = ffi.new 'char *[1]'
6073
compile = (pattern, flags) ->
6174
err_buff[0] = nil
6275
re = regex.compile pattern, flags, err_buff
6376
if err_buff[0] != nil
64-
return ffi.string err_buff[0]
77+
return ffi_util.string err_buff[0]
6578
ffi.gc re, regex.regex_free
6679

6780
-- Return the first n elements from ...
@@ -234,7 +247,7 @@ real_compile = (pattern, level, flags, stored_level) ->
234247

235248
re = compile pattern, flags
236249
if type(re) == 'string'
237-
error regex, level + 1
250+
error re, level + 1
238251

239252
RegEx re, stored_level or level + 1
240253

automation/tests/meson.build

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
aegisub_lua_args = []
2+
# force UTF8 for MSVC
3+
if host_machine.system() == 'windows'
4+
aegisub_lua_args += ['/utf-8']
5+
endif
6+
7+
aegisub_lua = executable(
8+
'aegisub-lua',
9+
'aegisub.cpp',
10+
include_directories: libaegisub_inc,
11+
dependencies: deps,
12+
cpp_args: aegisub_lua_args + conf_defines,
13+
link_with: libaegisub,
14+
)
15+
16+
# Requires busted installed through luarocks for Lua 5.1; the fixtures are
17+
# POSIX-only, so the suite is never registered on Windows
18+
have_busted = false
19+
if host_machine.system() != 'windows'
20+
luarocks = find_program('luarocks', required: false)
21+
if luarocks.found()
22+
have_busted = run_command(luarocks, '--lua-version=5.1', 'show', 'busted', check: false).returncode() == 0
23+
endif
24+
endif
25+
26+
if have_busted
27+
lr_path = run_command(luarocks, '--lua-version=5.1', 'path', '--lr-path', check: false).stdout().strip()
28+
lr_cpath = run_command(luarocks, '--lua-version=5.1', 'path', '--lr-cpath', check: false).stdout().strip()
29+
test('automation',
30+
aegisub_lua,
31+
args: ['tests/busted.lua', '-p', 'moon', 'tests/modules'],
32+
workdir: meson.current_source_dir() / '..',
33+
env: {'LUA_PATH': lr_path + ';;', 'LUA_CPATH': lr_cpath + ';;'},
34+
suite: 'automation')
35+
else
36+
message('luarocks with busted for Lua 5.1 not found; not adding the automation test suite')
37+
endif

automation/tests/modules/lfs.moon

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@
1313
-- OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1414

1515
lfs = require 'aegisub.lfs'
16-
uuid = require 'uuid'
1716

18-
uuid.randomseed os.time()
17+
-- os.tmpname creates the file on POSIX; we only want a unique name
18+
temp_name = ->
19+
name = os.tmpname!
20+
os.remove name
21+
name
1922

2023
get_pwd = ->
2124
pwd = io.popen 'pwd'
@@ -44,15 +47,15 @@ describe 'lfs', ->
4447
assert.is.equal get_pwd!, dir
4548

4649
it 'should fail on an invalid path', ->
47-
name = '/tmp/' .. uuid! .. '/' .. uuid!
50+
name = temp_name! .. '/child'
4851
res, msg = lfs.chdir name
4952

5053
assert.is.nil res
5154
assert.is.not.nil msg
5255

5356
describe 'mkdir', ->
5457
it 'should be able to create new directories', ->
55-
name = '/tmp/' .. uuid!
58+
name = temp_name!
5659
lfs.mkdir name
5760
assert.is.equal lfs.attributes(name, 'mode'), 'directory'
5861

@@ -61,9 +64,24 @@ describe 'lfs', ->
6164

6265
describe 'touch', ->
6366
it 'should create files if given a nonexistent filename', ->
64-
name = '/tmp/' .. uuid!
67+
name = temp_name!
6568
lfs.touch name
6669
assert.is.equal lfs.attributes(name).mode, 'file'
6770

6871
os.remove(name)
6972
assert.is.nil lfs.attributes name, 'mode'
73+
74+
describe 'dir', ->
75+
it 'should iterate over the files in a directory', ->
76+
name = temp_name!
77+
lfs.mkdir name
78+
lfs.touch name .. '/a'
79+
lfs.touch name .. '/b'
80+
81+
files = [f for f in lfs.dir name]
82+
table.sort files
83+
assert.is.same {'a', 'b'}, files
84+
85+
os.remove name .. '/a'
86+
os.remove name .. '/b'
87+
lfs.rmdir name

automation/tests/modules/re.moon

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ describe 'compile', ->
3333
it 'should throw an error when given an invalid regex', ->
3434
assert.is.error -> re.compile '('
3535

36+
it 'should throw the compile error message for an invalid regex', ->
37+
ok, err = pcall -> re.compile '('
38+
assert.is.false ok
39+
assert.is.equal 'string', type err
40+
3641
it 'should throw an error when given an empty regex', ->
3742
assert.is.error -> re.compile ''
3843

@@ -326,3 +331,14 @@ describe 'sub', ->
326331
assert.is.not.nil res
327332
assert.is.equal 'dadbdcd', res
328333

334+
describe 'invalid UTF-8', ->
335+
it 'should be reported as an error by find', ->
336+
ok, err = pcall -> re.find 'abc\255def', 'd'
337+
assert.is.false ok
338+
assert.is.truthy err\find 'UTF-8', 1, true
339+
340+
it 'should be reported as an error by match', ->
341+
ok, err = pcall -> re.match '\255', 'x'
342+
assert.is.false ok
343+
assert.is.truthy err\find 'UTF-8', 1, true
344+

automation/tests/modules/unicode.moon

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ describe 'to_upper_case', ->
5959
assert.is.equal 'SS', unicode.to_upper_case 'ß'
6060
it 'should support ligatures', ->
6161
assert.is.equal 'FFI', unicode.to_upper_case ''
62+
it 'should replace invalid UTF-8 with the replacement character', ->
63+
assert.is.equal '', unicode.to_upper_case '\255'
6264

6365
describe 'to_lower_case', ->
6466
it 'should support plain ASCII', ->

libaegisub/include/libaegisub/lua/ffi.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ void register_lib_table(lua_State *L, std::initializer_list<const char *> types,
4949
template<typename T>
5050
char *strndup(T const& str) {
5151
char *ret = static_cast<char*>(malloc(str.size() + 1));
52+
if (!ret) return nullptr;
5253
memcpy(ret, str.data(), str.size());
5354
ret[str.size()] = 0;
5455
return ret;

libaegisub/lua/modules/re.cpp

Lines changed: 60 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,30 @@ bool search(u32regex& re, const char *str, size_t len, int start, boost::cmatch&
4747
start > 0 ? boost::match_prev_avail | boost::match_not_bob : boost::match_default);
4848
}
4949

50-
match *regex_match(u32regex& re, const char *str, size_t len, int start) {
51-
auto result = std::make_unique<match>();
52-
if (!search(re, str, len, start, result->m))
53-
return nullptr;
54-
return result.release();
50+
// boost::regex can throw (e.g. on invalid UTF-8), and exceptions must not
51+
// unwind through LuaJIT frames, so trap everything and report errors through
52+
// an out-param
53+
template<typename Func>
54+
auto wrap(char **err, Func f) -> decltype(f()) {
55+
try {
56+
return f();
57+
}
58+
catch (std::exception const& e) {
59+
*err = strdup(e.what());
60+
}
61+
catch (...) {
62+
*err = strdup("Unknown error");
63+
}
64+
return decltype(f()){};
65+
}
66+
67+
match *regex_match(u32regex& re, const char *str, size_t len, int start, char **err) {
68+
return wrap(err, [&]() -> match * {
69+
auto result = std::make_unique<match>();
70+
if (!search(re, str, len, start, result->m))
71+
return nullptr;
72+
return result.release();
73+
});
5574
}
5675

5776
int *regex_get_match(match& match, size_t idx) {
@@ -62,48 +81,49 @@ int *regex_get_match(match& match, size_t idx) {
6281
return match.range;
6382
}
6483

65-
int *regex_search(u32regex& re, const char *str, size_t len, size_t start) {
66-
boost::cmatch result;
67-
if (!search(re, str, len, start, result))
68-
return nullptr;
69-
70-
auto ret = static_cast<int *>(malloc(sizeof(int) * 2));
71-
ret[0] = start + result.position() + 1;
72-
ret[1] = start + result.position() + result.length();
73-
return ret;
84+
int *regex_search(u32regex& re, const char *str, size_t len, size_t start, char **err) {
85+
return wrap(err, [&]() -> int * {
86+
boost::cmatch result;
87+
if (!search(re, str, len, start, result))
88+
return nullptr;
89+
90+
auto ret = static_cast<int *>(malloc(sizeof(int) * 2));
91+
if (!ret) return nullptr;
92+
ret[0] = start + result.position() + 1;
93+
ret[1] = start + result.position() + result.length();
94+
return ret;
95+
});
7496
}
7597

76-
char *regex_replace(u32regex& re, const char *replacement, const char *str, size_t len, int max_count) {
77-
// Can't just use regex_replace here since it can only do one or infinite replacements
78-
auto match = boost::u32regex_iterator<const char *>(str, str + len, re);
79-
auto end_it = boost::u32regex_iterator<const char *>();
80-
81-
auto suffix = str;
82-
83-
std::string ret;
84-
auto out = back_inserter(ret);
85-
while (match != end_it && max_count > 0) {
86-
copy(suffix, match->prefix().second, out);
87-
match->format(out, replacement);
88-
suffix = match->suffix().first;
89-
++match;
90-
--max_count;
91-
}
92-
93-
ret += suffix;
94-
return agi::lua::strndup(ret);
98+
char *regex_replace(u32regex& re, const char *replacement, const char *str, size_t len, int max_count, char **err) {
99+
return wrap(err, [&]() -> char * {
100+
// Can't just use regex_replace here since it can only do one or infinite replacements
101+
auto match = boost::u32regex_iterator<const char *>(str, str + len, re);
102+
auto end_it = boost::u32regex_iterator<const char *>();
103+
104+
auto suffix = str;
105+
106+
std::string ret;
107+
auto out = back_inserter(ret);
108+
while (match != end_it && max_count > 0) {
109+
copy(suffix, match->prefix().second, out);
110+
match->format(out, replacement);
111+
suffix = match->suffix().first;
112+
++match;
113+
--max_count;
114+
}
115+
116+
ret += suffix;
117+
return agi::lua::strndup(ret);
118+
});
95119
}
96120

97121
u32regex *regex_compile(const char *pattern, int flags, char **err) {
98-
auto re = std::make_unique<u32regex>();
99-
try {
122+
return wrap(err, [&]() -> u32regex * {
123+
auto re = std::make_unique<u32regex>();
100124
*re = boost::make_u32regex(pattern, boost::u32regex::perl | flags);
101125
return re.release();
102-
}
103-
catch (std::exception const& e) {
104-
*err = strdup(e.what());
105-
return nullptr;
106-
}
126+
});
107127
}
108128

109129
void regex_free(u32regex *re) { delete re; }

0 commit comments

Comments
 (0)