فهرست منبع

Fix ziplib array sources, tar.gz panics; doc filelib binary

Otto exports a JS array of strings as []string, which the ziplib create functions and 7z path parsing did not handle. Arrays were rejected or silently ignored. Add ziplibSourceList and a []string case in szParsePathsArg, and fail with "invalid source format" for unsupported values.

Use archiver.NewTarGz() instead of a zero-value TarGz, which panicked because its embedded Tar was nil.

filelib: attach readBinaryFile and writeBinaryFile to the JS object, so scripts can call them, and document both in the README. Raise a proper "File not exists" error instead of a nil error in the missing-file path.

Add tests for filelib binary I/O, ziplib sources, create/extract round trips and partial zip extraction.
Toby Chui 1 روز پیش
والد
کامیت
bdd01dfb39
6فایلهای تغییر یافته به همراه588 افزوده شده و 45 حذف شده
  1. 19 0
      src/mod/agi/README.md
  2. 5 0
      src/mod/agi/agi.7z.go
  3. 3 1
      src/mod/agi/agi.file.go
  4. 188 0
      src/mod/agi/agi.file_test.go
  5. 52 44
      src/mod/agi/agi.zip.go
  6. 321 0
      src/mod/agi/agi.zip_test.go

+ 19 - 0
src/mod/agi/README.md

