63f3b2c503
Annotation in 来源与目录 is now the only import decision surface: - migration 000033: catalog_import_intents (durable, rescan-proof per-file intent with locator snapshot + fetch lifecycle), source_items.policy_state, planned publication state, ui_setting_revisions.ingestion_concurrency - marking fans a folder decision out to per-file intents, materializes versioned items, reuses already-verified local bytes (compare, don't pull), and plans one publication per routed KB; blocked retires planned rows while live publications stay untouched (需下架 shows in the KB diff) - artifact.fetch worker lane (scripts/artifact_fetch.py): DingTalk export tree/live box API, AList /api/fs/get direct download with SSO-redirect fail-closed, PP ledger via locator hash; content-level dedupe on sha256 - 开始处理 promotes planned->pending inside the claim-time concurrency budget from system settings; claims enforce it in SQL - monitor overview rebuilt as knowledge-base diff: planned/needs_removal per KB + fetch pipeline summary; frontend lanes 待入库/需下架 replace the deprecated snapshot change-set confirmation - spool: images (png/jpg/gif/bmp/webp) accepted, ppt/pptx rejected in favour of PDF twins; DingTalk PPT resolves its converted PDF pair
311 lines
9.6 KiB
Go
311 lines
9.6 KiB
Go
package spool
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bufio"
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"hash"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"knowledge-governance/internal/governance"
|
|
)
|
|
|
|
type Expectation struct {
|
|
RelativeRef string
|
|
SHA256 string
|
|
SizeBytes int64
|
|
Format string
|
|
}
|
|
|
|
type Artifact struct {
|
|
file *os.File
|
|
expectedSHA string
|
|
expectedSize int64
|
|
streamHash hash.Hash
|
|
streamed int64
|
|
}
|
|
|
|
var supportedFormats = map[string]struct{}{
|
|
"pdf": {}, "docx": {}, "doc": {}, "xlsx": {}, "xls": {},
|
|
"md": {}, "txt": {}, "csv": {}, "json": {},
|
|
"png": {}, "jpg": {}, "jpeg": {}, "gif": {}, "bmp": {}, "webp": {},
|
|
}
|
|
|
|
func InspectVerified(root, relativeRef, format string, maxBytes int64) (Expectation, error) {
|
|
if !filepath.IsAbs(root) || relativeRef == "" || filepath.IsAbs(relativeRef) || maxBytes <= 0 {
|
|
return Expectation{}, errors.New("invalid spool reference")
|
|
}
|
|
cleanRef := filepath.Clean(relativeRef)
|
|
if cleanRef == "." || cleanRef != relativeRef || cleanRef == ".." || strings.HasPrefix(cleanRef, ".."+string(filepath.Separator)) {
|
|
return Expectation{}, errors.New("invalid spool reference")
|
|
}
|
|
if _, ok := supportedFormats[format]; !ok {
|
|
return Expectation{}, errors.New("unsupported spool artifact format")
|
|
}
|
|
file, err := openRegularNoSymlink(root, cleanRef)
|
|
if err != nil {
|
|
return Expectation{}, errors.New("open spool artifact")
|
|
}
|
|
defer file.Close()
|
|
info, err := file.Stat()
|
|
if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxBytes {
|
|
return Expectation{}, errors.New("spool artifact size or type mismatch")
|
|
}
|
|
digest := sha256.New()
|
|
bytesRead, err := io.Copy(digest, file)
|
|
if err != nil || bytesRead != info.Size() {
|
|
return Expectation{}, errors.New("inspect spool artifact content")
|
|
}
|
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
|
return Expectation{}, errors.New("rewind spool artifact")
|
|
}
|
|
if err := validateFormat(file, format, info.Size()); err != nil {
|
|
return Expectation{}, errors.New("spool artifact format mismatch")
|
|
}
|
|
return Expectation{
|
|
RelativeRef: cleanRef,
|
|
SHA256: hex.EncodeToString(digest.Sum(nil)),
|
|
SizeBytes: info.Size(),
|
|
Format: format,
|
|
}, nil
|
|
}
|
|
|
|
func OpenVerified(root string, expected Expectation, maxBytes int64) (*Artifact, error) {
|
|
if !filepath.IsAbs(root) || expected.RelativeRef == "" || filepath.IsAbs(expected.RelativeRef) {
|
|
return nil, errors.New("invalid spool reference")
|
|
}
|
|
cleanRef := filepath.Clean(expected.RelativeRef)
|
|
if cleanRef == "." || cleanRef != expected.RelativeRef || cleanRef == ".." || strings.HasPrefix(cleanRef, ".."+string(filepath.Separator)) {
|
|
return nil, errors.New("invalid spool reference")
|
|
}
|
|
if !governance.ValidSHA256(expected.SHA256) || expected.SizeBytes <= 0 || maxBytes <= 0 || expected.SizeBytes > maxBytes {
|
|
return nil, errors.New("invalid spool expectation")
|
|
}
|
|
if _, ok := supportedFormats[expected.Format]; !ok {
|
|
return nil, errors.New("unsupported spool artifact format")
|
|
}
|
|
file, err := openRegularNoSymlink(root, cleanRef)
|
|
if err != nil {
|
|
return nil, errors.New("open spool artifact")
|
|
}
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
file.Close()
|
|
return nil, errors.New("inspect spool artifact")
|
|
}
|
|
if !info.Mode().IsRegular() || info.Size() != expected.SizeBytes {
|
|
file.Close()
|
|
return nil, errors.New("spool artifact size or type mismatch")
|
|
}
|
|
beforeHash := sha256.New()
|
|
bytesRead, err := io.Copy(beforeHash, file)
|
|
if err != nil || bytesRead != expected.SizeBytes || hex.EncodeToString(beforeHash.Sum(nil)) != expected.SHA256 {
|
|
file.Close()
|
|
return nil, errors.New("spool artifact content mismatch")
|
|
}
|
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
|
file.Close()
|
|
return nil, errors.New("rewind spool artifact")
|
|
}
|
|
if err := validateFormat(file, expected.Format, expected.SizeBytes); err != nil {
|
|
file.Close()
|
|
return nil, errors.New("spool artifact format mismatch")
|
|
}
|
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
|
file.Close()
|
|
return nil, errors.New("rewind spool artifact after validation")
|
|
}
|
|
return &Artifact{
|
|
file: file,
|
|
expectedSHA: expected.SHA256,
|
|
expectedSize: expected.SizeBytes,
|
|
streamHash: sha256.New(),
|
|
}, nil
|
|
}
|
|
|
|
func validateFormat(file *os.File, format string, size int64) error {
|
|
switch format {
|
|
case "pdf":
|
|
header := make([]byte, len("%PDF-"))
|
|
if _, err := io.ReadFull(file, header); err != nil || !bytes.Equal(header, []byte("%PDF-")) {
|
|
return errors.New("invalid PDF magic")
|
|
}
|
|
case "docx", "xlsx":
|
|
archive, err := zip.NewReader(file, size)
|
|
if err != nil {
|
|
return errors.New("invalid Office ZIP")
|
|
}
|
|
required := map[string]string{
|
|
"docx": "word/document.xml",
|
|
"xlsx": "xl/workbook.xml",
|
|
}[format]
|
|
foundContentTypes := false
|
|
foundRequired := false
|
|
for _, entry := range archive.File {
|
|
switch entry.Name {
|
|
case "[Content_Types].xml":
|
|
foundContentTypes = true
|
|
case required:
|
|
foundRequired = true
|
|
}
|
|
}
|
|
if !foundContentTypes || !foundRequired {
|
|
return errors.New("missing Office package entry")
|
|
}
|
|
case "doc", "xls":
|
|
header := make([]byte, 8)
|
|
oleMagic := []byte{0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1}
|
|
if _, err := io.ReadFull(file, header); err != nil || !bytes.Equal(header, oleMagic) {
|
|
return errors.New("invalid legacy Office magic")
|
|
}
|
|
case "png", "jpg", "jpeg", "gif", "bmp", "webp":
|
|
header := make([]byte, 12)
|
|
n, err := file.Read(header)
|
|
if err != nil || n < 12 {
|
|
return errors.New("invalid image header")
|
|
}
|
|
valid := false
|
|
switch format {
|
|
case "png":
|
|
valid = bytes.Equal(header[:8], []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})
|
|
case "jpg", "jpeg":
|
|
valid = header[0] == 0xff && header[1] == 0xd8 && header[2] == 0xff
|
|
case "gif":
|
|
valid = bytes.Equal(header[:6], []byte("GIF87a")) || bytes.Equal(header[:6], []byte("GIF89a"))
|
|
case "bmp":
|
|
valid = header[0] == 'B' && header[1] == 'M'
|
|
case "webp":
|
|
valid = bytes.Equal(header[:4], []byte("RIFF")) && bytes.Equal(header[8:12], []byte("WEBP"))
|
|
}
|
|
if !valid {
|
|
return errors.New("invalid image magic")
|
|
}
|
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
|
return errors.New("rewind image artifact")
|
|
}
|
|
case "json":
|
|
decoder := json.NewDecoder(file)
|
|
var value any
|
|
if err := decoder.Decode(&value); err != nil {
|
|
return errors.New("invalid JSON")
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
return errors.New("multiple JSON values")
|
|
}
|
|
case "md", "txt", "csv":
|
|
prefixSize := size
|
|
if prefixSize > 4096 {
|
|
prefixSize = 4096
|
|
}
|
|
prefix := make([]byte, prefixSize)
|
|
if _, err := io.ReadFull(file, prefix); err != nil {
|
|
return errors.New("read text prefix")
|
|
}
|
|
lowerPrefix := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(string(prefix), "\ufeff")))
|
|
if strings.HasPrefix(lowerPrefix, "<!doctype html") || strings.HasPrefix(lowerPrefix, "<html") {
|
|
return errors.New("HTML is not a supported text artifact")
|
|
}
|
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
|
return errors.New("rewind text artifact")
|
|
}
|
|
reader := bufio.NewReader(file)
|
|
for {
|
|
character, width, err := reader.ReadRune()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil || character == 0 || (character == utf8.RuneError && width == 1) {
|
|
return errors.New("invalid text content")
|
|
}
|
|
}
|
|
default:
|
|
return errors.New("unsupported format")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func openRegularNoSymlink(rootPath, relativeRef string) (*os.File, error) {
|
|
rootInfo, err := os.Lstat(rootPath)
|
|
if err != nil || rootInfo.Mode()&os.ModeSymlink != 0 || !rootInfo.IsDir() {
|
|
return nil, errors.New("invalid spool root")
|
|
}
|
|
current, err := os.OpenRoot(rootPath)
|
|
if err != nil {
|
|
return nil, errors.New("open spool root")
|
|
}
|
|
openedRootInfo, err := current.Stat(".")
|
|
if err != nil || !os.SameFile(rootInfo, openedRootInfo) {
|
|
current.Close()
|
|
return nil, errors.New("spool root changed while opening")
|
|
}
|
|
|
|
parts := strings.Split(relativeRef, string(filepath.Separator))
|
|
for _, part := range parts[:len(parts)-1] {
|
|
before, err := current.Lstat(part)
|
|
if err != nil || before.Mode()&os.ModeSymlink != 0 || !before.IsDir() {
|
|
current.Close()
|
|
return nil, errors.New("invalid spool directory component")
|
|
}
|
|
next, err := current.OpenRoot(part)
|
|
if err != nil {
|
|
current.Close()
|
|
return nil, errors.New("open spool directory component")
|
|
}
|
|
after, err := next.Stat(".")
|
|
if err != nil || !os.SameFile(before, after) {
|
|
next.Close()
|
|
current.Close()
|
|
return nil, errors.New("spool directory changed while opening")
|
|
}
|
|
current.Close()
|
|
current = next
|
|
}
|
|
|
|
fileName := parts[len(parts)-1]
|
|
before, err := current.Lstat(fileName)
|
|
if err != nil || before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() {
|
|
current.Close()
|
|
return nil, errors.New("invalid spool artifact type")
|
|
}
|
|
file, err := current.Open(fileName)
|
|
if err != nil {
|
|
current.Close()
|
|
return nil, errors.New("open spool artifact")
|
|
}
|
|
after, err := file.Stat()
|
|
current.Close()
|
|
if err != nil || !os.SameFile(before, after) || !after.Mode().IsRegular() {
|
|
file.Close()
|
|
return nil, errors.New("spool artifact changed while opening")
|
|
}
|
|
return file, nil
|
|
}
|
|
|
|
func (artifact *Artifact) Read(buffer []byte) (int, error) {
|
|
count, err := artifact.file.Read(buffer)
|
|
if count > 0 {
|
|
artifact.streamed += int64(count)
|
|
_, _ = artifact.streamHash.Write(buffer[:count])
|
|
}
|
|
return count, err
|
|
}
|
|
|
|
func (artifact *Artifact) VerifyComplete() error {
|
|
if artifact.streamed != artifact.expectedSize || hex.EncodeToString(artifact.streamHash.Sum(nil)) != artifact.expectedSHA {
|
|
return errors.New("spool artifact changed during streaming")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (artifact *Artifact) Close() error {
|
|
return artifact.file.Close()
|
|
}
|