feat: add bounded file byte reads

This commit is contained in:
mirivlad 2026-06-29 03:16:16 +08:00
parent aa4905a097
commit 57677d1b1b
13 changed files with 208 additions and 13 deletions

View File

@ -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

View File

@ -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 |

View File

@ -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() {

View File

@ -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);

View File

@ -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)}`);
}

View File

@ -102,18 +102,20 @@ export function ReadPluginSetting(arg1:string,arg2:string):Promise<any>;
export function ReadPluginSettings(arg1:string):Promise<Record<string, any>|string>;
export function ReadVaultFileBytes(arg1:string,arg2:string):Promise<files.FileBytes|string>;
export function ReadVaultTextFile(arg1:string,arg2:string):Promise<string|string>;
export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise<string>;
export function ReloadPlugins():Promise<number|string>;
export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>;
export function RenameWorkspace(arg1:string,arg2:string):Promise<string>;
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>;
export function SelectDirectory():Promise<string>;
export function SelectVaultForOpen():Promise<string>;

View File

@ -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']();
}

View File

@ -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;

View File

@ -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 {

View File

@ -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"},

View File

@ -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 {

View File

@ -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)

View File

@ -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"`