@@ -375,6 +375,25 @@ Returns storage root display name.
 var root = filelib.rootName("user:/Desktop/a.txt");
 ```
 
+### `filelib.readBinaryFile(vpath)`
+Returns the file content as a lowercase hex string (two characters per byte), or
+`null` if the file does not exist. The whole file is loaded into memory, so keep
+it to small files.
+
+```javascript
+var hex = filelib.readBinaryFile("user:/icon.png");
+var firstByte = parseInt(hex.substr(0, 2), 16);
+```
+
+### `filelib.writeBinaryFile(vpath, hexContent)`
+Decodes a hex string (upper or lower case, even length) and writes the bytes to
+the file, replacing it if it exists. Returns `false` if the hex is invalid or the
+write fails.
+
+```javascript
+filelib.writeBinaryFile("user:/magic.bin", "89504e470d0a1a0a");
+```
+
 ## imagelib API
 
 Load:

+ 5 - 0
src/mod/agi/agi.7z.go

@@ -520,6 +520,11 @@ func sz7zExtractAll(srcRpath string, destFsh *filesystem.FileSystemHandler, dest
 func szParsePathsArg(pathsObj interface{}) []string {
 	var result []string
 	switch v := pathsObj.(type) {
+	case []string:
+		// Otto exports an array whose items are all strings as []string
+		for _, s := range v {
+			result = append(result, filepath.ToSlash(strings.TrimPrefix(s, "/")))
+		}
 	case []interface{}:
 		for _, s := range v {
 			if str, ok := s.(string); ok {

+ 3 - 1
src/mod/agi/agi.file.go

@@ -832,7 +832,7 @@ func (g *Gateway) injectFileLibFunctions(payload *static.AgiLibInjectionPayload)
 
 		if !fsh.FileSystemAbstraction.FileExists(rpath) {
 			//Check if the target file exists
-			g.RaiseError(err)
+			g.RaiseError(errors.New("File not exists"))
 			return otto.NullValue()
 		}
 
@@ -866,6 +866,8 @@ func (g *Gateway) injectFileLibFunctions(payload *static.AgiLibInjectionPayload)
 		filelib.mkdir = _filelib_mkdir;
 		filelib.mtime = _filelib_mtime;
 		filelib.rootName = _filelib_rname;
+		filelib.readBinaryFile = _filelib_readBinaryFile;
+		filelib.writeBinaryFile = _filelib_writeBinaryFile;
 
 		filelib.readdir = function(path, sortmode){
 			var s = _filelib_readdir(path, sortmode);

+ 188 - 0
src/mod/agi/agi.file_test.go

@@ -0,0 +1,188 @@
+package agi
+
+import (
+	"bytes"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/robertkrimen/otto"
+	"imuslab.com/arozos/mod/agi/static"
+	"imuslab.com/arozos/mod/filesystem"
+	"imuslab.com/arozos/mod/filesystem/abstractions/localfs"
+	"imuslab.com/arozos/mod/storage"
+	user "imuslab.com/arozos/mod/user"
+)
+
+// newFsTestUser returns a user whose only storage is a read-write local file
+// system in a temporary folder, reachable as "test:/". The public hierarchy
+// keeps quota bookkeeping (which needs a database) out of the way.
+func newFsTestUser(t *testing.T) (*user.User, string) {
+	t.Helper()
+	dir := t.TempDir()
+	fsh := &filesystem.FileSystemHandler{
+		Name:                  "test",
+		UUID:                  "test",
+		Path:                  dir + "/",
+		Hierarchy:             "public",
+		InitiationTime:        time.Now().Unix(),
+		FileSystemAbstraction: localfs.NewLocalFileSystemAbstraction("test", dir+"/", "public", false),
+		Filesystem:            "ext4",
+	}
+	u := &user.User{
+		Username: "alice",
+		HomeDirectories: &storage.StoragePool{
+			Owner:    "alice",
+			Storages: []*filesystem.FileSystemHandler{fsh},
+		},
+	}
+	return u, dir
+}
+
+// newFileLibTestVM returns a VM with filelib loaded for a user from newFsTestUser.
+func newFileLibTestVM(t *testing.T) (*otto.Otto, string) {
+	t.Helper()
+	u, dir := newFsTestUser(t)
+	vm := otto.New()
+	minimalGateway().injectFileLibFunctions(&static.AgiLibInjectionPayload{VM: vm, User: u})
+	return vm, dir
+}
+
+// Scripts reach the library through the filelib object; a function that is
+// registered but never attached to it is undefined for them.
+func TestInjectFileLib_JSObjectExposed(t *testing.T) {
+	vm, _ := newFileLibTestVM(t)
+
+	present := []string{
+		"writeFile", "readFile", "deleteFile", "walk", "glob", "aglob",
+		"filesize", "fileExists", "isDir", "md5", "mkdir", "mtime",
+		"rootName", "readdir", "readBinaryFile", "writeBinaryFile",
+	}
+	for _, fn := range present {
+		val, err := vm.Run(`typeof filelib.` + fn)
+		if err != nil {
+			t.Fatalf("evaluating filelib.%s: %v", fn, err)
+		}
+		if s, _ := val.ToString(); s != "function" {
+			t.Errorf("filelib.%s should be a function, got %q", fn, s)
+		}
+	}
+}
+
+func TestFileLibWriteBinaryFile(t *testing.T) {
+	cases := []struct {
+		name      string
+		hex       string
+		want      bool
+		wantBytes []byte // nil means no file is written
+	}{
+		{"ascii text", "68656c6c6f", true, []byte("hello")},
+		{"zero and high bytes", "00ff10fe", true, []byte{0x00, 0xff, 0x10, 0xfe}},
+		{"upper case hex", "DEADBEEF", true, []byte{0xde, 0xad, 0xbe, 0xef}},
+		{"empty content", "", true, []byte{}},
+		{"invalid hex", "zz", false, nil},
+		{"odd length hex", "abc", false, nil},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			vm, dir := newFileLibTestVM(t)
+			vm.Set("hexContent", c.hex)
+			val, err := vm.Run(`filelib.writeBinaryFile("test:/out.bin", hexContent)`)
+			if err != nil {
+				t.Fatalf("writeBinaryFile: %v", err)
+			}
+			if got, _ := val.ToBoolean(); got != c.want {
+				t.Fatalf("writeBinaryFile returned %v, want %v", got, c.want)
+			}
+
+			content, err := os.ReadFile(filepath.Join(dir, "out.bin"))
+			if c.wantBytes == nil {
+				if err == nil {
+					t.Errorf("a failed write left a file behind: %x", content)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("file not written: %v", err)
+			}
+			if !bytes.Equal(content, c.wantBytes) {
+				t.Errorf("file holds %x, want %x", content, c.wantBytes)
+			}
+		})
+	}
+}
+
+func TestFileLibReadBinaryFile(t *testing.T) {
+	cases := []struct {
+		name    string
+		content []byte // nil means the file does not exist
+		want    string
+		wantNil bool
+	}{
+		{"ascii text", []byte("hello"), "68656c6c6f", false},
+		{"zero and high bytes", []byte{0x00, 0xff, 0x10, 0xfe}, "00ff10fe", false},
+		{"empty file", []byte{}, "", false},
+		{"missing file", nil, "", true},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			vm, dir := newFileLibTestVM(t)
+			if c.content != nil {
+				if err := os.WriteFile(filepath.Join(dir, "in.bin"), c.content, 0644); err != nil {
+					t.Fatalf("write fixture: %v", err)
+				}
+			}
+			val, err := vm.Run(`filelib.readBinaryFile("test:/in.bin")`)
+			if err != nil {
+				t.Fatalf("readBinaryFile: %v", err)
+			}
+			if c.wantNil {
+				if !val.IsNull() {
+					t.Errorf("readBinaryFile of a missing file = %v, want null", val)
+				}
+				return
+			}
+			if got, _ := val.ToString(); got != c.want {
+				t.Errorf("readBinaryFile = %q, want %q", got, c.want)
+			}
+		})
+	}
+}
+
+// What writeBinaryFile writes, readBinaryFile reads back unchanged.
+func TestFileLibBinaryRoundTrip(t *testing.T) {
+	vm, _ := newFileLibTestVM(t)
+	val, err := vm.Run(`
+		var data = "";
+		for (var i = 0; i < 256; i++) {
+			data += (i < 16 ? "0" : "") + i.toString(16);
+		}
+		filelib.writeBinaryFile("test:/all.bin", data) && filelib.readBinaryFile("test:/all.bin") === data;
+	`)
+	if err != nil {
+		t.Fatalf("round trip: %v", err)
+	}
+	if ok, _ := val.ToBoolean(); !ok {
+		t.Errorf("bytes 00-ff did not survive a write and read back")
+	}
+}
+
+// Paths on storage the user has no access to are refused with an exception.
+func TestFileLibBinaryPermissionDenied(t *testing.T) {
+	cases := []struct {
+		name   string
+		script string
+	}{
+		{"read", `filelib.readBinaryFile("other:/x.bin")`},
+		{"write", `filelib.writeBinaryFile("other:/x.bin", "00")`},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			vm, _ := newFileLibTestVM(t)
+			if _, err := vm.Run(c.script); err == nil {
+				t.Errorf("%s on a storage outside the user's pool should throw", c.name)
+			}
+		})
+	}
+}

+ 52 - 44
src/mod/agi/agi.zip.go

@@ -112,20 +112,14 @@ func (g *Gateway) injectZipFileLibFunctions(payload *static.AgiLibInjectionPaylo
 		}
 
 		// Convert sources to string array
-		var sources []string
-		switch v := sourcesObj.(type) {
-		case []interface{}:
-			for _, s := range v {
-				if str, ok := s.(string); ok {
-					sources = append(sources, static.RelativeVpathRewrite(scriptFsh, str, vm, u))
-				}
-			}
-		case string:
-			sources = append(sources, static.RelativeVpathRewrite(scriptFsh, v, vm, u))
-		default:
+		sources, ok := ziplibSourceList(sourcesObj)
+		if !ok {
 			g.RaiseError(errors.New("invalid source format"))
 			return otto.FalseValue()
 		}
+		for i, src := range sources {
+			sources[i] = static.RelativeVpathRewrite(scriptFsh, src, vm, u)
+		}
 
 		outputVpath = static.RelativeVpathRewrite(scriptFsh, outputVpath, vm, u)
 
@@ -185,16 +179,13 @@ func (g *Gateway) injectZipFileLibFunctions(payload *static.AgiLibInjectionPaylo
 			return otto.FalseValue()
 		}
 
-		var sources []string
-		switch v := sourcesObj.(type) {
-		case []interface{}:
-			for _, s := range v {
-				if str, ok := s.(string); ok {
-					sources = append(sources, static.RelativeVpathRewrite(scriptFsh, str, vm, u))
-				}
-			}
-		case string:
-			sources = append(sources, static.RelativeVpathRewrite(scriptFsh, v, vm, u))
+		sources, ok := ziplibSourceList(sourcesObj)
+		if !ok {
+			g.RaiseError(errors.New("invalid source format"))
+			return otto.FalseValue()
+		}
+		for i, src := range sources {
+			sources[i] = static.RelativeVpathRewrite(scriptFsh, src, vm, u)
 		}
 
 		outputVpath = static.RelativeVpathRewrite(scriptFsh, outputVpath, vm, u)
@@ -304,16 +295,13 @@ func (g *Gateway) injectZipFileLibFunctions(payload *static.AgiLibInjectionPaylo
 			return otto.FalseValue()
 		}
 
-		var sources []string
-		switch v := sourcesObj.(type) {
-		case []interface{}:
-			for _, s := range v {
-				if str, ok := s.(string); ok {
-					sources = append(sources, static.RelativeVpathRewrite(scriptFsh, str, vm, u))
-				}
-			}
-		case string:
-			sources = append(sources, static.RelativeVpathRewrite(scriptFsh, v, vm, u))
+		sources, ok := ziplibSourceList(sourcesObj)
+		if !ok {
+			g.RaiseError(errors.New("invalid source format"))
+			return otto.FalseValue()
+		}
+		for i, src := range sources {
+			sources[i] = static.RelativeVpathRewrite(scriptFsh, src, vm, u)
 		}
 
 		outputVpath = static.RelativeVpathRewrite(scriptFsh, outputVpath, vm, u)
@@ -343,7 +331,7 @@ func (g *Gateway) injectZipFileLibFunctions(payload *static.AgiLibInjectionPaylo
 			return otto.FalseValue()
 		}
 
-		tgz := archiver.TarGz{}
+		tgz := archiver.NewTarGz()
 		err = tgz.Archive(sourceRpaths, outputRpath)
 		if err != nil {
 			g.RaiseError(err)
@@ -390,7 +378,7 @@ func (g *Gateway) injectZipFileLibFunctions(payload *static.AgiLibInjectionPaylo
 			return otto.FalseValue()
 		}
 
-		tgz := archiver.TarGz{}
+		tgz := archiver.NewTarGz()
 		err = tgz.Unarchive(srcRpath, destRpath)
 		if err != nil {
 			g.RaiseError(err)
@@ -1067,16 +1055,13 @@ func (g *Gateway) injectZipFileLibFunctions(payload *static.AgiLibInjectionPaylo
 			format = "zip"
 		}
 
-		var sources []string
-		switch v := sourcesObj.(type) {
-		case []interface{}:
-			for _, s := range v {
-				if str, ok := s.(string); ok {
-					sources = append(sources, static.RelativeVpathRewrite(scriptFsh, str, vm, u))
-				}
-			}
-		case string:
-			sources = append(sources, static.RelativeVpathRewrite(scriptFsh, v, vm, u))
+		sources, ok := ziplibSourceList(sourcesObj)
+		if !ok {
+			g.RaiseError(errors.New("invalid source format"))
+			return otto.FalseValue()
+		}
+		for i, src := range sources {
+			sources[i] = static.RelativeVpathRewrite(scriptFsh, src, vm, u)
 		}
 
 		outputVpath = static.RelativeVpathRewrite(scriptFsh, outputVpath, vm, u)
@@ -1115,7 +1100,7 @@ func (g *Gateway) injectZipFileLibFunctions(payload *static.AgiLibInjectionPaylo
 			tar := &archiver.Tar{}
 			err = tar.Archive(sourceRpaths, outputRpath)
 		case "tar.gz", "tgz", "targz":
-			tgz := &archiver.TarGz{}
+			tgz := archiver.NewTarGz()
 			err = tgz.Archive(sourceRpaths, outputRpath)
 		case "gz", "gzip":
 			g.RaiseError(errors.New("gz format requires createGzFile for single file compression"))
@@ -1490,6 +1475,29 @@ func (g *Gateway) injectZipFileLibFunctions(payload *static.AgiLibInjectionPaylo
 	g.inject7zLibFunctions(payload)
 }
 
+// ziplibSourceList converts the exported sources argument of the ziplib create
+// functions into a list of paths. Otto exports a JS array whose items are all
+// strings as []string, and an empty or mixed array as []interface{} (non-string
+// items are skipped); a single string is one source. ok is false for any other
+// value, such as undefined or a number.
+func ziplibSourceList(sourcesObj interface{}) (sources []string, ok bool) {
+	switch v := sourcesObj.(type) {
+	case []string:
+		return append([]string{}, v...), true
+	case []interface{}:
+		sources = []string{}
+		for _, s := range v {
+			if str, isString := s.(string); isString {
+				sources = append(sources, str)
+			}
+		}
+		return sources, true
+	case string:
+		return []string{v}, true
+	}
+	return nil, false
+}
+
 /*
 	Example Usages
 

+ 321 - 0
src/mod/agi/agi.zip_test.go

@@ -0,0 +1,321 @@
+package agi
+
+import (
+	"archive/tar"
+	"archive/zip"
+	"compress/gzip"
+	"io"
+	"os"
+	"path"
+	"path/filepath"
+	"reflect"
+	"sort"
+	"strings"
+	"testing"
+
+	"github.com/robertkrimen/otto"
+	"imuslab.com/arozos/mod/agi/static"
+)
+
+// exportJS evaluates a JS expression and returns its exported Go value, which
+// is exactly what the ziplib functions receive from call.Argument(n).Export().
+func exportJS(t *testing.T, expr string) interface{} {
+	t.Helper()
+	val, err := otto.New().Run("(" + expr + ")")
+	if err != nil {
+		t.Fatalf("evaluating %s: %v", expr, err)
+	}
+	exported, err := val.Export()
+	if err != nil {
+		t.Fatalf("exporting %s: %v", expr, err)
+	}
+	return exported
+}
+
+func TestZiplibSourceList(t *testing.T) {
+	cases := []struct {
+		name   string
+		expr   string
+		want   []string
+		wantOK bool
+	}{
+		{"array of strings", `["user:/a.txt", "user:/b.txt"]`, []string{"user:/a.txt", "user:/b.txt"}, true},
+		{"array of one string", `["user:/a.txt"]`, []string{"user:/a.txt"}, true},
+		{"empty array", `[]`, []string{}, true},
+		{"mixed array keeps the strings", `["user:/a.txt", 1, null]`, []string{"user:/a.txt"}, true},
+		{"single string", `"user:/a.txt"`, []string{"user:/a.txt"}, true},
+		{"undefined", `undefined`, nil, false},
+		{"number", `42`, nil, false},
+		{"array of numbers", `[1, 2]`, nil, false},
+		{"object", `({a: "user:/a.txt"})`, nil, false},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got, ok := ziplibSourceList(exportJS(t, c.expr))
+			if ok != c.wantOK {
+				t.Fatalf("ok = %v, want %v", ok, c.wantOK)
+			}
+			if !reflect.DeepEqual(got, c.want) {
+				t.Errorf("sources = %#v, want %#v", got, c.want)
+			}
+		})
+	}
+}
+
+// The returned list is rewritten in place by the callers, so it must not share
+// its backing array with the exported argument.
+func TestZiplibSourceListCopiesInput(t *testing.T) {
+	in := []string{"user:/a.txt"}
+	got, _ := ziplibSourceList(in)
+	got[0] = "changed"
+	if in[0] != "user:/a.txt" {
+		t.Errorf("ziplibSourceList shares its result with the input slice")
+	}
+}
+
+func TestSzParsePathsArg(t *testing.T) {
+	cases := []struct {
+		name string
+		expr string
+		want []string
+	}{
+		{"array of strings", `["docs/", "/a.txt"]`, []string{"docs/", "a.txt"}},
+		{"mixed array keeps the strings", `["a.txt", 1]`, []string{"a.txt"}},
+		{"JSON array string", `'["docs/", "/a.txt"]'`, []string{"docs/", "a.txt"}},
+		{"single path string", `"/a.txt"`, []string{"a.txt"}},
+		{"number", `42`, nil},
+		{"undefined", `undefined`, nil},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got := szParsePathsArg(exportJS(t, c.expr))
+			if !reflect.DeepEqual(got, c.want) {
+				t.Errorf("szParsePathsArg(%s) = %#v, want %#v", c.expr, got, c.want)
+			}
+		})
+	}
+}
+
+// newZipLibTestVM returns a VM with ziplib loaded for a user from newFsTestUser,
+// with a.txt, b.txt and docs/c.txt in the user's storage.
+func newZipLibTestVM(t *testing.T) (*otto.Otto, string) {
+	t.Helper()
+	u, dir := newFsTestUser(t)
+	files := map[string]string{"a.txt": "alpha", "b.txt": "bravo", "docs/c.txt": "charlie"}
+	for name, content := range files {
+		p := filepath.Join(dir, filepath.FromSlash(name))
+		if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
+			t.Fatalf("mkdir: %v", err)
+		}
+		if err := os.WriteFile(p, []byte(content), 0644); err != nil {
+			t.Fatalf("write fixture: %v", err)
+		}
+	}
+	vm := otto.New()
+	minimalGateway().injectZipFileLibFunctions(&static.AgiLibInjectionPayload{VM: vm, User: u})
+	return vm, dir
+}
+
+// archiveEntries lists the base names of the files in a zip, tar or tar.gz.
+func archiveEntries(t *testing.T, file string) []string {
+	t.Helper()
+	var names []string
+	if strings.HasSuffix(file, ".zip") {
+		r, err := zip.OpenReader(file)
+		if err != nil {
+			t.Fatalf("open zip: %v", err)
+		}
+		defer r.Close()
+		for _, f := range r.File {
+			if !f.FileInfo().IsDir() {
+				names = append(names, path.Base(f.Name))
+			}
+		}
+	} else {
+		f, err := os.Open(file)
+		if err != nil {
+			t.Fatalf("open archive: %v", err)
+		}
+		defer f.Close()
+		var r io.Reader = f
+		if strings.HasSuffix(file, ".tar.gz") {
+			gz, err := gzip.NewReader(f)
+			if err != nil {
+				t.Fatalf("open gzip: %v", err)
+			}
+			defer gz.Close()
+			r = gz
+		}
+		tr := tar.NewReader(r)
+		for {
+			h, err := tr.Next()
+			if err == io.EOF {
+				break
+			}
+			if err != nil {
+				t.Fatalf("read tar: %v", err)
+			}
+			if h.Typeflag != tar.TypeDir {
+				names = append(names, path.Base(h.Name))
+			}
+		}
+	}
+	sort.Strings(names)
+	return names
+}
+
+// A JS array of strings exports as []string, which the create functions used to
+// reject; every source shape must now produce the archive.
+func TestZiplibCreateFromSources(t *testing.T) {
+	cases := []struct {
+		name   string
+		call   string // %s is replaced with the output path
+		output string
+		want   []string // nil means the call fails and writes nothing
+	}{
+		{"zip from array", `ziplib.createZipFile(["test:/a.txt", "test:/b.txt"], "%s")`, "out.zip", []string{"a.txt", "b.txt"}},
+		{"zip from array of one", `ziplib.createZipFile(["test:/a.txt"], "%s")`, "out.zip", []string{"a.txt"}},
+		{"zip from string", `ziplib.createZipFile("test:/a.txt", "%s")`, "out.zip", []string{"a.txt"}},
+		{"zip from folder in array", `ziplib.createZipFile(["test:/docs"], "%s")`, "out.zip", []string{"c.txt"}},
+		{"zip from number", `ziplib.createZipFile(42, "%s")`, "out.zip", nil},
+		{"tar from array", `ziplib.createTarFile(["test:/a.txt", "test:/b.txt"], "%s")`, "out.tar", []string{"a.txt", "b.txt"}},
+		{"tar from string", `ziplib.createTarFile("test:/a.txt", "%s")`, "out.tar", []string{"a.txt"}},
+		{"tar from number", `ziplib.createTarFile(42, "%s")`, "out.tar", nil},
+		{"tar.gz from array", `ziplib.createTarGzFile(["test:/a.txt", "test:/b.txt"], "%s")`, "out.tar.gz", []string{"a.txt", "b.txt"}},
+		{"tar.gz from number", `ziplib.createTarGzFile(42, "%s")`, "out.tar.gz", nil},
+		{"any zip from array", `ziplib.createAnyZipFile(["test:/a.txt", "test:/b.txt"], "%s", "zip")`, "out.zip", []string{"a.txt", "b.txt"}},
+		{"any tar.gz from array", `ziplib.createAnyZipFile(["test:/a.txt", "test:/b.txt"], "%s", "tar.gz")`, "out.tar.gz", []string{"a.txt", "b.txt"}},
+		{"any zip from number", `ziplib.createAnyZipFile(42, "%s", "zip")`, "out.zip", nil},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			vm, dir := newZipLibTestVM(t)
+			val, err := vm.Run(strings.Replace(c.call, "%s", "test:/"+c.output, 1))
+			if err != nil {
+				t.Fatalf("%s: %v", c.call, err)
+			}
+			ok, _ := val.ToBoolean()
+			outFile := filepath.Join(dir, c.output)
+			if c.want == nil {
+				if ok {
+					t.Errorf("call returned true, want false")
+				}
+				if _, err := os.Stat(outFile); err == nil {
+					t.Errorf("a failed call left %s behind", c.output)
+				}
+				return
+			}
+			if !ok {
+				t.Fatalf("call returned false, want true")
+			}
+			if got := archiveEntries(t, outFile); !reflect.DeepEqual(got, c.want) {
+				t.Errorf("archive holds %v, want %v", got, c.want)
+			}
+		})
+	}
+}
+
+// writeTestZip writes a zip holding the given slash-separated entries, each
+// containing its own name.
+func writeTestZip(t *testing.T, file string, entries []string) {
+	t.Helper()
+	f, err := os.Create(file)
+	if err != nil {
+		t.Fatalf("create zip: %v", err)
+	}
+	defer f.Close()
+	zw := zip.NewWriter(f)
+	for _, name := range entries {
+		w, err := zw.Create(name)
+		if err != nil {
+			t.Fatalf("add %s: %v", name, err)
+		}
+		if _, err := w.Write([]byte(name)); err != nil {
+			t.Fatalf("write %s: %v", name, err)
+		}
+	}
+	if err := zw.Close(); err != nil {
+		t.Fatalf("close zip: %v", err)
+	}
+}
+
+func TestZiplibExtractPartialZip(t *testing.T) {
+	cases := []struct {
+		name  string
+		paths string
+		want  []string // files under the destination folder, slash separated
+	}{
+		{"array of one file", `["a.txt"]`, []string{"a.txt"}},
+		{"array of files", `["a.txt", "b.txt"]`, []string{"a.txt", "b.txt"}},
+		{"array with a folder", `["docs/"]`, []string{"docs/c.txt"}},
+		{"single path string", `"b.txt"`, []string{"b.txt"}},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			vm, dir := newZipLibTestVM(t)
+			writeTestZip(t, filepath.Join(dir, "in.zip"), []string{"a.txt", "b.txt", "docs/c.txt"})
+
+			val, err := vm.Run(`ziplib.extractPartialZip("test:/in.zip", ` + c.paths + `, "test:/out")`)
+			if err != nil {
+				t.Fatalf("extractPartialZip: %v", err)
+			}
+			if ok, _ := val.ToBoolean(); !ok {
+				t.Fatalf("extractPartialZip returned false")
+			}
+
+			var got []string
+			outDir := filepath.Join(dir, "out")
+			filepath.Walk(outDir, func(p string, info os.FileInfo, err error) error {
+				if err == nil && !info.IsDir() {
+					rel, _ := filepath.Rel(outDir, p)
+					got = append(got, filepath.ToSlash(rel))
+				}
+				return nil
+			})
+			sort.Strings(got)
+			if !reflect.DeepEqual(got, c.want) {
+				t.Errorf("extracted %v, want %v", got, c.want)
+			}
+		})
+	}
+}
+
+// Archives the create functions write can be read back by the extract
+// functions. tar.gz used to panic both ways on an archiver built without its
+// embedded Tar.
+func TestZiplibCreateThenExtract(t *testing.T) {
+	cases := []struct {
+		name    string
+		create  string
+		extract string
+	}{
+		{"tar", `ziplib.createTarFile(["test:/a.txt", "test:/b.txt"], "test:/x.tar")`, `ziplib.extractTarFile("test:/x.tar", "test:/out")`},
+		{"tar.gz", `ziplib.createTarGzFile(["test:/a.txt", "test:/b.txt"], "test:/x.tar.gz")`, `ziplib.extractTarGzFile("test:/x.tar.gz", "test:/out")`},
+		{"any tar.gz", `ziplib.createAnyZipFile(["test:/a.txt", "test:/b.txt"], "test:/x.tgz", "tgz")`, `ziplib.extractAnyFile("test:/x.tgz", "test:/out")`},
+		{"zip", `ziplib.createZipFile(["test:/a.txt", "test:/b.txt"], "test:/x.zip")`, `ziplib.extractZipFile("test:/x.zip", "test:/out")`},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			vm, dir := newZipLibTestVM(t)
+			for _, script := range []string{c.create, c.extract} {
+				val, err := vm.Run(script)
+				if err != nil {
+					t.Fatalf("%s: %v", script, err)
+				}
+				if ok, _ := val.ToBoolean(); !ok {
+					t.Fatalf("%s returned false", script)
+				}
+			}
+			for name, want := range map[string]string{"a.txt": "alpha", "b.txt": "bravo"} {
+				got, err := os.ReadFile(filepath.Join(dir, "out", name))
+				if err != nil {
+					t.Errorf("%s was not extracted: %v", name, err)
+					continue
+				}
+				if string(got) != want {
+					t.Errorf("%s holds %q, want %q", name, got, want)
+				}
+			}
+		})
+	}
+}