feat: add bounded file byte reads
This commit is contained in:
@@ -876,6 +876,21 @@ func (a *App) ReadVaultTextFile(pluginID, relativePath string) (string, string)
|
||||
return text, ""
|
||||
}
|
||||
|
||||
// ReadVaultFileBytes reads a bounded regular file as base64 for a plugin with files.read.
|
||||
func (a *App) ReadVaultFileBytes(pluginID, relativePath string) (corefiles.FileBytes, string) {
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.read"); err != nil {
|
||||
return corefiles.FileBytes{}, err.Error()
|
||||
}
|
||||
if a.files == nil {
|
||||
return corefiles.FileBytes{}, "files service not initialized"
|
||||
}
|
||||
data, err := a.files.ReadVaultFileBytes(relativePath)
|
||||
if err != nil {
|
||||
return corefiles.FileBytes{}, err.Error()
|
||||
}
|
||||
return data, ""
|
||||
}
|
||||
|
||||
// WriteVaultTextFile atomically writes a UTF-8 text file for a plugin with files.write.
|
||||
func (a *App) WriteVaultTextFile(pluginID, relativePath string, content string, options corefiles.WriteOptions) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
|
||||
|
||||
@@ -360,11 +360,29 @@ func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) {
|
||||
t.Fatalf("text = %q", text)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(root, "Docs", "image.png"), []byte{0x89, 0x50, 0x4e, 0x47}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bytesResult, errStr := app.ReadVaultFileBytes("files.plugin", "Docs/image.png")
|
||||
if errStr != "" {
|
||||
t.Fatalf("ReadVaultFileBytes: %s", errStr)
|
||||
}
|
||||
if bytesResult.RelativePath != "Docs/image.png" || bytesResult.MimeHint != "image/png" || bytesResult.DataBase64 != "iVBORw==" {
|
||||
t.Fatalf("bytes result = %+v", bytesResult)
|
||||
}
|
||||
|
||||
entries, errStr := app.ListVaultFiles("files.plugin", "Docs")
|
||||
if errStr != "" {
|
||||
t.Fatalf("ListVaultFiles: %s", errStr)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].RelativePath != "Docs/one.txt" {
|
||||
hasOne := false
|
||||
for _, entry := range entries {
|
||||
if entry.RelativePath == "Docs/one.txt" {
|
||||
hasOne = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasOne {
|
||||
t.Fatalf("entries = %+v", entries)
|
||||
}
|
||||
|
||||
@@ -594,6 +612,12 @@ func TestFilesBridgePermissions(t *testing.T) {
|
||||
call: func(app *App) string { _, errStr := app.ReadVaultTextFile("files.plugin", "one.txt"); return errStr },
|
||||
wantPhrase: "files.read",
|
||||
},
|
||||
{
|
||||
name: "read bytes requires read",
|
||||
perms: []string{"files.write", "files.delete"},
|
||||
call: func(app *App) string { _, errStr := app.ReadVaultFileBytes("files.plugin", "one.txt"); return errStr },
|
||||
wantPhrase: "files.read",
|
||||
},
|
||||
{
|
||||
name: "write requires write",
|
||||
perms: []string{"files.read", "files.delete"},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -148,6 +149,42 @@ func (s *Service) ReadVaultTextFile(relativePath string) (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (s *Service) ReadVaultFileBytes(relativePath string) (FileBytes, error) {
|
||||
root, rel, full, err := s.resolveFile(relativePath)
|
||||
if err != nil {
|
||||
return FileBytes{}, err
|
||||
}
|
||||
if err := rejectSymlinkPath(root, rel, true); err != nil {
|
||||
return FileBytes{}, err
|
||||
}
|
||||
info, err := os.Lstat(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return FileBytes{}, fmt.Errorf("not-found: %s", rel)
|
||||
}
|
||||
return FileBytes{}, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return FileBytes{}, fmt.Errorf("symlink-not-allowed: %s", rel)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return FileBytes{}, fmt.Errorf("not-regular-file: %s", rel)
|
||||
}
|
||||
if info.Size() > MaxBinaryReadBytes {
|
||||
return FileBytes{}, fmt.Errorf("file-too-large: %s", rel)
|
||||
}
|
||||
data, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return FileBytes{}, err
|
||||
}
|
||||
return FileBytes{
|
||||
RelativePath: rel,
|
||||
Size: int64(len(data)),
|
||||
MimeHint: mime.TypeByExtension(filepath.Ext(info.Name())),
|
||||
DataBase64: base64.StdEncoding.EncodeToString(data),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) WriteVaultTextFile(relativePath string, content string, options WriteOptions) error {
|
||||
root, rel, full, err := s.resolveFile(relativePath)
|
||||
if err != nil {
|
||||
|
||||
@@ -87,6 +87,9 @@ func TestPathPolicyRejectsUnsafeOperations(t *testing.T) {
|
||||
if _, err := s.ReadVaultTextFile(input); err == nil {
|
||||
t.Fatal("read: expected error")
|
||||
}
|
||||
if _, err := s.ReadVaultFileBytes(input); err == nil {
|
||||
t.Fatal("read bytes: expected error")
|
||||
}
|
||||
if err := s.WriteVaultTextFile(input, "x", WriteOptions{CreateIfMissing: true}); err == nil {
|
||||
t.Fatal("write: expected error")
|
||||
}
|
||||
@@ -137,6 +140,47 @@ func TestReadVaultTextFileRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadVaultFileBytesRules(t *testing.T) {
|
||||
s, root := newTestService(t)
|
||||
imageBytes := []byte{0x89, 0x50, 0x4e, 0x47}
|
||||
if err := os.WriteFile(filepath.Join(root, "image.png"), imageBytes, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "Folder"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "huge.bin"), []byte(strings.Repeat("a", int(MaxBinaryReadBytes)+1)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := s.ReadVaultFileBytes("image.png")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadVaultFileBytes image: %v", err)
|
||||
}
|
||||
if result.RelativePath != "image.png" {
|
||||
t.Fatalf("relative path = %q, want image.png", result.RelativePath)
|
||||
}
|
||||
if result.Size != int64(len(imageBytes)) {
|
||||
t.Fatalf("size = %d, want %d", result.Size, len(imageBytes))
|
||||
}
|
||||
if result.MimeHint != "image/png" {
|
||||
t.Fatalf("mime hint = %q, want image/png", result.MimeHint)
|
||||
}
|
||||
if result.DataBase64 != "iVBORw==" {
|
||||
t.Fatalf("dataBase64 = %q, want iVBORw==", result.DataBase64)
|
||||
}
|
||||
|
||||
if _, err := s.ReadVaultFileBytes("Folder"); err == nil || !strings.Contains(err.Error(), "not-regular-file") {
|
||||
t.Fatalf("read folder error = %v, want not-regular-file", err)
|
||||
}
|
||||
if _, err := s.ReadVaultFileBytes("missing.png"); err == nil || !strings.Contains(err.Error(), "not-found") {
|
||||
t.Fatalf("read missing error = %v, want not-found", err)
|
||||
}
|
||||
if _, err := s.ReadVaultFileBytes("huge.bin"); err == nil || !strings.Contains(err.Error(), "file-too-large") {
|
||||
t.Fatalf("read huge error = %v, want file-too-large", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteVaultTextFileAtomicAndConflictBehavior(t *testing.T) {
|
||||
s, root := newTestService(t)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package files
|
||||
|
||||
const MaxTextFileBytes int64 = 2 * 1024 * 1024
|
||||
const MaxBinaryReadBytes int64 = 8 * 1024 * 1024
|
||||
|
||||
type FileType string
|
||||
|
||||
@@ -39,6 +40,13 @@ type FileMetadata struct {
|
||||
CanWrite bool `json:"canWrite"`
|
||||
}
|
||||
|
||||
type FileBytes struct {
|
||||
RelativePath string `json:"relativePath"`
|
||||
Size int64 `json:"size"`
|
||||
MimeHint string `json:"mimeHint"`
|
||||
DataBase64 string `json:"dataBase64"`
|
||||
}
|
||||
|
||||
type ExternalOpenTarget struct {
|
||||
RelativePath string `json:"relativePath"`
|
||||
AbsolutePath string `json:"absolutePath"`
|
||||
|
||||
Reference in New Issue
Block a user