agi.zip_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. package agi
  2. import (
  3. "archive/tar"
  4. "archive/zip"
  5. "compress/gzip"
  6. "io"
  7. "os"
  8. "path"
  9. "path/filepath"
  10. "reflect"
  11. "sort"
  12. "strings"
  13. "testing"
  14. "github.com/robertkrimen/otto"
  15. "imuslab.com/arozos/mod/agi/static"
  16. )
  17. // exportJS evaluates a JS expression and returns its exported Go value, which
  18. // is exactly what the ziplib functions receive from call.Argument(n).Export().
  19. func exportJS(t *testing.T, expr string) interface{} {
  20. t.Helper()
  21. val, err := otto.New().Run("(" + expr + ")")
  22. if err != nil {
  23. t.Fatalf("evaluating %s: %v", expr, err)
  24. }
  25. exported, err := val.Export()
  26. if err != nil {
  27. t.Fatalf("exporting %s: %v", expr, err)
  28. }
  29. return exported
  30. }
  31. func TestZiplibSourceList(t *testing.T) {
  32. cases := []struct {
  33. name string
  34. expr string
  35. want []string
  36. wantOK bool
  37. }{
  38. {"array of strings", `["user:/a.txt", "user:/b.txt"]`, []string{"user:/a.txt", "user:/b.txt"}, true},
  39. {"array of one string", `["user:/a.txt"]`, []string{"user:/a.txt"}, true},
  40. {"empty array", `[]`, []string{}, true},
  41. {"mixed array keeps the strings", `["user:/a.txt", 1, null]`, []string{"user:/a.txt"}, true},
  42. {"single string", `"user:/a.txt"`, []string{"user:/a.txt"}, true},
  43. {"undefined", `undefined`, nil, false},
  44. {"number", `42`, nil, false},
  45. {"array of numbers", `[1, 2]`, nil, false},
  46. {"object", `({a: "user:/a.txt"})`, nil, false},
  47. }
  48. for _, c := range cases {
  49. t.Run(c.name, func(t *testing.T) {
  50. got, ok := ziplibSourceList(exportJS(t, c.expr))
  51. if ok != c.wantOK {
  52. t.Fatalf("ok = %v, want %v", ok, c.wantOK)
  53. }
  54. if !reflect.DeepEqual(got, c.want) {
  55. t.Errorf("sources = %#v, want %#v", got, c.want)
  56. }
  57. })
  58. }
  59. }
  60. // The returned list is rewritten in place by the callers, so it must not share
  61. // its backing array with the exported argument.
  62. func TestZiplibSourceListCopiesInput(t *testing.T) {
  63. in := []string{"user:/a.txt"}
  64. got, _ := ziplibSourceList(in)
  65. got[0] = "changed"
  66. if in[0] != "user:/a.txt" {
  67. t.Errorf("ziplibSourceList shares its result with the input slice")
  68. }
  69. }
  70. func TestSzParsePathsArg(t *testing.T) {
  71. cases := []struct {
  72. name string
  73. expr string
  74. want []string
  75. }{
  76. {"array of strings", `["docs/", "/a.txt"]`, []string{"docs/", "a.txt"}},
  77. {"mixed array keeps the strings", `["a.txt", 1]`, []string{"a.txt"}},
  78. {"JSON array string", `'["docs/", "/a.txt"]'`, []string{"docs/", "a.txt"}},
  79. {"single path string", `"/a.txt"`, []string{"a.txt"}},
  80. {"number", `42`, nil},
  81. {"undefined", `undefined`, nil},
  82. }
  83. for _, c := range cases {
  84. t.Run(c.name, func(t *testing.T) {
  85. got := szParsePathsArg(exportJS(t, c.expr))
  86. if !reflect.DeepEqual(got, c.want) {
  87. t.Errorf("szParsePathsArg(%s) = %#v, want %#v", c.expr, got, c.want)
  88. }
  89. })
  90. }
  91. }
  92. // newZipLibTestVM returns a VM with ziplib loaded for a user from newFsTestUser,
  93. // with a.txt, b.txt and docs/c.txt in the user's storage.
  94. func newZipLibTestVM(t *testing.T) (*otto.Otto, string) {
  95. t.Helper()
  96. u, dir := newFsTestUser(t)
  97. files := map[string]string{"a.txt": "alpha", "b.txt": "bravo", "docs/c.txt": "charlie"}
  98. for name, content := range files {
  99. p := filepath.Join(dir, filepath.FromSlash(name))
  100. if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
  101. t.Fatalf("mkdir: %v", err)
  102. }
  103. if err := os.WriteFile(p, []byte(content), 0644); err != nil {
  104. t.Fatalf("write fixture: %v", err)
  105. }
  106. }
  107. vm := otto.New()
  108. minimalGateway().injectZipFileLibFunctions(&static.AgiLibInjectionPayload{VM: vm, User: u})
  109. return vm, dir
  110. }
  111. // archiveEntries lists the base names of the files in a zip, tar or tar.gz.
  112. func archiveEntries(t *testing.T, file string) []string {
  113. t.Helper()
  114. var names []string
  115. if strings.HasSuffix(file, ".zip") {
  116. r, err := zip.OpenReader(file)
  117. if err != nil {
  118. t.Fatalf("open zip: %v", err)
  119. }
  120. defer r.Close()
  121. for _, f := range r.File {
  122. if !f.FileInfo().IsDir() {
  123. names = append(names, path.Base(f.Name))
  124. }
  125. }
  126. } else {
  127. f, err := os.Open(file)
  128. if err != nil {
  129. t.Fatalf("open archive: %v", err)
  130. }
  131. defer f.Close()
  132. var r io.Reader = f
  133. if strings.HasSuffix(file, ".tar.gz") {
  134. gz, err := gzip.NewReader(f)
  135. if err != nil {
  136. t.Fatalf("open gzip: %v", err)
  137. }
  138. defer gz.Close()
  139. r = gz
  140. }
  141. tr := tar.NewReader(r)
  142. for {
  143. h, err := tr.Next()
  144. if err == io.EOF {
  145. break
  146. }
  147. if err != nil {
  148. t.Fatalf("read tar: %v", err)
  149. }
  150. if h.Typeflag != tar.TypeDir {
  151. names = append(names, path.Base(h.Name))
  152. }
  153. }
  154. }
  155. sort.Strings(names)
  156. return names
  157. }
  158. // A JS array of strings exports as []string, which the create functions used to
  159. // reject; every source shape must now produce the archive.
  160. func TestZiplibCreateFromSources(t *testing.T) {
  161. cases := []struct {
  162. name string
  163. call string // %s is replaced with the output path
  164. output string
  165. want []string // nil means the call fails and writes nothing
  166. }{
  167. {"zip from array", `ziplib.createZipFile(["test:/a.txt", "test:/b.txt"], "%s")`, "out.zip", []string{"a.txt", "b.txt"}},
  168. {"zip from array of one", `ziplib.createZipFile(["test:/a.txt"], "%s")`, "out.zip", []string{"a.txt"}},
  169. {"zip from string", `ziplib.createZipFile("test:/a.txt", "%s")`, "out.zip", []string{"a.txt"}},
  170. {"zip from folder in array", `ziplib.createZipFile(["test:/docs"], "%s")`, "out.zip", []string{"c.txt"}},
  171. {"zip from number", `ziplib.createZipFile(42, "%s")`, "out.zip", nil},
  172. {"tar from array", `ziplib.createTarFile(["test:/a.txt", "test:/b.txt"], "%s")`, "out.tar", []string{"a.txt", "b.txt"}},
  173. {"tar from string", `ziplib.createTarFile("test:/a.txt", "%s")`, "out.tar", []string{"a.txt"}},
  174. {"tar from number", `ziplib.createTarFile(42, "%s")`, "out.tar", nil},
  175. {"tar.gz from array", `ziplib.createTarGzFile(["test:/a.txt", "test:/b.txt"], "%s")`, "out.tar.gz", []string{"a.txt", "b.txt"}},
  176. {"tar.gz from number", `ziplib.createTarGzFile(42, "%s")`, "out.tar.gz", nil},
  177. {"any zip from array", `ziplib.createAnyZipFile(["test:/a.txt", "test:/b.txt"], "%s", "zip")`, "out.zip", []string{"a.txt", "b.txt"}},
  178. {"any tar.gz from array", `ziplib.createAnyZipFile(["test:/a.txt", "test:/b.txt"], "%s", "tar.gz")`, "out.tar.gz", []string{"a.txt", "b.txt"}},
  179. {"any zip from number", `ziplib.createAnyZipFile(42, "%s", "zip")`, "out.zip", nil},
  180. }
  181. for _, c := range cases {
  182. t.Run(c.name, func(t *testing.T) {
  183. vm, dir := newZipLibTestVM(t)
  184. val, err := vm.Run(strings.Replace(c.call, "%s", "test:/"+c.output, 1))
  185. if err != nil {
  186. t.Fatalf("%s: %v", c.call, err)
  187. }
  188. ok, _ := val.ToBoolean()
  189. outFile := filepath.Join(dir, c.output)
  190. if c.want == nil {
  191. if ok {
  192. t.Errorf("call returned true, want false")
  193. }
  194. if _, err := os.Stat(outFile); err == nil {
  195. t.Errorf("a failed call left %s behind", c.output)
  196. }
  197. return
  198. }
  199. if !ok {
  200. t.Fatalf("call returned false, want true")
  201. }
  202. if got := archiveEntries(t, outFile); !reflect.DeepEqual(got, c.want) {
  203. t.Errorf("archive holds %v, want %v", got, c.want)
  204. }
  205. })
  206. }
  207. }
  208. // writeTestZip writes a zip holding the given slash-separated entries, each
  209. // containing its own name.
  210. func writeTestZip(t *testing.T, file string, entries []string) {
  211. t.Helper()
  212. f, err := os.Create(file)
  213. if err != nil {
  214. t.Fatalf("create zip: %v", err)
  215. }
  216. defer f.Close()
  217. zw := zip.NewWriter(f)
  218. for _, name := range entries {
  219. w, err := zw.Create(name)
  220. if err != nil {
  221. t.Fatalf("add %s: %v", name, err)
  222. }
  223. if _, err := w.Write([]byte(name)); err != nil {
  224. t.Fatalf("write %s: %v", name, err)
  225. }
  226. }
  227. if err := zw.Close(); err != nil {
  228. t.Fatalf("close zip: %v", err)
  229. }
  230. }
  231. func TestZiplibExtractPartialZip(t *testing.T) {
  232. cases := []struct {
  233. name string
  234. paths string
  235. want []string // files under the destination folder, slash separated
  236. }{
  237. {"array of one file", `["a.txt"]`, []string{"a.txt"}},
  238. {"array of files", `["a.txt", "b.txt"]`, []string{"a.txt", "b.txt"}},
  239. {"array with a folder", `["docs/"]`, []string{"docs/c.txt"}},
  240. {"single path string", `"b.txt"`, []string{"b.txt"}},
  241. }
  242. for _, c := range cases {
  243. t.Run(c.name, func(t *testing.T) {
  244. vm, dir := newZipLibTestVM(t)
  245. writeTestZip(t, filepath.Join(dir, "in.zip"), []string{"a.txt", "b.txt", "docs/c.txt"})
  246. val, err := vm.Run(`ziplib.extractPartialZip("test:/in.zip", ` + c.paths + `, "test:/out")`)
  247. if err != nil {
  248. t.Fatalf("extractPartialZip: %v", err)
  249. }
  250. if ok, _ := val.ToBoolean(); !ok {
  251. t.Fatalf("extractPartialZip returned false")
  252. }
  253. var got []string
  254. outDir := filepath.Join(dir, "out")
  255. filepath.Walk(outDir, func(p string, info os.FileInfo, err error) error {
  256. if err == nil && !info.IsDir() {
  257. rel, _ := filepath.Rel(outDir, p)
  258. got = append(got, filepath.ToSlash(rel))
  259. }
  260. return nil
  261. })
  262. sort.Strings(got)
  263. if !reflect.DeepEqual(got, c.want) {
  264. t.Errorf("extracted %v, want %v", got, c.want)
  265. }
  266. })
  267. }
  268. }
  269. // Archives the create functions write can be read back by the extract
  270. // functions. tar.gz used to panic both ways on an archiver built without its
  271. // embedded Tar.
  272. func TestZiplibCreateThenExtract(t *testing.T) {
  273. cases := []struct {
  274. name string
  275. create string
  276. extract string
  277. }{
  278. {"tar", `ziplib.createTarFile(["test:/a.txt", "test:/b.txt"], "test:/x.tar")`, `ziplib.extractTarFile("test:/x.tar", "test:/out")`},
  279. {"tar.gz", `ziplib.createTarGzFile(["test:/a.txt", "test:/b.txt"], "test:/x.tar.gz")`, `ziplib.extractTarGzFile("test:/x.tar.gz", "test:/out")`},
  280. {"any tar.gz", `ziplib.createAnyZipFile(["test:/a.txt", "test:/b.txt"], "test:/x.tgz", "tgz")`, `ziplib.extractAnyFile("test:/x.tgz", "test:/out")`},
  281. {"zip", `ziplib.createZipFile(["test:/a.txt", "test:/b.txt"], "test:/x.zip")`, `ziplib.extractZipFile("test:/x.zip", "test:/out")`},
  282. }
  283. for _, c := range cases {
  284. t.Run(c.name, func(t *testing.T) {
  285. vm, dir := newZipLibTestVM(t)
  286. for _, script := range []string{c.create, c.extract} {
  287. val, err := vm.Run(script)
  288. if err != nil {
  289. t.Fatalf("%s: %v", script, err)
  290. }
  291. if ok, _ := val.ToBoolean(); !ok {
  292. t.Fatalf("%s returned false", script)
  293. }
  294. }
  295. for name, want := range map[string]string{"a.txt": "alpha", "b.txt": "bravo"} {
  296. got, err := os.ReadFile(filepath.Join(dir, "out", name))
  297. if err != nil {
  298. t.Errorf("%s was not extracted: %v", name, err)
  299. continue
  300. }
  301. if string(got) != want {
  302. t.Errorf("%s holds %q, want %q", name, got, want)
  303. }
  304. }
  305. })
  306. }
  307. }