Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
35ef5d9
Add new entries method to hub module
msmakouz Jul 3, 2026
9aae6fa
feat(hub): pre-install read surface + cache eviction
wolfy-j Jul 4, 2026
2492248
refactor(hub): drop flat read methods for a handle-only surface
wolfy-j Jul 4, 2026
7986aed
Merge remote-tracking branch 'origin/main' into feat/hub-read-surface
wolfy-j Jul 4, 2026
110a744
test(hub): comprehensive read-surface + cache coverage
wolfy-j Jul 4, 2026
3802c73
test(hub): close package handle via resource store so Windows TempDir…
wolfy-j Jul 4, 2026
5fd473e
fix(fs): normalize ReadOnlyFS paths to io/fs forward-slash convention
wolfy-j Jul 4, 2026
0c5e228
fix(fs): resolve paths with io/fs forward-slash convention, not OS se…
wolfy-j Jul 4, 2026
22030c4
feat(hub): fully type the read surface manifest + add hub to test.sh
wolfy-j Jul 4, 2026
d8708c8
test(hub): split read-surface and cache tests into subject files
wolfy-j Jul 4, 2026
aa668dd
fix(hub): block cache-remove path traversal and evict unreadable cache
wolfy-j Jul 4, 2026
994c591
fix(hub): split cache paths at the semver boundary, not the last hyphen
wolfy-j Jul 4, 2026
380e76b
fix(hub): reject cache-remove components with separators or traversal
wolfy-j Jul 4, 2026
e2af911
fix(hub): cache read-surface artifacts in the lock-resolved vendor dir
wolfy-j Jul 4, 2026
eacdf73
fix(hub): split orphan cache filenames from the right at the semver b…
wolfy-j Jul 4, 2026
8fcd591
fix(hub): validate cache components before writing downloaded artifacts
wolfy-j Jul 4, 2026
ca9761e
test(fs): compare resolvePath output as forward-slash on every OS
wolfy-j Jul 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions runtime/lua/modules/fs/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"path"
"strings"

lua "github.com/wippyai/go-lua"
Expand Down Expand Up @@ -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
}

Expand Down
7 changes: 7 additions & 0 deletions runtime/lua/modules/fs/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions runtime/lua/modules/fs/path_security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
package fs

import (
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -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) {
Expand Down
126 changes: 98 additions & 28 deletions runtime/lua/modules/hub/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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 {
Expand Down
Loading