feat: add bounded file byte reads
This commit is contained in:
parent
aa4905a097
commit
57677d1b1b
|
|
@ -85,12 +85,13 @@ Frontend bundles are mounted with a plugin-scoped API created by
|
||||||
- `capabilities.list/get/has`
|
- `capabilities.list/get/has`
|
||||||
- `commands.register/execute` for handlers declared in `contributes.commands`
|
- `commands.register/execute` for handlers declared in `contributes.commands`
|
||||||
- `events.publish/subscribe` using the bundled frontend event bus
|
- `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`,
|
for canonical vault-relative slash paths guarded by `files.read`,
|
||||||
`files.write`, `files.delete`, and `files.openExternal`. Backslashes,
|
`files.write`, `files.delete`, and `files.openExternal`. Backslashes,
|
||||||
Windows absolute paths, UNC paths, traversal, `.verstak` variants, and
|
Windows absolute paths, UNC paths, traversal, `.verstak` variants, and
|
||||||
symlink read/write/move/trash/external-open operations are rejected. Text
|
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
|
- `workbench.openResource/editResource` for routing vault resources to
|
||||||
contributed `openProviders`. Plugins must declare `workbench.open`; this is a
|
contributed `openProviders`. Plugins must declare `workbench.open`; this is a
|
||||||
policy/contract check. Files and Notes plugins call this API and do not import
|
policy/contract check. Files and Notes plugins call this API and do not import
|
||||||
|
|
|
||||||
|
|
@ -437,6 +437,8 @@ contributions summary.
|
||||||
- `files.list(relativeDir)` — list directory using a vault-relative path.
|
- `files.list(relativeDir)` — list directory using a vault-relative path.
|
||||||
- `files.metadata(relativePath)` — returns file/folder/symlink metadata.
|
- `files.metadata(relativePath)` — returns file/folder/symlink metadata.
|
||||||
- `files.readText(relativePath)` — reads a UTF-8 regular file, with a size limit.
|
- `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
|
- `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.
|
||||||
|
|
@ -464,8 +466,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 is text-only for read/write in Milestone 6a. `readText` is limited
|
- Files API writes are text-only. `readText` is limited to UTF-8 regular files
|
||||||
to UTF-8 regular files up to 2 MB. Binary streaming is deferred.
|
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.
|
- 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
|
||||||
|
|
@ -540,6 +543,7 @@ bundled runtime. Это реальный runtime contract для cooperative bun
|
||||||
| `api.files.list(relativeDir)` | ✅ Работает | Список vault-relative директории, `.verstak` скрыта |
|
| `api.files.list(relativeDir)` | ✅ Работает | Список vault-relative директории, `.verstak` скрыта |
|
||||||
| `api.files.metadata(relativePath)` | ✅ Работает | Metadata для файла/папки/symlink без чтения содержимого |
|
| `api.files.metadata(relativePath)` | ✅ Работает | Metadata для файла/папки/symlink без чтения содержимого |
|
||||||
| `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.writeText(relativePath, content, options)` | ✅ Работает | Atomic text write с явным create/overwrite policy |
|
| `api.files.writeText(relativePath, content, options)` | ✅ Работает | Atomic text write с явным 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 |
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,12 @@ export function createPluginAPI(pluginId) {
|
||||||
return App.ReadVaultTextFile(pluginId, relativePath);
|
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) {
|
writeText: function(relativePath, content, options) {
|
||||||
assertActive('files.writeText(' + relativePath + ')');
|
assertActive('files.writeText(' + relativePath + ')');
|
||||||
return callBackendErrorString(pluginId, 'files.writeText(' + relativePath + ')', function() {
|
return callBackendErrorString(pluginId, 'files.writeText(' + relativePath + ')', function() {
|
||||||
|
|
|
||||||
|
|
@ -1287,6 +1287,23 @@
|
||||||
if (node.type !== 'file') return Promise.resolve(['', 'not-regular-file: ' + norm.path]);
|
if (node.type !== 'file') return Promise.resolve(['', 'not-regular-file: ' + norm.path]);
|
||||||
return Promise.resolve([node.content || '', '']);
|
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) {
|
WriteVaultTextFile: function (pluginId, relativePath, content, options) {
|
||||||
var err = requirePluginPermission(pluginId, 'files.write');
|
var err = requirePluginPermission(pluginId, 'files.write');
|
||||||
if (err) return Promise.resolve(err);
|
if (err) return Promise.resolve(err);
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,12 @@ globalThis.window = {
|
||||||
go: {
|
go: {
|
||||||
api: {
|
api: {
|
||||||
App: {
|
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) => {
|
RestoreVaultTrash: (pluginId, trashId, options) => {
|
||||||
calls.push({ pluginId, trashId, options });
|
calls.push({ method: 'RestoreVaultTrash', pluginId, trashId, options });
|
||||||
return Promise.resolve(['Docs/restored.txt', '']);
|
return Promise.resolve(['Docs/restored.txt', '']);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -33,12 +37,23 @@ const api = apiModule.createPluginAPI('verstak.files');
|
||||||
if (!api.files || typeof api.files.restoreTrash !== 'function') {
|
if (!api.files || typeof api.files.restoreTrash !== 'function') {
|
||||||
throw new Error('api.files.restoreTrash is missing');
|
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 });
|
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 !== 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)}`);
|
throw new Error(`unexpected RestoreVaultTrash call: ${JSON.stringify(calls)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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 ReadVaultTextFile(arg1:string,arg2:string):Promise<string|string>;
|
||||||
|
|
||||||
export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise<string>;
|
export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise<string>;
|
||||||
|
|
||||||
export function ReloadPlugins():Promise<number|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 RenameWorkspace(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
export function RenameWorkspaceNode(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 SelectDirectory():Promise<string>;
|
||||||
|
|
||||||
export function SelectVaultForOpen():Promise<string>;
|
export function SelectVaultForOpen():Promise<string>;
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,10 @@ export function ReadPluginSettings(arg1) {
|
||||||
return window['go']['api']['App']['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) {
|
export function ReadVaultTextFile(arg1, arg2) {
|
||||||
return window['go']['api']['App']['ReadVaultTextFile'](arg1, arg2);
|
return window['go']['api']['App']['ReadVaultTextFile'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
@ -202,10 +206,6 @@ export function ReloadPlugins() {
|
||||||
return window['go']['api']['App']['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) {
|
export function RenameWorkspace(arg1, arg2) {
|
||||||
return window['go']['api']['App']['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);
|
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() {
|
export function SelectDirectory() {
|
||||||
return window['go']['api']['App']['SelectDirectory']();
|
return window['go']['api']['App']['SelectDirectory']();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -369,6 +369,24 @@ export namespace capability {
|
||||||
|
|
||||||
export namespace files {
|
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 {
|
export class FileEntry {
|
||||||
name: string;
|
name: string;
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
|
|
|
||||||
|
|
@ -876,6 +876,21 @@ func (a *App) ReadVaultTextFile(pluginID, relativePath string) (string, string)
|
||||||
return text, ""
|
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.
|
// 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 {
|
func (a *App) WriteVaultTextFile(pluginID, relativePath string, content string, options corefiles.WriteOptions) string {
|
||||||
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
|
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -360,11 +360,29 @@ func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) {
|
||||||
t.Fatalf("text = %q", text)
|
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")
|
entries, errStr := app.ListVaultFiles("files.plugin", "Docs")
|
||||||
if errStr != "" {
|
if errStr != "" {
|
||||||
t.Fatalf("ListVaultFiles: %s", 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)
|
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 },
|
call: func(app *App) string { _, errStr := app.ReadVaultTextFile("files.plugin", "one.txt"); return errStr },
|
||||||
wantPhrase: "files.read",
|
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",
|
name: "write requires write",
|
||||||
perms: []string{"files.read", "files.delete"},
|
perms: []string{"files.read", "files.delete"},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package files
|
package files
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
|
@ -148,6 +149,42 @@ func (s *Service) ReadVaultTextFile(relativePath string) (string, error) {
|
||||||
return string(data), nil
|
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 {
|
func (s *Service) WriteVaultTextFile(relativePath string, content string, options WriteOptions) error {
|
||||||
root, rel, full, err := s.resolveFile(relativePath)
|
root, rel, full, err := s.resolveFile(relativePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,9 @@ func TestPathPolicyRejectsUnsafeOperations(t *testing.T) {
|
||||||
if _, err := s.ReadVaultTextFile(input); err == nil {
|
if _, err := s.ReadVaultTextFile(input); err == nil {
|
||||||
t.Fatal("read: expected error")
|
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 {
|
if err := s.WriteVaultTextFile(input, "x", WriteOptions{CreateIfMissing: true}); err == nil {
|
||||||
t.Fatal("write: expected error")
|
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) {
|
func TestWriteVaultTextFileAtomicAndConflictBehavior(t *testing.T) {
|
||||||
s, root := newTestService(t)
|
s, root := newTestService(t)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package files
|
package files
|
||||||
|
|
||||||
const MaxTextFileBytes int64 = 2 * 1024 * 1024
|
const MaxTextFileBytes int64 = 2 * 1024 * 1024
|
||||||
|
const MaxBinaryReadBytes int64 = 8 * 1024 * 1024
|
||||||
|
|
||||||
type FileType string
|
type FileType string
|
||||||
|
|
||||||
|
|
@ -39,6 +40,13 @@ type FileMetadata struct {
|
||||||
CanWrite bool `json:"canWrite"`
|
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 {
|
type ExternalOpenTarget struct {
|
||||||
RelativePath string `json:"relativePath"`
|
RelativePath string `json:"relativePath"`
|
||||||
AbsolutePath string `json:"absolutePath"`
|
AbsolutePath string `json:"absolutePath"`
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue