247 lines
7.3 KiB
Go
247 lines
7.3 KiB
Go
package docrevision
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
ProjectionAligned = "ALIGNED"
|
|
ProjectionLegacyCanonicalizationDrift = "LEGACY_CANONICALIZATION_DRIFT"
|
|
ProjectionContentFork = "CONTENT_FORK"
|
|
ProjectionMissing = "MISSING"
|
|
ProjectionEmpty = "EMPTY"
|
|
ProjectionNoLedger = "NO_LEDGER"
|
|
)
|
|
|
|
// ProjectionClass is a dry-run classification of file vs ledger head.
|
|
type ProjectionClass struct {
|
|
Class string
|
|
RawSHA string
|
|
CanonSHA string
|
|
LegacySHA string
|
|
HeadSHA string
|
|
RawBytes int
|
|
CanonBytes int
|
|
}
|
|
|
|
func ClassifyProjection(raw []byte, headMarkdown string) ProjectionClass {
|
|
head := CanonicalizeMarkdown(headMarkdown)
|
|
out := ProjectionClass{HeadSHA: SHA256Hex(head)}
|
|
if raw == nil {
|
|
out.Class = ProjectionMissing
|
|
return out
|
|
}
|
|
if len(raw) == 0 {
|
|
out.Class = ProjectionEmpty
|
|
return out
|
|
}
|
|
out.RawBytes = len(raw)
|
|
out.RawSHA = SHA256Hex(string(raw))
|
|
canon := CanonicalizeMarkdown(string(raw))
|
|
out.CanonSHA = SHA256Hex(canon)
|
|
out.CanonBytes = len(canon)
|
|
out.LegacySHA = SHA256Hex(LegacyDestructiveCanonicalizeMarkdown(string(raw)))
|
|
if headMarkdown == "" {
|
|
out.Class = ProjectionNoLedger
|
|
return out
|
|
}
|
|
if out.CanonSHA == out.HeadSHA {
|
|
out.Class = ProjectionAligned
|
|
return out
|
|
}
|
|
if out.LegacySHA == out.HeadSHA {
|
|
out.Class = ProjectionLegacyCanonicalizationDrift
|
|
return out
|
|
}
|
|
out.Class = ProjectionContentFork
|
|
return out
|
|
}
|
|
|
|
func (s *Service) resolveWorkDir(workDir, repoRoot string) string {
|
|
path, err := s.resolveWorkDirErr(workDir, repoRoot)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return path
|
|
}
|
|
|
|
func (s *Service) resolveWorkDirErr(workDir, repoRoot string) (string, error) {
|
|
if s != nil && (strings.TrimSpace(s.StoredDataRoot) != "" || strings.TrimSpace(s.HostDataRoot) != "") {
|
|
mapped, err := MapStoredDataPath(workDir, s.StoredDataRoot, s.HostDataRoot)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
workDir = mapped
|
|
}
|
|
if filepath.IsAbs(workDir) {
|
|
return workDir, nil
|
|
}
|
|
base := repoRoot
|
|
if s != nil && s.DataDir != "" {
|
|
base = s.DataDir
|
|
}
|
|
if base == "" {
|
|
return "", nil
|
|
}
|
|
return filepath.Join(base, workDir), nil
|
|
}
|
|
|
|
// MapStoredDataPath rewrites a stored work_dir under storedRoot onto hostRoot.
|
|
// When mapping is configured, workDir must live under storedRoot; other
|
|
// absolute paths are rejected instead of being reused silently.
|
|
func MapStoredDataPath(workDir, storedRoot, hostRoot string) (string, error) {
|
|
workDir = strings.TrimSpace(workDir)
|
|
storedRoot = strings.TrimSpace(storedRoot)
|
|
hostRoot = strings.TrimSpace(hostRoot)
|
|
if workDir == "" {
|
|
return "", nil
|
|
}
|
|
if storedRoot == "" && hostRoot == "" {
|
|
return workDir, nil
|
|
}
|
|
if storedRoot == "" || hostRoot == "" {
|
|
return "", fmt.Errorf("docrevision: stored-data-root and host-data-root must be set together")
|
|
}
|
|
workSlash := filepath.ToSlash(filepath.Clean(workDir))
|
|
storedSlash := filepath.ToSlash(filepath.Clean(storedRoot))
|
|
host := filepath.Clean(hostRoot)
|
|
if workSlash == storedSlash {
|
|
return host, nil
|
|
}
|
|
prefix := storedSlash + "/"
|
|
if !strings.HasPrefix(workSlash, prefix) {
|
|
return "", fmt.Errorf("docrevision: stored work_dir %q is not under stored-data-root %q", workDir, storedRoot)
|
|
}
|
|
rel := strings.TrimPrefix(workSlash, prefix)
|
|
mapped := filepath.Join(host, filepath.FromSlash(rel))
|
|
mappedSlash := filepath.ToSlash(filepath.Clean(mapped))
|
|
hostSlash := filepath.ToSlash(host)
|
|
if mappedSlash != hostSlash && !strings.HasPrefix(mappedSlash, hostSlash+"/") {
|
|
return "", fmt.Errorf("docrevision: mapped path escapes host data root")
|
|
}
|
|
return mapped, nil
|
|
}
|
|
|
|
// InspectProjection classifies workspace deliverable bytes against the current ledger head.
|
|
func (s *Service) InspectProjection(ctx context.Context, docID, repoRoot string) (ProjectionClass, error) {
|
|
if err := requireDocID(docID); err != nil {
|
|
return ProjectionClass{}, err
|
|
}
|
|
// T20 B3: hydrate 段持 doc 锁;deliverable 读取在锁外。
|
|
g := s.lockDoc(docID)
|
|
s.mu.Lock()
|
|
if err := s.hydrateDocLocked(ctx, docID); err != nil {
|
|
s.mu.Unlock()
|
|
g.Release()
|
|
return ProjectionClass{}, err
|
|
}
|
|
workDir, headMD, _, _, ok := s.lookupHeadLocked(docID)
|
|
s.mu.Unlock()
|
|
g.Release()
|
|
if !ok {
|
|
return ProjectionClass{Class: ProjectionNoLedger}, nil
|
|
}
|
|
path, rerr := s.resolveWorkDirErr(workDir, repoRoot)
|
|
if rerr != nil {
|
|
return ProjectionClass{}, rerr
|
|
}
|
|
if path == "" {
|
|
return ProjectionClass{Class: ProjectionMissing, HeadSHA: SHA256Hex(CanonicalizeMarkdown(headMD))}, nil
|
|
}
|
|
if s.deliverables == nil {
|
|
return ProjectionClass{Class: ProjectionMissing, HeadSHA: SHA256Hex(CanonicalizeMarkdown(headMD))}, nil
|
|
}
|
|
raw, err := s.deliverables.Read(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return ClassifyProjection(nil, headMD), nil
|
|
}
|
|
return ProjectionClass{}, err
|
|
}
|
|
return ClassifyProjection(raw, headMD), nil
|
|
}
|
|
|
|
func (s *Service) lookupHeadLocked(docID string) (workDir, md, sha string, snapID int64, ok bool) {
|
|
root := s.roots[docID]
|
|
if root == nil || root.HeadSnapshotID == nil {
|
|
return "", "", "", 0, false
|
|
}
|
|
snap := s.snapshots[*root.HeadSnapshotID]
|
|
if snap == nil {
|
|
return "", "", "", 0, false
|
|
}
|
|
blob := s.blobs[snap.ContentSHA256]
|
|
if blob == nil {
|
|
return "", "", "", 0, false
|
|
}
|
|
return root.WorkDir, blob.ContentMarkdown, snap.ContentSHA256, snap.ID, true
|
|
}
|
|
|
|
// HeadContent returns ledger head markdown and SHA. ErrNotFound if no ledger.
|
|
func (s *Service) HeadContent(ctx context.Context, docID string) (string, string, error) {
|
|
if err := requireDocID(docID); err != nil {
|
|
return "", "", err
|
|
}
|
|
g := s.lockDoc(docID)
|
|
defer g.Release()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if err := s.hydrateDocLocked(ctx, docID); err != nil {
|
|
return "", "", err
|
|
}
|
|
_, md, sha, _, ok := s.lookupHeadLocked(docID)
|
|
if !ok {
|
|
return "", "", ErrNotFound
|
|
}
|
|
return md, sha, nil
|
|
}
|
|
|
|
// ProjectOutputMarkdown writes head bytes to the workspace deliverable after an intentional write.
|
|
func (s *Service) ProjectOutputMarkdown(docID, repoRoot string) error {
|
|
return s.projectOutputMarkdown(docID, repoRoot, true)
|
|
}
|
|
|
|
// ProjectOutputMarkdownIfSafe refuses to overwrite legacy drift or a real fork.
|
|
func (s *Service) ProjectOutputMarkdownIfSafe(docID, repoRoot string) error {
|
|
return s.projectOutputMarkdown(docID, repoRoot, false)
|
|
}
|
|
|
|
func (s *Service) projectOutputMarkdown(docID, repoRoot string, force bool) error {
|
|
// T20 B3: 读 head 持 doc 锁;deliverable 写在锁外(对齐/冲突判定在读取快照上做)。
|
|
g := s.lockDoc(docID)
|
|
s.mu.Lock()
|
|
workDir, md, _, _, ok := s.lookupHeadLocked(docID)
|
|
s.mu.Unlock()
|
|
g.Release()
|
|
if !ok || workDir == "" {
|
|
return nil
|
|
}
|
|
path, pathErr := s.resolveWorkDirErr(workDir, repoRoot)
|
|
if pathErr != nil {
|
|
return pathErr
|
|
}
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
if s.deliverables == nil {
|
|
return fmt.Errorf("docrevision: deliverable store not configured")
|
|
}
|
|
existing, err := s.deliverables.Read(path)
|
|
if err == nil {
|
|
class := ClassifyProjection(existing, md)
|
|
if class.Class == ProjectionAligned && string(existing) == md {
|
|
return nil
|
|
}
|
|
if !force && (class.Class == ProjectionLegacyCanonicalizationDrift || class.Class == ProjectionContentFork) {
|
|
return fmt.Errorf("docrevision: refuse silent overwrite of %s (file=%s head=%s)", class.Class, class.RawSHA, class.HeadSHA)
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return s.deliverables.Write(path, []byte(md))
|
|
}
|