Files

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))
}