diff --git a/docs/DEV_PLUGINS.md b/docs/DEV_PLUGINS.md index 250a369..bba00fa 100644 --- a/docs/DEV_PLUGINS.md +++ b/docs/DEV_PLUGINS.md @@ -85,12 +85,13 @@ Frontend bundles are mounted with a plugin-scoped API created by - `capabilities.list/get/has` - `commands.register/execute` for handlers declared in `contributes.commands` - `events.publish/subscribe` using the bundled frontend event bus -- `files.list/metadata/readText/writeText/createFolder/move/trash/openExternal/showInFolder` +- `files.list/metadata/readText/readBytes/writeText/createFolder/move/trash/openExternal/showInFolder` for canonical vault-relative slash paths guarded by `files.read`, `files.write`, `files.delete`, and `files.openExternal`. Backslashes, Windows absolute paths, UNC paths, traversal, `.verstak` variants, and symlink read/write/move/trash/external-open operations are rejected. Text - read/write is UTF-8 only and limited to 2 MB for reads. + read/write is UTF-8 only and limited to 2 MB for text reads; `readBytes` + returns base64 for regular files up to 8 MB. - `workbench.openResource/editResource` for routing vault resources to contributed `openProviders`. Plugins must declare `workbench.open`; this is a policy/contract check. Files and Notes plugins call this API and do not import diff --git a/docs/PLUGIN_RUNTIME.md b/docs/PLUGIN_RUNTIME.md index daed9f1..11aafc0 100644 --- a/docs/PLUGIN_RUNTIME.md +++ b/docs/PLUGIN_RUNTIME.md @@ -437,6 +437,8 @@ contributions summary. - `files.list(relativeDir)` — list directory using a vault-relative path. - `files.metadata(relativePath)` — returns file/folder/symlink metadata. - `files.readText(relativePath)` — reads a UTF-8 regular file, with a size limit. +- `files.readBytes(relativePath)` — reads a regular file up to 8 MB and returns + `{ relativePath, size, mimeHint, dataBase64 }` for bounded binary preview use. - `files.writeText(relativePath, content, options)` — atomically writes text via temp-file-and-rename. `options.createIfMissing` and `options.overwrite` control conflicts. @@ -464,8 +466,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 is text-only for read/write in Milestone 6a. `readText` is limited - to UTF-8 regular files up to 2 MB. Binary streaming is deferred. +- 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. - 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 @@ -540,6 +543,7 @@ bundled runtime. Это реальный runtime contract для cooperative bun | `api.files.list(relativeDir)` | ✅ Работает | Список vault-relative директории, `.verstak` скрыта | | `api.files.metadata(relativePath)` | ✅ Работает | Metadata для файла/папки/symlink без чтения содержимого | | `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.createFolder(relativePath)` | ✅ Работает | Создаёт vault-relative folder | | `api.files.move(from, to, options)` | ✅ Работает | Move file/folder с conflict и path-policy checks | diff --git a/frontend/src/lib/plugin-host/VerstakPluginAPI.js b/frontend/src/lib/plugin-host/VerstakPluginAPI.js index 62f3821..ad695d8 100644 --- a/frontend/src/lib/plugin-host/VerstakPluginAPI.js +++ b/frontend/src/lib/plugin-host/VerstakPluginAPI.js @@ -242,6 +242,12 @@ export function createPluginAPI(pluginId) { return App.ReadVaultTextFile(pluginId, relativePath); }); }, + readBytes: function(relativePath) { + assertActive('files.readBytes(' + relativePath + ')'); + return callBackend(pluginId, 'files.readBytes(' + relativePath + ')', function() { + return App.ReadVaultFileBytes(pluginId, relativePath); + }); + }, writeText: function(relativePath, content, options) { assertActive('files.writeText(' + relativePath + ')'); return callBackendErrorString(pluginId, 'files.writeText(' + relativePath + ')', function() { diff --git a/frontend/src/lib/test/wails-mock.js b/frontend/src/lib/test/wails-mock.js index 6877e06..c131257 100644 --- a/frontend/src/lib/test/wails-mock.js +++ b/frontend/src/lib/test/wails-mock.js @@ -1287,6 +1287,23 @@ if (node.type !== 'file') return Promise.resolve(['', 'not-regular-file: ' + norm.path]); return Promise.resolve([node.content || '', '']); }, + ReadVaultFileBytes: function (pluginId, relativePath) { + var err = requirePluginPermission(pluginId, 'files.read'); + if (err) return Promise.resolve([{}, err]); + var norm = normalizeVaultPath(relativePath, false); + if (norm.error) return Promise.resolve([{}, norm.error]); + var node = vaultFiles[norm.path]; + if (!node) return Promise.resolve([{}, 'not-found: ' + norm.path]); + if (node.type !== 'file') return Promise.resolve([{}, 'not-regular-file: ' + norm.path]); + var content = node.content || ''; + var dataBase64 = typeof btoa === 'function' ? btoa(content) : ''; + return Promise.resolve([{ + relativePath: norm.path, + size: content.length, + mimeHint: norm.path.toLowerCase().endsWith('.png') ? 'image/png' : '', + dataBase64: dataBase64 + }, '']); + }, WriteVaultTextFile: function (pluginId, relativePath, content, options) { 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 17c2649..a181215 100644 --- a/frontend/tests/plugin-api-files-test.mjs +++ b/frontend/tests/plugin-api-files-test.mjs @@ -11,8 +11,12 @@ globalThis.window = { go: { api: { App: { + ReadVaultFileBytes: (pluginId, relativePath) => { + calls.push({ method: 'ReadVaultFileBytes', pluginId, relativePath }); + return Promise.resolve([{ relativePath, size: 4, mimeHint: 'image/png', dataBase64: 'iVBORw==' }, '']); + }, RestoreVaultTrash: (pluginId, trashId, options) => { - calls.push({ pluginId, trashId, options }); + calls.push({ method: 'RestoreVaultTrash', pluginId, trashId, options }); return Promise.resolve(['Docs/restored.txt', '']); }, }, @@ -33,12 +37,23 @@ const api = apiModule.createPluginAPI('verstak.files'); if (!api.files || typeof api.files.restoreTrash !== 'function') { throw new Error('api.files.restoreTrash is missing'); } +if (typeof api.files.readBytes !== 'function') { + throw new Error('api.files.readBytes 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)}`); +} 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 !== 1 || calls[0].pluginId !== 'verstak.files' || calls[0].trashId !== 'trash-1' || calls[0].options.overwrite !== true) { +if (calls.length !== 2 || 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) { 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 d47e0b5..20b551c 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -102,18 +102,20 @@ export function ReadPluginSetting(arg1:string,arg2:string):Promise; export function ReadPluginSettings(arg1:string):Promise|string>; +export function ReadVaultFileBytes(arg1:string,arg2:string):Promise; + export function ReadVaultTextFile(arg1:string,arg2:string):Promise; export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise; export function ReloadPlugins():Promise; -export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise; - export function RenameWorkspace(arg1:string,arg2:string):Promise; export function RenameWorkspaceNode(arg1:string,arg2:string):Promise; +export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise; + export function SelectDirectory():Promise; export function SelectVaultForOpen():Promise; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index cdb7d2a..4d146b0 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -190,6 +190,10 @@ export function ReadPluginSettings(arg1) { return window['go']['api']['App']['ReadPluginSettings'](arg1); } +export function ReadVaultFileBytes(arg1, arg2) { + return window['go']['api']['App']['ReadVaultFileBytes'](arg1, arg2); +} + export function ReadVaultTextFile(arg1, arg2) { return window['go']['api']['App']['ReadVaultTextFile'](arg1, arg2); } @@ -202,10 +206,6 @@ export function ReloadPlugins() { return window['go']['api']['App']['ReloadPlugins'](); } -export function RestoreVaultTrash(arg1, arg2, arg3) { - return window['go']['api']['App']['RestoreVaultTrash'](arg1, arg2, arg3); -} - export function RenameWorkspace(arg1, arg2) { return window['go']['api']['App']['RenameWorkspace'](arg1, arg2); } @@ -214,6 +214,10 @@ export function RenameWorkspaceNode(arg1, arg2) { return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2); } +export function RestoreVaultTrash(arg1, arg2, arg3) { + return window['go']['api']['App']['RestoreVaultTrash'](arg1, arg2, arg3); +} + export function SelectDirectory() { return window['go']['api']['App']['SelectDirectory'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 74e8403..7349869 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -369,6 +369,24 @@ export namespace capability { export namespace files { + export class FileBytes { + relativePath: string; + size: number; + mimeHint: string; + dataBase64: string; + + static createFrom(source: any = {}) { + return new FileBytes(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.relativePath = source["relativePath"]; + this.size = source["size"]; + this.mimeHint = source["mimeHint"]; + this.dataBase64 = source["dataBase64"]; + } + } export class FileEntry { name: string; relativePath: string; diff --git a/internal/api/app.go b/internal/api/app.go index 31f22da..3db7a93 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -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 { diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 3831d3a..7afb4b6 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -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"}, diff --git a/internal/core/files/service.go b/internal/core/files/service.go index 98f5378..0a49476 100644 --- a/internal/core/files/service.go +++ b/internal/core/files/service.go @@ -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 { diff --git a/internal/core/files/service_test.go b/internal/core/files/service_test.go index e291dc3..be98b76 100644 --- a/internal/core/files/service_test.go +++ b/internal/core/files/service_test.go @@ -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) diff --git a/internal/core/files/types.go b/internal/core/files/types.go index fa55427..25b570d 100644 --- a/internal/core/files/types.go +++ b/internal/core/files/types.go @@ -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"`