From fe91784a8ef14ca8fe4441f8f659b19c8b2c3a58 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Mon, 29 Jun 2026 10:12:26 +0800 Subject: [PATCH] feat: add bounded binary file writes --- docs/PLUGIN_RUNTIME.md | 10 ++- .../src/lib/plugin-host/VerstakPluginAPI.js | 6 ++ frontend/src/lib/test/wails-mock.js | 16 +++++ frontend/tests/plugin-api-files-test.mjs | 16 ++++- frontend/wailsjs/go/api/App.d.ts | 2 + frontend/wailsjs/go/api/App.js | 4 ++ internal/api/app.go | 54 +++++++++++++-- internal/api/app_test.go | 65 ++++++++++++++++++- internal/core/browserreceiver/receiver.go | 14 ++-- .../core/browserreceiver/receiver_test.go | 6 +- internal/core/files/service.go | 17 ++++- internal/core/files/service_test.go | 50 ++++++++++++++ 12 files changed, 240 insertions(+), 20 deletions(-) diff --git a/docs/PLUGIN_RUNTIME.md b/docs/PLUGIN_RUNTIME.md index 9be977b..e48d491 100644 --- a/docs/PLUGIN_RUNTIME.md +++ b/docs/PLUGIN_RUNTIME.md @@ -442,6 +442,9 @@ contributions summary. - `files.writeText(relativePath, content, options)` — atomically writes text via temp-file-and-rename. `options.createIfMissing` and `options.overwrite` control conflicts. +- `files.writeBytes(relativePath, dataBase64, options)` — decodes a base64 + payload up to 8 MB and atomically writes bytes via temp-file-and-rename. + `options.createIfMissing` and `options.overwrite` control conflicts. - `files.createFolder(relativePath)` — creates one folder when the parent exists. - `files.move(from, to, options)` — moves a file or folder; rejects moving a folder into itself and conflicts unless `options.overwrite` is true. @@ -466,9 +469,9 @@ contributions summary. - `files.metadata` may report a final symlink as `type: "symlink"`, but `files.list` through a symlink directory and all read/write/move/trash operations through symlinks are forbidden in Milestone 6a. -- Files API writes are text-only. `readText` is limited to UTF-8 regular files - up to 2 MB. `readBytes` is a bounded read-only byte contract up to 8 MB; - write streaming is deferred. +- `readText` is limited to UTF-8 regular files up to 2 MB. `readBytes` and + `writeBytes` are bounded byte contracts up to 8 MB; chunked streaming is + deferred. - Live watcher refresh is active while Verstak is running and a vault is open. It performs an initial no-event snapshot, then publishes `file.changed` for external creates, updates, and deletes outside `.verstak/`. It does not keep a @@ -558,6 +561,7 @@ bundled runtime. Это реальный runtime contract для cooperative bun | `api.files.readText(relativePath)` | ✅ Работает | Читает UTF-8 regular file до 2 MB | | `api.files.readBytes(relativePath)` | ✅ Работает | Читает regular file до 8 MB как base64 payload | | `api.files.writeText(relativePath, content, options)` | ✅ Работает | Atomic text write с явным create/overwrite policy | +| `api.files.writeBytes(relativePath, dataBase64, options)` | ✅ Работает | Atomic bounded byte write до 8 MB с явным create/overwrite policy | | `api.files.createFolder(relativePath)` | ✅ Работает | Создаёт vault-relative folder | | `api.files.move(from, to, options)` | ✅ Работает | Move file/folder с conflict и path-policy checks | | `api.files.trash(relativePath)` | ✅ Работает | Перемещает в internal trash, permanent delete нет | diff --git a/frontend/src/lib/plugin-host/VerstakPluginAPI.js b/frontend/src/lib/plugin-host/VerstakPluginAPI.js index 50f709e..eef6881 100644 --- a/frontend/src/lib/plugin-host/VerstakPluginAPI.js +++ b/frontend/src/lib/plugin-host/VerstakPluginAPI.js @@ -288,6 +288,12 @@ export function createPluginAPI(pluginId) { return App.WriteVaultTextFile(pluginId, relativePath, String(content == null ? '' : content), options || {}); }); }, + writeBytes: function(relativePath, dataBase64, options) { + assertActive('files.writeBytes(' + relativePath + ')'); + return callBackendErrorString(pluginId, 'files.writeBytes(' + relativePath + ')', function() { + return App.WriteVaultFileBytes(pluginId, relativePath, String(dataBase64 == null ? '' : dataBase64), options || {}); + }); + }, createFolder: function(relativePath) { assertActive('files.createFolder(' + relativePath + ')'); return callBackendErrorString(pluginId, 'files.createFolder(' + relativePath + ')', function() { diff --git a/frontend/src/lib/test/wails-mock.js b/frontend/src/lib/test/wails-mock.js index e4b703a..8b59776 100644 --- a/frontend/src/lib/test/wails-mock.js +++ b/frontend/src/lib/test/wails-mock.js @@ -1327,6 +1327,22 @@ vaultFiles[norm.path] = { type: 'file', content: String(content == null ? '' : content), modifiedAt: new Date().toISOString() }; return Promise.resolve(''); }, + WriteVaultFileBytes: function (pluginId, relativePath, dataBase64, options) { + var err = requirePluginPermission(pluginId, 'files.write'); + if (err) return Promise.resolve(err); + var norm = normalizeVaultPath(relativePath, false); + if (norm.error) return Promise.resolve(norm.error); + options = options || {}; + var existing = vaultFiles[norm.path]; + if (existing && existing.type !== 'file') return Promise.resolve('not-regular-file: ' + norm.path); + if (existing && !options.overwrite) return Promise.resolve('conflict: ' + norm.path); + if (!existing && !options.createIfMissing) return Promise.resolve('not-found: ' + norm.path); + var parent = parentPath(norm.path); + if (!vaultFiles[parent] || vaultFiles[parent].type !== 'folder') return Promise.resolve('parent-not-found: ' + parent); + var content = typeof atob === 'function' ? atob(String(dataBase64 || '')) : ''; + vaultFiles[norm.path] = { type: 'file', content: content, modifiedAt: new Date().toISOString() }; + return Promise.resolve(''); + }, CreateVaultFolder: function (pluginId, relativePath) { var err = requirePluginPermission(pluginId, 'files.write'); if (err) return Promise.resolve(err); diff --git a/frontend/tests/plugin-api-files-test.mjs b/frontend/tests/plugin-api-files-test.mjs index a181215..43b2a99 100644 --- a/frontend/tests/plugin-api-files-test.mjs +++ b/frontend/tests/plugin-api-files-test.mjs @@ -19,6 +19,10 @@ globalThis.window = { calls.push({ method: 'RestoreVaultTrash', pluginId, trashId, options }); return Promise.resolve(['Docs/restored.txt', '']); }, + WriteVaultFileBytes: (pluginId, relativePath, dataBase64, options) => { + calls.push({ method: 'WriteVaultFileBytes', pluginId, relativePath, dataBase64, options }); + return Promise.resolve(''); + }, }, }, }, @@ -40,20 +44,28 @@ if (!api.files || typeof api.files.restoreTrash !== 'function') { if (typeof api.files.readBytes !== 'function') { throw new Error('api.files.readBytes is missing'); } +if (typeof api.files.writeBytes !== 'function') { + throw new Error('api.files.writeBytes is missing'); +} const bytes = await api.files.readBytes('Docs/image.png'); if (bytes.dataBase64 !== 'iVBORw==' || bytes.mimeHint !== 'image/png') { throw new Error(`unexpected readBytes result: ${JSON.stringify(bytes)}`); } +await api.files.writeBytes('Docs/copy.png', 'iVBORw==', { createIfMissing: true }); + const restored = await api.files.restoreTrash('trash-1', { overwrite: true }); if (restored !== 'Docs/restored.txt') { throw new Error(`unexpected restore result: ${JSON.stringify(restored)}`); } -if (calls.length !== 2 || calls[0].method !== 'ReadVaultFileBytes' || calls[0].pluginId !== 'verstak.files' || calls[0].relativePath !== 'Docs/image.png') { +if (calls.length !== 3 || calls[0].method !== 'ReadVaultFileBytes' || calls[0].pluginId !== 'verstak.files' || calls[0].relativePath !== 'Docs/image.png') { throw new Error(`unexpected ReadVaultFileBytes call: ${JSON.stringify(calls)}`); } -if (calls[1].method !== 'RestoreVaultTrash' || calls[1].pluginId !== 'verstak.files' || calls[1].trashId !== 'trash-1' || calls[1].options.overwrite !== true) { +if (calls[1].method !== 'WriteVaultFileBytes' || calls[1].pluginId !== 'verstak.files' || calls[1].relativePath !== 'Docs/copy.png' || calls[1].dataBase64 !== 'iVBORw==' || calls[1].options.createIfMissing !== true) { + throw new Error(`unexpected WriteVaultFileBytes call: ${JSON.stringify(calls)}`); +} +if (calls[2].method !== 'RestoreVaultTrash' || calls[2].pluginId !== 'verstak.files' || calls[2].trashId !== 'trash-1' || calls[2].options.overwrite !== true) { throw new Error(`unexpected RestoreVaultTrash call: ${JSON.stringify(calls)}`); } diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index 20b551c..7c93e49 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -149,3 +149,5 @@ export function WritePluginSetting(arg1:string,arg2:string,arg3:any):Promise):Promise; export function WriteVaultTextFile(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise; + +export function WriteVaultFileBytes(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index 4d146b0..8000b3c 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -285,3 +285,7 @@ export function WritePluginSettings(arg1, arg2) { export function WriteVaultTextFile(arg1, arg2, arg3, arg4) { return window['go']['api']['App']['WriteVaultTextFile'](arg1, arg2, arg3, arg4); } + +export function WriteVaultFileBytes(arg1, arg2, arg3, arg4) { + return window['go']['api']['App']['WriteVaultFileBytes'](arg1, arg2, arg3, arg4); +} diff --git a/internal/api/app.go b/internal/api/app.go index a7286fa..5c6a48a 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -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) diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 0b80803..0caddc4 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -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"`}, } diff --git a/internal/core/browserreceiver/receiver.go b/internal/core/browserreceiver/receiver.go index 1f391c9..9d744c0 100644 --- a/internal/core/browserreceiver/receiver.go +++ b/internal/core/browserreceiver/receiver.go @@ -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 } diff --git a/internal/core/browserreceiver/receiver_test.go b/internal/core/browserreceiver/receiver_test.go index 453437c..4beb920 100644 --- a/internal/core/browserreceiver/receiver_test.go +++ b/internal/core/browserreceiver/receiver_test.go @@ -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) { diff --git a/internal/core/files/service.go b/internal/core/files/service.go index 0a49476..d0349ad 100644 --- a/internal/core/files/service.go +++ b/internal/core/files/service.go @@ -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 } diff --git a/internal/core/files/service_test.go b/internal/core/files/service_test.go index be98b76..177880a 100644 --- a/internal/core/files/service_test.go +++ b/internal/core/files/service_test.go @@ -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 {