feat: add bounded binary file writes

This commit is contained in:
2026-06-29 10:12:26 +08:00
parent df4538532f
commit fe91784a8e
12 changed files with 240 additions and 20 deletions
+49 -5
View File
@@ -253,6 +253,8 @@ func (a *App) appendActivityEvent(pluginID string, activity map[string]interface
func activityFromEvent(event events.Event) map[string]interface{} {
payload := eventPayloadMap(event.Payload)
delete(payload, "fileDataBase64")
delete(payload, "dataBase64")
now := time.Now().UTC().Format(time.RFC3339Nano)
occurredAt := firstPayloadText(payload, "occurredAt", "capturedAt")
if occurredAt == "" {
@@ -281,7 +283,11 @@ func activityFromEvent(event events.Event) map[string]interface{} {
func eventPayloadMap(payload interface{}) map[string]interface{} {
switch value := payload.(type) {
case map[string]interface{}:
return value
result := make(map[string]interface{}, len(value))
for key, item := range value {
result[key] = item
}
return result
case map[string]string:
result := make(map[string]interface{}, len(value))
for key, item := range value {
@@ -922,6 +928,37 @@ func (a *App) WriteVaultTextFile(pluginID, relativePath string, content string,
return ""
}
// WriteVaultFileBytes atomically writes a bounded base64 file for a plugin with files.write.
func (a *App) WriteVaultFileBytes(pluginID, relativePath string, dataBase64 string, options corefiles.WriteOptions) string {
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
return err.Error()
}
if a.files == nil {
return "files service not initialized"
}
opType := syncsvc.OpUpdate
if _, err := a.files.GetVaultFileMetadata(relativePath); err != nil {
if isSyncNotFound(err) {
opType = syncsvc.OpCreate
} else {
return err.Error()
}
}
if err := a.files.WriteVaultFileBytes(relativePath, dataBase64, options); err != nil {
return err.Error()
}
if err := a.recordFileSyncOp(syncsvc.EntityFile, relativePath, opType, map[string]string{
"path": relativePath,
"dataBase64": dataBase64,
}); err != nil {
return err.Error()
}
a.publishFileActivity("file.changed", pluginID, relativePath, map[string]interface{}{
"operation": opType,
})
return ""
}
// CreateVaultFolder creates a vault-relative folder for a plugin with files.write.
func (a *App) CreateVaultFolder(pluginID, relativePath string) string {
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
@@ -2280,10 +2317,11 @@ func (a *App) applyRemoteOp(op syncsvc.Op) error {
}
type syncFilePayload struct {
Path string `json:"path"`
Content string `json:"content"`
FromPath string `json:"fromPath"`
ToPath string `json:"toPath"`
Path string `json:"path"`
Content string `json:"content"`
DataBase64 *string `json:"dataBase64"`
FromPath string `json:"fromPath"`
ToPath string `json:"toPath"`
}
func parseSyncFilePayload(payloadJSON string) (syncFilePayload, error) {
@@ -2304,12 +2342,18 @@ func (a *App) applyRemoteFileOp(op syncsvc.Op, payload syncFilePayload) error {
if path == "" {
return fmt.Errorf("missing file path")
}
if payload.DataBase64 != nil {
return a.files.WriteVaultFileBytes(path, *payload.DataBase64, corefiles.WriteOptions{CreateIfMissing: true})
}
return a.files.WriteVaultTextFile(path, payload.Content, corefiles.WriteOptions{CreateIfMissing: true})
case syncsvc.OpUpdate:
path := syncPayloadPath(op, payload)
if path == "" {
return fmt.Errorf("missing file path")
}
if payload.DataBase64 != nil {
return a.files.WriteVaultFileBytes(path, *payload.DataBase64, corefiles.WriteOptions{CreateIfMissing: true, Overwrite: true})
}
return a.files.WriteVaultTextFile(path, payload.Content, corefiles.WriteOptions{CreateIfMissing: true, Overwrite: true})
case syncsvc.OpDelete:
path := syncPayloadPath(op, payload)
+63 -2
View File
@@ -370,6 +370,16 @@ func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) {
if bytesResult.RelativePath != "Docs/image.png" || bytesResult.MimeHint != "image/png" || bytesResult.DataBase64 != "iVBORw==" {
t.Fatalf("bytes result = %+v", bytesResult)
}
if errStr := app.WriteVaultFileBytes("files.plugin", "Docs/from-api.bin", "AQID", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" {
t.Fatalf("WriteVaultFileBytes: %s", errStr)
}
writtenBytes, errStr := app.ReadVaultFileBytes("files.plugin", "Docs/from-api.bin")
if errStr != "" {
t.Fatalf("ReadVaultFileBytes written: %s", errStr)
}
if writtenBytes.DataBase64 != "AQID" || writtenBytes.Size != 3 {
t.Fatalf("written bytes result = %+v", writtenBytes)
}
entries, errStr := app.ListVaultFiles("files.plugin", "Docs")
if errStr != "" {
@@ -532,6 +542,30 @@ func TestActivityProviderRecordsFileChangedWithoutMountedView(t *testing.T) {
}
}
func TestActivityFromEventRedactsBinaryPayload(t *testing.T) {
activity := activityFromEvent(events.Event{
Name: "browser.capture.file",
Timestamp: "2026-06-29T00:00:00Z",
Payload: map[string]interface{}{
"captureId": "capture-binary",
"title": "logo.png",
"workspaceRootPath": "Project",
"fileDataBase64": "iVBORw==",
"fileText": "preview",
},
})
payload, ok := activity["payload"].(map[string]interface{})
if !ok {
t.Fatalf("payload = %#v, want map", activity["payload"])
}
if _, ok := payload["fileDataBase64"]; ok {
t.Fatalf("activity payload leaked fileDataBase64: %#v", payload)
}
if payload["fileText"] != "preview" {
t.Fatalf("activity payload fileText = %#v, want preview", payload["fileText"])
}
}
func TestFilesBridgeOpenExternalUsesVaultPathPolicyAndPermission(t *testing.T) {
app, root := newFilesTestApp(t, []string{"files.openExternal"})
filePath := filepath.Join(root, "Docs", "one.txt")
@@ -626,6 +660,14 @@ func TestFilesBridgePermissions(t *testing.T) {
},
wantPhrase: "files.write",
},
{
name: "write bytes requires write",
perms: []string{"files.read", "files.delete"},
call: func(app *App) string {
return app.WriteVaultFileBytes("files.plugin", "one.bin", "AQID", corefiles.WriteOptions{CreateIfMissing: true})
},
wantPhrase: "files.write",
},
{
name: "create folder requires write",
perms: []string{"files.read", "files.delete"},
@@ -751,6 +793,14 @@ func TestApplyRemoteFileOps(t *testing.T) {
OpType: syncsvc.OpUpdate,
PayloadJSON: `{"path":"Docs/one.txt","content":"updated"}`,
},
{
OpID: "binary-create",
DeviceID: "remote-device",
EntityType: syncsvc.EntityFile,
EntityID: "Docs/image.bin",
OpType: syncsvc.OpCreate,
PayloadJSON: `{"path":"Docs/image.bin","dataBase64":"AQID"}`,
},
{
OpID: "file-move",
DeviceID: "remote-device",
@@ -773,6 +823,13 @@ func TestApplyRemoteFileOps(t *testing.T) {
if text != "updated" {
t.Fatalf("content = %q, want updated", text)
}
binaryBytes, errStr := app.ReadVaultFileBytes("files.plugin", "Docs/image.bin")
if errStr != "" {
t.Fatalf("ReadVaultFileBytes binary: %s", errStr)
}
if binaryBytes.DataBase64 != "AQID" {
t.Fatalf("binary dataBase64 = %q, want AQID", binaryBytes.DataBase64)
}
if _, errStr := app.GetVaultFileMetadata("files.plugin", "Docs/one.txt"); !strings.Contains(errStr, "not-found") {
t.Fatalf("old path metadata err = %q, want not-found", errStr)
}
@@ -869,6 +926,9 @@ func TestFileBridgeRecordsSyncOps(t *testing.T) {
if errStr := app.WriteVaultTextFile("files.plugin", "Docs/one.txt", "updated", corefiles.WriteOptions{Overwrite: true}); errStr != "" {
t.Fatalf("WriteVaultTextFile update: %s", errStr)
}
if errStr := app.WriteVaultFileBytes("files.plugin", "Docs/image.bin", "AQID", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" {
t.Fatalf("WriteVaultFileBytes create: %s", errStr)
}
if errStr := app.MoveVaultPath("files.plugin", "Docs/one.txt", "Docs/two.txt", corefiles.MoveOptions{}); errStr != "" {
t.Fatalf("MoveVaultPath: %s", errStr)
}
@@ -880,8 +940,8 @@ func TestFileBridgeRecordsSyncOps(t *testing.T) {
if err != nil {
t.Fatalf("GetUnpushedOps: %v", err)
}
if len(ops) != 5 {
t.Fatalf("ops len = %d, want 5: %#v", len(ops), ops)
if len(ops) != 6 {
t.Fatalf("ops len = %d, want 6: %#v", len(ops), ops)
}
want := []struct {
@@ -893,6 +953,7 @@ func TestFileBridgeRecordsSyncOps(t *testing.T) {
{syncsvc.EntityFolder, "Docs", syncsvc.OpCreate, `"path":"Docs"`},
{syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpCreate, `"content":"hello"`},
{syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpUpdate, `"content":"updated"`},
{syncsvc.EntityFile, "Docs/image.bin", syncsvc.OpCreate, `"dataBase64":"AQID"`},
{syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpMove, `"toPath":"Docs/two.txt"`},
{syncsvc.EntityFile, "Docs/two.txt", syncsvc.OpDelete, `"path":"Docs/two.txt"`},
}
+8 -6
View File
@@ -68,10 +68,11 @@ type CaptureLink struct {
}
type CaptureFile struct {
Name string `json:"name"`
Mime string `json:"mime"`
Size int64 `json:"size"`
Text string `json:"text"`
Name string `json:"name"`
Mime string `json:"mime"`
Size int64 `json:"size"`
Text string `json:"text"`
DataBase64 string `json:"dataBase64"`
}
type CaptureBrowser struct {
@@ -232,8 +233,8 @@ func (p CapturePayload) Validate() error {
if p.Kind == "file" && (p.File == nil || strings.TrimSpace(p.File.Name) == "") {
return fmt.Errorf("file.name is required")
}
if p.Kind == "file" && (p.File == nil || p.File.Text == "") {
return fmt.Errorf("file.text is required")
if p.Kind == "file" && (p.File == nil || (p.File.Text == "" && strings.TrimSpace(p.File.DataBase64) == "")) {
return fmt.Errorf("file.text or file.dataBase64 is required")
}
return nil
}
@@ -269,6 +270,7 @@ func (p CapturePayload) EventPayload() map[string]interface{} {
result["fileMime"] = strings.TrimSpace(p.File.Mime)
result["fileSize"] = p.File.Size
result["fileText"] = p.File.Text
result["fileDataBase64"] = strings.TrimSpace(p.File.DataBase64)
}
return result
}
@@ -108,7 +108,8 @@ func TestReceiverAcceptsFileCaptureAndPublishesEvent(t *testing.T) {
"name": "notes.txt",
"mime": "text/plain",
"size": 11,
"text": "hello file"
"text": "hello file",
"dataBase64": "aGVsbG8gZmlsZQ=="
},
"browser": {
"name": "Firefox"
@@ -146,6 +147,9 @@ func TestReceiverAcceptsFileCaptureAndPublishesEvent(t *testing.T) {
if payload["fileText"] != "hello file" {
t.Fatalf("payload fileText = %v, want hello file", payload["fileText"])
}
if payload["fileDataBase64"] != "aGVsbG8gZmlsZQ==" {
t.Fatalf("payload fileDataBase64 = %v, want aGVsbG8gZmlsZQ==", payload["fileDataBase64"])
}
}
func TestReceiverAnnotatesCaptureWithCurrentWorkspace(t *testing.T) {
+16 -1
View File
@@ -186,6 +186,21 @@ func (s *Service) ReadVaultFileBytes(relativePath string) (FileBytes, error) {
}
func (s *Service) WriteVaultTextFile(relativePath string, content string, options WriteOptions) error {
return s.writeVaultFileData(relativePath, []byte(content), options)
}
func (s *Service) WriteVaultFileBytes(relativePath string, dataBase64 string, options WriteOptions) error {
data, err := base64.StdEncoding.DecodeString(dataBase64)
if err != nil {
return fmt.Errorf("invalid-base64: %w", err)
}
if int64(len(data)) > MaxBinaryReadBytes {
return fmt.Errorf("file-too-large: %s", relativePath)
}
return s.writeVaultFileData(relativePath, data, options)
}
func (s *Service) writeVaultFileData(relativePath string, data []byte, options WriteOptions) error {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return err
@@ -234,7 +249,7 @@ func (s *Service) WriteVaultTextFile(relativePath string, content string, option
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.WriteString(content); err != nil {
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
+50
View File
@@ -1,6 +1,7 @@
package files
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
@@ -221,6 +222,55 @@ func TestWriteVaultTextFileAtomicAndConflictBehavior(t *testing.T) {
}
}
func TestWriteVaultFileBytesAtomicAndConflictBehavior(t *testing.T) {
s, root := newTestService(t)
if err := s.WriteVaultFileBytes("Images/logo.png", "iVBORw==", WriteOptions{CreateIfMissing: true}); err == nil {
t.Fatal("write bytes should fail when parent folder is missing")
}
if err := s.CreateVaultFolder("Images"); err != nil {
t.Fatalf("CreateVaultFolder: %v", err)
}
if err := s.WriteVaultFileBytes("Images/logo.png", "iVBORw==", WriteOptions{CreateIfMissing: true}); err != nil {
t.Fatalf("write bytes create: %v", err)
}
data, err := os.ReadFile(filepath.Join(root, "Images", "logo.png"))
if err != nil {
t.Fatal(err)
}
if string(data) != string([]byte{0x89, 0x50, 0x4e, 0x47}) {
t.Fatalf("file bytes = %v", data)
}
if err := s.WriteVaultFileBytes("Images/logo.png", "AQID", WriteOptions{CreateIfMissing: true}); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("write bytes conflict error = %v, want conflict", err)
}
if err := s.WriteVaultFileBytes("Images/logo.png", "AQID", WriteOptions{Overwrite: true}); err != nil {
t.Fatalf("write bytes overwrite: %v", err)
}
data, err = os.ReadFile(filepath.Join(root, "Images", "logo.png"))
if err != nil {
t.Fatal(err)
}
if string(data) != string([]byte{0x01, 0x02, 0x03}) {
t.Fatalf("overwritten bytes = %v", data)
}
matches, err := filepath.Glob(filepath.Join(root, "Images", ".verstak-write-*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("atomic byte write left temp files: %v", matches)
}
if err := s.WriteVaultFileBytes("Images/bad.bin", "not-base64!", WriteOptions{CreateIfMissing: true}); err == nil || !strings.Contains(err.Error(), "invalid-base64") {
t.Fatalf("invalid base64 error = %v, want invalid-base64", err)
}
tooLarge := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("a", int(MaxBinaryReadBytes)+1)))
if err := s.WriteVaultFileBytes("Images/huge.bin", tooLarge, WriteOptions{CreateIfMissing: true}); err == nil || !strings.Contains(err.Error(), "file-too-large") {
t.Fatalf("oversized bytes error = %v, want file-too-large", err)
}
}
func TestCreateVaultFolderConflict(t *testing.T) {
s, _ := newTestService(t)
if err := s.CreateVaultFolder("Folder"); err != nil {