diff --git a/runtime/lua/modules/fs/fs.go b/runtime/lua/modules/fs/fs.go index 49e9acce4..c05182e1c 100644 --- a/runtime/lua/modules/fs/fs.go +++ b/runtime/lua/modules/fs/fs.go @@ -6,7 +6,7 @@ import ( "fmt" "io" "os" - "path/filepath" + "path" "strings" lua "github.com/wippyai/go-lua" @@ -44,15 +44,16 @@ func (f *FS) resolvePath(p string) (string, error) { case p[0] == '/': res = strings.TrimLeft(p, "/") default: - res = filepath.Join(f.cwd, p) + res = path.Join(f.cwd, p) } if res == "" { return ".", nil } - // Clean and validate path doesn't escape root - res = filepath.Clean(res) - if res == ".." || strings.HasPrefix(res, "../") || strings.HasPrefix(res, "..\\") { + // The underlying fsapi.FS follows the io/fs contract, where paths are always + // forward-slash regardless of OS, so clean with path (not filepath). + res = path.Clean(res) + if res == ".." || strings.HasPrefix(res, "../") { return "", ErrPathTraversal } diff --git a/runtime/lua/modules/fs/module.go b/runtime/lua/modules/fs/module.go index 31d2642ee..6b29565d9 100644 --- a/runtime/lua/modules/fs/module.go +++ b/runtime/lua/modules/fs/module.go @@ -128,6 +128,13 @@ func fsGet(l *lua.LState) int { return 2 } +// PushFS wraps fsys in an fs.FS handle and pushes it onto the stack using the +// shared fs.FS metatable. It lets other modules expose a filesystem through the +// exact userdata type fs.get returns, so callers use the normal fs verbs. +func PushFS(l *lua.LState, fsys fsapi.FS, cwd string) *lua.LUserData { + return value.PushUserData(l, NewFS(fsys, cwd), fsMetatable) +} + func checkFS(l *lua.LState, idx int) *FS { //nolint:unparam ud := l.CheckUserData(idx) if v, ok := ud.Value.(*FS); ok { diff --git a/runtime/lua/modules/fs/path_security_test.go b/runtime/lua/modules/fs/path_security_test.go index 452f26dc7..c608eabf0 100644 --- a/runtime/lua/modules/fs/path_security_test.go +++ b/runtime/lua/modules/fs/path_security_test.go @@ -3,7 +3,6 @@ package fs import ( - "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -13,9 +12,12 @@ import ( // resolvePath only strips one leading slash. Double-slash input bypasses // the traversal check and produces an absolute path. +// resolvePath returns forward-slash paths on every OS (the fsapi.FS io/fs +// contract), so the expected value is compared as-is rather than converted to +// OS separators. func assertResolvedPath(t *testing.T, expectedSlashPath, actual string, msgAndArgs ...any) { t.Helper() - assert.Equal(t, filepath.FromSlash(expectedSlashPath), actual, msgAndArgs...) + assert.Equal(t, expectedSlashPath, actual, msgAndArgs...) } func TestResolvePath_DoubleSlashStrippedToRelative(t *testing.T) { diff --git a/runtime/lua/modules/hub/artifact.go b/runtime/lua/modules/hub/artifact.go index e967ba255..819ba6309 100644 --- a/runtime/lua/modules/hub/artifact.go +++ b/runtime/lua/modules/hub/artifact.go @@ -37,61 +37,61 @@ type artifactInspection struct { } func inspectVersionArtifact(ctx context.Context, client ArtifactClient, params *boothub.DownloadParams, vendorDir string) (*artifactInspection, error) { + path, info, err := ensureCachedArtifact(ctx, client, params, vendorDir) + if err != nil { + return nil, err + } + displayVersion := firstNonEmpty(info.Version, params.Version, params.VersionID, params.Label) + return inspectCachedArtifact(path, info, displayVersion) +} + +func ensureCachedArtifact(ctx context.Context, client ArtifactClient, params *boothub.DownloadParams, vendorDir string) (string, *boothub.DownloadInfo, error) { if client == nil { - return nil, fmt.Errorf("artifact client required") + return "", nil, fmt.Errorf("artifact client required") } info, err := client.GetDownloadURL(ctx, params) if err != nil { - return nil, err + return "", nil, err } if info == nil || strings.TrimSpace(info.URL) == "" { - return nil, fmt.Errorf("artifact download URL unavailable") + return "", nil, fmt.Errorf("artifact download URL unavailable") } path, pathErr := artifactCachePath(params, info, vendorDir) if pathErr != nil { - return nil, pathErr + return "", nil, pathErr } - displayVersion := firstNonEmpty(info.Version, params.Version, params.VersionID, params.Label) + if _, statErr := os.Stat(path); statErr == nil { - if err := boothub.VerifyDownloadedArtifact(path, info.Digest, info.Size); err == nil { - inspection, inspectErr := inspectCachedArtifact(path, info, displayVersion) - if inspectErr == nil { - return inspection, nil - } + // A cached file is reused only when it both verifies and opens as a + // valid WAPP: a registry that omits digest/size makes VerifyDownloadedArtifact + // a no-op, so a truncated or corrupt cache entry would otherwise be + // returned and permanently break reads. Evict and re-download instead. + if err := boothub.VerifyDownloadedArtifact(path, info.Digest, info.Size); err == nil && isReadableArtifact(path) { + return path, info, nil } _ = os.Remove(path) } else if !os.IsNotExist(statErr) { - return nil, statErr + return "", nil, statErr } if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return nil, fmt.Errorf("create artifact cache directory: %w", err) + return "", nil, fmt.Errorf("create artifact cache directory: %w", err) } if err := client.DownloadToFile(ctx, info.URL, path); err != nil { - return nil, err + return "", nil, err } if err := boothub.VerifyDownloadedArtifact(path, info.Digest, info.Size); err != nil { _ = os.Remove(path) - return nil, fmt.Errorf("verify artifact: %w", err) + return "", nil, fmt.Errorf("verify artifact: %w", err) } - return inspectCachedArtifact(path, info, displayVersion) + return path, info, nil } func inspectCachedArtifact(path string, info *boothub.DownloadInfo, version string) (*artifactInspection, error) { - file, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("open artifact: %w", err) - } - defer file.Close() - - reader, err := wapp.NewReader(file) + entries, err := readEntriesFromFile(path) if err != nil { - return nil, fmt.Errorf("read artifact: %w", err) - } - entries, err := reader.GetEntries() - if err != nil { - return nil, fmt.Errorf("read artifact entries: %w", err) + return nil, err } kinds := make(map[string]struct{}) @@ -124,6 +124,62 @@ func inspectCachedArtifact(path string, info *boothub.DownloadInfo, version stri }, nil } +func readEntriesFromFile(path string) ([]wapp.Entry, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open artifact: %w", err) + } + defer file.Close() + + reader, err := wapp.NewReader(file) + if err != nil { + return nil, fmt.Errorf("read artifact: %w", err) + } + entries, err := reader.GetEntries() + if err != nil { + return nil, fmt.Errorf("read artifact entries: %w", err) + } + return entries, nil +} + +// isReadableArtifact reports whether path opens as a valid WAPP, used to reject +// a cache entry that passed digest/size verification but is structurally +// corrupt (e.g. truncated, or accepted because the registry omitted a digest). +func isReadableArtifact(path string) bool { + file, _, err := openArtifactReader(path) + if err != nil { + return false + } + _ = file.Close() + return true +} + +// openArtifactReader opens a cached artifact and returns its file and reader. +// The caller owns both and must close the file when done; the reader reads +// lazily from the file, so it must stay open while the reader is in use. +func openArtifactReader(path string) (*os.File, *wapp.Reader, error) { + file, err := os.Open(path) + if err != nil { + return nil, nil, fmt.Errorf("open artifact: %w", err) + } + reader, err := wapp.NewReader(file) + if err != nil { + _ = file.Close() + return nil, nil, fmt.Errorf("read artifact: %w", err) + } + return file, reader, nil +} + +// parseResourceID converts an "ns:name" resource identifier back to a wapp.ID. +// A missing namespace maps to an empty namespace, the inverse of ID.String. +func parseResourceID(raw string) wapp.ID { + raw = strings.TrimSpace(raw) + if i := strings.Index(raw, ":"); i >= 0 { + return wapp.NewID(raw[:i], raw[i+1:]) + } + return wapp.NewID("", raw) +} + func artifactCachePath(params *boothub.DownloadParams, info *boothub.DownloadInfo, vendorDir string) (string, error) { vendorDir = strings.TrimSpace(vendorDir) if vendorDir == "" { @@ -150,7 +206,21 @@ func artifactCachePath(params *boothub.DownloadParams, info *boothub.DownloadInf if err != nil { return "", err } - return filepath.Join(vendorDir, lock.WappPath(name, version)), nil + // A version or module component with separators or ".." (including a + // registry-returned version) would make filepath.Join write the downloaded + // artifact outside the vendor directory. Require clean components and verify + // the result stays inside the cache. + if !isCleanComponent(version) { + return "", fmt.Errorf("artifact version must be a single path component") + } + if !isCleanComponent(name.Module) || (name.Organization != "" && !isCleanComponent(name.Organization)) { + return "", fmt.Errorf("artifact module must be a single path component") + } + target := filepath.Join(vendorDir, lock.WappPath(name, version)) + if !isWithinDir(vendorDir, target) { + return "", fmt.Errorf("resolved cache path escapes the vendor directory") + } + return target, nil } func sanitizeArtifactCachePart(value string) string { diff --git a/runtime/lua/modules/hub/cache.go b/runtime/lua/modules/hub/cache.go new file mode 100644 index 000000000..ee6e4ed25 --- /dev/null +++ b/runtime/lua/modules/hub/cache.go @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: MPL-2.0 + +package hub + +import ( + iofs "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Masterminds/semver/v3" + lua "github.com/wippyai/go-lua" + + "github.com/wippyai/runtime/boot/deps/graph" + "github.com/wippyai/runtime/boot/deps/lock" + "github.com/wippyai/runtime/runtime/security" +) + +// cacheEntry describes one cached artifact under the resolved vendor directory. +type cacheEntry struct { + module string + version string + relPath string + size int64 + pinned bool +} + +func (h *hubModule) cacheList(l *lua.LState) int { + ctx, err := h.requireContext(l) + if err != nil { + return pushError(l, err) + } + if !security.IsAllowed(ctx, "hub.cache.list", "", nil) { + return pushError(l, permissionDenied(l, "hub.cache.list", "")) + } + + entries, _, cerr := collectCacheEntries() + if cerr != nil { + return pushError(l, hubCallError(l, cerr)) + } + + arr := lua.CreateTable(len(entries), 0) + for i, e := range entries { + arr.RawSetInt(i+1, cacheEntryToTable(e)) + } + l.Push(arr) + l.Push(lua.LNil) + return 2 +} + +func (h *hubModule) cacheRemove(l *lua.LState) int { + module := strings.TrimSpace(l.CheckString(1)) + version := strings.TrimSpace(l.CheckString(2)) + + ctx, err := h.requireContext(l) + if err != nil { + return pushError(l, err) + } + if !security.IsAllowed(ctx, "hub.cache.remove", module, nil) { + return pushError(l, permissionDenied(l, "hub.cache.remove", module)) + } + + if module == "" || version == "" { + return pushError(l, invalidArgument(l, "module and version required")) + } + + force, err := parseForceOption(l, 3) + if err != nil { + return pushError(l, err) + } + + name, parseErr := graph.ParseName(module) + if parseErr != nil { + return pushError(l, invalidArgument(l, parseErr.Error())) + } + + lockObj, vendorDir, loadErr := loadLockAndVendor() + if loadErr != nil { + return pushError(l, hubCallError(l, loadErr)) + } + + if mod, ok := lockObj.GetModule(module); ok && mod.Version == version && !force { + return pushError(l, lua.NewLuaError(l, "cannot remove lock-pinned artifact: "+module+"@"+version). + WithKind(lua.Conflict).WithRetryable(false).WithDetails(map[string]any{ + "module": module, + "version": version, + })) + } + + // The pin and permission checks above were made for this exact module and + // version, so the deletion target must be that artifact and no other. + // Separators or traversal in the components could normalize to a sibling + // module's file (even a pinned one) inside the vendor dir, so reject them + // before building the path; isWithinDir then backstops any escape. + if !isCleanComponent(version) { + return pushError(l, invalidArgument(l, "version must be a single path component")) + } + if !isCleanComponent(name.Module) || (name.Organization != "" && !isCleanComponent(name.Organization)) { + return pushError(l, invalidArgument(l, "module must be a single path component")) + } + target := filepath.Join(vendorDir, lock.WappPath(name, version)) + if !isWithinDir(vendorDir, target) { + return pushError(l, invalidArgument(l, "module or version resolves outside the cache directory")) + } + if removeErr := os.RemoveAll(target); removeErr != nil { + return pushError(l, hubCallError(l, removeErr)) + } + + l.Push(lua.LTrue) + l.Push(lua.LNil) + return 2 +} + +func (h *hubModule) cachePrune(l *lua.LState) int { + ctx, err := h.requireContext(l) + if err != nil { + return pushError(l, err) + } + if !security.IsAllowed(ctx, "hub.cache.prune", "", nil) { + return pushError(l, permissionDenied(l, "hub.cache.prune", "")) + } + + dryRun, err := parseDryRunOption(l, 1) + if err != nil { + return pushError(l, err) + } + + entries, vendorDir, cerr := collectCacheEntries() + if cerr != nil { + return pushError(l, hubCallError(l, cerr)) + } + + arr := lua.CreateTable(0, 0) + count := 0 + for _, e := range entries { + if e.pinned { + continue + } + if !dryRun { + target := filepath.Join(vendorDir, filepath.FromSlash(e.relPath)) + if removeErr := os.RemoveAll(target); removeErr != nil { + return pushError(l, hubCallError(l, removeErr)) + } + } + count++ + arr.RawSetInt(count, cacheEntryToTable(e)) + } + + l.Push(arr) + l.Push(lua.LNil) + return 2 +} + +func cacheEntryToTable(e cacheEntry) *lua.LTable { + t := lua.CreateTable(0, 4) + t.RawSetString("module", lua.LString(e.module)) + t.RawSetString("version", lua.LString(e.version)) + t.RawSetString("size", lua.LNumber(e.size)) + t.RawSetString("pinned", lua.LBool(e.pinned)) + return t +} + +// loadLockAndVendor loads the project lock file and resolves the vendor dir. +// A missing lock file yields an empty lock with default directories. +func loadLockAndVendor() (*lock.Lock, string, error) { + cwd, err := os.Getwd() + if err != nil { + return nil, "", err + } + lockObj, err := lock.New(filepath.Join(cwd, lock.DefaultFilename)) + if err != nil { + return nil, "", err + } + vendorDir := lock.ResolveLockPath(filepath.Dir(lockObj.Path()), lockObj.GetVendorPath()) + return lockObj, vendorDir, nil +} + +// resolveVendorDir returns the lock-resolved vendor directory so read-surface +// caching lands where hub.cache.* looks. Falls back to the default vendor path +// (empty string, resolved downstream) when no project context is available. +func resolveVendorDir() string { + _, vendorDir, err := loadLockAndVendor() + if err != nil { + return "" + } + return vendorDir +} + +// collectCacheEntries scans the vendor directory for cached .wapp artifacts and +// annotates each with lock-file pin status. Lock modules provide exact +// module/version for pinned artifacts; orphans are parsed from their path. +func collectCacheEntries() ([]cacheEntry, string, error) { + lockObj, vendorDir, err := loadLockAndVendor() + if err != nil { + return nil, "", err + } + + pinned := make(map[string]lock.Module) + for _, mod := range lockObj.GetModules() { + name, parseErr := graph.ParseName(mod.Name) + if parseErr != nil { + continue + } + pinned[filepath.ToSlash(lock.WappPath(name, mod.Version))] = mod + } + + var entries []cacheEntry + walkErr := filepath.WalkDir(vendorDir, func(p string, d iofs.DirEntry, walkErr error) error { + if walkErr != nil { + if os.IsNotExist(walkErr) { + return nil + } + return walkErr + } + if d.IsDir() || !strings.HasSuffix(d.Name(), ".wapp") { + return nil + } + + rel, relErr := filepath.Rel(vendorDir, p) + if relErr != nil { + return nil + } + relSlash := filepath.ToSlash(rel) + + var size int64 + if info, infoErr := d.Info(); infoErr == nil { + size = info.Size() + } + + entry := cacheEntry{relPath: relSlash, size: size} + if mod, ok := pinned[relSlash]; ok { + entry.module = mod.Name + entry.version = mod.Version + entry.pinned = true + } else { + entry.module, entry.version = parseWappRelPath(relSlash) + } + entries = append(entries, entry) + return nil + }) + if walkErr != nil && !os.IsNotExist(walkErr) { + return nil, vendorDir, walkErr + } + + sort.Slice(entries, func(i, j int) bool { + if entries[i].module != entries[j].module { + return entries[i].module < entries[j].module + } + return entries[i].version < entries[j].version + }) + + return entries, vendorDir, nil +} + +// isCleanComponent reports whether s is a single safe path component: non-empty, +// not a "." or ".." traversal element, and free of path separators. +func isCleanComponent(s string) bool { + if s == "" || s == "." || s == ".." { + return false + } + return !strings.ContainsAny(s, `/\`) +} + +// isWithinDir reports whether target resolves inside dir, guarding cache +// removal against module or version components that contain "..". +func isWithinDir(dir, target string) bool { + rel, err := filepath.Rel(dir, target) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// parseWappRelPath reverses lock.WappPath ("org/module-version.wapp") into a +// module name and version, splitting at the semantic-version boundary so both +// hyphenated module names and prerelease versions round-trip. +func parseWappRelPath(rel string) (string, string) { + rel = strings.TrimSuffix(rel, ".wapp") + + org := "" + file := rel + if slash := strings.LastIndex(rel, "/"); slash >= 0 { + org = rel[:slash] + file = rel[slash+1:] + } + + module, version := splitModuleVersion(file) + if org != "" { + module = org + "/" + module + } + return strings.Trim(module, "/"), version +} + +// splitModuleVersion splits "module-version" at the rightmost hyphen whose +// suffix parses as a semantic version. Both module names and semver prereleases +// contain hyphens (e.g. "my-module-v1.2.3-beta.1"), so the final hyphen alone +// loses the prerelease. Scanning from the right takes the shortest valid version +// suffix: a prerelease fragment like "beta.1" is not itself a valid version and +// is skipped, while a module ending in a version-like segment ("api-v2-v1.0.0") +// is not over-consumed. Falls back to the final hyphen when no suffix parses. +func splitModuleVersion(file string) (string, string) { + for i := len(file) - 1; i >= 0; i-- { + if file[i] != '-' { + continue + } + candidate := file[i+1:] + if _, err := semver.NewVersion(candidate); err == nil { + return file[:i], candidate + } + } + if dash := strings.LastIndex(file, "-"); dash >= 0 { + return file[:dash], file[dash+1:] + } + return file, "" +} + +func parseForceOption(l *lua.LState, idx int) (bool, *lua.Error) { + return parseBoolOption(l, idx, "force") +} + +func parseDryRunOption(l *lua.LState, idx int) (bool, *lua.Error) { + return parseBoolOption(l, idx, "dry_run") +} + +func parseBoolOption(l *lua.LState, idx int, key string) (bool, *lua.Error) { + if l.GetTop() < idx { + return false, nil + } + val := l.Get(idx) + if val == lua.LNil { + return false, nil + } + tbl, ok := val.(*lua.LTable) + if !ok { + return false, invalidOptionError(l, "options", "table", val) + } + value, _, err := tableBool(l, tbl, key) + if err != nil { + return false, err + } + return value, nil +} diff --git a/runtime/lua/modules/hub/cache_test.go b/runtime/lua/modules/hub/cache_test.go new file mode 100644 index 000000000..3d371da1b --- /dev/null +++ b/runtime/lua/modules/hub/cache_test.go @@ -0,0 +1,369 @@ +// SPDX-License-Identifier: MPL-2.0 + +package hub + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func writeCacheArtifact(t *testing.T, vendorDir, org, module, version string) string { + t.Helper() + dir := filepath.Join(vendorDir, org) + require.NoError(t, os.MkdirAll(dir, 0755)) + path := filepath.Join(dir, module+"-"+version+".wapp") + require.NoError(t, os.WriteFile(path, []byte("cached artifact bytes"), 0600)) + return path +} + +func writeLockFile(t *testing.T, dir string, modules ...[2]string) { + t.Helper() + content := "directories:\n modules: .wippy\n src: .\n" + if len(modules) > 0 { + content += "modules:\n" + for _, mod := range modules { + content += " - name: " + mod[0] + "\n version: " + mod[1] + "\n" + } + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "wippy.lock"), []byte(content), 0600)) +} + +func TestCacheRemoveRejectsPathTraversal(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + require.NoError(t, os.MkdirAll(filepath.Join(root, ".wippy", "vendor"), 0755)) + writeLockFile(t, root) + + // A file outside the vendor directory that an unchecked traversal would hit: + // filepath.Join(vendorDir, "wippy/x-../../../../outside.wapp") resolves here. + outside := filepath.Join(root, "outside.wapp") + require.NoError(t, os.WriteFile(outside, []byte("keep"), 0600)) + + l := newReadSurfaceState(t, artifactClientReturning(t, nil, nil)) + + if err := l.DoString(` + local ok, err = hub.cache.remove("wippy/x", "../../../../outside") + if err == nil then error("expected error removing a traversing version") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } + + require.FileExists(t, outside, "path traversal must not delete files outside the cache") +} + +func TestCacheListRemovePrune(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + vendorDir := filepath.Join(root, ".wippy", "vendor") + + pinnedPath := writeCacheArtifact(t, vendorDir, "wippy", "pinned", "v1.0.0") + orphanPath := writeCacheArtifact(t, vendorDir, "wippy", "orphan", "v2.0.0") + writeLockFile(t, root, [2]string{"wippy/pinned", "v1.0.0"}) + + l := newReadSurfaceState(t, artifactClientReturning(t, nil, nil)) + + if err := l.DoString(` + local list, err = hub.cache.list() + if err then error(err) end + if #list ~= 2 then error("cache list count mismatch: " .. tostring(#list)) end + + local pinned, orphan + for _, e in ipairs(list) do + if e.module == "wippy/pinned" then pinned = e end + if e.module == "wippy/orphan" then orphan = e end + end + if pinned == nil then error("pinned entry missing") end + if orphan == nil then error("orphan entry missing") end + if pinned.pinned ~= true then error("pinned flag wrong") end + if pinned.version ~= "v1.0.0" then error("pinned version mismatch: " .. tostring(pinned.version)) end + if orphan.pinned ~= false then error("orphan pinned flag wrong") end + if orphan.version ~= "v2.0.0" then error("orphan version mismatch: " .. tostring(orphan.version)) end + + local _, refuseErr = hub.cache.remove("wippy/pinned", "v1.0.0") + if refuseErr == nil then error("expected refusal removing pinned artifact") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } + + require.FileExists(t, pinnedPath) + require.FileExists(t, orphanPath) + + if err := l.DoString(` + local ok, err = hub.cache.remove("wippy/orphan", "v2.0.0") + if err then error(err) end + if ok ~= true then error("remove did not return true") end + + local forced, ferr = hub.cache.remove("wippy/pinned", "v1.0.0", { force = true }) + if ferr then error(ferr) end + if forced ~= true then error("forced remove did not return true") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } + + require.NoFileExists(t, orphanPath) + require.NoFileExists(t, pinnedPath) +} + +func TestCachePruneDryRun(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + vendorDir := filepath.Join(root, ".wippy", "vendor") + + pinnedPath := writeCacheArtifact(t, vendorDir, "wippy", "pinned", "v1.0.0") + orphanPath := writeCacheArtifact(t, vendorDir, "wippy", "orphan", "v2.0.0") + writeLockFile(t, root, [2]string{"wippy/pinned", "v1.0.0"}) + + l := newReadSurfaceState(t, artifactClientReturning(t, nil, nil)) + + if err := l.DoString(` + local dry, derr = hub.cache.prune({ dry_run = true }) + if derr then error(derr) end + if #dry ~= 1 then error("dry run prune count mismatch: " .. tostring(#dry)) end + if dry[1].module ~= "wippy/orphan" then error("dry run module mismatch") end + `); err != nil { + t.Fatalf("lua dry-run error: %v", err) + } + + require.FileExists(t, orphanPath, "dry run must not delete") + + if err := l.DoString(` + local pruned, perr = hub.cache.prune() + if perr then error(perr) end + if #pruned ~= 1 then error("prune count mismatch: " .. tostring(#pruned)) end + if pruned[1].module ~= "wippy/orphan" then error("prune module mismatch") end + `); err != nil { + t.Fatalf("lua prune error: %v", err) + } + + require.NoFileExists(t, orphanPath) + require.FileExists(t, pinnedPath, "pinned artifact must survive prune") +} + +func TestCacheListEmptyVendor(t *testing.T) { + t.Chdir(t.TempDir()) + l := newReadSurfaceState(t, &fakeArtifactClient{}) + if err := l.DoString(` + local list, err = hub.cache.list() + if err then error(err) end + if #list ~= 0 then error("expected empty list, got " .. tostring(#list)) end + `); err != nil { + t.Fatalf("lua error: %v", err) + } +} + +func TestCacheListFlagsAndSizes(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + vendorDir := filepath.Join(root, ".wippy", "vendor") + pinnedPath := writeCacheArtifact(t, vendorDir, "wippy", "pinned", "v1.0.0") + orphanPath := writeCacheArtifact(t, vendorDir, "wippy", "orphan", "v2.0.0") + writeLockFile(t, root, [2]string{"wippy/pinned", "v1.0.0"}) + + l := newReadSurfaceState(t, &fakeArtifactClient{}) + if err := l.DoString(` + local list, err = hub.cache.list() + if err then error(err) end + if #list ~= 2 then error("cache list count mismatch: " .. tostring(#list)) end + local pinned, orphan + for _, e in ipairs(list) do + if e.module == "wippy/pinned" then pinned = e end + if e.module == "wippy/orphan" then orphan = e end + end + if pinned == nil then error("pinned entry missing") end + if orphan == nil then error("orphan entry missing") end + if pinned.pinned ~= true then error("pinned flag wrong") end + if orphan.pinned ~= false then error("orphan flag wrong") end + if pinned.version ~= "v1.0.0" then error("pinned version mismatch") end + if orphan.version ~= "v2.0.0" then error("orphan version mismatch") end + if pinned.size <= 0 then error("pinned size must be positive") end + if orphan.size <= 0 then error("orphan size must be positive") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } + require.FileExists(t, pinnedPath) + require.FileExists(t, orphanPath) +} + +func TestCacheRemoveArgumentErrors(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + l := newReadSurfaceState(t, &fakeArtifactClient{}) + if err := l.DoString(` + local ok, err = hub.cache.remove("", "v1.0.0") + if err == nil then error("expected error for missing module") end + if ok ~= nil then error("expected nil result for missing module") end + + local ok2, err2 = hub.cache.remove("wippy/mod", "") + if err2 == nil then error("expected error for missing version") end + if ok2 ~= nil then error("expected nil result for missing version") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } +} + +func TestCacheRemovePinnedAndOrphan(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + vendorDir := filepath.Join(root, ".wippy", "vendor") + pinnedPath := writeCacheArtifact(t, vendorDir, "wippy", "pinned", "v1.0.0") + orphanPath := writeCacheArtifact(t, vendorDir, "wippy", "orphan", "v2.0.0") + writeLockFile(t, root, [2]string{"wippy/pinned", "v1.0.0"}) + + l := newReadSurfaceState(t, &fakeArtifactClient{}) + + // Refusal without force; file survives. + if err := l.DoString(` + local ok, err = hub.cache.remove("wippy/pinned", "v1.0.0") + if err == nil then error("expected refusal removing pinned artifact") end + if ok ~= nil then error("expected nil result on refusal") end + `); err != nil { + t.Fatalf("lua refusal error: %v", err) + } + require.FileExists(t, pinnedPath) + + // Orphan removed without force. + if err := l.DoString(` + local ok, err = hub.cache.remove("wippy/orphan", "v2.0.0") + if err then error(err) end + if ok ~= true then error("orphan remove did not return true") end + `); err != nil { + t.Fatalf("lua orphan error: %v", err) + } + require.NoFileExists(t, orphanPath) + + // Pinned removed with force. + if err := l.DoString(` + local ok, err = hub.cache.remove("wippy/pinned", "v1.0.0", { force = true }) + if err then error(err) end + if ok ~= true then error("forced remove did not return true") end + `); err != nil { + t.Fatalf("lua force error: %v", err) + } + require.NoFileExists(t, pinnedPath) +} + +func TestCachePruneAllPinnedNoop(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + vendorDir := filepath.Join(root, ".wippy", "vendor") + a := writeCacheArtifact(t, vendorDir, "wippy", "one", "v1.0.0") + b := writeCacheArtifact(t, vendorDir, "wippy", "two", "v2.0.0") + writeLockFile(t, root, + [2]string{"wippy/one", "v1.0.0"}, + [2]string{"wippy/two", "v2.0.0"}) + + l := newReadSurfaceState(t, &fakeArtifactClient{}) + if err := l.DoString(` + local pruned, err = hub.cache.prune() + if err then error(err) end + if #pruned ~= 0 then error("expected nothing pruned, got " .. tostring(#pruned)) end + `); err != nil { + t.Fatalf("lua error: %v", err) + } + require.FileExists(t, a) + require.FileExists(t, b) +} + +func TestCachePruneDryRunAndReal(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + vendorDir := filepath.Join(root, ".wippy", "vendor") + pinnedPath := writeCacheArtifact(t, vendorDir, "wippy", "pinned", "v1.0.0") + orphanPath := writeCacheArtifact(t, vendorDir, "wippy", "orphan", "v2.0.0") + writeLockFile(t, root, [2]string{"wippy/pinned", "v1.0.0"}) + + l := newReadSurfaceState(t, &fakeArtifactClient{}) + + if err := l.DoString(` + local dry, err = hub.cache.prune({ dry_run = true }) + if err then error(err) end + if #dry ~= 1 then error("dry run count mismatch: " .. tostring(#dry)) end + if dry[1].module ~= "wippy/orphan" then error("dry run module mismatch") end + if dry[1].pinned ~= false then error("dry run entry must be unpinned") end + `); err != nil { + t.Fatalf("lua dry-run error: %v", err) + } + require.FileExists(t, orphanPath, "dry run must not delete") + require.FileExists(t, pinnedPath) + + if err := l.DoString(` + local pruned, err = hub.cache.prune() + if err then error(err) end + if #pruned ~= 1 then error("prune count mismatch: " .. tostring(#pruned)) end + if pruned[1].module ~= "wippy/orphan" then error("prune module mismatch") end + `); err != nil { + t.Fatalf("lua prune error: %v", err) + } + require.NoFileExists(t, orphanPath) + require.FileExists(t, pinnedPath, "pinned artifact must survive prune") +} + +func TestCachePermissionDenied(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + vendorDir := filepath.Join(root, ".wippy", "vendor") + pinnedPath := writeCacheArtifact(t, vendorDir, "wippy", "pinned", "v1.0.0") + writeLockFile(t, root, [2]string{"wippy/pinned", "v1.0.0"}) + + l := newStrictReadSurfaceState(t, &fakeArtifactClient{}) + if err := l.DoString(` + local _, lerr = hub.cache.list() + if lerr == nil then error("expected permission denied for list") end + + local _, rerr = hub.cache.remove("wippy/pinned", "v1.0.0") + if rerr == nil then error("expected permission denied for remove") end + + local _, perr = hub.cache.prune() + if perr == nil then error("expected permission denied for prune") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } + // Nothing was touched under strict denial. + require.FileExists(t, pinnedPath) +} + +func TestParseWappRelPath(t *testing.T) { + cases := []struct { + rel string + module string + version string + }{ + {"wippy/pinned-v1.0.0.wapp", "wippy/pinned", "v1.0.0"}, + {"wippy/orphan-v1.2.3-beta.1.wapp", "wippy/orphan", "v1.2.3-beta.1"}, + {"wippy/my-module-v2.0.0.wapp", "wippy/my-module", "v2.0.0"}, + {"wippy/tool-v1.0.0-rc.2.wapp", "wippy/tool", "v1.0.0-rc.2"}, + {"org/api-v2-v1.0.0.wapp", "org/api-v2", "v1.0.0"}, + {"org/mod.wapp", "org/mod", ""}, + } + for _, tc := range cases { + t.Run(tc.rel, func(t *testing.T) { + module, version := parseWappRelPath(tc.rel) + require.Equal(t, tc.module, module) + require.Equal(t, tc.version, version) + }) + } +} + +func TestCacheRemoveRejectsModuleSwap(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + vendorDir := filepath.Join(root, ".wippy", "vendor") + + // A pinned artifact that a crafted version could normalize onto. + victim := writeCacheArtifact(t, vendorDir, "wippy", "other", "v1.0.0") + writeLockFile(t, root, [2]string{"wippy/other", "v1.0.0"}) + + l := newReadSurfaceState(t, artifactClientReturning(t, nil, nil)) + + if err := l.DoString(` + local ok, err = hub.cache.remove("wippy/mod", "x/../other-v1.0.0") + if err == nil then error("expected error for version with separators") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } + + require.FileExists(t, victim, "a crafted version must not delete another module's artifact") +} diff --git a/runtime/lua/modules/hub/manifest_test.go b/runtime/lua/modules/hub/manifest_test.go new file mode 100644 index 000000000..3fd452700 --- /dev/null +++ b/runtime/lua/modules/hub/manifest_test.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MPL-2.0 + +package hub + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/wippyai/go-lua/types/typ" +) + +func recordField(t *testing.T, r *typ.Record, name string) typ.Type { + t.Helper() + for _, f := range r.Fields { + if f.Name == name { + return f.Type + } + } + t.Fatalf("record has no field %q", name) + return nil +} + +func ifaceMethod(t *testing.T, i *typ.Interface, name string) *typ.Function { + t.Helper() + for _, m := range i.Methods { + if m.Name == name { + return m.Type + } + } + t.Fatalf("interface has no method %q", name) + return nil +} + +// TestManifestTypesReadSurface walks the manifest and proves hub.versions.open +// returns a typed package handle (a record whose methods are declared), not +// typ.Any, and that hub.cache.list returns a typed array. +func TestManifestTypesReadSurface(t *testing.T) { + export, ok := ModuleTypes().EnrichedExport().(*typ.Record) + require.True(t, ok, "hub export is not a record") + + versions, ok := recordField(t, export, "versions").(*typ.Interface) + require.True(t, ok, "hub.versions is not an interface") + + open := ifaceMethod(t, versions, "open") + require.Lenf(t, open.Returns, 2, "open should return (handle, error?), got %d returns", len(open.Returns)) + + handle, ok := open.Returns[0].(*typ.Record) + require.Truef(t, ok, "open returns %T, want a typed *typ.Record handle (not Any)", open.Returns[0]) + + // scalar fields typed + for _, f := range []string{"version", "digest", "packed"} { + require.NotNil(t, recordField(t, handle, f)) + } + + // methods typed via the metatable + meta, ok := handle.Metatable.(*typ.Interface) + require.True(t, ok, "handle metatable is not an interface") + for _, name := range []string{"metadata", "entries", "resources", "fs", "close"} { + require.NotNil(t, ifaceMethod(t, meta, name)) + } + require.Lenf(t, meta.Methods, 5, "handle should declare exactly 5 methods, got %d", len(meta.Methods)) + + // cache.list returns a typed array, not Any + cache, ok := recordField(t, export, "cache").(*typ.Interface) + require.True(t, ok, "hub.cache is not an interface") + list := ifaceMethod(t, cache, "list") + _, isArray := list.Returns[0].(*typ.Array) + require.Truef(t, isArray, "cache.list returns %T, want a typed *typ.Array (not Any)", list.Returns[0]) +} diff --git a/runtime/lua/modules/hub/module.go b/runtime/lua/modules/hub/module.go index d4ade4aa1..62ab27ffc 100644 --- a/runtime/lua/modules/hub/module.go +++ b/runtime/lua/modules/hub/module.go @@ -83,7 +83,7 @@ func (h *hubModule) authStore() *bootauth.Store { } func (h *hubModule) build() (*lua.LTable, []luaapi.YieldType) { - mod := lua.CreateTable(0, 6) + mod := lua.CreateTable(0, 7) modules := lua.CreateTable(0, 4) modules.RawSetString("list", lua.LGoFunc(h.modulesList)) @@ -92,10 +92,11 @@ func (h *hubModule) build() (*lua.LTable, []luaapi.YieldType) { modules.RawSetString("readme", lua.LGoFunc(h.modulesReadme)) modules.Immutable = true - versions := lua.CreateTable(0, 3) + versions := lua.CreateTable(0, 4) versions.RawSetString("list", lua.LGoFunc(h.versionsList)) versions.RawSetString("get", lua.LGoFunc(h.versionsGet)) versions.RawSetString("inspect", lua.LGoFunc(h.versionsInspect)) + versions.RawSetString("open", lua.LGoFunc(h.versionsOpen)) versions.Immutable = true dependencies := lua.CreateTable(0, 1) @@ -116,12 +117,19 @@ func (h *hubModule) build() (*lua.LTable, []luaapi.YieldType) { auth.RawSetString("status", lua.LGoFunc(h.authStatus)) auth.Immutable = true + cache := lua.CreateTable(0, 3) + cache.RawSetString("list", lua.LGoFunc(h.cacheList)) + cache.RawSetString("remove", lua.LGoFunc(h.cacheRemove)) + cache.RawSetString("prune", lua.LGoFunc(h.cachePrune)) + cache.Immutable = true + mod.RawSetString("modules", modules) mod.RawSetString("versions", versions) mod.RawSetString("dependencies", dependencies) mod.RawSetString("dependents", dependents) mod.RawSetString("files", files) mod.RawSetString("auth", auth) + mod.RawSetString("cache", cache) mod.Immutable = true return mod, nil @@ -408,7 +416,7 @@ func (h *hubModule) versionsInspect(l *lua.LState) int { ctx, cancel := withTimeout(ctx, base.timeout) defer cancel() - inspection, callErr := inspectVersionArtifact(ctx, client, params, "") + inspection, callErr := inspectVersionArtifact(ctx, client, params, resolveVendorDir()) if callErr != nil { return pushError(l, hubCallError(l, callErr)) } @@ -417,6 +425,58 @@ func (h *hubModule) versionsInspect(l *lua.LState) int { return 1 } +func (h *hubModule) versionsOpen(l *lua.LState) int { + moduleRef, moduleKey, err := parseModuleRef(l, 1) + if err != nil { + return pushError(l, err) + } + versionRef, err := parseVersionRef(l, 2) + if err != nil { + return pushError(l, err) + } + + ctx, err := h.requireContext(l) + if err != nil { + return pushError(l, err) + } + if !security.IsAllowed(ctx, "hub.versions.open", moduleKey, nil) { + return pushError(l, permissionDenied(l, "hub.versions.open", moduleKey)) + } + + base, err := parseBaseOptions(l, 3) + if err != nil { + return pushError(l, err) + } + + client, err := h.artifactClient(l, base) + if err != nil { + return pushError(l, err) + } + params, paramsErr := downloadParamsFromRefs(moduleRef, versionRef) + if paramsErr != nil { + return pushError(l, invalidArgument(l, paramsErr.Error())) + } + + reqCtx, cancel := withTimeout(ctx, base.timeout) + defer cancel() + + path, info, callErr := ensureCachedArtifact(reqCtx, client, params, resolveVendorDir()) + if callErr != nil { + return pushError(l, hubCallError(l, callErr)) + } + + file, reader, openErr := openArtifactReader(path) + if openErr != nil { + return pushError(l, hubCallError(l, openErr)) + } + + version := firstNonEmpty(info.Version, params.Version, params.VersionID, params.Label) + handle := newPackageHandle(ctx, file, reader, version, info.Digest) + pushPackageHandle(l, handle) + l.Push(lua.LNil) + return 2 +} + func (h *hubModule) dependenciesGet(l *lua.LState) int { moduleRef, moduleKey, err := parseModuleRef(l, 1) if err != nil { @@ -1048,6 +1108,38 @@ func parseReadmeOptions(l *lua.LState, idx int) (baseOptions, readmeOptions, *lu return base, opts, nil } +func parseKindFilter(l *lua.LState, tbl *lua.LTable) (map[string]struct{}, bool, *lua.Error) { + val := tbl.RawGetString("kind") + if val == lua.LNil { + return nil, false, nil + } + + switch v := val.(type) { + case lua.LString: + return map[string]struct{}{string(v): {}}, true, nil + case *lua.LTable: + kinds := make(map[string]struct{}) + var typeErr *lua.Error + v.ForEach(func(_, item lua.LValue) { + if typeErr != nil { + return + } + str, ok := item.(lua.LString) + if !ok { + typeErr = invalidOptionError(l, "kind", "array of strings", item) + return + } + kinds[string(str)] = struct{}{} + }) + if typeErr != nil { + return nil, false, typeErr + } + return kinds, true, nil + default: + return nil, false, invalidOptionError(l, "kind", "string or array", val) + } +} + func parseBaseOptions(l *lua.LState, idx int) (baseOptions, *lua.Error) { base, _, err := parseOptionsTable(l, idx) if err != nil { diff --git a/runtime/lua/modules/hub/module_test.go b/runtime/lua/modules/hub/module_test.go index 96f7d39bc..6e3eac490 100644 --- a/runtime/lua/modules/hub/module_test.go +++ b/runtime/lua/modules/hub/module_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "testing" "connectrpc.com/connect" @@ -173,7 +174,7 @@ func TestModulesList(t *testing.T) { mod := NewModule(Options{ModuleClient: fake}) l := lua.NewState() defer l.Close() - l.SetContext(setupContext()) + l.SetContext(hubTestStoreContext(setupContext(), t)) tbl, _ := mod.Build() l.SetGlobal(mod.Name, tbl) @@ -196,7 +197,7 @@ func TestVersionsGetRequiresVersion(t *testing.T) { mod := NewModule(Options{ModuleClient: fake}) l := lua.NewState() defer l.Close() - l.SetContext(setupContext()) + l.SetContext(hubTestStoreContext(setupContext(), t)) tbl, _ := mod.Build() l.SetGlobal(mod.Name, tbl) @@ -249,7 +250,7 @@ func TestVersionsInspectExtractsRequirementsFromArtifact(t *testing.T) { mod := NewModule(Options{ArtifactClient: fake}) l := lua.NewState() defer l.Close() - l.SetContext(setupContext()) + l.SetContext(hubTestStoreContext(setupContext(), t)) tbl, _ := mod.Build() l.SetGlobal(mod.Name, tbl) @@ -260,7 +261,7 @@ func TestVersionsInspectExtractsRequirementsFromArtifact(t *testing.T) { if res.version ~= "v0.1.2" then error("version mismatch") end if res.entry_count ~= 2 then error("entry_count mismatch") end local cache_path = string.gsub(res.cache_path, "\\", "/") - if cache_path ~= ".wippy/vendor/wippy/dummy-v0.1.2.wapp" then error("cache path mismatch: " .. tostring(res.cache_path)) end + if not cache_path:find("%.wippy/vendor/wippy/dummy%-v0%.1%.2%.wapp$") then error("cache path mismatch: " .. tostring(res.cache_path)) end if res.requirements[1].name ~= "router" then error("requirement name mismatch") end if res.requirements[1].description ~= "Router to register endpoints on" then error("description mismatch") end if res.requirements[1].default ~= "app:router" then error("default mismatch") end @@ -280,6 +281,132 @@ func TestVersionsInspectExtractsRequirementsFromArtifact(t *testing.T) { require.Equal(t, "v0.1.2", requested.Version) } +func TestVersionsEntriesReturnsEntries(t *testing.T) { + t.Chdir(t.TempDir()) + artifact := buildWappBytesForHubModuleTest(t, []wapp.Entry{ + { + ID: wapp.NewID("wippy.dummy", "router"), + Kind: "ns.requirement", + Meta: wapp.Metadata{"description": "Router to register endpoints on"}, + Data: map[string]any{ + "default": "app:router", + "targets": []any{ + map[string]any{"entry": "wippy.dummy:ping", "path": "meta.router"}, + }, + }, + }, + { + ID: wapp.NewID("wippy.dummy", "ping"), + Kind: "function.lua", + }, + }) + + var downloads int + fake := &fakeArtifactClient{ + getDownloadFn: func(_ context.Context, _ *boothub.DownloadParams) (*boothub.DownloadInfo, error) { + return &boothub.DownloadInfo{URL: "memory://dummy", Version: "v0.1.2"}, nil + }, + downloadFn: func(_ context.Context, url, destPath string) error { + downloads++ + require.Equal(t, "memory://dummy", url) + return os.WriteFile(destPath, artifact, 0600) + }, + } + + mod := NewModule(Options{ArtifactClient: fake}) + l := lua.NewState() + defer l.Close() + l.SetContext(hubTestStoreContext(setupContext(), t)) + + tbl, _ := mod.Build() + l.SetGlobal(mod.Name, tbl) + + if err := l.DoString(` + local pkg, err = hub.versions.open("wippy/dummy", "v0.1.2") + if err then error(err) end + if pkg.version ~= "v0.1.2" then error("version mismatch") end + + local entries, eerr = pkg:entries() + if eerr then error(eerr) end + if #entries ~= 2 then error("total mismatch: " .. tostring(#entries)) end + + local router, ping + for _, e in ipairs(entries) do + if e.id == "wippy.dummy:router" then router = e end + if e.id == "wippy.dummy:ping" then ping = e end + end + if router == nil then error("router entry missing") end + if ping == nil then error("ping entry missing") end + if router.kind ~= "ns.requirement" then error("kind mismatch") end + if router.meta.description ~= "Router to register endpoints on" then error("meta mismatch") end + if router.data.default ~= "app:router" then error("data default mismatch") end + if router.data.targets[1].entry ~= "wippy.dummy:ping" then error("target entry mismatch") end + if router.data.targets[1].path ~= "meta.router" then error("target path mismatch") end + if ping.kind ~= "function.lua" then error("ping kind mismatch") end + + local filtered, ferr = pkg:entries({ kind = "ns.requirement", include_data = false }) + if ferr then error(ferr) end + if #filtered ~= 1 then error("filtered items count mismatch: " .. tostring(#filtered)) end + if filtered[1].kind ~= "ns.requirement" then error("filtered kind mismatch") end + if filtered[1].data ~= nil then error("expected no data when include_data=false") end + if filtered[1].meta.description ~= "Router to register endpoints on" then error("filtered meta mismatch") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } + + require.Equal(t, 1, downloads) +} + +func TestVersionsEntriesCacheReusesArtifact(t *testing.T) { + t.Chdir(t.TempDir()) + artifact := buildWappBytesForHubModuleTest(t, []wapp.Entry{ + { + ID: wapp.NewID("wippy.dummy", "router"), + Kind: "ns.requirement", + Meta: wapp.Metadata{"description": "Router"}, + }, + }) + + var downloads int + fake := &fakeArtifactClient{ + getDownloadFn: func(_ context.Context, _ *boothub.DownloadParams) (*boothub.DownloadInfo, error) { + return &boothub.DownloadInfo{URL: "memory://dummy", Version: "v0.1.2"}, nil + }, + downloadFn: func(_ context.Context, _, destPath string) error { + downloads++ + return os.WriteFile(destPath, artifact, 0600) + }, + } + + mod := NewModule(Options{ArtifactClient: fake}) + l := lua.NewState() + defer l.Close() + l.SetContext(hubTestStoreContext(setupContext(), t)) + + tbl, _ := mod.Build() + l.SetGlobal(mod.Name, tbl) + + if err := l.DoString(` + local pkg, err = hub.versions.open("wippy/dummy", "v0.1.2") + if err then error(err) end + local entries, eerr = pkg:entries() + if eerr then error(eerr) end + if #entries ~= 1 then error("total mismatch") end + if entries[1].id ~= "wippy.dummy:router" then error("name mismatch") end + + local again, err2 = hub.versions.open("wippy/dummy", "v0.1.2") + if err2 then error(err2) end + local cached, cerr = again:entries() + if cerr then error(cerr) end + if cached[1].id ~= "wippy.dummy:router" then error("cached name mismatch") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } + + require.Equal(t, 1, downloads) + require.FileExists(t, filepath.Join(".wippy", "vendor", "wippy", "dummy-v0.1.2.wapp")) +} + func TestDependenciesGetOptionalVersion(t *testing.T) { fake := &fakeModuleClient{} fake.getDepsFn = func(_ context.Context, req *connect.Request[modulev1.GetDependenciesRequest]) (*connect.Response[modulev1.GetDependenciesResponse], error) { @@ -290,7 +417,7 @@ func TestDependenciesGetOptionalVersion(t *testing.T) { mod := NewModule(Options{ModuleClient: fake}) l := lua.NewState() defer l.Close() - l.SetContext(setupContext()) + l.SetContext(hubTestStoreContext(setupContext(), t)) tbl, _ := mod.Build() l.SetGlobal(mod.Name, tbl) @@ -311,7 +438,7 @@ func TestHubModule_ModuleClientShortCircuitDoesNotInitializeStore(t *testing.T) l := lua.NewState() defer l.Close() - l.SetContext(setupContext()) + l.SetContext(hubTestStoreContext(setupContext(), t)) _, err := h.moduleClient(l, baseOptions{}) require.Nil(t, err) @@ -350,7 +477,7 @@ func TestAuthAuthenticateSucceeds(t *testing.T) { mod := NewModule(Options{}) l := lua.NewState() defer l.Close() - l.SetContext(setupContext()) + l.SetContext(hubTestStoreContext(setupContext(), t)) tbl, _ := mod.Build() l.SetGlobal(mod.Name, tbl) @@ -385,7 +512,7 @@ func TestAuthAuthenticateRejectedNotStored(t *testing.T) { mod := NewModule(Options{}) l := lua.NewState() defer l.Close() - l.SetContext(setupContext()) + l.SetContext(hubTestStoreContext(setupContext(), t)) tbl, _ := mod.Build() l.SetGlobal(mod.Name, tbl) @@ -411,7 +538,7 @@ func TestAuthAuthenticateInvalidFormat(t *testing.T) { mod := NewModule(Options{}) l := lua.NewState() defer l.Close() - l.SetContext(setupContext()) + l.SetContext(hubTestStoreContext(setupContext(), t)) tbl, _ := mod.Build() l.SetGlobal(mod.Name, tbl) @@ -460,7 +587,7 @@ func TestAuthLogout(t *testing.T) { mod := NewModule(Options{}) l := lua.NewState() defer l.Close() - l.SetContext(setupContext()) + l.SetContext(hubTestStoreContext(setupContext(), t)) tbl, _ := mod.Build() l.SetGlobal(mod.Name, tbl) @@ -507,7 +634,7 @@ func TestAuthStatusAuthenticated(t *testing.T) { mod := NewModule(Options{AuthStore: store}) l := lua.NewState() defer l.Close() - l.SetContext(setupContext()) + l.SetContext(hubTestStoreContext(setupContext(), t)) tbl, _ := mod.Build() l.SetGlobal(mod.Name, tbl) @@ -534,7 +661,7 @@ func TestAuthStatusNotAuthenticated(t *testing.T) { mod := NewModule(Options{}) l := lua.NewState() defer l.Close() - l.SetContext(setupContext()) + l.SetContext(hubTestStoreContext(setupContext(), t)) tbl, _ := mod.Build() l.SetGlobal(mod.Name, tbl) @@ -568,7 +695,7 @@ func TestAuthStatusInvalidTokenSuppressesIdentity(t *testing.T) { mod := NewModule(Options{AuthStore: store}) l := lua.NewState() defer l.Close() - l.SetContext(setupContext()) + l.SetContext(hubTestStoreContext(setupContext(), t)) tbl, _ := mod.Build() l.SetGlobal(mod.Name, tbl) diff --git a/runtime/lua/modules/hub/package_handle.go b/runtime/lua/modules/hub/package_handle.go new file mode 100644 index 000000000..097fec6ab --- /dev/null +++ b/runtime/lua/modules/hub/package_handle.go @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: MPL-2.0 + +package hub + +import ( + "context" + "fmt" + "os" + "sync" + + lua "github.com/wippyai/go-lua" + fsapi "github.com/wippyai/runtime/api/fs" + "github.com/wippyai/runtime/api/runtime/resource" + luaconv "github.com/wippyai/runtime/runtime/lua/engine/payload" + "github.com/wippyai/runtime/runtime/lua/engine/value" + fsmod "github.com/wippyai/runtime/runtime/lua/modules/fs" + "github.com/wippyai/wapp" +) + +const packageTypeName = "hub.Package" + +var packageMetatable *lua.LTable + +func init() { + packageMetatable = value.RegisterTypeMethods(nil, packageTypeName, + map[string]lua.LGoFunc{ + "__index": packageIndex, + "__tostring": packageToString, + }, + nil) +} + +// packageHandle is an owned artifact handle. It keeps the wapp file and reader +// open for the lifetime of the frame, mirroring fs.File cleanup semantics: the +// file must stay open because the resource filesystem reads lazily from the +// reader. Cleanup is registered on the resource store and cancelled on close. +type packageHandle struct { + file *os.File + reader *wapp.Reader + cancelCleanup func() + version string + digest string + mu sync.Mutex + closed bool + packed bool +} + +func newPackageHandle(ctx context.Context, file *os.File, reader *wapp.Reader, version, digest string) *packageHandle { + h := &packageHandle{ + version: version, + digest: digest, + file: file, + reader: reader, + packed: true, + } + + store := resource.GetStore(ctx) + if store != nil { + h.cancelCleanup = store.AddCleanup(func() error { + h.mu.Lock() + defer h.mu.Unlock() + if !h.closed { + h.closed = true + return h.file.Close() + } + return nil + }) + } + + return h +} + +func (h *packageHandle) Close() error { + h.mu.Lock() + defer h.mu.Unlock() + + if h.closed { + return nil + } + + h.closed = true + cancel := h.cancelCleanup + h.cancelCleanup = nil + + if cancel != nil { + cancel() + } + + return h.file.Close() +} + +func (h *packageHandle) isClosed() bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.closed +} + +func pushPackageHandle(l *lua.LState, h *packageHandle) { + value.PushUserData(l, h, packageMetatable) +} + +func checkPackage(l *lua.LState, idx int) *packageHandle { + ud := l.CheckUserData(idx) + if v, ok := ud.Value.(*packageHandle); ok { + return v + } + l.ArgError(idx, "hub package expected") + return nil +} + +func pushPackageClosed(l *lua.LState) int { + l.Push(lua.LNil) + l.Push(lua.NewLuaError(l, "package handle is closed").WithKind(lua.Invalid).WithRetryable(false)) + return 2 +} + +func packageIndex(l *lua.LState) int { + if checkPackage(l, 1) == nil { + return 0 + } + h := l.CheckUserData(1).Value.(*packageHandle) + key := l.CheckString(2) + switch key { + case "version": + l.Push(lua.LString(h.version)) + case "digest": + l.Push(lua.LString(h.digest)) + case "packed": + l.Push(lua.LBool(h.packed)) + case "metadata": + l.Push(lua.LGoFunc(packageMetadata)) + case "entries": + l.Push(lua.LGoFunc(packageEntries)) + case "resources": + l.Push(lua.LGoFunc(packageResources)) + case "fs": + l.Push(lua.LGoFunc(packageFS)) + case "close": + l.Push(lua.LGoFunc(packageClose)) + default: + l.Push(lua.LNil) + } + return 1 +} + +func packageToString(l *lua.LState) int { + h := checkPackage(l, 1) + if h == nil { + return 0 + } + l.Push(lua.LString(fmt.Sprintf("hub.Package{version=%s}", h.version))) + return 1 +} + +func packageMetadata(l *lua.LState) int { + h := checkPackage(l, 1) + if h == nil { + return 0 + } + if h.isClosed() { + return pushPackageClosed(l) + } + + meta, err := h.reader.GetMetadata() + if err != nil { + l.Push(lua.LNil) + l.Push(lua.WrapErrorWithLua(l, err, "read pack metadata").WithKind(lua.Internal).WithRetryable(false)) + return 2 + } + + tbl, convErr := luaconv.GoToLua(map[string]any(meta)) + if convErr != nil { + l.Push(lua.LNil) + l.Push(lua.WrapErrorWithLua(l, convErr, "convert pack metadata").WithKind(lua.Internal).WithRetryable(false)) + return 2 + } + + l.Push(tbl) + l.Push(lua.LNil) + return 2 +} + +func packageEntries(l *lua.LState) int { + h := checkPackage(l, 1) + if h == nil { + return 0 + } + if h.isClosed() { + return pushPackageClosed(l) + } + + kinds, includeData, optErr := parsePackageEntryOptions(l, 2) + if optErr != nil { + return pushError(l, optErr) + } + + entries, err := h.reader.GetEntries() + if err != nil { + l.Push(lua.LNil) + l.Push(lua.WrapErrorWithLua(l, err, "read artifact entries").WithKind(lua.Internal).WithRetryable(false)) + return 2 + } + + arr, convErr := entriesToArray(l, entries, kinds, includeData) + if convErr != nil { + l.Push(lua.LNil) + l.Push(lua.WrapErrorWithLua(l, convErr, "convert artifact entries").WithKind(lua.Internal).WithRetryable(false)) + return 2 + } + + l.Push(arr) + l.Push(lua.LNil) + return 2 +} + +func packageResources(l *lua.LState) int { + h := checkPackage(l, 1) + if h == nil { + return 0 + } + if h.isClosed() { + return pushPackageClosed(l) + } + + arr, err := resourcesToArray(l, h.reader.ListResources()) + if err != nil { + l.Push(lua.LNil) + l.Push(lua.WrapErrorWithLua(l, err, "convert artifact resources").WithKind(lua.Internal).WithRetryable(false)) + return 2 + } + + l.Push(arr) + l.Push(lua.LNil) + return 2 +} + +func packageFS(l *lua.LState) int { + h := checkPackage(l, 1) + if h == nil { + return 0 + } + if h.isClosed() { + return pushPackageClosed(l) + } + + resourceID := l.CheckString(2) + if resourceID == "" { + l.Push(lua.LNil) + l.Push(lua.NewLuaError(l, "resource id required").WithKind(lua.Invalid).WithRetryable(false)) + return 2 + } + + rdfs, err := h.reader.GetFS(parseResourceID(resourceID)) + if err != nil { + l.Push(lua.LNil) + l.Push(lua.WrapErrorWithLua(l, err, "open resource filesystem").WithKind(lua.NotFound).WithRetryable(false)) + return 2 + } + + fsmod.PushFS(l, fsapi.NewReadOnlyFS(rdfs), ".") + l.Push(lua.LNil) + return 2 +} + +func packageClose(l *lua.LState) int { + h := checkPackage(l, 1) + if h == nil { + return 0 + } + if err := h.Close(); err != nil { + l.Push(lua.LFalse) + l.Push(lua.WrapErrorWithLua(l, err, "close package").WithKind(lua.Internal).WithRetryable(false)) + return 2 + } + l.Push(lua.LTrue) + l.Push(lua.LNil) + return 2 +} + +// parsePackageEntryOptions parses the { kind?, include_data? } table accepted by +// pkg:entries. include_data defaults to true. +func parsePackageEntryOptions(l *lua.LState, idx int) (map[string]struct{}, bool, *lua.Error) { + includeData := true + if l.GetTop() < idx { + return nil, includeData, nil + } + val := l.Get(idx) + if val == lua.LNil { + return nil, includeData, nil + } + tbl, ok := val.(*lua.LTable) + if !ok { + return nil, includeData, invalidOptionError(l, "options", "table", val) + } + + kinds, _, err := parseKindFilter(l, tbl) + if err != nil { + return nil, includeData, err + } + + if include, ok, err := tableBool(l, tbl, "include_data"); err != nil { + return nil, includeData, err + } else if ok { + includeData = include + } + + return kinds, includeData, nil +} + +// entriesToArray converts wapp entries to a bare Lua array of +// { id = "ns:name", kind, meta, data }. data is omitted when includeData is +// false and stays raw (GoToLua does not resolve ${env:...}/_env literals). +func entriesToArray(_ *lua.LState, entries []wapp.Entry, kinds map[string]struct{}, includeData bool) (*lua.LTable, error) { + arr := lua.CreateTable(len(entries), 0) + count := 0 + for _, entry := range entries { + if kinds != nil { + if _, ok := kinds[entry.Kind]; !ok { + continue + } + } + + result := lua.CreateTable(0, 4) + result.RawSetString("id", lua.LString(entry.ID.String())) + result.RawSetString("kind", lua.LString(entry.Kind)) + + meta, err := luaconv.GoToLua(map[string]any(entry.Meta)) + if err != nil { + return nil, fmt.Errorf("convert entry %s meta: %w", entry.ID.String(), err) + } + result.RawSetString("meta", meta) + + if includeData { + data, err := luaconv.GoToLua(entry.Data) + if err != nil { + return nil, fmt.Errorf("convert entry %s data: %w", entry.ID.String(), err) + } + result.RawSetString("data", data) + } + + count++ + arr.RawSetInt(count, result) + } + return arr, nil +} + +// resourcesToArray converts resource info to a bare Lua array of +// { id = "ns:name", type, hash, size, file_count, meta }. +func resourcesToArray(_ *lua.LState, resources []wapp.ResourceInfo) (*lua.LTable, error) { + arr := lua.CreateTable(len(resources), 0) + for i, info := range resources { + result := lua.CreateTable(0, 6) + result.RawSetString("id", lua.LString(info.ID.String())) + result.RawSetString("type", lua.LString(info.Type)) + result.RawSetString("hash", lua.LString(info.Hash)) + result.RawSetString("size", lua.LNumber(info.Size)) + result.RawSetString("file_count", lua.LNumber(info.FileCount)) + + meta, err := luaconv.GoToLua(map[string]any(info.Meta)) + if err != nil { + return nil, fmt.Errorf("convert resource %s meta: %w", info.ID.String(), err) + } + result.RawSetString("meta", meta) + + arr.RawSetInt(i+1, result) + } + return arr, nil +} diff --git a/runtime/lua/modules/hub/read_surface_test.go b/runtime/lua/modules/hub/read_surface_test.go new file mode 100644 index 000000000..cdf90e18f --- /dev/null +++ b/runtime/lua/modules/hub/read_surface_test.go @@ -0,0 +1,723 @@ +// SPDX-License-Identifier: MPL-2.0 + +package hub + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/require" + lua "github.com/wippyai/go-lua" + "github.com/wippyai/runtime/api/runtime/resource" + boothub "github.com/wippyai/runtime/boot/deps/hub" + "github.com/wippyai/wapp" +) + +func buildWappWithResourceForHubTest(t *testing.T, entries []wapp.Entry, resID wapp.ID, files map[string]string) []byte { + t.Helper() + fsys := fstest.MapFS{} + for name, content := range files { + fsys[name] = &fstest.MapFile{Data: []byte(content)} + } + var buf bytes.Buffer + writer := wapp.NewWriter() + require.NoError(t, writer.Pack(wapp.Metadata{"name": "dummy"}, entries, fsys, resID, wapp.Metadata{"role": "assets"}, &buf)) + return buf.Bytes() +} + +func newReadSurfaceState(t *testing.T, fake *fakeArtifactClient) *lua.LState { + t.Helper() + mod := NewModule(Options{ArtifactClient: fake}) + l := lua.NewState() + t.Cleanup(l.Close) + l.SetContext(hubTestStoreContext(setupContext(), t)) + tbl, _ := mod.Build() + l.SetGlobal(mod.Name, tbl) + return l +} + +// hubTestStoreContext attaches a resource store to ctx and closes it in test +// cleanup, so a package handle's frame-end AddCleanup runs and releases the open +// .wapp file before t.TempDir removal. On Windows an open handle blocks removal; +// this mirrors the request-end store close that happens in production. +func hubTestStoreContext(ctx context.Context, t *testing.T) context.Context { + t.Helper() + store := resource.NewStore() + require.NoError(t, resource.SetStore(ctx, store)) + t.Cleanup(func() { _ = store.Close() }) + return ctx +} + +func artifactClientReturning(t *testing.T, artifact []byte, downloads *int) *fakeArtifactClient { + t.Helper() + sum := sha256.Sum256(artifact) + digest := "sha256:" + hex.EncodeToString(sum[:]) + return &fakeArtifactClient{ + getDownloadFn: func(_ context.Context, _ *boothub.DownloadParams) (*boothub.DownloadInfo, error) { + return &boothub.DownloadInfo{URL: "memory://dummy", Version: "v0.1.2", Digest: digest}, nil + }, + downloadFn: func(_ context.Context, url, destPath string) error { + if downloads != nil { + *downloads++ + } + require.Equal(t, "memory://dummy", url) + return os.WriteFile(destPath, artifact, 0600) + }, + } +} + +// buildWappWithMetadataForHubTest packs an entries-only artifact with arbitrary +// top-level metadata so metadata fidelity can be asserted across many keys. +func buildWappWithMetadataForHubTest(t *testing.T, meta wapp.Metadata, entries []wapp.Entry) []byte { + t.Helper() + var buf bytes.Buffer + writer := wapp.NewWriter() + require.NoError(t, writer.PackEntries(meta, entries, &buf)) + return buf.Bytes() +} + +// newStrictReadSurfaceState mirrors newReadSurfaceState but installs a strict +// security context so permission checks deny by default. +func newStrictReadSurfaceState(t *testing.T, fake *fakeArtifactClient) *lua.LState { + t.Helper() + mod := NewModule(Options{ArtifactClient: fake}) + l := lua.NewState() + t.Cleanup(l.Close) + l.SetContext(hubTestStoreContext(setupStrictContext(), t)) + tbl, _ := mod.Build() + l.SetGlobal(mod.Name, tbl) + return l +} + +func TestVersionsOpenInspectsPackage(t *testing.T) { + t.Chdir(t.TempDir()) + artifact := buildWappWithResourceForHubTest(t, + []wapp.Entry{ + { + ID: wapp.NewID("wippy.dummy", "router"), + Kind: "ns.requirement", + Meta: wapp.Metadata{"description": "Router"}, + Data: map[string]any{"default": "app:router"}, + }, + {ID: wapp.NewID("wippy.dummy", "ping"), Kind: "function.lua"}, + }, + wapp.NewID("wippy.dummy", "assets"), + map[string]string{"config/app.yaml": "name: dummy\n"}, + ) + + var downloads int + l := newReadSurfaceState(t, artifactClientReturning(t, artifact, &downloads)) + + if err := l.DoString(` + local pkg, err = hub.versions.open("wippy/dummy", "v0.1.2") + if err then error(err) end + if pkg.version ~= "v0.1.2" then error("version mismatch: " .. tostring(pkg.version)) end + if pkg.digest:sub(1, 7) ~= "sha256:" then error("digest mismatch: " .. tostring(pkg.digest)) end + if pkg.packed ~= true then error("packed mismatch") end + + local meta, merr = pkg:metadata() + if merr then error(merr) end + if meta.name ~= "dummy" then error("metadata name mismatch: " .. tostring(meta.name)) end + + local entries, eerr = pkg:entries() + if eerr then error(eerr) end + if #entries ~= 2 then error("entries count mismatch: " .. tostring(#entries)) end + local router + for _, e in ipairs(entries) do + if e.id == "wippy.dummy:router" then router = e end + end + if router == nil then error("router entry missing") end + if router.kind ~= "ns.requirement" then error("kind mismatch") end + if router.meta.description ~= "Router" then error("meta mismatch") end + if router.data.default ~= "app:router" then error("raw data mismatch") end + + local filtered, ferr = pkg:entries({ kind = "function.lua", include_data = false }) + if ferr then error(ferr) end + if #filtered ~= 1 then error("filtered count mismatch: " .. tostring(#filtered)) end + if filtered[1].id ~= "wippy.dummy:ping" then error("filtered id mismatch") end + if filtered[1].data ~= nil then error("expected no data when include_data=false") end + + local resources, rerr = pkg:resources() + if rerr then error(rerr) end + if #resources ~= 1 then error("resources count mismatch: " .. tostring(#resources)) end + if resources[1].id ~= "wippy.dummy:assets" then error("resource id mismatch: " .. tostring(resources[1].id)) end + if resources[1].type ~= "tree" then error("resource type mismatch: " .. tostring(resources[1].type)) end + if resources[1].file_count ~= 1 then error("file_count mismatch: " .. tostring(resources[1].file_count)) end + + local vfs, verr = pkg:fs("wippy.dummy:assets") + if verr then error(verr) end + local body, berr = vfs:read_file("config/app.yaml") + if berr then error(berr) end + if body ~= "name: dummy\n" then error("fs read mismatch: " .. tostring(body)) end + + local ok, cerr = pkg:close() + if cerr then error(cerr) end + if ok ~= true then error("close did not return true") end + + local _, closedErr = pkg:metadata() + if closedErr == nil then error("expected error after close") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } + + require.Equal(t, 1, downloads) +} + +func TestVersionsOpenResourcesAndFileRead(t *testing.T) { + t.Chdir(t.TempDir()) + artifact := buildWappWithResourceForHubTest(t, + []wapp.Entry{{ID: wapp.NewID("wippy.dummy", "ping"), Kind: "function.lua"}}, + wapp.NewID("wippy.dummy", "assets"), + map[string]string{"data/readme.txt": "hello"}, + ) + + l := newReadSurfaceState(t, artifactClientReturning(t, artifact, nil)) + + if err := l.DoString(` + local pkg, err = hub.versions.open("wippy/dummy", "v0.1.2") + if err then error(err) end + + local resources, rserr = pkg:resources() + if rserr then error(rserr) end + if #resources ~= 1 then error("resources count mismatch") end + if resources[1].id ~= "wippy.dummy:assets" then error("resource id mismatch") end + + local vfs, verr = pkg:fs("wippy.dummy:assets") + if verr then error(verr) end + + local body, rerr = vfs:read_file("data/readme.txt") + if rerr then error(rerr) end + if body ~= "hello" then error("read_file mismatch: " .. tostring(body)) end + + local slashBody, serr = vfs:read_file("/data/readme.txt") + if serr then error(serr) end + if slashBody ~= "hello" then error("leading slash read mismatch") end + + local _, missErr = vfs:read_file("data/missing.txt") + if missErr == nil then error("expected error for missing file") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } +} + +func TestVersionsOpenArgumentAndPermissionErrors(t *testing.T) { + t.Chdir(t.TempDir()) + artifact := buildWappBytesForHubModuleTest(t, + []wapp.Entry{{ID: wapp.NewID("wippy.dummy", "ping"), Kind: "function.lua"}}) + + // Malformed module ref and missing/empty version each fail before any download. + l := newReadSurfaceState(t, artifactClientReturning(t, artifact, nil)) + if err := l.DoString(` + local pkg, err = hub.versions.open("wippy/", "v0.1.2") + if err == nil then error("expected error for malformed module ref") end + if pkg ~= nil then error("expected nil pkg for malformed module ref") end + + local pkg2, err2 = hub.versions.open("wippy/dummy") + if err2 == nil then error("expected error for missing version") end + if pkg2 ~= nil then error("expected nil pkg for missing version") end + + local pkg3, err3 = hub.versions.open("wippy/dummy", "") + if err3 == nil then error("expected error for empty version") end + if pkg3 ~= nil then error("expected nil pkg for empty version") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } + + // Strict context with no grant denies open even for a well-formed call. + strict := newStrictReadSurfaceState(t, artifactClientReturning(t, artifact, nil)) + if err := strict.DoString(` + local pkg, err = hub.versions.open("wippy/dummy", "v0.1.2") + if err == nil then error("expected permission denied") end + if pkg ~= nil then error("expected nil pkg on permission denied") end + `); err != nil { + t.Fatalf("lua strict error: %v", err) + } +} + +func TestVersionsOpenDownloadFailureSurfaced(t *testing.T) { + t.Chdir(t.TempDir()) + fake := &fakeArtifactClient{ + getDownloadFn: func(_ context.Context, _ *boothub.DownloadParams) (*boothub.DownloadInfo, error) { + return &boothub.DownloadInfo{URL: "memory://dummy", Version: "v0.1.2"}, nil + }, + downloadFn: func(_ context.Context, _, _ string) error { + return errors.New("network unreachable") + }, + } + + l := newReadSurfaceState(t, fake) + if err := l.DoString(` + local pkg, err = hub.versions.open("wippy/dummy", "v0.1.2") + if err == nil then error("expected download error") end + if pkg ~= nil then error("expected nil pkg on download failure") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } +} + +func TestVersionsOpenMetadataAllKeys(t *testing.T) { + t.Chdir(t.TempDir()) + artifact := buildWappWithMetadataForHubTest(t, + wapp.Metadata{ + "name": "dummy", + "version": "v0.1.2", + "description": "a dummy module", + }, + []wapp.Entry{{ID: wapp.NewID("wippy.dummy", "ping"), Kind: "function.lua"}}) + + l := newReadSurfaceState(t, artifactClientReturning(t, artifact, nil)) + if err := l.DoString(` + local pkg, err = hub.versions.open("wippy/dummy", "v0.1.2") + if err then error(err) end + local meta, merr = pkg:metadata() + if merr then error(merr) end + if meta.name ~= "dummy" then error("name mismatch: " .. tostring(meta.name)) end + if meta.version ~= "v0.1.2" then error("version mismatch: " .. tostring(meta.version)) end + if meta.description ~= "a dummy module" then error("description mismatch: " .. tostring(meta.description)) end + `); err != nil { + t.Fatalf("lua error: %v", err) + } +} + +func TestVersionsOpenEntriesKindFilterAndRawData(t *testing.T) { + t.Chdir(t.TempDir()) + artifact := buildWappBytesForHubModuleTest(t, []wapp.Entry{ + { + ID: wapp.NewID("wippy.dummy", "router"), + Kind: "ns.requirement", + Meta: wapp.Metadata{"description": "Router"}, + Data: map[string]any{ + "url": "${env:SECRET}", + "password_env": "DB_PASS", + }, + }, + {ID: wapp.NewID("wippy.dummy", "ping"), Kind: "function.lua"}, + {ID: wapp.NewID("wippy.dummy", "bare"), Kind: "library.lua"}, + }) + + l := newReadSurfaceState(t, artifactClientReturning(t, artifact, nil)) + if err := l.DoString(` + local pkg, err = hub.versions.open("wippy/dummy", "v0.1.2") + if err then error(err) end + + -- Array of kinds returns the union. + local union, uerr = pkg:entries({ kind = {"function.lua", "ns.requirement"} }) + if uerr then error(uerr) end + if #union ~= 2 then error("union count mismatch: " .. tostring(#union)) end + local seen = {} + for _, e in ipairs(union) do seen[e.kind] = true end + if not seen["function.lua"] then error("union missing function.lua") end + if not seen["ns.requirement"] then error("union missing ns.requirement") end + + -- A kind matching nothing yields an empty array. + local none, nerr = pkg:entries({ kind = {"does.not.exist"} }) + if nerr then error(nerr) end + if #none ~= 0 then error("expected empty array, got " .. tostring(#none)) end + + -- Default (no opts) includes data. + local all, aerr = pkg:entries() + if aerr then error(aerr) end + if #all ~= 3 then error("all count mismatch: " .. tostring(#all)) end + + -- RAW sealing: placeholder + *_env stay literal, never resolved. + local router + for _, e in ipairs(all) do + if e.id == "wippy.dummy:router" then router = e end + end + if router == nil then error("router entry missing") end + if router.data.url ~= "${env:SECRET}" then error("env placeholder resolved: " .. tostring(router.data.url)) end + if router.data.password_env ~= "DB_PASS" then error("password_env mismatch: " .. tostring(router.data.password_env)) end + + -- Entry with no meta and no data decodes cleanly. + local ping + for _, e in ipairs(all) do + if e.id == "wippy.dummy:ping" then ping = e end + end + if ping == nil then error("ping entry missing") end + if ping.data ~= nil and next(ping.data) ~= nil then error("expected no data for bare entry") end + if ping.meta ~= nil and next(ping.meta) ~= nil then error("expected empty meta for bare entry") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } +} + +func TestVersionsOpenEntryDataTypeFidelity(t *testing.T) { + t.Chdir(t.TempDir()) + artifact := buildWappBytesForHubModuleTest(t, []wapp.Entry{ + { + ID: wapp.NewID("wippy.dummy", "config"), + Kind: "config", + Data: map[string]any{ + "str": "hello", + "num": 42, + "flag": true, + "arr": []any{1, 2, 3}, + "nested": map[string]any{"inner": "value"}, + }, + }, + }) + + l := newReadSurfaceState(t, artifactClientReturning(t, artifact, nil)) + if err := l.DoString(` + local pkg, err = hub.versions.open("wippy/dummy", "v0.1.2") + if err then error(err) end + local entries, eerr = pkg:entries() + if eerr then error(eerr) end + local d = entries[1].data + if type(d.str) ~= "string" or d.str ~= "hello" then error("str fidelity") end + if type(d.num) ~= "number" or d.num ~= 42 then error("num fidelity") end + if type(d.flag) ~= "boolean" or d.flag ~= true then error("flag fidelity") end + if type(d.arr) ~= "table" or #d.arr ~= 3 then error("arr fidelity") end + if d.arr[1] ~= 1 or d.arr[2] ~= 2 or d.arr[3] ~= 3 then error("arr element fidelity") end + if type(d.nested) ~= "table" or d.nested.inner ~= "value" then error("nested fidelity") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } +} + +func TestVersionsOpenResourcesEmptyAndMultiFile(t *testing.T) { + t.Chdir(t.TempDir()) + + // No resource: resources() is empty and fs() errors. + noRes := buildWappBytesForHubModuleTest(t, + []wapp.Entry{{ID: wapp.NewID("wippy.dummy", "ping"), Kind: "function.lua"}}) + l := newReadSurfaceState(t, artifactClientReturning(t, noRes, nil)) + if err := l.DoString(` + local pkg, err = hub.versions.open("wippy/dummy", "v0.1.2") + if err then error(err) end + local res, rerr = pkg:resources() + if rerr then error(rerr) end + if #res ~= 0 then error("expected empty resources, got " .. tostring(#res)) end + local vfs, verr = pkg:fs("wippy.dummy:assets") + if verr == nil then error("expected error opening fs with no resource") end + if vfs ~= nil then error("expected nil fs handle") end + `); err != nil { + t.Fatalf("lua no-resource error: %v", err) + } + + // Multi-file resource: file_count, hash, size, and type all present. + multi := buildWappWithResourceForHubTest(t, + []wapp.Entry{{ID: wapp.NewID("wippy.dummy", "ping"), Kind: "function.lua"}}, + wapp.NewID("wippy.dummy", "assets"), + map[string]string{ + "one.txt": "aaa", + "two.txt": "bbbb", + "sub/three.txt": "cc", + }, + ) + l2 := newReadSurfaceState(t, artifactClientReturning(t, multi, nil)) + if err := l2.DoString(` + local pkg, err = hub.versions.open("wippy/dummy", "v0.1.2") + if err then error(err) end + local res, rerr = pkg:resources() + if rerr then error(rerr) end + if #res ~= 1 then error("resource count mismatch: " .. tostring(#res)) end + local r = res[1] + if r.id ~= "wippy.dummy:assets" then error("resource id mismatch: " .. tostring(r.id)) end + if r.type ~= "tree" then error("resource type mismatch: " .. tostring(r.type)) end + if r.file_count ~= 3 then error("file_count mismatch: " .. tostring(r.file_count)) end + if type(r.hash) ~= "string" or #r.hash == 0 then error("hash missing") end + if r.size <= 0 then error("size must be positive: " .. tostring(r.size)) end + `); err != nil { + t.Fatalf("lua multi-file error: %v", err) + } +} + +func TestVersionsOpenFSDeep(t *testing.T) { + t.Chdir(t.TempDir()) + artifact := buildWappWithResourceForHubTest(t, + []wapp.Entry{{ID: wapp.NewID("wippy.dummy", "ping"), Kind: "function.lua"}}, + wapp.NewID("wippy.dummy", "assets"), + map[string]string{ + "root.txt": "top", + "a/b/c.txt": "deep", + "a/b/d.txt": "sibling", + }, + ) + + l := newReadSurfaceState(t, artifactClientReturning(t, artifact, nil)) + if err := l.DoString(` + local pkg, err = hub.versions.open("wippy/dummy", "v0.1.2") + if err then error(err) end + + -- Unknown resource id errors. + local _, verr = pkg:fs("wippy.dummy:missing") + if verr == nil then error("expected error for unknown resource id") end + + local vfs, ferr = pkg:fs("wippy.dummy:assets") + if ferr then error(ferr) end + + -- readdir at root collects entries via the iterator. + local top = {} + for e in vfs:readdir(".") do top[e.name] = e.type end + if top["root.txt"] ~= "file" then error("root.txt not listed as file") end + if top["a"] ~= "directory" then error("a not listed as directory") end + + -- readdir into a nested path collects both files. + local nested = {} + for e in vfs:readdir("a/b") do nested[e.name] = e.type end + if nested["c.txt"] ~= "file" then error("c.txt missing in a/b") end + if nested["d.txt"] ~= "file" then error("d.txt missing in a/b") end + + -- Nested read. + local body, berr = vfs:read_file("a/b/c.txt") + if berr then error(berr) end + if body ~= "deep" then error("nested read mismatch: " .. tostring(body)) end + + -- stat on a file reports size and non-dir. + local st, serr = vfs:stat("a/b/c.txt") + if serr then error(serr) end + if st.is_dir ~= false then error("expected file, not dir") end + if st.size ~= 4 then error("stat size mismatch: " .. tostring(st.size)) end + `); err != nil { + t.Fatalf("lua error: %v", err) + } +} + +func TestVersionsOpenHandleLifecycle(t *testing.T) { + t.Chdir(t.TempDir()) + artifact := buildWappWithResourceForHubTest(t, + []wapp.Entry{{ID: wapp.NewID("wippy.dummy", "ping"), Kind: "function.lua"}}, + wapp.NewID("wippy.dummy", "assets"), + map[string]string{"readme.txt": "hi"}, + ) + + l := newReadSurfaceState(t, artifactClientReturning(t, artifact, nil)) + if err := l.DoString(` + local pkg, err = hub.versions.open("wippy/dummy", "v0.1.2") + if err then error(err) end + + local ok, cerr = pkg:close() + if cerr then error(cerr) end + if ok ~= true then error("close did not return true") end + + -- Every accessor errors after close. + local _, m = pkg:metadata() + if m == nil then error("metadata must error after close") end + local _, e = pkg:entries() + if e == nil then error("entries must error after close") end + local _, r = pkg:resources() + if r == nil then error("resources must error after close") end + local _, f = pkg:fs("wippy.dummy:assets") + if f == nil then error("fs must error after close") end + + -- Double close is idempotent. + local ok2, cerr2 = pkg:close() + if cerr2 then error(cerr2) end + if ok2 ~= true then error("second close did not return true") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } +} + +func TestVersionsOpenTwoIndependentPackages(t *testing.T) { + t.Chdir(t.TempDir()) + artifactA := buildWappBytesForHubModuleTest(t, + []wapp.Entry{{ID: wapp.NewID("wippy.alpha", "one"), Kind: "function.lua"}}) + artifactB := buildWappBytesForHubModuleTest(t, + []wapp.Entry{ + {ID: wapp.NewID("wippy.beta", "two"), Kind: "library.lua"}, + {ID: wapp.NewID("wippy.beta", "three"), Kind: "config"}, + }) + + sumA := sha256.Sum256(artifactA) + sumB := sha256.Sum256(artifactB) + digestA := "sha256:" + hex.EncodeToString(sumA[:]) + digestB := "sha256:" + hex.EncodeToString(sumB[:]) + + fake := &fakeArtifactClient{ + getDownloadFn: func(_ context.Context, params *boothub.DownloadParams) (*boothub.DownloadInfo, error) { + if params.Module == "alpha" { + return &boothub.DownloadInfo{URL: "memory://alpha", Version: "v1.0.0", Digest: digestA}, nil + } + return &boothub.DownloadInfo{URL: "memory://beta", Version: "v2.0.0", Digest: digestB}, nil + }, + downloadFn: func(_ context.Context, url, destPath string) error { + if url == "memory://alpha" { + return os.WriteFile(destPath, artifactA, 0600) + } + return os.WriteFile(destPath, artifactB, 0600) + }, + } + + l := newReadSurfaceState(t, fake) + if err := l.DoString(` + local a, aerr = hub.versions.open("wippy/alpha", "v1.0.0") + if aerr then error(aerr) end + local b, berr = hub.versions.open("wippy/beta", "v2.0.0") + if berr then error(berr) end + + local ea = a:entries() + local eb = b:entries() + if #ea ~= 1 then error("alpha entry count mismatch: " .. tostring(#ea)) end + if ea[1].id ~= "wippy.alpha:one" then error("alpha id mismatch: " .. tostring(ea[1].id)) end + if #eb ~= 2 then error("beta entry count mismatch: " .. tostring(#eb)) end + + if a.version ~= "v1.0.0" then error("alpha version mismatch") end + if b.version ~= "v2.0.0" then error("beta version mismatch") end + if a.digest == b.digest then error("expected distinct digests") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } +} + +// TestPackageHandleAutoClosedByStore verifies the frame-end contract that keeps +// Windows TempDir cleanup working: newPackageHandle registers an AddCleanup on +// the resource store, so closing the store (request end) releases the open .wapp +// even when the caller never calls pkg:close(). +func TestPackageHandleAutoClosedByStore(t *testing.T) { + t.Chdir(t.TempDir()) + artifact := buildWappBytesForHubModuleTest(t, []wapp.Entry{ + {ID: wapp.NewID("wippy.dummy", "ping"), Kind: "function.lua"}, + }) + require.NoError(t, os.WriteFile("pkg.wapp", artifact, 0600)) + + ctx := setupContext() + store := resource.NewStore() + require.NoError(t, resource.SetStore(ctx, store)) + + file, reader, err := openArtifactReader("pkg.wapp") + require.NoError(t, err) + h := newPackageHandle(ctx, file, reader, "v0.1.2", "sha256:test") + require.False(t, h.isClosed()) + + require.NoError(t, store.Close()) + require.True(t, h.isClosed(), "closing the resource store must release the handle") + + require.NoError(t, h.Close()) // idempotent after store-driven close +} + +func findCachedWapp(t *testing.T, root string) string { + t.Helper() + var found string + require.NoError(t, filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && strings.HasSuffix(p, ".wapp") { + found = p + } + return nil + })) + require.NotEmpty(t, found, "no cached .wapp found under %s", root) + return found +} + +// A cache entry that passes digest/size verification but is not a readable +// WAPP (e.g. the registry omitted a digest and the file is truncated) must be +// evicted and re-downloaded, not returned as-is. +func TestVersionsOpenReDownloadsCorruptCache(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + + artifact := buildWappWithResourceForHubTest(t, + []wapp.Entry{{ID: wapp.NewID("wippy.dummy", "ping"), Kind: "function.lua"}}, + wapp.NewID("wippy.dummy", "assets"), + map[string]string{"a.txt": "hi"}) + + var downloads int + // No digest/size, so VerifyDownloadedArtifact is a no-op and cannot catch + // a corrupt cache entry on its own. + client := &fakeArtifactClient{ + getDownloadFn: func(_ context.Context, _ *boothub.DownloadParams) (*boothub.DownloadInfo, error) { + return &boothub.DownloadInfo{URL: "memory://dummy", Version: "v0.1.2"}, nil + }, + downloadFn: func(_ context.Context, _, destPath string) error { + downloads++ + return os.WriteFile(destPath, artifact, 0600) + }, + } + l := newReadSurfaceState(t, client) + + if err := l.DoString(` + local pkg, err = hub.versions.open("wippy/dummy", "v0.1.2") + if err then error(err) end + pkg:close() + `); err != nil { + t.Fatalf("first open: %v", err) + } + require.Equal(t, 1, downloads) + + cached := findCachedWapp(t, root) + require.NoError(t, os.WriteFile(cached, []byte("not a wapp"), 0600)) + + if err := l.DoString(` + local pkg, err = hub.versions.open("wippy/dummy", "v0.1.2") + if err then error(err) end + pkg:close() + `); err != nil { + t.Fatalf("second open after corruption: %v", err) + } + require.Equal(t, 2, downloads, "corrupt cache must trigger a re-download") +} + +// A directories.modules override must apply to the read-surface cache too, so +// hub.versions.open caches where hub.cache.* looks. +func TestVersionsOpenCachesInLockResolvedVendorDir(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + require.NoError(t, os.WriteFile(filepath.Join(root, "wippy.lock"), + []byte("directories:\n modules: custom-mods\n src: .\n"), 0600)) + + artifact := buildWappWithResourceForHubTest(t, + []wapp.Entry{{ID: wapp.NewID("wippy.dummy", "ping"), Kind: "function.lua"}}, + wapp.NewID("wippy.dummy", "assets"), + map[string]string{"a.txt": "hi"}) + + l := newReadSurfaceState(t, artifactClientReturning(t, artifact, nil)) + + if err := l.DoString(` + local pkg, err = hub.versions.open("wippy/dummy", "v0.1.2") + if err then error(err) end + pkg:close() + + local list, lerr = hub.cache.list() + if lerr then error(lerr) end + if #list < 1 then error("opened artifact not visible to cache.list under override") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } + + require.DirExists(t, filepath.Join(root, "custom-mods", "vendor"), + "open must cache under the lock-resolved vendor dir") + require.NoDirExists(t, filepath.Join(root, ".wippy", "vendor"), + "open must not fall back to the hard-coded default vendor dir") +} + +// A version with path separators (including one returned by the registry) must +// be rejected before it can steer the download write outside the vendor dir. +func TestVersionsOpenRejectsTraversalVersion(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + + var downloads int + client := &fakeArtifactClient{ + getDownloadFn: func(_ context.Context, _ *boothub.DownloadParams) (*boothub.DownloadInfo, error) { + return &boothub.DownloadInfo{URL: "memory://dummy", Version: "../../../outside"}, nil + }, + downloadFn: func(_ context.Context, _, destPath string) error { + downloads++ + return os.WriteFile(destPath, []byte("x"), 0600) + }, + } + l := newReadSurfaceState(t, client) + + if err := l.DoString(` + local pkg, err = hub.versions.open("wippy/dummy", "v0.1.2") + if err == nil then error("expected error for a traversal version") end + `); err != nil { + t.Fatalf("lua error: %v", err) + } + + require.Equal(t, 0, downloads, "a traversal version must be rejected before any download write") + require.NoFileExists(t, filepath.Join(root, "outside.wapp")) +} diff --git a/runtime/lua/modules/hub/spec.md b/runtime/lua/modules/hub/spec.md index 0b34a8d7b..9789d27bb 100644 --- a/runtime/lua/modules/hub/spec.md +++ b/runtime/lua/modules/hub/spec.md @@ -62,6 +62,36 @@ Download and inspect a version artifact by `version` or `{ id, version, label }` Artifacts are cached under `.wippy/vendor` and verified before reuse. Returns artifact-derived metadata including `{ version, digest, size_bytes, entry_count, entry_kinds, requirements, cache_path }`. +### `hub.versions.open(module, version, opts?)` +Open a version artifact for inspection before install and return an owned package +handle. The artifact goes through the verified `.wippy/vendor` disk cache. The +handle keeps the underlying file open until it is closed or the frame ends, so +`pkg:fs()` can read lazily from it. + +Options: `registry`, `token`, `timeout`. + +The handle exposes fields and methods: +- `pkg.version` (string), `pkg.digest` (string), `pkg.packed` (boolean, currently always `true`). +- `pkg:metadata()` — the pack manifest as a Lua table. +- `pkg:entries({ kind?, include_data? })` — a bare array of `{ id = "ns:name", kind, meta, data }`. `include_data` defaults to `true`; `data` is raw (`${env:...}`/`_env` literals are preserved). +- `pkg:resources()` — a bare array of `{ id = "ns:name", type, hash, size, file_count, meta }`. +- `pkg:fs(resource_id)` — an `fs.FS` handle over the named resource, using the same handle type `fs.get` returns; read it with the normal fs verbs (`stat`, `readdir`, `read_file`, ...). +- `pkg:close()` — close the handle explicitly. It is also closed automatically at frame end. + +### `hub.cache.list(opts?)` +List cached artifacts under the resolved vendor directory. Returns a bare array of +`{ module, version, size, pinned }`. `pinned` is `true` when the artifact is +referenced by the lock file. + +### `hub.cache.remove(module, version, opts?)` +Remove a cached artifact addressed by `module` name and `version`. Refuses to +remove a lock-pinned artifact unless `opts.force` is `true`. Returns `true`. + +### `hub.cache.prune(opts?)` +Remove cached artifacts not referenced by the lock file. With `opts.dry_run = true` +nothing is deleted. Returns a bare array of the pruned (or would-be-pruned) +`{ module, version, size, pinned }` entries. + ### `hub.dependencies.get(module, version?, opts?)` Get dependencies for a module version. diff --git a/runtime/lua/modules/hub/types.go b/runtime/lua/modules/hub/types.go index 5f2ab43f3..251d16e08 100644 --- a/runtime/lua/modules/hub/types.go +++ b/runtime/lua/modules/hub/types.go @@ -28,6 +28,64 @@ func ModuleTypes() *io.Manifest { Field("version", typ.String). Build() + // Base call options shared by the artifact-reading methods. + baseOpts := typ.NewRecord(). + OptField("registry", typ.String). + OptField("token", typ.String). + OptField("timeout", typ.Number). + Build() + + // meta and data carry decoded config verbatim; their leaf values are + // arbitrary, matching how the registry module types entries. + metaMap := typ.NewMap(typ.String, typ.Any) + + packageEntryType := typ.NewRecord(). + Field("id", typ.String). + Field("kind", typ.String). + Field("meta", metaMap). + OptField("data", typ.Any). + Build() + + packageResourceType := typ.NewRecord(). + Field("id", typ.String). + Field("type", typ.String). + Field("hash", typ.String). + Field("size", typ.Number). + Field("file_count", typ.Number). + Field("meta", metaMap). + Build() + + packageEntriesOpts := typ.NewRecord(). + OptField("kind", typ.NewUnion(typ.String, typ.NewArray(typ.String))). + OptField("include_data", typ.Boolean). + Build() + + // The package handle from hub.versions.open: scalar fields plus methods + // resolved through the metatable. + packageMethods := typ.NewInterface("hub.Package", []typ.Method{ + {Name: "metadata", Type: typ.Func().Param("self", typ.Self).Returns(metaMap, typ.NewOptional(typ.LuaError)).Build()}, + {Name: "entries", Type: typ.Func().Param("self", typ.Self).OptParam("opts", packageEntriesOpts).Returns(typ.NewArray(packageEntryType), typ.NewOptional(typ.LuaError)).Build()}, + {Name: "resources", Type: typ.Func().Param("self", typ.Self).Returns(typ.NewArray(packageResourceType), typ.NewOptional(typ.LuaError)).Build()}, + // fs returns the shared fs module handle (a foreign userdata). + {Name: "fs", Type: typ.Func().Param("self", typ.Self).Param("resource", typ.String).Returns(typ.Any, typ.NewOptional(typ.LuaError)).Build()}, + {Name: "close", Type: typ.Func().Param("self", typ.Self).Returns(typ.Boolean, typ.NewOptional(typ.LuaError)).Build()}, + }) + + packageType := typ.NewRecord(). + ReadonlyField("version", typ.String). + ReadonlyField("digest", typ.String). + ReadonlyField("packed", typ.Boolean). + Metatable(packageMethods). + Build() + + cacheEntryType := typ.NewRecord(). + Field("module", typ.String). + Field("version", typ.String). + Field("size", typ.Number). + Field("pinned", typ.Boolean). + Build() + cacheList := typ.NewArray(cacheEntryType) + modulesIface := typ.NewInterface("hub.modules", []typ.Method{ {Name: "list", Type: typ.Func().OptParam("opts", typ.Any).Returns(listResponse, typ.NewOptional(typ.LuaError)).Build()}, {Name: "search", Type: typ.Func().Param("query", typ.String).OptParam("opts", typ.Any).Returns(listResponse, typ.NewOptional(typ.LuaError)).Build()}, @@ -39,6 +97,15 @@ func ModuleTypes() *io.Manifest { {Name: "list", Type: typ.Func().Param("module", typ.Any).OptParam("opts", typ.Any).Returns(listResponse, typ.NewOptional(typ.LuaError)).Build()}, {Name: "get", Type: typ.Func().Param("module", typ.Any).Param("version", typ.Any).OptParam("opts", typ.Any).Returns(typ.Any, typ.NewOptional(typ.LuaError)).Build()}, {Name: "inspect", Type: typ.Func().Param("module", typ.Any).Param("version", typ.Any).OptParam("opts", typ.Any).Returns(typ.Any, typ.NewOptional(typ.LuaError)).Build()}, + {Name: "open", Type: typ.Func().Param("module", typ.Any).Param("version", typ.Any).OptParam("opts", baseOpts).Returns(packageType, typ.NewOptional(typ.LuaError)).Build()}, + }) + + cacheRemoveOpts := typ.NewRecord().OptField("force", typ.Boolean).Build() + cachePruneOpts := typ.NewRecord().OptField("dry_run", typ.Boolean).Build() + cacheIface := typ.NewInterface("hub.cache", []typ.Method{ + {Name: "list", Type: typ.Func().OptParam("opts", baseOpts).Returns(cacheList, typ.NewOptional(typ.LuaError)).Build()}, + {Name: "remove", Type: typ.Func().Param("module", typ.String).Param("version", typ.String).OptParam("opts", cacheRemoveOpts).Returns(typ.Boolean, typ.NewOptional(typ.LuaError)).Build()}, + {Name: "prune", Type: typ.Func().OptParam("opts", cachePruneOpts).Returns(cacheList, typ.NewOptional(typ.LuaError)).Build()}, }) dependenciesIface := typ.NewInterface("hub.dependencies", []typ.Method{ @@ -72,6 +139,7 @@ func ModuleTypes() *io.Manifest { Field("dependents", dependentsIface). Field("files", filesIface). Field("auth", authIface). + Field("cache", cacheIface). Build() m.SetExport(moduleType) diff --git a/test.sh b/test.sh index 504c38085..9c5ec9fb3 100755 --- a/test.sh +++ b/test.sh @@ -34,6 +34,9 @@ go test \ ./service/sql/... \ ./service/cdc/postgres \ ./runtime/lua/modules/cdc \ + ./runtime/lua/modules/hub \ + ./runtime/lua/modules/fs \ + ./api/fs \ ./boot/components/dispatchers if [[ -n "${WIPPY_CDC_IT_REPL_DSN:-}" && -n "${WIPPY_CDC_IT_ADMIN_DSN:-}" ]]; then