feat: add bounded binary file writes
This commit is contained in:
parent
df4538532f
commit
fe91784a8e
|
|
@ -442,6 +442,9 @@ contributions summary.
|
||||||
- `files.writeText(relativePath, content, options)` — atomically writes text via
|
- `files.writeText(relativePath, content, options)` — atomically writes text via
|
||||||
temp-file-and-rename. `options.createIfMissing` and `options.overwrite`
|
temp-file-and-rename. `options.createIfMissing` and `options.overwrite`
|
||||||
control conflicts.
|
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.createFolder(relativePath)` — creates one folder when the parent exists.
|
||||||
- `files.move(from, to, options)` — moves a file or folder; rejects moving a
|
- `files.move(from, to, options)` — moves a file or folder; rejects moving a
|
||||||
folder into itself and conflicts unless `options.overwrite` is true.
|
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.metadata` may report a final symlink as `type: "symlink"`, but
|
||||||
`files.list` through a symlink directory and all read/write/move/trash
|
`files.list` through a symlink directory and all read/write/move/trash
|
||||||
operations through symlinks are forbidden in Milestone 6a.
|
operations through symlinks are forbidden in Milestone 6a.
|
||||||
- Files API writes are text-only. `readText` is limited to UTF-8 regular files
|
- `readText` is limited to UTF-8 regular files up to 2 MB. `readBytes` and
|
||||||
up to 2 MB. `readBytes` is a bounded read-only byte contract up to 8 MB;
|
`writeBytes` are bounded byte contracts up to 8 MB; chunked streaming is
|
||||||
write streaming is deferred.
|
deferred.
|
||||||
- Live watcher refresh is active while Verstak is running and a vault is open.
|
- 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
|
It performs an initial no-event snapshot, then publishes `file.changed` for
|
||||||
external creates, updates, and deletes outside `.verstak/`. It does not keep a
|
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.readText(relativePath)` | ✅ Работает | Читает UTF-8 regular file до 2 MB |
|
||||||
| `api.files.readBytes(relativePath)` | ✅ Работает | Читает regular file до 8 MB как base64 payload |
|
| `api.files.readBytes(relativePath)` | ✅ Работает | Читает regular file до 8 MB как base64 payload |
|
||||||
| `api.files.writeText(relativePath, content, options)` | ✅ Работает | Atomic text write с явным create/overwrite policy |
|
| `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.createFolder(relativePath)` | ✅ Работает | Создаёт vault-relative folder |
|
||||||
| `api.files.move(from, to, options)` | ✅ Работает | Move file/folder с conflict и path-policy checks |
|
| `api.files.move(from, to, options)` | ✅ Работает | Move file/folder с conflict и path-policy checks |
|
||||||
| `api.files.trash(relativePath)` | ✅ Работает | Перемещает в internal trash, permanent delete нет |
|
| `api.files.trash(relativePath)` | ✅ Работает | Перемещает в internal trash, permanent delete нет |
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,12 @@ export function createPluginAPI(pluginId) {
|
||||||
return App.WriteVaultTextFile(pluginId, relativePath, String(content == null ? '' : content), options || {});
|
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) {
|
createFolder: function(relativePath) {
|
||||||
assertActive('files.createFolder(' + relativePath + ')');
|
assertActive('files.createFolder(' + relativePath + ')');
|
||||||
return callBackendErrorString(pluginId, 'files.createFolder(' + relativePath + ')', function() {
|
return callBackendErrorString(pluginId, 'files.createFolder(' + relativePath + ')', function() {
|
||||||
|
|
|
||||||
|
|
@ -1327,6 +1327,22 @@
|
||||||
vaultFiles[norm.path] = { type: 'file', content: String(content == null ? '' : content), modifiedAt: new Date().toISOString() };
|
vaultFiles[norm.path] = { type: 'file', content: String(content == null ? '' : content), modifiedAt: new Date().toISOString() };
|
||||||
return Promise.resolve('');
|
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) {
|
CreateVaultFolder: function (pluginId, relativePath) {
|
||||||
var err = requirePluginPermission(pluginId, 'files.write');
|
var err = requirePluginPermission(pluginId, 'files.write');
|
||||||
if (err) return Promise.resolve(err);
|
if (err) return Promise.resolve(err);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ globalThis.window = {
|
||||||
calls.push({ method: 'RestoreVaultTrash', pluginId, trashId, options });
|
calls.push({ method: 'RestoreVaultTrash', pluginId, trashId, options });
|
||||||
return Promise.resolve(['Docs/restored.txt', '']);
|
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') {
|
if (typeof api.files.readBytes !== 'function') {
|
||||||
throw new Error('api.files.readBytes is missing');
|
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');
|
const bytes = await api.files.readBytes('Docs/image.png');
|
||||||
if (bytes.dataBase64 !== 'iVBORw==' || bytes.mimeHint !== 'image/png') {
|
if (bytes.dataBase64 !== 'iVBORw==' || bytes.mimeHint !== 'image/png') {
|
||||||
throw new Error(`unexpected readBytes result: ${JSON.stringify(bytes)}`);
|
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 });
|
const restored = await api.files.restoreTrash('trash-1', { overwrite: true });
|
||||||
if (restored !== 'Docs/restored.txt') {
|
if (restored !== 'Docs/restored.txt') {
|
||||||
throw new Error(`unexpected restore result: ${JSON.stringify(restored)}`);
|
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)}`);
|
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)}`);
|
throw new Error(`unexpected RestoreVaultTrash call: ${JSON.stringify(calls)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,3 +149,5 @@ export function WritePluginSetting(arg1:string,arg2:string,arg3:any):Promise<str
|
||||||
export function WritePluginSettings(arg1:string,arg2:Record<string, any>):Promise<string>;
|
export function WritePluginSettings(arg1:string,arg2:Record<string, any>):Promise<string>;
|
||||||
|
|
||||||
export function WriteVaultTextFile(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise<string>;
|
export function WriteVaultTextFile(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise<string>;
|
||||||
|
|
||||||
|
export function WriteVaultFileBytes(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise<string>;
|
||||||
|
|
|
||||||
|
|
@ -285,3 +285,7 @@ export function WritePluginSettings(arg1, arg2) {
|
||||||
export function WriteVaultTextFile(arg1, arg2, arg3, arg4) {
|
export function WriteVaultTextFile(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['api']['App']['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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,8 @@ func (a *App) appendActivityEvent(pluginID string, activity map[string]interface
|
||||||
|
|
||||||
func activityFromEvent(event events.Event) map[string]interface{} {
|
func activityFromEvent(event events.Event) map[string]interface{} {
|
||||||
payload := eventPayloadMap(event.Payload)
|
payload := eventPayloadMap(event.Payload)
|
||||||
|
delete(payload, "fileDataBase64")
|
||||||
|
delete(payload, "dataBase64")
|
||||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
occurredAt := firstPayloadText(payload, "occurredAt", "capturedAt")
|
occurredAt := firstPayloadText(payload, "occurredAt", "capturedAt")
|
||||||
if occurredAt == "" {
|
if occurredAt == "" {
|
||||||
|
|
@ -281,7 +283,11 @@ func activityFromEvent(event events.Event) map[string]interface{} {
|
||||||
func eventPayloadMap(payload interface{}) map[string]interface{} {
|
func eventPayloadMap(payload interface{}) map[string]interface{} {
|
||||||
switch value := payload.(type) {
|
switch value := payload.(type) {
|
||||||
case map[string]interface{}:
|
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:
|
case map[string]string:
|
||||||
result := make(map[string]interface{}, len(value))
|
result := make(map[string]interface{}, len(value))
|
||||||
for key, item := range value {
|
for key, item := range value {
|
||||||
|
|
@ -922,6 +928,37 @@ func (a *App) WriteVaultTextFile(pluginID, relativePath string, content string,
|
||||||
return ""
|
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.
|
// CreateVaultFolder creates a vault-relative folder for a plugin with files.write.
|
||||||
func (a *App) CreateVaultFolder(pluginID, relativePath string) string {
|
func (a *App) CreateVaultFolder(pluginID, relativePath string) string {
|
||||||
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
|
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
|
||||||
|
|
@ -2282,6 +2319,7 @@ func (a *App) applyRemoteOp(op syncsvc.Op) error {
|
||||||
type syncFilePayload struct {
|
type syncFilePayload struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
DataBase64 *string `json:"dataBase64"`
|
||||||
FromPath string `json:"fromPath"`
|
FromPath string `json:"fromPath"`
|
||||||
ToPath string `json:"toPath"`
|
ToPath string `json:"toPath"`
|
||||||
}
|
}
|
||||||
|
|
@ -2304,12 +2342,18 @@ func (a *App) applyRemoteFileOp(op syncsvc.Op, payload syncFilePayload) error {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return fmt.Errorf("missing file 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})
|
return a.files.WriteVaultTextFile(path, payload.Content, corefiles.WriteOptions{CreateIfMissing: true})
|
||||||
case syncsvc.OpUpdate:
|
case syncsvc.OpUpdate:
|
||||||
path := syncPayloadPath(op, payload)
|
path := syncPayloadPath(op, payload)
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return fmt.Errorf("missing file 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})
|
return a.files.WriteVaultTextFile(path, payload.Content, corefiles.WriteOptions{CreateIfMissing: true, Overwrite: true})
|
||||||
case syncsvc.OpDelete:
|
case syncsvc.OpDelete:
|
||||||
path := syncPayloadPath(op, payload)
|
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==" {
|
if bytesResult.RelativePath != "Docs/image.png" || bytesResult.MimeHint != "image/png" || bytesResult.DataBase64 != "iVBORw==" {
|
||||||
t.Fatalf("bytes result = %+v", bytesResult)
|
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")
|
entries, errStr := app.ListVaultFiles("files.plugin", "Docs")
|
||||||
if errStr != "" {
|
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) {
|
func TestFilesBridgeOpenExternalUsesVaultPathPolicyAndPermission(t *testing.T) {
|
||||||
app, root := newFilesTestApp(t, []string{"files.openExternal"})
|
app, root := newFilesTestApp(t, []string{"files.openExternal"})
|
||||||
filePath := filepath.Join(root, "Docs", "one.txt")
|
filePath := filepath.Join(root, "Docs", "one.txt")
|
||||||
|
|
@ -626,6 +660,14 @@ func TestFilesBridgePermissions(t *testing.T) {
|
||||||
},
|
},
|
||||||
wantPhrase: "files.write",
|
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",
|
name: "create folder requires write",
|
||||||
perms: []string{"files.read", "files.delete"},
|
perms: []string{"files.read", "files.delete"},
|
||||||
|
|
@ -751,6 +793,14 @@ func TestApplyRemoteFileOps(t *testing.T) {
|
||||||
OpType: syncsvc.OpUpdate,
|
OpType: syncsvc.OpUpdate,
|
||||||
PayloadJSON: `{"path":"Docs/one.txt","content":"updated"}`,
|
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",
|
OpID: "file-move",
|
||||||
DeviceID: "remote-device",
|
DeviceID: "remote-device",
|
||||||
|
|
@ -773,6 +823,13 @@ func TestApplyRemoteFileOps(t *testing.T) {
|
||||||
if text != "updated" {
|
if text != "updated" {
|
||||||
t.Fatalf("content = %q, want updated", text)
|
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") {
|
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)
|
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 != "" {
|
if errStr := app.WriteVaultTextFile("files.plugin", "Docs/one.txt", "updated", corefiles.WriteOptions{Overwrite: true}); errStr != "" {
|
||||||
t.Fatalf("WriteVaultTextFile update: %s", 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 != "" {
|
if errStr := app.MoveVaultPath("files.plugin", "Docs/one.txt", "Docs/two.txt", corefiles.MoveOptions{}); errStr != "" {
|
||||||
t.Fatalf("MoveVaultPath: %s", errStr)
|
t.Fatalf("MoveVaultPath: %s", errStr)
|
||||||
}
|
}
|
||||||
|
|
@ -880,8 +940,8 @@ func TestFileBridgeRecordsSyncOps(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetUnpushedOps: %v", err)
|
t.Fatalf("GetUnpushedOps: %v", err)
|
||||||
}
|
}
|
||||||
if len(ops) != 5 {
|
if len(ops) != 6 {
|
||||||
t.Fatalf("ops len = %d, want 5: %#v", len(ops), ops)
|
t.Fatalf("ops len = %d, want 6: %#v", len(ops), ops)
|
||||||
}
|
}
|
||||||
|
|
||||||
want := []struct {
|
want := []struct {
|
||||||
|
|
@ -893,6 +953,7 @@ func TestFileBridgeRecordsSyncOps(t *testing.T) {
|
||||||
{syncsvc.EntityFolder, "Docs", syncsvc.OpCreate, `"path":"Docs"`},
|
{syncsvc.EntityFolder, "Docs", syncsvc.OpCreate, `"path":"Docs"`},
|
||||||
{syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpCreate, `"content":"hello"`},
|
{syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpCreate, `"content":"hello"`},
|
||||||
{syncsvc.EntityFile, "Docs/one.txt", syncsvc.OpUpdate, `"content":"updated"`},
|
{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/one.txt", syncsvc.OpMove, `"toPath":"Docs/two.txt"`},
|
||||||
{syncsvc.EntityFile, "Docs/two.txt", syncsvc.OpDelete, `"path":"Docs/two.txt"`},
|
{syncsvc.EntityFile, "Docs/two.txt", syncsvc.OpDelete, `"path":"Docs/two.txt"`},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ type CaptureFile struct {
|
||||||
Mime string `json:"mime"`
|
Mime string `json:"mime"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
DataBase64 string `json:"dataBase64"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CaptureBrowser struct {
|
type CaptureBrowser struct {
|
||||||
|
|
@ -232,8 +233,8 @@ func (p CapturePayload) Validate() error {
|
||||||
if p.Kind == "file" && (p.File == nil || strings.TrimSpace(p.File.Name) == "") {
|
if p.Kind == "file" && (p.File == nil || strings.TrimSpace(p.File.Name) == "") {
|
||||||
return fmt.Errorf("file.name is required")
|
return fmt.Errorf("file.name is required")
|
||||||
}
|
}
|
||||||
if p.Kind == "file" && (p.File == nil || p.File.Text == "") {
|
if p.Kind == "file" && (p.File == nil || (p.File.Text == "" && strings.TrimSpace(p.File.DataBase64) == "")) {
|
||||||
return fmt.Errorf("file.text is required")
|
return fmt.Errorf("file.text or file.dataBase64 is required")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -269,6 +270,7 @@ func (p CapturePayload) EventPayload() map[string]interface{} {
|
||||||
result["fileMime"] = strings.TrimSpace(p.File.Mime)
|
result["fileMime"] = strings.TrimSpace(p.File.Mime)
|
||||||
result["fileSize"] = p.File.Size
|
result["fileSize"] = p.File.Size
|
||||||
result["fileText"] = p.File.Text
|
result["fileText"] = p.File.Text
|
||||||
|
result["fileDataBase64"] = strings.TrimSpace(p.File.DataBase64)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,8 @@ func TestReceiverAcceptsFileCaptureAndPublishesEvent(t *testing.T) {
|
||||||
"name": "notes.txt",
|
"name": "notes.txt",
|
||||||
"mime": "text/plain",
|
"mime": "text/plain",
|
||||||
"size": 11,
|
"size": 11,
|
||||||
"text": "hello file"
|
"text": "hello file",
|
||||||
|
"dataBase64": "aGVsbG8gZmlsZQ=="
|
||||||
},
|
},
|
||||||
"browser": {
|
"browser": {
|
||||||
"name": "Firefox"
|
"name": "Firefox"
|
||||||
|
|
@ -146,6 +147,9 @@ func TestReceiverAcceptsFileCaptureAndPublishesEvent(t *testing.T) {
|
||||||
if payload["fileText"] != "hello file" {
|
if payload["fileText"] != "hello file" {
|
||||||
t.Fatalf("payload fileText = %v, want hello file", payload["fileText"])
|
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) {
|
func TestReceiverAnnotatesCaptureWithCurrentWorkspace(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,21 @@ func (s *Service) ReadVaultFileBytes(relativePath string) (FileBytes, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) WriteVaultTextFile(relativePath string, content string, options WriteOptions) 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)
|
root, rel, full, err := s.resolveFile(relativePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -234,7 +249,7 @@ func (s *Service) WriteVaultTextFile(relativePath string, content string, option
|
||||||
_ = os.Remove(tmpName)
|
_ = os.Remove(tmpName)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if _, err := tmp.WriteString(content); err != nil {
|
if _, err := tmp.Write(data); err != nil {
|
||||||
_ = tmp.Close()
|
_ = tmp.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package files
|
package files
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"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) {
|
func TestCreateVaultFolderConflict(t *testing.T) {
|
||||||
s, _ := newTestService(t)
|
s, _ := newTestService(t)
|
||||||
if err := s.CreateVaultFolder("Folder"); err != nil {
|
if err := s.CreateVaultFolder("Folder"); err != nil {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue