feat: add bounded binary file writes
This commit is contained in:
+49
-5
@@ -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)
|
||||
|
||||
@@ -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"`},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user