mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-16 08:57:30 +02:00
feat(工作流):支持本地图编排策略包导入导出 (#195)
* docs: define local workflow package mvp * docs: fix workflow package api contract * feat: add local workflow package mvp * feat(workflow): add package API client * feat(workflow): add package import interface * feat(workflow): connect package import flow * fix(workflow): map package validation errors * fix(workflow): handle import key generation errors * feat(workflow): localize package import states * fix(workflow): reset package import modal on open --------- Co-authored-by: ruanmingchen <“ruanm@chenchen”>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
package workflowpackage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Export builds a deterministic, human-readable single-workflow package.
|
||||
func Export(source Document) ([]byte, ExportMetadata, error) {
|
||||
source.ID = strings.TrimSpace(source.ID)
|
||||
source.Name = strings.TrimSpace(source.Name)
|
||||
if source.ID == "" || source.Name == "" || source.Version <= 0 || !safePackageWorkflowID(source.ID) {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("workflow id, name and version are required")
|
||||
}
|
||||
if strings.TrimSpace(source.GraphJSON) == "" {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("workflow graph_json is required")
|
||||
}
|
||||
contentHash, graphHash, payload, err := DocumentHashes(source)
|
||||
if err != nil {
|
||||
return nil, ExportMetadata{}, err
|
||||
}
|
||||
workflowPath := path.Join("workflows", source.ID+".json")
|
||||
createdAt := source.UpdatedAt.UTC()
|
||||
if createdAt.IsZero() {
|
||||
createdAt = time.Unix(0, 0).UTC()
|
||||
}
|
||||
manifest := Manifest{
|
||||
PackageFormat: PackageFormat,
|
||||
FormatVersion: FormatVersion,
|
||||
PackageID: "pkg_" + strings.TrimPrefix(contentHash, "sha256:")[:16],
|
||||
CreatedAt: createdAt.Format(time.RFC3339),
|
||||
Items: []ManifestItem{{
|
||||
Type: "workflow",
|
||||
Path: workflowPath,
|
||||
SourceID: source.ID,
|
||||
SourceRevision: source.Version,
|
||||
ContentHash: contentHash,
|
||||
GraphHash: graphHash,
|
||||
}},
|
||||
}
|
||||
manifestBytes, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("marshal manifest: %w", err)
|
||||
}
|
||||
checksums := fmt.Sprintf("%s manifest.json\n%s %s\n", strings.TrimPrefix(sha256Prefixed(manifestBytes), "sha256:"), strings.TrimPrefix(contentHash, "sha256:"), workflowPath)
|
||||
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
for _, entry := range []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{name: "checksums.sha256", data: []byte(checksums)},
|
||||
{name: "manifest.json", data: manifestBytes},
|
||||
{name: workflowPath, data: payload},
|
||||
} {
|
||||
header := &zip.FileHeader{Name: entry.name, Method: zip.Store}
|
||||
header.SetModTime(time.Unix(0, 0).UTC())
|
||||
writer, err := zw.CreateHeader(header)
|
||||
if err != nil {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("write %s: %w", entry.name, err)
|
||||
}
|
||||
if _, err := writer.Write(entry.data); err != nil {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("write %s: %w", entry.name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("close package: %w", err)
|
||||
}
|
||||
pkg := out.Bytes()
|
||||
return pkg, ExportMetadata{
|
||||
PackageHash: sha256Prefixed(pkg),
|
||||
ContentHash: contentHash,
|
||||
GraphHash: graphHash,
|
||||
SourceRevision: source.Version,
|
||||
FileName: source.ID + ".csapkg.zip",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DocumentHashes returns the canonical package item and graph hashes together
|
||||
// with the canonical item bytes used by export and inspection persistence.
|
||||
func DocumentHashes(source Document) (string, string, []byte, error) {
|
||||
graph, err := canonicalJSON([]byte(source.GraphJSON))
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("canonicalize graph_json: %w", err)
|
||||
}
|
||||
source.GraphJSON = string(graph)
|
||||
payload, err := json.Marshal(source)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("marshal workflow payload: %w", err)
|
||||
}
|
||||
return sha256Prefixed(payload), sha256Prefixed(graph), payload, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package workflowpackage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testDocument() Document {
|
||||
return Document{
|
||||
ID: "web-src-hunting",
|
||||
Name: "Web SRC 猎洞",
|
||||
Description: "面向 SRC Web 资产的侦察与漏洞候选流程",
|
||||
Version: 18,
|
||||
Enabled: true,
|
||||
GraphJSON: `{"nodes":[{"id":"start-1","type":"start","label":"开始","position":{"x":0,"y":0},"config":{}},{"id":"out-1","type":"output","label":"输出","position":{"x":0,"y":120},"config":{"output_key":"result","source_binding":{"from":"inputs","field":"message"}}}],"edges":[{"id":"e1","source":"start-1","target":"out-1"}],"config":{"schema_version":1}}`,
|
||||
UpdatedAt: time.Date(2026, 7, 13, 10, 0, 0, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportIsDeterministicAndSelfDescribing(t *testing.T) {
|
||||
first, firstMeta, err := Export(testDocument())
|
||||
if err != nil {
|
||||
t.Fatalf("first export: %v", err)
|
||||
}
|
||||
second, secondMeta, err := Export(testDocument())
|
||||
if err != nil {
|
||||
t.Fatalf("second export: %v", err)
|
||||
}
|
||||
if !bytes.Equal(first, second) {
|
||||
t.Fatal("identical document must produce byte-identical package")
|
||||
}
|
||||
if firstMeta.PackageHash != secondMeta.PackageHash || !strings.HasPrefix(firstMeta.PackageHash, "sha256:") {
|
||||
t.Fatalf("unexpected deterministic package hash: %#v / %#v", firstMeta, secondMeta)
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(first), int64(len(first)))
|
||||
if err != nil {
|
||||
t.Fatalf("open package: %v", err)
|
||||
}
|
||||
if len(zr.File) != 3 {
|
||||
t.Fatalf("zip entry count = %d, want 3", len(zr.File))
|
||||
}
|
||||
wantNames := []string{"checksums.sha256", "manifest.json", "workflows/web-src-hunting.json"}
|
||||
for i, f := range zr.File {
|
||||
if f.Name != wantNames[i] {
|
||||
t.Fatalf("entry %d = %q, want %q", i, f.Name, wantNames[i])
|
||||
}
|
||||
}
|
||||
if firstMeta.SourceRevision != 18 {
|
||||
t.Fatalf("source revision = %d, want 18", firstMeta.SourceRevision)
|
||||
}
|
||||
if !strings.HasPrefix(firstMeta.ContentHash, "sha256:") || !strings.HasPrefix(firstMeta.GraphHash, "sha256:") {
|
||||
t.Fatalf("content/graph hashes must be sha256: %#v", firstMeta)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package workflowpackage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxArchiveBytes = 10 << 20
|
||||
MaxExtractedBytes = 20 << 20
|
||||
)
|
||||
|
||||
// PackageError contains only a contract error code and safe, client-facing fields.
|
||||
type PackageError struct {
|
||||
Code string
|
||||
Message string
|
||||
Details map[string]any
|
||||
}
|
||||
|
||||
func (e *PackageError) Error() string { return e.Code + ": " + e.Message }
|
||||
|
||||
func packageError(code, message string) error {
|
||||
return &PackageError{Code: code, Message: message}
|
||||
}
|
||||
|
||||
// ErrorCode returns a package contract code without exposing internal errors.
|
||||
func ErrorCode(err error) string {
|
||||
var target *PackageError
|
||||
if errors.As(err, &target) {
|
||||
return target.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type InspectionResult struct {
|
||||
PackageHash string
|
||||
Manifest Manifest
|
||||
Document Document
|
||||
ContentHash string
|
||||
GraphHash string
|
||||
NodeCount int
|
||||
EdgeCount int
|
||||
}
|
||||
|
||||
// InspectArchive verifies an archive without executing any package content.
|
||||
// validateGraph is injected by the application so this format package has no
|
||||
// dependency on the workflow runtime or database driver.
|
||||
func InspectArchive(ctx context.Context, archive []byte, validateGraph func(context.Context, string) error) (*InspectionResult, error) {
|
||||
if len(archive) == 0 {
|
||||
return nil, packageError("WFPKG_FILE_REQUIRED", "必须上传工作流包文件")
|
||||
}
|
||||
if len(archive) > MaxArchiveBytes {
|
||||
return nil, packageError("WFPKG_FILE_TOO_LARGE", "工作流包文件超过大小限制")
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包不是有效 ZIP 文件")
|
||||
}
|
||||
entries := make(map[string][]byte, len(zr.File))
|
||||
var extracted int64
|
||||
for _, file := range zr.File {
|
||||
if !safeArchivePath(file.Name) || file.FileInfo().IsDir() || file.FileInfo().Mode()&os.ModeSymlink != 0 {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包包含不安全文件路径")
|
||||
}
|
||||
if _, exists := entries[file.Name]; exists {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包包含重复文件")
|
||||
}
|
||||
if file.UncompressedSize64 > MaxExtractedBytes || extracted+int64(file.UncompressedSize64) > MaxExtractedBytes {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包解压后超过大小限制")
|
||||
}
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "无法读取工作流包文件")
|
||||
}
|
||||
data, readErr := io.ReadAll(io.LimitReader(reader, int64(MaxExtractedBytes)-extracted+1))
|
||||
closeErr := reader.Close()
|
||||
if readErr != nil || closeErr != nil || len(data) > MaxExtractedBytes-int(extracted) {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包解压后超过大小限制")
|
||||
}
|
||||
extracted += int64(len(data))
|
||||
entries[file.Name] = data
|
||||
}
|
||||
|
||||
manifestRaw, hasManifest := entries["manifest.json"]
|
||||
checksumsRaw, hasChecksums := entries["checksums.sha256"]
|
||||
if !hasManifest || !hasChecksums {
|
||||
return nil, packageError("WFPKG_UNSUPPORTED_FORMAT", "工作流包缺少必需文件")
|
||||
}
|
||||
manifest, err := parseManifest(manifestRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(manifest.Items) != 1 || manifest.Items[0].Type != "workflow" {
|
||||
return nil, packageError("WFPKG_MULTIPLE_WORKFLOWS", "工作流包必须且只能包含一个工作流")
|
||||
}
|
||||
item := manifest.Items[0]
|
||||
workflowRaw, exists := entries[item.Path]
|
||||
if !exists || !safeWorkflowPath(item.Path) || len(entries) != 3 {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包包含未声明文件")
|
||||
}
|
||||
checksums, err := parseChecksums(checksumsRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(checksums) != 2 || checksums["manifest.json"] != sha256Prefixed(manifestRaw) || checksums[item.Path] != sha256Prefixed(workflowRaw) {
|
||||
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包校验和不匹配")
|
||||
}
|
||||
if item.ContentHash != sha256Prefixed(workflowRaw) || !validHash(item.ContentHash) || !validHash(item.GraphHash) {
|
||||
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包内容校验和不匹配")
|
||||
}
|
||||
doc, err := parseDocument(workflowRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !safePackageWorkflowID(doc.ID) || doc.ID != item.SourceID || doc.Version != item.SourceRevision {
|
||||
return nil, packageError("WFPKG_INVALID_MANIFEST", "工作流包清单与工作流内容不一致")
|
||||
}
|
||||
graph, err := canonicalJSON([]byte(doc.GraphJSON))
|
||||
if err != nil || item.GraphHash != sha256Prefixed(graph) {
|
||||
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流图校验和不匹配")
|
||||
}
|
||||
if validateGraph == nil || validateGraph(ctx, string(graph)) != nil {
|
||||
return nil, packageError("WFPKG_WORKFLOW_INVALID", "工作流图校验失败")
|
||||
}
|
||||
var graphShape struct {
|
||||
Nodes []json.RawMessage `json:"nodes"`
|
||||
Edges []json.RawMessage `json:"edges"`
|
||||
}
|
||||
if err := json.Unmarshal(graph, &graphShape); err != nil {
|
||||
return nil, packageError("WFPKG_WORKFLOW_INVALID", "工作流图不是有效 JSON")
|
||||
}
|
||||
return &InspectionResult{
|
||||
PackageHash: sha256Prefixed(archive),
|
||||
Manifest: manifest,
|
||||
Document: doc,
|
||||
ContentHash: item.ContentHash,
|
||||
GraphHash: item.GraphHash,
|
||||
NodeCount: len(graphShape.Nodes),
|
||||
EdgeCount: len(graphShape.Edges),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func safeArchivePath(name string) bool {
|
||||
return name != "" && !strings.Contains(name, `\`) && !strings.HasPrefix(name, "/") && path.Clean(name) == name && !strings.HasPrefix(name, "../") && name != ".."
|
||||
}
|
||||
|
||||
func safeWorkflowPath(name string) bool {
|
||||
rest := strings.TrimPrefix(name, "workflows/")
|
||||
return safeArchivePath(name) && strings.HasPrefix(name, "workflows/") && rest != "" && !strings.Contains(rest, "/") && strings.HasSuffix(rest, ".json")
|
||||
}
|
||||
|
||||
func safePackageWorkflowID(id string) bool {
|
||||
if id == "" || strings.ContainsAny(id, `/\`) {
|
||||
return false
|
||||
}
|
||||
for _, r := range id {
|
||||
if unicode.IsControl(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseManifest(raw []byte) (Manifest, error) {
|
||||
var manifest Manifest
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&manifest); err != nil {
|
||||
return Manifest{}, packageError("WFPKG_INVALID_MANIFEST", "工作流包清单格式无效")
|
||||
}
|
||||
if err := consumeJSONEnd(dec); err != nil || manifest.PackageFormat != PackageFormat || manifest.FormatVersion != FormatVersion || strings.TrimSpace(manifest.PackageID) == "" || len(manifest.Items) == 0 {
|
||||
return Manifest{}, packageError("WFPKG_INVALID_MANIFEST", "工作流包清单格式不受支持")
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func parseDocument(raw []byte) (Document, error) {
|
||||
var doc Document
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&doc); err != nil || consumeJSONEnd(dec) != nil {
|
||||
return Document{}, packageError("WFPKG_WORKFLOW_INVALID", "工作流定义格式无效")
|
||||
}
|
||||
doc.ID = strings.TrimSpace(doc.ID)
|
||||
doc.Name = strings.TrimSpace(doc.Name)
|
||||
if doc.ID == "" || doc.Name == "" || doc.Version <= 0 || strings.TrimSpace(doc.GraphJSON) == "" {
|
||||
return Document{}, packageError("WFPKG_WORKFLOW_INVALID", "工作流定义缺少必需字段")
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func consumeJSONEnd(dec *json.Decoder) error {
|
||||
var extra any
|
||||
if err := dec.Decode(&extra); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("multiple JSON values")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseChecksums(raw []byte) (map[string]string, error) {
|
||||
entries := make(map[string]string)
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
|
||||
parts := strings.SplitN(strings.TrimSpace(line), " ", 2)
|
||||
if len(parts) != 2 || !validHash("sha256:"+parts[0]) || !safeArchivePath(parts[1]) {
|
||||
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包校验和格式无效")
|
||||
}
|
||||
if _, exists := entries[parts[1]]; exists {
|
||||
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包校验和重复")
|
||||
}
|
||||
entries[parts[1]] = "sha256:" + parts[0]
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package workflowpackage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInspectArchiveAcceptsSingleVerifiedWorkflow(t *testing.T) {
|
||||
pkg, meta, err := Export(testDocument())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := InspectArchive(context.Background(), pkg, func(context.Context, string) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("InspectArchive: %v", err)
|
||||
}
|
||||
if result.PackageHash != meta.PackageHash || result.Document.ID != "web-src-hunting" {
|
||||
t.Fatalf("unexpected inspection: %#v", result)
|
||||
}
|
||||
if result.NodeCount != 2 || result.EdgeCount != 1 {
|
||||
t.Fatalf("counts = %d/%d, want 2/1", result.NodeCount, result.EdgeCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectArchiveRejectsUnsafeArchiveShapes(t *testing.T) {
|
||||
valid, _, err := Export(testDocument())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
archive []byte
|
||||
}{
|
||||
{name: "duplicate entry", archive: appendZipEntry(t, valid, "manifest.json", []byte(`{}`), 0)},
|
||||
{name: "path traversal", archive: appendZipEntry(t, valid, "../payload.json", []byte(`{}`), 0)},
|
||||
{name: "symlink", archive: appendZipEntry(t, valid, "workflows/link.json", []byte("target"), 0o120777)},
|
||||
{name: "undeclared file", archive: appendZipEntry(t, valid, "notes.txt", []byte("not allowed"), 0)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := InspectArchive(context.Background(), tc.archive, func(context.Context, string) error { return nil })
|
||||
if ErrorCode(err) != "WFPKG_INVALID_ARCHIVE" {
|
||||
t.Fatalf("code = %q, err = %v", ErrorCode(err), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectArchiveRejectsChecksumMismatchAndInvalidWorkflow(t *testing.T) {
|
||||
pkg, _, err := Export(testDocument())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
badChecksum := replaceZipEntry(t, pkg, "checksums.sha256", []byte("00 manifest.json\n"), 0)
|
||||
if _, err := InspectArchive(context.Background(), badChecksum, func(context.Context, string) error { return nil }); ErrorCode(err) != "WFPKG_CHECKSUM_MISMATCH" {
|
||||
t.Fatalf("checksum code = %q, err = %v", ErrorCode(err), err)
|
||||
}
|
||||
if _, err := InspectArchive(context.Background(), pkg, func(context.Context, string) error { return errors.New("invalid graph") }); ErrorCode(err) != "WFPKG_WORKFLOW_INVALID" {
|
||||
t.Fatalf("graph code = %q, err = %v", ErrorCode(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
func appendZipEntry(t *testing.T, archive []byte, name string, data []byte, mode os.FileMode) []byte {
|
||||
t.Helper()
|
||||
return rewriteZip(t, archive, func(zw *zip.Writer) error {
|
||||
h := &zip.FileHeader{Name: name, Method: zip.Store}
|
||||
h.SetMode(mode)
|
||||
w, err := zw.CreateHeader(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func replaceZipEntry(t *testing.T, archive []byte, name string, data []byte, mode os.FileMode) []byte {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
for _, f := range zr.File {
|
||||
if f.Name == name {
|
||||
h := &zip.FileHeader{Name: name, Method: zip.Store}
|
||||
h.SetMode(mode)
|
||||
w, err := zw.CreateHeader(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
r, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &zip.FileHeader{Name: f.Name, Method: zip.Store}
|
||||
w, err := zw.CreateHeader(h)
|
||||
if err == nil {
|
||||
_, err = io.Copy(w, r)
|
||||
}
|
||||
_ = r.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func rewriteZip(t *testing.T, archive []byte, appendEntry func(*zip.Writer) error) []byte {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
for _, f := range zr.File {
|
||||
r, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &zip.FileHeader{Name: f.Name, Method: zip.Store}
|
||||
w, err := zw.CreateHeader(h)
|
||||
if err == nil {
|
||||
_, err = io.Copy(w, r)
|
||||
}
|
||||
_ = r.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := appendEntry(zw); err != nil {
|
||||
t.Fatal(fmt.Errorf("append entry: %w", err))
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package workflowpackage
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
PackageFormat = "cyberstrikeai.workflow-package"
|
||||
FormatVersion = "1.0"
|
||||
)
|
||||
|
||||
// Document is the single non-executable workflow definition carried by a package.
|
||||
// Version is the source instance revision and is never applied as a target version.
|
||||
type Document struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version int `json:"version"`
|
||||
GraphJSON string `json:"graph_json"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
PackageFormat string `json:"package_format"`
|
||||
FormatVersion string `json:"format_version"`
|
||||
PackageID string `json:"package_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Items []ManifestItem `json:"items"`
|
||||
}
|
||||
|
||||
type ManifestItem struct {
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
SourceID string `json:"source_id"`
|
||||
SourceRevision int `json:"source_revision"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
GraphHash string `json:"graph_hash"`
|
||||
}
|
||||
|
||||
type ExportMetadata struct {
|
||||
PackageHash string
|
||||
ContentHash string
|
||||
GraphHash string
|
||||
SourceRevision int
|
||||
FileName string
|
||||
}
|
||||
|
||||
func canonicalJSON(raw []byte) ([]byte, error) {
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.UseNumber()
|
||||
var value any
|
||||
if err := dec.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dec.More() {
|
||||
return nil, fmt.Errorf("extra JSON values")
|
||||
}
|
||||
return json.Marshal(value)
|
||||
}
|
||||
|
||||
func sha256Prefixed(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func validHash(value string) bool {
|
||||
if !strings.HasPrefix(value, "sha256:") || len(value) != len("sha256:")+64 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:"))
|
||||
return err == nil && value == strings.ToLower(value)
|
||||
}
|
||||
Reference in New Issue
Block a user