v3 candidate
This commit is contained in:
+20
-2
@@ -48,6 +48,7 @@ const (
|
||||
maxInlineSFTPUploadBytes = 25 * 1024 * 1024
|
||||
maxSFTPUploadChunkBytes = 1024 * 1024
|
||||
maxStreamingSFTPUploadSize = int64(2 * 1024 * 1024 * 1024)
|
||||
maxSSHPublicKeyBytes = 64 * 1024
|
||||
)
|
||||
|
||||
// Service is the main API surface exposed to the frontend via Wails bindings.
|
||||
@@ -66,6 +67,8 @@ type Service struct {
|
||||
|
||||
uploadMu sync.Mutex
|
||||
uploads map[string]*sftpUpload
|
||||
|
||||
leviathan *leviathanState
|
||||
}
|
||||
|
||||
type sftpUpload struct {
|
||||
@@ -93,6 +96,7 @@ func NewService(
|
||||
auditLog: auditLog,
|
||||
prefs: prefs,
|
||||
uploads: make(map[string]*sftpUpload),
|
||||
leviathan: &leviathanState{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1763,7 +1767,7 @@ func (s *Service) ListSSHKeyPairs() ([]SSHKeyInfo, error) {
|
||||
seen[baseName] = true
|
||||
|
||||
// Read public key content
|
||||
pubBytes, err := os.ReadFile(filepath.Join(sshDir, name))
|
||||
pubBytes, err := readSSHPublicKeyFile(filepath.Join(sshDir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -1849,7 +1853,7 @@ func (s *Service) GetSSHPublicKey(name string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(pubPath)
|
||||
data, err := readSSHPublicKeyFile(pubPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to read public key: %w", err)
|
||||
}
|
||||
@@ -1891,6 +1895,20 @@ func sshKeyPairPaths(name string) (string, string, error) {
|
||||
return privPath, pubPath, nil
|
||||
}
|
||||
|
||||
func readSSHPublicKeyFile(path string) ([]byte, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("public key path is a directory")
|
||||
}
|
||||
if info.Size() > maxSSHPublicKeyBytes {
|
||||
return nil, fmt.Errorf("public key file is too large: %d bytes exceeds %d bytes", info.Size(), maxSSHPublicKeyBytes)
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func validateSSHKeyName(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
|
||||
@@ -2,11 +2,13 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/andydixon/nexus/internal/audit"
|
||||
"github.com/andydixon/nexus/internal/leviathan/document"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/andydixon/nexus/internal/storage"
|
||||
)
|
||||
@@ -169,6 +171,63 @@ func TestImportProfilesNormalizesSparseRAWProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeviathanDocumentBytesRejectsOversizedDocument(t *testing.T) {
|
||||
doc := document.New("large.bin", []byte("abcd"))
|
||||
_, err := leviathanDocumentBytes(doc, 3, "test")
|
||||
if err == nil {
|
||||
t.Fatal("expected oversized document to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too large") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLeviathanDocumentFileWritesPatchedBytes(t *testing.T) {
|
||||
doc := document.New("patched.bin", []byte("abcd"))
|
||||
if _, err := doc.Set(1, 'Z'); err != nil {
|
||||
t.Fatalf("Set() failed: %v", err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "out.bin")
|
||||
if err := writeLeviathanDocumentFile(path, doc); err != nil {
|
||||
t.Fatalf("writeLeviathanDocumentFile() failed: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() failed: %v", err)
|
||||
}
|
||||
if string(got) != "aZcd" {
|
||||
t.Fatalf("written bytes = %q, want %q", string(got), "aZcd")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLeviathanSmallFileRejectsOversizedFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "big.img")
|
||||
if err := os.WriteFile(path, []byte("abcd"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() failed: %v", err)
|
||||
}
|
||||
_, err := readLeviathanSmallFile(path, 3, "image")
|
||||
if err == nil {
|
||||
t.Fatal("expected oversized file to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too large") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSSHPublicKeyFileRejectsOversizedFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "big.pub")
|
||||
if err := os.WriteFile(path, []byte(strings.Repeat("x", maxSSHPublicKeyBytes+1)), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() failed: %v", err)
|
||||
}
|
||||
_, err := readSSHPublicKeyFile(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected oversized public key to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too large") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProfileNormalizesLegacyVNCSecretMode(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
profile := models.NewProfile(models.ProtocolVNC)
|
||||
|
||||
@@ -0,0 +1,907 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"github.com/andydixon/nexus/internal/leviathan/analysis"
|
||||
"github.com/andydixon/nexus/internal/leviathan/binsnoop"
|
||||
"github.com/andydixon/nexus/internal/leviathan/disasm"
|
||||
"github.com/andydixon/nexus/internal/leviathan/document"
|
||||
"github.com/andydixon/nexus/internal/leviathan/parsers"
|
||||
"github.com/andydixon/nexus/internal/leviathan/patch"
|
||||
"github.com/andydixon/nexus/internal/leviathan/pdf"
|
||||
"github.com/andydixon/nexus/internal/leviathan/spectrogram"
|
||||
"github.com/andydixon/nexus/internal/leviathan/squashfs"
|
||||
"github.com/andydixon/nexus/internal/leviathan/stego"
|
||||
"github.com/andydixon/nexus/internal/leviathan/transforms"
|
||||
)
|
||||
|
||||
type leviathanState struct {
|
||||
mu sync.Mutex
|
||||
doc *document.Document
|
||||
cmp *document.Document
|
||||
}
|
||||
|
||||
type leviathanDocState struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Dirty bool `json:"dirty"`
|
||||
UndoLen int `json:"undoLen"`
|
||||
RedoLen int `json:"redoLen"`
|
||||
Compare *leviathanDocState `json:"compare,omitempty"`
|
||||
PreviewHex []map[string]any `json:"previewHex"`
|
||||
}
|
||||
|
||||
func (s *Service) leviathanEngine() *leviathanState {
|
||||
if s.leviathan == nil {
|
||||
s.leviathan = &leviathanState{}
|
||||
}
|
||||
return s.leviathan
|
||||
}
|
||||
|
||||
// LeviathanOpenFile prompts for a binary and loads it into the Leviathan workbench.
|
||||
func (s *Service) LeviathanOpenFile() (map[string]any, error) {
|
||||
if s.ctx == nil {
|
||||
return nil, fmt.Errorf("application context not set")
|
||||
}
|
||||
path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{
|
||||
Title: "Open Binary",
|
||||
})
|
||||
if err != nil || path == "" {
|
||||
return nil, err
|
||||
}
|
||||
return s.LeviathanLoadFile(path, false)
|
||||
}
|
||||
|
||||
// LeviathanOpenCompare prompts for a binary to compare against the working file.
|
||||
func (s *Service) LeviathanOpenCompare() (map[string]any, error) {
|
||||
if s.ctx == nil {
|
||||
return nil, fmt.Errorf("application context not set")
|
||||
}
|
||||
path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{
|
||||
Title: "Open Compare Binary",
|
||||
})
|
||||
if err != nil || path == "" {
|
||||
return nil, err
|
||||
}
|
||||
return s.LeviathanLoadFile(path, true)
|
||||
}
|
||||
|
||||
// LeviathanLoadFile loads a path supplied by drag/drop or the native file picker.
|
||||
func (s *Service) LeviathanLoadFile(path string, compare bool) (map[string]any, error) {
|
||||
d, err := document.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
engine := s.leviathanEngine()
|
||||
engine.mu.Lock()
|
||||
if compare {
|
||||
engine.cmp = d
|
||||
} else {
|
||||
engine.doc = d
|
||||
}
|
||||
state := engine.stateLocked()
|
||||
engine.mu.Unlock()
|
||||
return map[string]any{"state": state}, nil
|
||||
}
|
||||
|
||||
// LeviathanState returns the current binary workbench state.
|
||||
func (s *Service) LeviathanState() (leviathanDocState, error) {
|
||||
engine := s.leviathanEngine()
|
||||
engine.mu.Lock()
|
||||
defer engine.mu.Unlock()
|
||||
return engine.stateLocked(), nil
|
||||
}
|
||||
|
||||
// LeviathanRunTool runs one Leviathan analysis/editing tool.
|
||||
func (s *Service) LeviathanRunTool(tool string, params map[string]any) (map[string]any, error) {
|
||||
engine := s.leviathanEngine()
|
||||
engine.mu.Lock()
|
||||
if engine.doc == nil {
|
||||
engine.mu.Unlock()
|
||||
return nil, errors.New("open a file first")
|
||||
}
|
||||
if tool == "spectrogram-encode" || tool == "spectrogram-insert" || tool == "spectrogram-view" || tool == "case" {
|
||||
doc := engine.doc
|
||||
engine.mu.Unlock()
|
||||
switch tool {
|
||||
case "spectrogram-encode":
|
||||
return s.leviathanSpectrogramEncode(doc, params)
|
||||
case "spectrogram-insert":
|
||||
return s.leviathanSpectrogramInsert(doc, params)
|
||||
case "case":
|
||||
return s.leviathanExportCase(doc)
|
||||
default:
|
||||
return leviathanSpectrogramView(doc, params)
|
||||
}
|
||||
}
|
||||
defer engine.mu.Unlock()
|
||||
return engine.runToolLocked(tool, params)
|
||||
}
|
||||
|
||||
// LeviathanSetByte patches one byte in the working document.
|
||||
func (s *Service) LeviathanSetByte(offset int64, value string) (map[string]any, error) {
|
||||
engine := s.leviathanEngine()
|
||||
engine.mu.Lock()
|
||||
defer engine.mu.Unlock()
|
||||
if engine.doc == nil {
|
||||
return nil, errors.New("open a file first")
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.TrimPrefix(strings.TrimPrefix(value, "0x"), "0X")
|
||||
if len(value) == 1 {
|
||||
value = "0" + value
|
||||
}
|
||||
if len(value) != 2 {
|
||||
return nil, errors.New("byte value must be two hex digits")
|
||||
}
|
||||
b, err := hex.DecodeString(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := engine.doc.Set(offset, b[0]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"state": engine.stateLocked()}, nil
|
||||
}
|
||||
|
||||
// LeviathanUndo reverts the latest byte edit.
|
||||
func (s *Service) LeviathanUndo() (leviathanDocState, error) {
|
||||
engine := s.leviathanEngine()
|
||||
engine.mu.Lock()
|
||||
defer engine.mu.Unlock()
|
||||
if engine.doc == nil {
|
||||
return leviathanDocState{}, errors.New("open a file first")
|
||||
}
|
||||
engine.doc.Undo()
|
||||
return engine.stateLocked(), nil
|
||||
}
|
||||
|
||||
// LeviathanRedo reapplies the latest undone byte edit.
|
||||
func (s *Service) LeviathanRedo() (leviathanDocState, error) {
|
||||
engine := s.leviathanEngine()
|
||||
engine.mu.Lock()
|
||||
defer engine.mu.Unlock()
|
||||
if engine.doc == nil {
|
||||
return leviathanDocState{}, errors.New("open a file first")
|
||||
}
|
||||
engine.doc.Redo()
|
||||
return engine.stateLocked(), nil
|
||||
}
|
||||
|
||||
// LeviathanRevert clears all working edits.
|
||||
func (s *Service) LeviathanRevert() (leviathanDocState, error) {
|
||||
engine := s.leviathanEngine()
|
||||
engine.mu.Lock()
|
||||
defer engine.mu.Unlock()
|
||||
if engine.doc == nil {
|
||||
return leviathanDocState{}, errors.New("open a file first")
|
||||
}
|
||||
engine.doc.Revert()
|
||||
return engine.stateLocked(), nil
|
||||
}
|
||||
|
||||
// LeviathanSaveWorking prompts for a destination and writes the patched bytes.
|
||||
func (s *Service) LeviathanSaveWorking() (string, error) {
|
||||
engine := s.leviathanEngine()
|
||||
engine.mu.Lock()
|
||||
if engine.doc == nil {
|
||||
engine.mu.Unlock()
|
||||
return "", errors.New("open a file first")
|
||||
}
|
||||
doc := engine.doc
|
||||
engine.mu.Unlock()
|
||||
if s.ctx == nil {
|
||||
return "", fmt.Errorf("application context not set")
|
||||
}
|
||||
path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
|
||||
Title: "Save Working Copy",
|
||||
DefaultFilename: doc.Name() + ".patched",
|
||||
})
|
||||
if err != nil || path == "" {
|
||||
return "", err
|
||||
}
|
||||
return path, writeLeviathanDocumentFile(path, doc)
|
||||
}
|
||||
|
||||
// LeviathanExportJSON writes a tool result to disk.
|
||||
func (s *Service) LeviathanExportJSON(name string, value map[string]any) (string, error) {
|
||||
if s.ctx == nil {
|
||||
return "", fmt.Errorf("application context not set")
|
||||
}
|
||||
path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
|
||||
Title: "Export JSON",
|
||||
DefaultFilename: safeExportName(name, "leviathan-result.json"),
|
||||
})
|
||||
if err != nil || path == "" {
|
||||
return "", err
|
||||
}
|
||||
b, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, os.WriteFile(path, b, 0600)
|
||||
}
|
||||
|
||||
func (l *leviathanState) stateLocked() leviathanDocState {
|
||||
state := leviathanDocState{}
|
||||
if l.doc != nil {
|
||||
state = leviathanDocStateFor(l.doc, true)
|
||||
}
|
||||
if l.cmp != nil {
|
||||
cmp := leviathanDocStateFor(l.cmp, false)
|
||||
state.Compare = &cmp
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func leviathanDocStateFor(d *document.Document, preview bool) leviathanDocState {
|
||||
s := leviathanDocState{
|
||||
Name: d.Name(),
|
||||
Size: d.Size(),
|
||||
Dirty: d.IsDirty(),
|
||||
UndoLen: d.HistoryLen(),
|
||||
RedoLen: d.RedoLen(),
|
||||
}
|
||||
if preview {
|
||||
s.PreviewHex = leviathanHexRows(d, 0, 512, 8)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (l *leviathanState) runToolLocked(tool string, params map[string]any) (map[string]any, error) {
|
||||
start := int64Param(params, "start", 0)
|
||||
length := int64Param(params, "length", -1)
|
||||
switch tool {
|
||||
case "overview":
|
||||
return analysis.Overview(l.doc)
|
||||
case "hashes":
|
||||
return analysis.Hashes(l.doc, start, length)
|
||||
case "entropy":
|
||||
return analysis.Entropy(l.doc, start, length, int64Param(params, "window", 4096), int64Param(params, "step", 4096))
|
||||
case "strings":
|
||||
items, err := analysis.ExtractStrings(l.doc, start, length, intParam(params, "min", 4), []string{"ascii", "utf16le", "utf16be"}, intParam(params, "max", 5000))
|
||||
return map[string]any{"items": items}, err
|
||||
case "iocs":
|
||||
return analysis.ExtractIOCs(l.doc, start, length, intParam(params, "min", 5))
|
||||
case "carve":
|
||||
items, err := analysis.Carve(l.doc, start, length, intParam(params, "max", 256))
|
||||
return map[string]any{"items": items}, err
|
||||
case "keys":
|
||||
items, err := analysis.ScanKeys(l.doc)
|
||||
return map[string]any{"items": items}, err
|
||||
case "timestamps":
|
||||
return analysis.ScanTimestamps(l.doc, start, length, intParam(params, "max", 1000))
|
||||
case "search":
|
||||
return l.runSearch(params, start, length)
|
||||
case "multi-search":
|
||||
return analysis.MultiSearch(l.doc, start, length, stringParam(params, "spec", ""), intParam(params, "max", 1000))
|
||||
case "parse":
|
||||
name, tree, err := parseLeviathanDocument(l.doc, stringParam(params, "kind", "auto"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"parser": name, "tree": tree.ToMap()}, nil
|
||||
case "disasm":
|
||||
return l.runDisasm(params, start, length)
|
||||
case "diff":
|
||||
if l.cmp == nil {
|
||||
return nil, errors.New("open a compare file first")
|
||||
}
|
||||
return analysis.Diff(l.doc, l.cmp, start, length)
|
||||
case "stego":
|
||||
out, err := stego.AnalyseAppended(l.doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, _ := stego.ScanMetadata(l.doc)
|
||||
out["metadata"] = meta
|
||||
return out, nil
|
||||
case "metadata":
|
||||
items, err := stego.ScanMetadata(l.doc)
|
||||
return map[string]any{"items": items}, err
|
||||
case "pdf":
|
||||
return pdf.Parse(l.doc)
|
||||
case "squashfs":
|
||||
fs, err := squashfs.Open(l.doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := fs.Walk()
|
||||
return map[string]any{"superblock": fs.Superblock(), "compression": fs.CompressionName(), "entries": entries}, err
|
||||
case "binsnoop":
|
||||
buf, err := l.rangeBytes(0, min64(l.doc.Size(), int64Param(params, "maxBytes", 64<<20)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scanner := binsnoop.New()
|
||||
return map[string]any{"results": scanner.AnalyzeBytes(buf, l.doc.Name(), false), "signatures": binsnoop.ListSignatures()}, nil
|
||||
case "rules":
|
||||
rules, err := analysis.ParseRules(stringParam(params, "source", ""))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := analysis.RunRules(l.doc, start, length, rules, intParam(params, "max", 1000))
|
||||
return map[string]any{"items": items}, err
|
||||
case "patch-plan":
|
||||
steps, err := patch.Parse(stringParam(params, "source", ""))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan, err := patch.Plan(l.doc, steps)
|
||||
return map[string]any{"plan": plan}, err
|
||||
case "patch-apply":
|
||||
steps, err := patch.Parse(stringParam(params, "source", ""))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := patch.Plan(l.doc, steps); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"state": l.stateLocked()}, patch.Apply(l.doc, steps)
|
||||
case "transform":
|
||||
return l.runTransform(params, start, length)
|
||||
case "hex":
|
||||
return map[string]any{"rows": leviathanHexRows(l.doc, start, int64Param(params, "bytes", 1024), intParam(params, "width", 16))}, nil
|
||||
case "case":
|
||||
return nil, errors.New("case export must run through the service")
|
||||
default:
|
||||
return nil, errors.New("unknown tool: " + tool)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *leviathanState) runSearch(params map[string]any, start, length int64) (map[string]any, error) {
|
||||
mode := stringParam(params, "mode", "text")
|
||||
query := stringParam(params, "query", "")
|
||||
max := intParam(params, "max", 1000)
|
||||
switch mode {
|
||||
case "hex":
|
||||
b, err := hex.DecodeString(strings.ReplaceAll(query, " ", ""))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hits, err := analysis.SearchBytes(l.doc, start, length, b, max)
|
||||
return map[string]any{"hits": analysis.HitsToMaps(hits)}, err
|
||||
case "regex":
|
||||
hits, err := analysis.SearchRegex(l.doc, start, length, query, max)
|
||||
return map[string]any{"hits": analysis.HitsToMaps(hits)}, err
|
||||
default:
|
||||
hits, err := analysis.SearchBytes(l.doc, start, length, []byte(query), max)
|
||||
return map[string]any{"hits": analysis.HitsToMaps(hits)}, err
|
||||
}
|
||||
}
|
||||
|
||||
func (l *leviathanState) runDisasm(params map[string]any, start, length int64) (map[string]any, error) {
|
||||
if length < 0 || length > 65536 {
|
||||
length = 4096
|
||||
}
|
||||
buf, err := l.rangeBytes(start, length)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"items": disasm.Disassemble(buf, start, stringParam(params, "arch", "x86_64"), intParam(params, "max", 1000))}, nil
|
||||
}
|
||||
|
||||
func (l *leviathanState) runTransform(params map[string]any, start, length int64) (map[string]any, error) {
|
||||
if length < 0 || length > 8<<20 {
|
||||
length = min64(l.doc.Size()-start, 1<<20)
|
||||
}
|
||||
buf, err := l.rangeBytes(start, length)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
steps := parseLeviathanTransformPipeline(stringParam(params, "pipeline", "hex.encode"))
|
||||
results, err := transforms.ApplyPipeline(buf, steps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(results))
|
||||
for i, r := range results {
|
||||
out = append(out, map[string]any{"step": i, "length": len(r), "preview": previewLeviathanBytes(r)})
|
||||
}
|
||||
return map[string]any{"steps": steps, "results": out}, nil
|
||||
}
|
||||
|
||||
func leviathanSpectrogramView(doc *document.Document, params map[string]any) (map[string]any, error) {
|
||||
buf, err := leviathanDocumentBytes(doc, spectrogram.MaxAudioBytes, "audio")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pngBytes, summary, err := spectrogram.AudioSpectrogramPNG(buf, doc.Name(), leviathanSpectrogramViewOptions(params))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"mime": "image/png",
|
||||
"pngBase64": base64.StdEncoding.EncodeToString(pngBytes),
|
||||
"summary": summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) leviathanExportCase(doc *document.Document) (map[string]any, error) {
|
||||
if s.ctx == nil {
|
||||
return nil, fmt.Errorf("application context not set")
|
||||
}
|
||||
filename := strings.TrimSuffix(doc.Name(), filepath.Ext(doc.Name())) + ".case.zip"
|
||||
path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
|
||||
Title: "Export Leviathan Case",
|
||||
DefaultFilename: filename,
|
||||
})
|
||||
if err != nil || path == "" {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
zw := zip.NewWriter(f)
|
||||
add := func(name string, data []byte) error {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
}
|
||||
hashes, _ := analysis.Hashes(doc, 0, -1)
|
||||
overview, _ := analysis.Overview(doc)
|
||||
manifest, _ := json.MarshalIndent(map[string]any{"name": doc.Name(), "size": doc.Size(), "exportedAt": time.Now().Format(time.RFC3339), "hashes": hashes}, "", " ")
|
||||
overviewJSON, _ := json.MarshalIndent(overview, "", " ")
|
||||
patchesJSON, _ := json.MarshalIndent(doc.Patches(), "", " ")
|
||||
if err := add("manifest.json", manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := addDocument("working.bin", doc, zw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("overview.json", overviewJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("patches.json", patchesJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"path": path, "bytes": info.Size()}, nil
|
||||
}
|
||||
|
||||
func (s *Service) leviathanSpectrogramEncode(doc *document.Document, params map[string]any) (map[string]any, error) {
|
||||
if s.ctx == nil {
|
||||
return nil, fmt.Errorf("application context not set")
|
||||
}
|
||||
imageBytes, err := leviathanDocumentBytes(doc, spectrogram.MaxImageBytes, "image")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wavBytes, summary, err := spectrogram.ImageToWAV(imageBytes, leviathanSpectrogramEncodeOptions(params))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defaultName := strings.TrimSuffix(doc.Name(), filepath.Ext(doc.Name())) + ".spectrogram.wav"
|
||||
path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
|
||||
Title: "Save Spectrogram WAV",
|
||||
DefaultFilename: safeExportName(defaultName, "spectrogram.wav"),
|
||||
Filters: []wailsRuntime.FileFilter{
|
||||
{DisplayName: "WAV Audio (*.wav)", Pattern: "*.wav"},
|
||||
},
|
||||
})
|
||||
if err != nil || path == "" {
|
||||
return nil, err
|
||||
}
|
||||
if filepath.Ext(strings.ToLower(path)) != ".wav" {
|
||||
path += ".wav"
|
||||
}
|
||||
if err := os.WriteFile(path, wavBytes, 0600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary["path"] = path
|
||||
return map[string]any{"path": path, "summary": summary}, nil
|
||||
}
|
||||
|
||||
func (s *Service) leviathanSpectrogramInsert(doc *document.Document, params map[string]any) (map[string]any, error) {
|
||||
if s.ctx == nil {
|
||||
return nil, fmt.Errorf("application context not set")
|
||||
}
|
||||
imagePath := strings.TrimSpace(stringParam(params, "imagePath", ""))
|
||||
if imagePath == "" {
|
||||
return nil, errors.New("select an image to insert")
|
||||
}
|
||||
imageBytes, err := readLeviathanSmallFile(imagePath, spectrogram.MaxImageBytes, "image")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read image: %w", err)
|
||||
}
|
||||
audioBytes, err := leviathanDocumentBytes(doc, spectrogram.MaxAudioBytes, "audio")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wavBytes, summary, err := spectrogram.InsertImageIntoAudio(imageBytes, audioBytes, doc.Name(), leviathanSpectrogramInsertOptions(params))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defaultName := strings.TrimSuffix(doc.Name(), filepath.Ext(doc.Name())) + ".with-spectrogram.wav"
|
||||
path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
|
||||
Title: "Save Audio With Spectrogram Image",
|
||||
DefaultFilename: safeExportName(defaultName, "with-spectrogram.wav"),
|
||||
Filters: []wailsRuntime.FileFilter{
|
||||
{DisplayName: "WAV Audio (*.wav)", Pattern: "*.wav"},
|
||||
},
|
||||
})
|
||||
if err != nil || path == "" {
|
||||
return nil, err
|
||||
}
|
||||
if filepath.Ext(strings.ToLower(path)) != ".wav" {
|
||||
path += ".wav"
|
||||
}
|
||||
if err := os.WriteFile(path, wavBytes, 0600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary["path"] = path
|
||||
summary["imagePath"] = imagePath
|
||||
return map[string]any{"path": path, "summary": summary}, nil
|
||||
}
|
||||
|
||||
func writeLeviathanDocumentFile(path string, doc *document.Document) error {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
if err := doc.ForEachChunk(0, doc.Size(), func(_ int64, chunk []byte) error {
|
||||
_, err := f.Write(chunk)
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Sync()
|
||||
}
|
||||
|
||||
func addDocument(name string, doc *document.Document, zw *zip.Writer) error {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return doc.ForEachChunk(0, doc.Size(), func(_ int64, chunk []byte) error {
|
||||
_, err := w.Write(chunk)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func leviathanDocumentBytes(doc *document.Document, maxBytes int64, label string) ([]byte, error) {
|
||||
size := doc.Size()
|
||||
if maxBytes > 0 && size > maxBytes {
|
||||
return nil, fmt.Errorf("%s file is too large for this operation: %s exceeds %s", label, humanBytes(size), humanBytes(maxBytes))
|
||||
}
|
||||
return doc.Range(0, size)
|
||||
}
|
||||
|
||||
func readLeviathanSmallFile(path string, maxBytes int64, label string) ([]byte, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("%s path is a directory", label)
|
||||
}
|
||||
if maxBytes > 0 && info.Size() > maxBytes {
|
||||
return nil, fmt.Errorf("%s file is too large for this operation: %s exceeds %s", label, humanBytes(info.Size()), humanBytes(maxBytes))
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func humanBytes(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
value := float64(n)
|
||||
for _, suffix := range []string{"KiB", "MiB", "GiB", "TiB"} {
|
||||
value /= unit
|
||||
if value < unit {
|
||||
return fmt.Sprintf("%.1f %s", value, suffix)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%.1f PiB", value/unit)
|
||||
}
|
||||
|
||||
func (l *leviathanState) rangeBytes(start, length int64) ([]byte, error) {
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if length < 0 || start+length > l.doc.Size() {
|
||||
length = l.doc.Size() - start
|
||||
}
|
||||
if length < 0 {
|
||||
length = 0
|
||||
}
|
||||
return l.doc.Range(start, length)
|
||||
}
|
||||
|
||||
func parseLeviathanDocument(d *document.Document, kind string) (string, *parsers.Field, error) {
|
||||
kind = strings.ToLower(strings.TrimSpace(kind))
|
||||
if kind == "" || kind == "auto" {
|
||||
return parsers.DetectAndParse(d)
|
||||
}
|
||||
var tree *parsers.Field
|
||||
var err error
|
||||
switch kind {
|
||||
case "png":
|
||||
tree, err = parsers.ParsePNG(d)
|
||||
case "jpeg", "jpg":
|
||||
tree, err = parsers.ParseJPEG(d)
|
||||
kind = "jpeg"
|
||||
case "gif":
|
||||
tree, err = parsers.ParseGIF(d)
|
||||
case "bmp":
|
||||
tree, err = parsers.ParseBMP(d)
|
||||
case "zip":
|
||||
tree, err = parsers.ParseZIP(d)
|
||||
case "elf":
|
||||
tree, err = parsers.ParseELF(d)
|
||||
case "pe":
|
||||
tree, err = parsers.ParsePE(d)
|
||||
case "macho":
|
||||
tree, err = parsers.ParseMachO(d)
|
||||
case "riff":
|
||||
tree, err = parsers.ParseRIFF(d)
|
||||
case "asn1":
|
||||
tree, err = parsers.ParseASN1(d, 0, d.Size())
|
||||
case "cert":
|
||||
tree, err = parsers.ParseCerts(d)
|
||||
default:
|
||||
return "", nil, errors.New("unknown parser: " + kind)
|
||||
}
|
||||
return kind, tree, err
|
||||
}
|
||||
|
||||
func leviathanHexRows(d *document.Document, start, length int64, width int) []map[string]any {
|
||||
if width <= 0 {
|
||||
width = 16
|
||||
}
|
||||
if length < 0 || length > 8192 {
|
||||
length = 8192
|
||||
}
|
||||
rows := []map[string]any{}
|
||||
for off := start; off < start+length && off < d.Size(); off += int64(width) {
|
||||
n := int64(width)
|
||||
if off+n > d.Size() {
|
||||
n = d.Size() - off
|
||||
}
|
||||
buf, err := d.Range(off, n)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
rows = append(rows, map[string]any{"offset": off, "hex": spacedLeviathanHex(buf), "bytes": byteHexCells(buf), "ascii": asciiLeviathanPreview(buf)})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func parseLeviathanTransformPipeline(src string) []transforms.Step {
|
||||
parts := strings.Split(src, "|")
|
||||
steps := []transforms.Step{}
|
||||
for _, part := range parts {
|
||||
fields := strings.Fields(strings.TrimSpace(part))
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
op := fields[0]
|
||||
arg := ""
|
||||
if len(fields) > 1 {
|
||||
arg = strings.Join(fields[1:], " ")
|
||||
}
|
||||
switch op {
|
||||
case "hex":
|
||||
op = "hex.encode"
|
||||
case "unhex":
|
||||
op = "hex.decode"
|
||||
case "base64", "b64":
|
||||
op = "base64.encode"
|
||||
case "unbase64", "unb64":
|
||||
op = "base64.decode"
|
||||
case "url":
|
||||
op = "url.encode"
|
||||
case "unurl":
|
||||
op = "url.decode"
|
||||
}
|
||||
steps = append(steps, transforms.Step{Op: op, Arg: arg})
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
func spacedLeviathanHex(buf []byte) string {
|
||||
return strings.Join(byteHexCells(buf), " ")
|
||||
}
|
||||
|
||||
func byteHexCells(buf []byte) []string {
|
||||
parts := make([]string, len(buf))
|
||||
for i, b := range buf {
|
||||
parts[i] = fmt.Sprintf("%02x", b)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func asciiLeviathanPreview(buf []byte) string {
|
||||
var b strings.Builder
|
||||
for _, c := range buf {
|
||||
if c >= 32 && c < 127 {
|
||||
b.WriteByte(c)
|
||||
} else {
|
||||
b.WriteByte('.')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func previewLeviathanBytes(buf []byte) string {
|
||||
if len(buf) > 2048 {
|
||||
buf = buf[:2048]
|
||||
}
|
||||
if isMostlyLeviathanText(buf) {
|
||||
return string(buf)
|
||||
}
|
||||
return spacedLeviathanHex(buf)
|
||||
}
|
||||
|
||||
func isMostlyLeviathanText(buf []byte) bool {
|
||||
if len(buf) == 0 {
|
||||
return true
|
||||
}
|
||||
printable := 0
|
||||
for _, b := range buf {
|
||||
if b == '\n' || b == '\r' || b == '\t' || (b >= 32 && b < 127) {
|
||||
printable++
|
||||
}
|
||||
}
|
||||
return printable*100/len(buf) > 85
|
||||
}
|
||||
|
||||
func intParam(m map[string]any, key string, def int) int {
|
||||
return int(int64Param(m, key, int64(def)))
|
||||
}
|
||||
|
||||
func int64Param(m map[string]any, key string, def int64) int64 {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return def
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case int64:
|
||||
return n
|
||||
case int:
|
||||
return int64(n)
|
||||
case float64:
|
||||
return int64(n)
|
||||
case string:
|
||||
if n == "" {
|
||||
return def
|
||||
}
|
||||
if parsed, err := strconv.ParseInt(n, 0, 64); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func stringParam(m map[string]any, key, def string) string {
|
||||
if v, ok := m[key]; ok && v != nil {
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func floatParam(m map[string]any, key string, def float64) float64 {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return def
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case float32:
|
||||
return float64(n)
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
case string:
|
||||
if n == "" {
|
||||
return def
|
||||
}
|
||||
if parsed, err := strconv.ParseFloat(n, 64); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func boolParam(m map[string]any, key string, def bool) bool {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return def
|
||||
}
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
return b
|
||||
case string:
|
||||
if b == "" {
|
||||
return def
|
||||
}
|
||||
if parsed, err := strconv.ParseBool(b); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func leviathanSpectrogramEncodeOptions(params map[string]any) spectrogram.EncodeOptions {
|
||||
return spectrogram.EncodeOptions{
|
||||
DurationSec: floatParam(params, "durationSec", 5),
|
||||
SampleRate: intParam(params, "sampleRate", 44100),
|
||||
MinHz: floatParam(params, "minHz", 200),
|
||||
MaxHz: floatParam(params, "maxHz", 20000),
|
||||
Bins: intParam(params, "bins", 160),
|
||||
Columns: intParam(params, "columns", 768),
|
||||
Invert: boolParam(params, "invert", true),
|
||||
PreserveAR: boolParam(params, "preserveAspectRatio", true),
|
||||
Gain: floatParam(params, "gain", 0.85),
|
||||
}
|
||||
}
|
||||
|
||||
func leviathanSpectrogramInsertOptions(params map[string]any) spectrogram.InsertOptions {
|
||||
return spectrogram.InsertOptions{
|
||||
EncodeOptions: leviathanSpectrogramEncodeOptions(params),
|
||||
StartSec: floatParam(params, "startSec", 0),
|
||||
MixLevel: floatParam(params, "mixLevel", 0.35),
|
||||
}
|
||||
}
|
||||
|
||||
func leviathanSpectrogramViewOptions(params map[string]any) spectrogram.ViewOptions {
|
||||
return spectrogram.ViewOptions{
|
||||
FFTSize: intParam(params, "fftSize", 2048),
|
||||
Height: intParam(params, "height", 1024),
|
||||
MaxWidth: intParam(params, "maxWidth", 1400),
|
||||
Aspect: floatParam(params, "aspectRatio", 1),
|
||||
MinHz: floatParam(params, "minHz", 0),
|
||||
MaxHz: floatParam(params, "maxHz", 0),
|
||||
Brightness: floatParam(params, "brightness", 1),
|
||||
Contrast: floatParam(params, "contrast", 1),
|
||||
}
|
||||
}
|
||||
|
||||
func min64(a, b int64) int64 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func safeExportName(name, fallback string) string {
|
||||
name = filepath.Base(strings.TrimSpace(name))
|
||||
if name == "" || name == "." || strings.ContainsRune(name, 0) {
|
||||
return fallback
|
||||
}
|
||||
return name
|
||||
}
|
||||
Reference in New Issue
Block a user