-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlua.go
More file actions
1417 lines (1166 loc) · 48.3 KB
/
Copy pathlua.go
File metadata and controls
1417 lines (1166 loc) · 48.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package client
// luaEngine is the gopher-lua backend (BackendLua). It boots the embedded
// Tarantool LuaRocks fork (3.9.2) into a single, long-lived gopher-lua VM and
// dispatches write operations through extra/wrapper.lua's exec() entry point.
//
// Environment-override policy:
//
// The engine installs a custom os.getenv into the VM that consults an
// envOverride map first and falls through to the host process env for any
// other key. The override map serves the LuaRocks build-config keys
// used by the embedded VM — LUAROCKS_PREFIX, LUA_INCDIR, LUA_BINDIR,
// TARANTOOL_DIR, TT_CLI_TARANTOOL_VERSION and LUAROCKS_CONFIG. Every other os.getenv read (CC, CFLAGS, LDFLAGS, AR, RANLIB,
// LINK, MT, MAKE, WINDRES, CMAKE_*, PATH, HOME, USER, TMPDIR/TMP/TEMP, XDG_*,
// http_proxy/https_proxy/no_proxy, LUAROCKS_SYSCONFDIR,
// LUAROCKS_CROSS_COMPILING, LUA_PATH_/LUA_CPATH_, and Windows-only vars) falls
// through to the host process env by design — the build toolchain genuinely
// lives there. The engine never mutates the host env: it never calls
// os.Setenv.
//
// Boot is lazy: newLuaEngine does not touch the VM; the LState is created
// and populated on the first call() via bootOnce. The LState is single-threaded:
// concurrent call()s serialize through e.mu. Only Lua that arrived via
// the embedded FS is executed — embedded module bytes via LoadString plus the
// fixed wrapper dispatch string via DoString; no caller-supplied Lua source and
// no loadfile/dofile exposure.
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
rocks "github.com/tarantool/go-luarocks"
luarocksembed "github.com/tarantool/go-luarocks/internal/luarocks"
lua "github.com/yuin/gopher-lua"
)
// osExitSentinel prefixes the Lua error raised by the engine's os.exit
// replacement so call() can recover the requested exit code from the error
// message produced by DoString.
const osExitSentinel = "go-luarocks os.exit: "
const (
// luaConfigFileMode is the permission for the generated LUAROCKS_CONFIG
// file: owner read/write only (it holds no secrets but needs no wider access).
luaConfigFileMode = 0o600
// ioOpenModeArgIndex is the 1-based Lua argument position of io.open's mode
// string; ioOpenMinArgsForMode is the argument count at or above which a
// mode string is present (io.open(filename, mode)).
ioOpenMinArgsForMode = 2
// printWriteArgCount is the number of values pushed for the io.stdout write
// call in the print shim (the file handle plus the line string).
printWriteArgCount = 2
// searchFieldsMin is the minimum tab-separated field count a --porcelain
// search line must have to be a result record (name, version, arch, repo).
searchFieldsMin = 4
// decimalBase is the radix used when accumulating the exit-code digits.
decimalBase = 10
// globalArgsTreePair counts the two argv entries the mandatory --tree global
// always contributes; per-server entries add two more apiece.
globalArgsTreePair = 2
argsPerServer = 2
)
// luaEngine implements the Engine interface against an embedded LuaRocks VM.
type luaEngine struct {
cfg rocks.Config
store rocks.ManifestStore
logger *slog.Logger
envOverride map[string]string
lstate *lua.LState
bootOnce sync.Once
bootErr error
mu sync.Mutex
// configDir is the temp dir holding the generated LUAROCKS_CONFIG file
// (set by writeConfigFile during boot). It must outlive boot since cfg.lua
// is read on first require; a finalizer (set in newLuaEngine) removes it and
// closes the LState when the engine becomes unreachable — best-effort, since
// the engine has no explicit Close in the public API.
configDir string
// callImpl, when non-nil, replaces callViaState as the dispatch backend.
// It exists solely as a test seam so dispatch tests can assert the exact
// argv a method builds (and feed a canned stdout to data-returning
// methods) without booting the embedded VM. Production code leaves it nil.
callImpl func(argv []string) (string, error)
}
// newLuaEngine constructs the engine and populates the env-override map from
// cfg.Tarantool. It does NOT boot the LState — boot is lazy on first
// call().
func newLuaEngine(cfg rocks.Config, store rocks.ManifestStore, logger *slog.Logger) *luaEngine {
envOverride := map[string]string{}
// Only add an entry when the source value is non-empty so the host-env
// fallback can still apply for keys we have no opinion on.
if cfg.Tarantool.Prefix != "" {
envOverride["LUAROCKS_PREFIX"] = cfg.Tarantool.Prefix
envOverride["LUA_BINDIR"] = filepath.Join(cfg.Tarantool.Prefix, "bin")
}
if cfg.Tarantool.IncludeDir != "" {
envOverride["LUA_INCDIR"] = cfg.Tarantool.IncludeDir
envOverride["TARANTOOL_DIR"] = filepath.Dir(cfg.Tarantool.IncludeDir)
}
if cfg.Tarantool.Version != "" {
// The tarantool/luarocks fork registers `tarantool` as a provided
// dependency from this env var (util.lua add_provided_versions) — there
// is no `_TARANTOOL` global in the gopher-lua VM, so the env is the only
// source. Without it, rockspecs with `dependencies = {"tarantool >= X"}`
// fail resolution. Mirrors tt's TT_CLI_TARANTOOL_VERSION.
envOverride["TT_CLI_TARANTOOL_VERSION"] = cfg.Tarantool.Version
}
e := &luaEngine{
cfg: cfg,
store: store,
logger: logger,
envOverride: envOverride,
}
// Best-effort cleanup of the temp config dir and the cached LState when the
// engine is garbage-collected. The engine has no public Close (adding one
// would burden every caller); a finalizer keeps New(...WithBackend(BackendLua))
// from leaking a temp dir per instance (e.g. across test runs).
runtime.SetFinalizer(e, func(e *luaEngine) { e.cleanup() })
return e
}
// cleanup removes the generated config dir and closes the cached LState. Safe
// to call when neither was created (empty configDir, nil lstate).
func (e *luaEngine) cleanup() {
if e.configDir != "" {
_ = os.RemoveAll(e.configDir)
}
if e.lstate != nil {
e.lstate.Close()
}
}
// luaPreloadMap maps gopher-lua require names to their embedded-FS paths.
// Mirrors tt/cli/rocks/rocks.go's rocks_preload, with the path prefixes adapted
// to this module's embed layout: upstream modules live under "src/src/" (the
// git subtree placed the upstream repo root at internal/luarocks/src/), shims
// under "extra/". macosx maps to the upstream module — we ship no extra shim.
var luaPreloadMap = func() map[string]string {
rocksPath := "src/src/"
extraPath := "extra/"
return map[string]string{
"extra.wrapper": extraPath + "wrapper.lua",
"luarocks.core.hardcoded": extraPath + "hardcoded.lua",
"luarocks.core.util": rocksPath + "luarocks/core/util.lua",
"luarocks.core.persist": rocksPath + "luarocks/core/persist.lua",
"luarocks.core.sysdetect": rocksPath + "luarocks/core/sysdetect.lua",
"luarocks.core.cfg": rocksPath + "luarocks/core/cfg.lua",
"luarocks.core.dir": rocksPath + "luarocks/core/dir.lua",
"luarocks.core.path": rocksPath + "luarocks/core/path.lua",
"luarocks.core.manif": rocksPath + "luarocks/core/manif.lua",
"luarocks.core.vers": rocksPath + "luarocks/core/vers.lua",
"luarocks.util": rocksPath + "luarocks/util.lua",
"luarocks.loader": rocksPath + "luarocks/loader.lua",
"luarocks.dir": rocksPath + "luarocks/dir.lua",
"luarocks.path": rocksPath + "luarocks/path.lua",
"luarocks.fs": rocksPath + "luarocks/fs.lua",
"luarocks.persist": rocksPath + "luarocks/persist.lua",
"luarocks.fun": rocksPath + "luarocks/fun.lua",
"luarocks.tools.patch": rocksPath + "luarocks/tools/patch.lua",
"luarocks.tools.zip": rocksPath + "luarocks/tools/zip.lua",
"luarocks.tools.tar": rocksPath + "luarocks/tools/tar.lua",
"luarocks.fs.unix": rocksPath + "luarocks/fs/unix.lua",
// luarocks.fs.macosx is INTENTIONALLY NOT preloaded. Its is_dir/is_file
// (internal/luarocks/.../fs/macosx.lua) probe directory-ness via
// io.open(path.."/.", "r") and inspect the PUC-Lua errno returned as the
// third result (codes 2/13/20/21). gopher-lua's io.open does not surface
// libc errno that way, so macosx.is_dir returns false for EVERY existing
// directory — which breaks fs.check_command_permissions (the tree always
// looks unwritable) and every downstream mkdir/copy. Omitting the module
// makes require("luarocks.fs.macosx") fail in load_platform_fns's pcall,
// so fs.init falls through to the shell-out tools backend
// (luarocks.fs.unix.tools → `test -d`), which works correctly in-VM.
"luarocks.fs.unix.tools": rocksPath + "luarocks/fs/unix/tools.lua",
"luarocks.fs.lua": rocksPath + "luarocks/fs/lua.lua",
"luarocks.fs.tools": rocksPath + "luarocks/fs/tools.lua",
"luarocks.queries": rocksPath + "luarocks/queries.lua",
"luarocks.type_check": rocksPath + "luarocks/type_check.lua",
"luarocks.type.rockspec": rocksPath + "luarocks/type/rockspec.lua",
"luarocks.rockspecs": rocksPath + "luarocks/rockspecs.lua",
"luarocks.signing": rocksPath + "luarocks/signing.lua",
"luarocks.fetch": rocksPath + "luarocks/fetch.lua",
"luarocks.type.manifest": rocksPath + "luarocks/type/manifest.lua",
"luarocks.manif": rocksPath + "luarocks/manif.lua",
"luarocks.build.builtin": rocksPath + "luarocks/build/builtin.lua",
"luarocks.deps": rocksPath + "luarocks/deps.lua",
"luarocks.deplocks": rocksPath + "luarocks/deplocks.lua",
"luarocks.cmd": rocksPath + "luarocks/cmd.lua",
"luarocks.argparse": rocksPath + "luarocks/argparse.lua",
"luarocks.test.busted": rocksPath + "luarocks/test/busted.lua",
"luarocks.test.command": rocksPath + "luarocks/test/command.lua",
"luarocks.results": rocksPath + "luarocks/results.lua",
"luarocks.search": rocksPath + "luarocks/search.lua",
"luarocks.repos": rocksPath + "luarocks/repos.lua",
"luarocks.cmd.show": rocksPath + "luarocks/cmd/show.lua",
"luarocks.cmd.path": rocksPath + "luarocks/cmd/path.lua",
"luarocks.cmd.write_rockspec": rocksPath + "luarocks/cmd/write_rockspec.lua",
"luarocks.manif.writer": rocksPath + "luarocks/manif/writer.lua",
"luarocks.remove": rocksPath + "luarocks/remove.lua",
"luarocks.pack": rocksPath + "luarocks/pack.lua",
"luarocks.build": rocksPath + "luarocks/build.lua",
"luarocks.cmd.make": rocksPath + "luarocks/cmd/make.lua",
"luarocks.cmd.build": rocksPath + "luarocks/cmd/build.lua",
"luarocks.cmd.install": rocksPath + "luarocks/cmd/install.lua",
"luarocks.cmd.list": rocksPath + "luarocks/cmd/list.lua",
"luarocks.download": rocksPath + "luarocks/download.lua",
"luarocks.cmd.download": rocksPath + "luarocks/cmd/download.lua",
"luarocks.cmd.search": rocksPath + "luarocks/cmd/search.lua",
"luarocks.cmd.pack": rocksPath + "luarocks/cmd/pack.lua",
"luarocks.cmd.new_version": rocksPath + "luarocks/cmd/new_version.lua",
"luarocks.cmd.purge": rocksPath + "luarocks/cmd/purge.lua",
"luarocks.cmd.init": rocksPath + "luarocks/cmd/init.lua",
"luarocks.cmd.lint": rocksPath + "luarocks/cmd/lint.lua",
"luarocks.test": rocksPath + "luarocks/test.lua",
"luarocks.cmd.test": rocksPath + "luarocks/cmd/test.lua",
"luarocks.cmd.which": rocksPath + "luarocks/cmd/which.lua",
"luarocks.cmd.remove": rocksPath + "luarocks/cmd/remove.lua",
"luarocks.upload.multipart": rocksPath + "luarocks/upload/multipart.lua",
"luarocks.upload.api": rocksPath + "luarocks/upload/api.lua",
"luarocks.cmd.upload": rocksPath + "luarocks/cmd/upload.lua",
"luarocks.cmd.doc": rocksPath + "luarocks/cmd/doc.lua",
"luarocks.cmd.unpack": rocksPath + "luarocks/cmd/unpack.lua",
"luarocks.cmd.config": rocksPath + "luarocks/cmd/config.lua",
"luarocks.require": rocksPath + "luarocks/require.lua",
"luarocks.build.cmake": rocksPath + "luarocks/build/cmake.lua",
"luarocks.build.make": rocksPath + "luarocks/build/make.lua",
"luarocks.build.command": rocksPath + "luarocks/build/command.lua",
"luarocks.fetch.cvs": rocksPath + "luarocks/fetch/cvs.lua",
"luarocks.fetch.svn": rocksPath + "luarocks/fetch/svn.lua",
"luarocks.fetch.sscm": rocksPath + "luarocks/fetch/sscm.lua",
"luarocks.fetch.git": rocksPath + "luarocks/fetch/git.lua",
"luarocks.fetch.git_file": rocksPath + "luarocks/fetch/git_file.lua",
"luarocks.fetch.git_http": rocksPath + "luarocks/fetch/git_http.lua",
"luarocks.fetch.git_https": rocksPath + "luarocks/fetch/git_https.lua",
"luarocks.fetch.git_ssh": rocksPath + "luarocks/fetch/git_ssh.lua",
"luarocks.fetch.hg": rocksPath + "luarocks/fetch/hg.lua",
"luarocks.fetch.hg_http": rocksPath + "luarocks/fetch/hg_http.lua",
"luarocks.fetch.hg_https": rocksPath + "luarocks/fetch/hg_https.lua",
"luarocks.fetch.hg_ssh": rocksPath + "luarocks/fetch/hg_ssh.lua",
"luarocks.admin.cache": rocksPath + "luarocks/admin/cache.lua",
"luarocks.admin.cmd.refresh_cache": rocksPath + "luarocks/admin/cmd/refresh_cache.lua",
"luarocks.admin.index": rocksPath + "luarocks/admin/index.lua",
"luarocks.admin.cmd.add": rocksPath + "luarocks/admin/cmd/add.lua",
"luarocks.admin.cmd.remove": rocksPath + "luarocks/admin/cmd/remove.lua",
"luarocks.admin.cmd.make_manifest": rocksPath + "luarocks/admin/cmd/make_manifest.lua",
}
}()
// boot creates the gopher-lua VM, installs the custom os.getenv and the
// glr_getwd global, preloads every embedded module, and caches the LState on
// e.lstate for the engine's lifetime. It is invoked exactly once via
// e.bootOnce.Do. The LState is intentionally NOT closed here — it lives as long
// as the engine.
//
// LState reuse vs tt: tt opens a fresh lua.NewState per command and closes it,
// so each command re-initializes LuaRocks config. We reuse one cached LState to
// amortize the preload cost, which means LuaRocks' module-level state —
// notably core.cfg, guarded by cfg.initialized in the tarantool fork — persists
// across calls. This is safe here because cfg.Tree and cfg.Tarantool are fixed
// for the engine's lifetime, so the cached config stays correct;
// TestLuaEngine_ReusedLState_SequentialMakes is the regression gate. The one
// known caveat is cfg.rocks_servers, which the fork *prepends* per --server, so
// repeated calls passing different InstallOpts.Servers on the SAME engine
// accumulate sources; a caller needing isolated server sets constructs a new
// *Rocks (the same "want isolation → new client" rule).
func (e *luaEngine) boot() error {
// Materialize the LuaRocks config file that aligns the tree layout with the
// native backend (tt-style subdirs). The vendored cfg.lua does NOT
// consume hardcoded.lua's ROCKS_SUBDIR / LUA_MODULES_*_SUBDIR keys (those
// were tt patches); left to its defaults it would write to
// <tree>/lib/luarocks/rocks-5.1 and <tree>/share/lua/5.1, diverging from the
// native engine's <tree>/share/tarantool/rocks etc. We point LUAROCKS_CONFIG
// at a generated file that sets the three path subdirs, which cfg.init
// deep-merges over the defaults. This is the LuaRocks-native override
// mechanism and keeps the no-Setenv invariant intact: LUAROCKS_CONFIG is
// served via the in-VM os.getenv shim (envOverride), never os.Setenv.
err := e.writeConfigFile()
if err != nil {
return err
}
L := lua.NewState() // full stdlibs; do NOT skip OpenLibs
// Install the custom os.getenv and glr_getwd BEFORE preloading, since
// hardcoded.lua reads them at require time.
osTbl, ok := L.GetField(L.Get(lua.EnvironIndex), "os").(*lua.LTable)
if !ok {
return errors.New("luaEngine: os table missing from Lua environment")
}
L.SetField(osTbl, "getenv", L.NewFunction(func(L *lua.LState) int {
key := L.CheckString(1)
if v, ok := e.envOverride[key]; ok {
L.Push(lua.LString(v))
return 1
}
if v, ok := os.LookupEnv(key); ok {
L.Push(lua.LString(v))
return 1
}
L.Push(lua.LNil)
return 1
}))
L.SetGlobal("glr_getwd", L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LString(e.cfg.WorkingDir))
return 1
}))
// Normalize io.open mode strings. LuaRocks' luarocks/fs/lua.lua opens files
// with PUC-Lua-5.1 mode strings of the form "<mode>+b" (e.g. "r+b", "w+b",
// "a+b") for fs.is_writable / fs.copy_binary. gopher-lua's io.open accepts
// only the "<mode>b+" ordering ("rb+", "wb+", "ab+") and raises "invalid
// option" otherwise, crashing the command (exit 99). Wrap the original
// io.open to rewrite the "+b" tail to "b+" before delegating; every other
// mode passes through untouched. This is a VM-compatibility shim, not a
// behavior change — the resulting file is opened in the identical mode.
ioTbl, ok := L.GetField(L.Get(lua.EnvironIndex), "io").(*lua.LTable)
if !ok {
return errors.New("luaEngine: io table missing from Lua environment")
}
origOpen, ok := L.GetField(ioTbl, "open").(*lua.LFunction)
if !ok {
return errors.New("luaEngine: io.open missing from Lua environment")
}
L.SetField(ioTbl, "open", L.NewFunction(func(L *lua.LState) int {
// Collect every argument so we can re-dispatch verbatim, rewriting
// only the mode (arg 2) when it uses the unsupported "+b" ordering.
top := L.GetTop()
args := make([]lua.LValue, top)
for i := 1; i <= top; i++ {
args[i-1] = L.Get(i)
}
if top >= ioOpenMinArgsForMode {
if ls, ok := args[1].(lua.LString); ok {
args[1] = lua.LString(normalizeOpenMode(string(ls)))
}
}
L.Push(origOpen)
for _, a := range args {
L.Push(a)
}
// io.open returns (file) on success or (nil, errmsg, errno) on failure;
// propagate all of them so LuaRocks' error handling is unchanged.
L.Call(len(args), lua.MultRet)
return L.GetTop() - top
}))
// Intercept os.exit. The vendored LuaRocks CLI terminates every command
// path with os.exit (luarocks/cmd.lua), which gopher-lua maps to Go's
// os.Exit — fatal for our in-process VM (one long-lived LState for the
// engine lifetime). Replace it with a function that raises a Lua error
// carrying the requested exit code (prefixed with osExitSentinel) so the
// surrounding DoString unwinds back into call() instead of killing the
// process. call() maps code 0 to a nil error and any non-zero code to a
// descriptive Go error.
L.SetField(osTbl, "exit", L.NewFunction(func(L *lua.LState) int {
code := L.OptInt(1, 0)
L.RaiseError("%s%d", osExitSentinel, code)
return 0
}))
// Route the base print() through the VM's io.stdout. gopher-lua's stock
// print writes via fmt.Print to the host process stdout, bypassing the
// io.stdout field that redirectIO swaps for an in-memory buffer. A
// handful of LuaRocks commands emit user-facing data with print rather than
// util.printout — notably `config <key>` (cmd/config.lua print_entry prints
// string values via print). Without this shim that output would escape to
// the real stdout and the captured string callViaState returns would be
// empty, breaking the Config data parse. The shim tab-joins its args and
// appends a newline exactly like PUC-Lua print, then writes to whatever
// io.stdout currently is — so it honors the redirect and the no-Setenv
// invariant (in-VM handle only, never the host stdout or os.Setenv).
L.SetGlobal("print", L.NewFunction(func(L *lua.LState) int {
ioTbl, ok := L.GetField(L.Get(lua.EnvironIndex), "io").(*lua.LTable)
if !ok {
return 0
}
out := L.GetField(ioTbl, "stdout")
writeFn := L.GetField(out, "write")
top := L.GetTop()
parts := make([]string, top)
for i := 1; i <= top; i++ {
parts[i-1] = lua.LVAsString(L.Get(i))
}
line := strings.Join(parts, "\t") + "\n"
L.Push(writeFn)
L.Push(out)
L.Push(lua.LString(line))
L.Call(printWriteArgCount, 0)
return 0
}))
preload := L.GetField(L.GetField(L.Get(lua.EnvironIndex), "package"), "preload")
for modName, path := range luaPreloadMap {
src, err := luarocksembed.FS.ReadFile(path)
if err != nil {
return fmt.Errorf("luaEngine: read embedded %s: %w", path, err)
}
mod, err := L.LoadString(string(src))
if err != nil {
return fmt.Errorf("luaEngine: load %s: %w", modName, err)
}
L.SetField(preload, modName, mod)
}
e.lstate = L
return nil
}
// luaConfigContents builds the LuaRocks config file the engine generates. It
// serves two purposes:
//
// 1. Tree-layout parity with the native backend (tree/paths.go):
// rocks_subdir=/share/tarantool/rocks (RocksDir),
// lua_modules_path=/share/tarantool (DeployLuaDir),
// lib_modules_path=/lib/tarantool (DeployLibDir). These mirror
// hardcoded.lua's ROCKS_SUBDIR / LUA_MODULES_*_SUBDIR, which this vendored
// cfg.lua does not read. cfg.init deep-merges them over defaults.
//
// 2. Working-directory anchoring without host mutation. The shell-out fs
// backend (luarocks.fs.tools) resolves the base directory for every
// `cd <dir> && <cmd>` it runs from `cfg.variables.PWD` (default "pwd"),
// which would return the host process cwd — wrong for an in-process engine
// whose logical cwd is cfg.WorkingDir. We override PWD to echo WorkingDir,
// so relative build paths (e.g. builtin `cp src/foo.lua`) resolve against
// WorkingDir. This replaces a host os.Chdir (forbidden) with a
// per-command cd into the engine's configured working directory.
//
// workDir is single-quote-escaped for the Lua string literal and shell echo.
func luaConfigContents(workDir string) string {
escaped := strings.ReplaceAll(workDir, `'`, `'\''`)
return fmt.Sprintf(`-- Generated by go-luarocks luaEngine. Aligns tree layout with the native
-- backend (tree/paths.go) and anchors the shell-out cwd to WorkingDir. Do not
-- edit by hand.
rocks_subdir = "/share/tarantool/rocks"
lua_modules_path = "/share/tarantool"
lib_modules_path = "/lib/tarantool"
variables = {
PWD = "echo '%s'",
}
`, escaped)
}
// writeConfigFile materializes the generated config to a temp path and records
// LUAROCKS_CONFIG in the env-override map so cfg.init loads it. Content depends
// on cfg.WorkingDir, so it is regenerated per engine.
func (e *luaEngine) writeConfigFile() error {
dir, err := os.MkdirTemp("", "go-luarocks-cfg-")
if err != nil {
return fmt.Errorf("luaEngine: create config dir: %w", err)
}
e.configDir = dir // tracked for finalizer cleanup
path := filepath.Join(dir, "config-5.1.lua")
if err := os.WriteFile(path, []byte(luaConfigContents(e.cfg.WorkingDir)), luaConfigFileMode); err != nil {
return fmt.Errorf("luaEngine: write config file: %w", err)
}
e.envOverride["LUAROCKS_CONFIG"] = path
return nil
}
// dispatch routes argv to the active dispatch backend. callImpl is a test seam
// (nil in production); when nil, the real embedded-VM path callViaState runs.
// It returns the stdout the LuaRocks command printed plus any error.
func (e *luaEngine) dispatch(argv []string) (string, error) {
if e.callImpl != nil {
return e.callImpl(argv)
}
return e.callViaState(argv)
}
// call is a thin error-only wrapper over callViaState, retained for the boot
// smoke tests in lua_test.go that predate the unified seam. New code uses
// dispatch/callViaState directly.
func (e *luaEngine) call(argv []string) error {
_, err := e.callViaState(argv)
return err
}
// callViaState dispatches argv through extra/wrapper.lua's exec(). It serializes
// access to the single-threaded LState via e.mu, lazily booting on first
// use. progname is fixed to the default value. Each arg is single-quote-
// escaped for embedding in the Lua single-quoted dispatch string.
//
// stdout/stderr capture: before running the command, callViaState
// replaces the Lua VM's io.stdout and io.stderr fields with buffer-backed file
// tables (newWriterFile) pointing at in-memory Go buffers, restoring the
// originals afterward. LuaRocks emits all user-facing text through
// util.printout/printerr, which write to io.stdout/io.stderr, so this captures
// it. After the command finishes, the captured text is drained into e.logger at
// info level (slog.Default() if e.logger is nil); the no-Setenv invariant holds
// — we touch only the in-VM io handles, never the host process stdout/stderr or
// os.Setenv. The
// captured stdout string is returned so data-returning methods (e.g. Pack) can
// parse it.
func (e *luaEngine) callViaState(argv []string) (string, error) {
e.mu.Lock()
defer e.mu.Unlock()
e.bootOnce.Do(func() {
e.bootErr = e.boot()
})
if e.bootErr != nil {
return "", e.bootErr
}
var stdout, stderr bytes.Buffer
restore := e.redirectIO(&stdout, &stderr)
defer restore()
doErr := e.lstate.DoString(dispatchString("go-luarocks", argv))
// Drain whatever the command printed into the logger before interpreting
// the result, so even a failing command's diagnostics reach the caller.
restore()
e.drainOutput(argv, stdout.String(), stderr.String())
out := stdout.String()
if doErr == nil {
// The wrapper returned normally (no os.exit). Treat as success.
return out, nil
}
// The CLI almost always unwinds via our os.exit replacement, surfacing as
// a Lua error whose message embeds osExitSentinel + the exit code. A code
// of 0 is success; any non-zero code (or a non-sentinel error) is a real
// failure surfaced verbatim.
if code, ok := parseOsExit(doErr.Error()); ok {
if code == 0 {
return out, nil
}
return out, fmt.Errorf("luaEngine: %v exited with code %d", argv, code)
}
return out, doErr
}
// dispatchString builds the Lua one-liner that invokes extra/wrapper.lua's
// exec() with the given program name and the single-quote-escaped argv. It is
// shared by callViaState (capturing, with the default progname) and callRaw
// (passthrough, caller-supplied progname so `tt rocks` can show its own usage).
func dispatchString(progname string, argv []string) string {
parts := make([]string, 0, len(argv)+1)
parts = append(parts, quoteLua(progname))
for _, arg := range argv {
parts = append(parts, quoteLua(arg))
}
return fmt.Sprintf("t=require('extra.wrapper').exec(%s)", strings.Join(parts, ", "))
}
// quoteLua wraps s in single quotes, escaping embedded single quotes, for
// embedding in the dispatch string.
func quoteLua(s string) string {
return "'" + strings.ReplaceAll(s, "'", `\'`) + "'"
}
// callRaw dispatches argv through extra/wrapper.lua's exec() WITHOUT capturing
// io.stdout/io.stderr, so LuaRocks writes straight to the process's stdout and
// stderr. It backs the public Rocks.Exec escape hatch (tt's `tt rocks`), which
// must reproduce upstream's terminal output verbatim; the capturing
// callViaState exists for programmatic methods that parse the output.
//
// progname is the program name LuaRocks prints in its own usage/help, letting
// the caller (e.g. tt) show "tt rocks" instead of the default progname.
//
// Like callViaState it serializes on e.mu and lazily boots on first use.
// Exit-code handling matches callViaState: os.exit(0) and a clean return
// are success, any other code is surfaced as an error.
func (e *luaEngine) callRaw(progname string, argv []string) error {
e.mu.Lock()
defer e.mu.Unlock()
e.bootOnce.Do(func() {
e.bootErr = e.boot()
})
if e.bootErr != nil {
return e.bootErr
}
doErr := e.lstate.DoString(dispatchString(progname, argv))
if doErr == nil {
return nil
}
if code, ok := parseOsExit(doErr.Error()); ok {
if code == 0 {
return nil
}
return fmt.Errorf("luaEngine: %v exited with code %d", argv, code)
}
return doErr
}
// redirectIO points the VM's io.stdout and io.stderr at the supplied Go
// buffers by installing buffer-backed file userdata via io.output()/io.errput.
// It returns a function that restores the original handles; the function is
// idempotent so callViaState can both defer it and call it eagerly. Only the
// in-VM io handles are touched.
func (e *luaEngine) redirectIO(stdout, stderr io.Writer) func() {
L := e.lstate
ioTbl, ok := L.GetField(L.Get(lua.EnvironIndex), "io").(*lua.LTable)
if !ok {
return func() {}
}
origOut := L.GetField(ioTbl, "stdout")
origErr := L.GetField(ioTbl, "stderr")
L.SetField(ioTbl, "stdout", newWriterFile(L, stdout))
L.SetField(ioTbl, "stderr", newWriterFile(L, stderr))
restored := false
return func() {
if restored {
return
}
restored = true
L.SetField(ioTbl, "stdout", origOut)
L.SetField(ioTbl, "stderr", origErr)
}
}
// newWriterFile builds a Lua table that quacks like an io file handle for the
// subset of methods LuaRocks' util.printout/printerr use: write (called as
// f:write(...)) and the no-op flush/close. All bytes go to w.
func newWriterFile(L *lua.LState, w io.Writer) *lua.LTable { //nolint:gocritic // L is the conventional gopher-lua LState receiver name used throughout
tbl := L.NewTable()
write := L.NewFunction(func(L *lua.LState) int {
// arg 1 is the file table (self); the rest are the strings to write.
top := L.GetTop()
for i := 2; i <= top; i++ {
v := L.Get(i)
if v == lua.LNil {
continue
}
if _, err := io.WriteString(w, lua.LVAsString(v)); err != nil {
L.RaiseError("go-luarocks io capture: %v", err)
}
}
L.Push(tbl) // io files return self for chaining
return 1
})
noop := L.NewFunction(func(L *lua.LState) int {
L.Push(tbl)
return 1
})
L.SetField(tbl, "write", write)
L.SetField(tbl, "flush", noop)
L.SetField(tbl, "close", noop)
return tbl
}
// drainOutput logs captured stdout/stderr at info level so command diagnostics
// are not lost. e.logger is used when set; otherwise slog.Default().
func (e *luaEngine) drainOutput(argv []string, stdout, stderr string) {
logger := e.logger
if logger == nil {
logger = slog.Default()
}
cmd := strings.Join(argv, " ")
if s := strings.TrimRight(stdout, "\n"); s != "" {
logger.Info("luaEngine stdout", "cmd", cmd, "output", s)
}
if s := strings.TrimRight(stderr, "\n"); s != "" {
logger.Info("luaEngine stderr", "cmd", cmd, "output", s)
}
}
// packedPathRE matches the line upstream luarocks pack prints on success:
// `Packed: <path>` (luarocks/pack.lua report_and_sign_local_file).
var packedPathRE = regexp.MustCompile(`(?m)^Packed:\s*(.+?)\s*$`)
// parsePackPath extracts the produced rock path from pack's captured stdout.
// It returns ("", false) when no `Packed:` line is present so the caller can
// raise a real error rather than fabricating a path (no silent fallback).
func parsePackPath(stdout string) (string, bool) {
m := packedPathRE.FindStringSubmatch(stdout)
if m == nil {
return "", false
}
return m[1], true
}
// wrotePathRE matches the line LuaRocks prints after writing a rockspec file:
// - new_version: `Wrote <path>` (cmd/new_version.lua).
// - write_rockspec: `Wrote template at <path> -- you should now edit ...`
// (cmd/write_rockspec.lua).
//
// Both share the `Wrote ` / `Wrote template at ` prefix; the alternation
// captures the path and stops the write_rockspec variant before its trailing
// " -- you should now edit and finish it." hint.
var wrotePathRE = regexp.MustCompile(`(?m)^Wrote (?:template at )?(.+?)(?: -- .*)?\s*$`)
// parseWrotePath extracts the written rockspec path from new_version /
// write_rockspec stdout. Returns ("", false) when no `Wrote` line is present so
// the caller raises a real error rather than fabricating a path.
func parseWrotePath(stdout string) (string, bool) {
m := wrotePathRE.FindStringSubmatch(stdout)
if m == nil {
return "", false
}
return m[1], true
}
// parseSearchResults parses the --porcelain listing search.print_result_tree
// emits: one tab-separated record per match,
// "<name>\t<version>\t<arch>\t<repo>\t<namespace>". Title lines are suppressed
// under --porcelain, so every non-blank line with at least three tab fields is
// a result; lines that do not match that shape are skipped (defensive against
// stray diagnostics that may share the buffer). Name, Version and Server (the
// repo URL, field index 3) are surfaced.
func parseSearchResults(stdout string) []SearchResult {
var out []SearchResult
for line := range strings.SplitSeq(stdout, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) < searchFieldsMin {
continue
}
out = append(out, SearchResult{
Name: fields[0],
Version: fields[1],
Server: fields[3],
})
}
return out
}
// normalizeOpenMode rewrites a PUC-Lua-5.1 file mode string into the form
// gopher-lua's io.open accepts. gopher-lua rejects the "<mode>+b" ordering
// (r+b, w+b, a+b) but accepts the equivalent "<mode>b+" ordering (rb+, wb+,
// ab+); both denote the same update-binary mode. Any other mode (including
// already-normalized or non-binary modes) is returned unchanged.
func normalizeOpenMode(mode string) string {
switch mode {
case "r+b":
return "rb+"
case "w+b":
return "wb+"
case "a+b":
return "ab+"
default:
return mode
}
}
// parseOsExit extracts the exit code from a DoString error message produced by
// the engine's os.exit replacement. The gopher-lua error string embeds the
// sentinel somewhere after a position prefix; returns (code, true) on a match.
func parseOsExit(msg string) (int, bool) {
_, after, ok := strings.Cut(msg, osExitSentinel)
if !ok {
return 0, false
}
rest := after
// The code runs up to the first non-digit (gopher-lua may append a
// stack traceback after the message).
end := 0
for end < len(rest) && rest[end] >= '0' && rest[end] <= '9' {
end++
}
if end == 0 {
return 0, false
}
code := 0
for i := range end {
code = code*decimalBase + int(rest[i]-'0')
}
return code, true
}
// --- Engine interface implementation ---
//
// The five methods the native backend also serves (Install, Build, Make, Pack,
// Unpack) build the upstream argv and dispatch; the remaining methods map to
// upstream-only commands.
// globalArgs returns the LuaRocks global options that must precede the
// subcommand name. --tree is ALWAYS emitted so the lua backend installs into
// the SAME tree the native engine uses (backend parity); it reads the engine's
// cfg.Tree. servers, when non-empty, append a --server <s> per entry (the
// global option lives on the main parser, before the subcommand).
func (e *luaEngine) globalArgs(servers []string) []string {
argv := make([]string, 0, globalArgsTreePair+argsPerServer*len(servers))
argv = append(argv, "--tree", e.cfg.Tree)
for _, s := range servers {
argv = append(argv, "--server", s)
}
return argv
}
// depsModeArg maps a DepsPolicy to upstream's --deps-mode choices
// {all, one, order, none}. DepsAll→"all", DepsNone→"none". DepsOnlyNew has no
// exact upstream equivalent; "order" (use the current tree plus those below it
// on rocks_trees) is the closest match to the "only new" documented intent.
func depsModeArg(p DepsPolicy) string {
switch p {
case DepsAll:
return "all"
case DepsNone:
return "none"
case DepsOnlyNew:
// Closest upstream match to "only new" — see DepsPolicy doc.
return "order"
default:
return "all"
}
}
func (e *luaEngine) Install(ctx context.Context, name string, opts InstallOpts) error {
argv := e.globalArgs(opts.Servers)
argv = append(argv, "install", name)
if opts.Version != "" {
argv = append(argv, opts.Version)
}
argv = append(argv, "--deps-mode", depsModeArg(opts.Deps))
_, err := e.dispatch(argv)
return err
}
func (e *luaEngine) Build(ctx context.Context, specPath string, opts BuildOpts) error {
argv := e.globalArgs(nil)
argv = append(argv, "build", specPath)
if opts.Keep {
argv = append(argv, "--keep")
}
_, err := e.dispatch(argv)
return err
}
func (e *luaEngine) Make(ctx context.Context, opts MakeOpts) error {
argv := e.globalArgs(nil)
argv = append(argv, "make")
// Upstream make searches the current dir (glr_getwd) when no rockspec
// positional is given; only append it when explicitly set.
if opts.RockspecPath != "" {
argv = append(argv, opts.RockspecPath)
}
_, err := e.dispatch(argv)
return err
}
func (e *luaEngine) Pack(ctx context.Context, target string, opts PackOpts) (string, error) {
// opts.SrcOnly: upstream pack has no dedicated flag — it produces a
// .src.rock when target is a rockspec and a binary .rock when target is an
// installed rock name (luarocks/cmd/pack.lua). There is no clean argv
// expression to force src-only for an installed-rock target through this
// path, so SrcOnly is honored implicitly by the target kind. We do NOT
// silently swallow it: when SrcOnly is set against a non-rockspec target it
// simply has no effect here, left for a follow-up rather than guessed.
argv := e.globalArgs(nil)
argv = append(argv, "pack", target)
out, err := e.dispatch(argv)
if err != nil {
return "", err
}
path, ok := parsePackPath(out)
if !ok {
return "", fmt.Errorf("luaEngine: pack %s succeeded but no 'Packed:' path line found in output: %q", target, out)
}
return path, nil
}
func (e *luaEngine) Unpack(ctx context.Context, archive, destDir string) error {
// Upstream unpack has no destination flag: it creates a directory derived
// from the rock name inside the current working dir (luarocks/cmd/unpack.lua
// run_unpacker → fs.make_dir(dir_name)). The VM's cwd is reported by
// glr_getwd, which returns cfg.WorkingDir. destDir is therefore not
// expressible as an argv flag here; the caller controls the extraction root
// via cfg.WorkingDir. We do not invent a flag or silently drop destDir —
// the parameter's effect is documented as bounded by cfg.WorkingDir.
_ = destDir
argv := e.globalArgs(nil)
argv = append(argv, "unpack", archive)
_, err := e.dispatch(argv)
return err
}
// Remove runs `luarocks remove`. It is tree-scoped: the --tree global precedes
// the subcommand. name is the rock; opts.Version narrows to one version.
func (e *luaEngine) Remove(ctx context.Context, name string, opts RemoveOpts) error {
argv := e.globalArgs(nil)
argv = append(argv, "remove", name)
if opts.Version != "" {
argv = append(argv, opts.Version)
}
if opts.Force {
argv = append(argv, "--force")
}
if opts.ForceFast {