// Package localstorage is a built-in plugin that exposes a directory on the host // machine's own filesystem as a storage "drive", behind the same capability // surface (list/stat/download/upload/delete/mkdir) as the remote filetransfer // connector. Where filetransfer dials FTP/SFTP, this one just calls the os // package — there is no network, no auth, and nothing to dial. // // Every caller-supplied path is confined under the configured base path: paths // are treated as relative to the base and cleaned so that ".." or a leading // separator can never escape the storage root. This is the one piece of extra // care a local-filesystem connector needs that a remote one gets from the remote // server's own chroot/permissions. package localstorage import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "sync" "time" "pilotvault/apiserver/internal/plugins" ) // maxReadBytes caps a download so a huge file can't exhaust memory; the health // probe and Invoke both honour it (mirrors filetransfer). const maxReadBytes = 32 << 20 // 32 MiB func init() { plugins.Register("localstorage", func() plugins.Plugin { return &Plugin{} }) } // Plugin is the local-filesystem connector. Fields are guarded by mu because // Init may run concurrently with a HealthCheck/Invoke from another request. type Plugin struct { mu sync.Mutex basePath string createMissing bool // create the base path (and upload/mkdir parents) if absent readOnly bool // reject upload/delete/mkdir when true } func (p *Plugin) Descriptor() plugins.Descriptor { return plugins.Descriptor{ Name: "localstorage", Provider: "Local Filesystem", Version: "1.0.0", Kind: plugins.KindBuiltin, Category: plugins.CategoryDrivesLocal, AuthType: plugins.AuthNone, Capabilities: []plugins.Capability{ {ID: "list", Method: "GET", Endpoint: "/", Description: "List a directory under the base path. params: {path}"}, {ID: "stat", Method: "GET", Endpoint: "/", Description: "Stat one path under the base path. params: {path}"}, {ID: "download", Method: "GET", Endpoint: "/", Description: "Read a file (base64, ≤32 MiB). params: {path}"}, {ID: "upload", Method: "PUT", Endpoint: "/", Description: "Write a file (creates parent dirs). params: {path, contentBase64}"}, {ID: "delete", Method: "DELETE", Endpoint: "/", Description: "Delete a file or empty directory. params: {path}"}, {ID: "mkdir", Method: "PUT", Endpoint: "/", Description: "Create a directory. params: {path}"}, }, ConfigFields: []plugins.ConfigField{ // No field is Required: like the other drive plugins, this can be enabled // as a master switch with an empty config; a missing base path is reported // gracefully by the health probe rather than blocking the switch. {Key: "basePath", Label: "Base path", Type: "text", Help: `Absolute directory used as the storage root, e.g. /data or /var/lib/pilotvault. In Docker this should be a mounted volume so data survives redeploys, and the container user must own it. Every operation is confined within it — ".." and absolute paths cannot escape.`}, {Key: "createMissing", Label: "Create base path", Type: "select", Default: "false", Options: []plugins.SelectOption{ {Value: "false", Label: "Require the directory to already exist"}, {Value: "true", Label: "Create it if missing (also creates upload/mkdir parents)"}, }, Help: "When on, the base path is created by the health check and parent directories are created on upload/mkdir."}, {Key: "readOnly", Label: "Access mode", Type: "select", Default: "false", Options: []plugins.SelectOption{ {Value: "false", Label: "Read-write"}, {Value: "true", Label: "Read-only — reject upload, delete and mkdir"}, }, Help: "Read-only is a safety guard for pointing at a directory you only want to serve from."}, }, } } func (p *Plugin) Init(_ context.Context, config map[string]string) error { p.mu.Lock() defer p.mu.Unlock() p.basePath = strings.TrimSpace(config["basePath"]) p.createMissing = strings.EqualFold(strings.TrimSpace(config["createMissing"]), "true") p.readOnly = strings.EqualFold(strings.TrimSpace(config["readOnly"]), "true") return nil } // resolve confines a caller-supplied path under the base path. The path is always // treated as relative to the base; a leading separator or ".." segments are // neutralized by cleaning against a virtual root, so the result can never escape. func (p *Plugin) resolve(rel string) (string, error) { base := strings.TrimSpace(p.basePath) if base == "" { return "", errors.New("no base path configured") } absBase, err := filepath.Abs(base) if err != nil { return "", err } // Clean against a virtual root so "..", ".", and leading separators collapse to // a path that stays at or below "/", then strip the root and join under base. virtual := filepath.ToSlash(strings.TrimSpace(rel)) cleaned := filepath.Clean("/" + strings.TrimLeft(virtual, "/")) sub := filepath.FromSlash(strings.TrimPrefix(cleaned, "/")) joined := filepath.Join(absBase, sub) // Belt-and-braces containment check after joining. if joined != absBase && !strings.HasPrefix(joined, absBase+string(os.PathSeparator)) { return "", fmt.Errorf("path %q escapes the base directory", rel) } return joined, nil } // HealthCheck verifies the base path exists, is a directory, is readable, and // (unless read-only) is writable. Missing-but-creatable resolves to OK. func (p *Plugin) HealthCheck(_ context.Context) plugins.Health { start := time.Now() p.mu.Lock() base, create, readOnly := p.basePath, p.createMissing, p.readOnly p.mu.Unlock() if strings.TrimSpace(base) == "" { return plugins.Health{Status: plugins.StatusDown, Detail: "no base path configured"} } absBase, err := filepath.Abs(base) if err != nil { return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start), Detail: err.Error()} } info, err := os.Stat(absBase) if err != nil { if os.IsNotExist(err) && create { if mkErr := os.MkdirAll(absBase, 0o755); mkErr != nil { return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start), Detail: fmt.Sprintf("base path %q does not exist and could not be created: %v", absBase, mkErr)} } info, err = os.Stat(absBase) } if err != nil { detail := fmt.Sprintf("base path %q not accessible: %v", absBase, err) if os.IsNotExist(err) { detail = fmt.Sprintf("base path %q does not exist (enable \"Create base path\" to create it)", absBase) } return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start), Detail: detail} } } if !info.IsDir() { return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start), Detail: fmt.Sprintf("base path %q is not a directory", absBase)} } entries, err := os.ReadDir(absBase) if err != nil { return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: ms(start), Detail: fmt.Sprintf("base path %q is not readable: %v", absBase, err)} } detail := fmt.Sprintf("%q reachable — %d entr%s", absBase, len(entries), plural(len(entries))) status := plugins.StatusOK if readOnly { detail += " · read-only" } else if werr := probeWritable(absBase); werr != nil { status = plugins.StatusDegraded detail += fmt.Sprintf(" · not writable: %v", werr) } else { detail += " · read-write" } return plugins.Health{Status: status, LatencyMs: ms(start), Detail: detail} } // Invoke runs one capability against the local filesystem. func (p *Plugin) Invoke(_ context.Context, action string, params json.RawMessage) (json.RawMessage, error) { p.mu.Lock() create, readOnly := p.createMissing, p.readOnly p.mu.Unlock() switch action { case "list": var in pathParams _ = json.Unmarshal(params, &in) target, err := p.resolve(in.Path) if err != nil { return nil, err } entries, err := os.ReadDir(target) if err != nil { return nil, err } out := make([]fileInfo, 0, len(entries)) for _, e := range entries { out = append(out, dirEntryToInfo(e)) } return json.Marshal(map[string]any{"path": target, "entries": out}) case "stat": var in pathParams _ = json.Unmarshal(params, &in) target, err := p.resolve(in.Path) if err != nil { return nil, err } fi, err := os.Stat(target) if err != nil { return nil, err } return json.Marshal(statToInfo(fi)) case "download": var in pathParams _ = json.Unmarshal(params, &in) target, err := p.resolve(in.Path) if err != nil { return nil, err } data, err := readCapped(target) if err != nil { return nil, err } return json.Marshal(map[string]any{ "path": target, "size": len(data), "contentBase64": base64.StdEncoding.EncodeToString(data), }) case "upload": if readOnly { return nil, errReadOnly } var in writeParams if err := json.Unmarshal(params, &in); err != nil { return nil, fmt.Errorf("invalid params: %w", err) } target, err := p.resolve(in.Path) if err != nil { return nil, err } data, err := base64.StdEncoding.DecodeString(in.ContentBase64) if err != nil { return nil, fmt.Errorf("contentBase64 is not valid base64: %w", err) } if create { if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return nil, err } } if err := os.WriteFile(target, data, 0o644); err != nil { return nil, err } return json.Marshal(map[string]any{"path": target, "size": len(data), "ok": true}) case "delete": if readOnly { return nil, errReadOnly } var in pathParams _ = json.Unmarshal(params, &in) target, err := p.resolve(in.Path) if err != nil { return nil, err } // Refuse to delete the base path itself. absBase, _ := filepath.Abs(strings.TrimSpace(p.basePath)) if target == absBase { return nil, errors.New("refusing to delete the base directory") } if err := os.Remove(target); err != nil { return nil, err } return json.Marshal(map[string]any{"path": target, "ok": true}) case "mkdir": if readOnly { return nil, errReadOnly } var in pathParams _ = json.Unmarshal(params, &in) target, err := p.resolve(in.Path) if err != nil { return nil, err } if err := os.MkdirAll(target, 0o755); err != nil { return nil, err } return json.Marshal(map[string]any{"path": target, "ok": true}) default: return nil, errors.New("unknown action: " + action) } } func (p *Plugin) Shutdown(context.Context) error { return nil } var errReadOnly = errors.New("plugin is configured read-only") // pathParams / writeParams are the Invoke request shapes (mirrors filetransfer). type pathParams struct { Path string `json:"path"` } type writeParams struct { Path string `json:"path"` ContentBase64 string `json:"contentBase64"` } // fileInfo is the normalized directory-entry shape returned by list/stat. type fileInfo struct { Name string `json:"name"` Size int64 `json:"size"` IsDir bool `json:"isDir"` ModTime string `json:"modTime,omitempty"` } func statToInfo(fi os.FileInfo) fileInfo { return fileInfo{ Name: fi.Name(), Size: fi.Size(), IsDir: fi.IsDir(), ModTime: fi.ModTime().UTC().Format(time.RFC3339), } } // dirEntryToInfo normalizes an os.DirEntry, tolerating a stat failure on a single // entry (e.g. a broken symlink) by reporting name/isDir without size/modtime. func dirEntryToInfo(e os.DirEntry) fileInfo { fi, err := e.Info() if err != nil { return fileInfo{Name: e.Name(), IsDir: e.IsDir()} } return statToInfo(fi) } // readCapped reads a file up to maxReadBytes. func readCapped(path string) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() return io.ReadAll(io.LimitReader(f, maxReadBytes)) } // probeWritable confirms the directory accepts a write by creating and removing a // short-lived temp file. func probeWritable(dir string) error { f, err := os.CreateTemp(dir, ".pilotvault-health-*") if err != nil { return err } name := f.Name() _ = f.Close() return os.Remove(name) } func ms(start time.Time) int64 { return time.Since(start).Milliseconds() } func plural(n int) string { if n == 1 { return "y" } return "ies" }