package verify
import (
"database/sql"
"fmt"
"slices"
"github.com/danmestas/go-libfossil/db"
"github.com/danmestas/go-libfossil/internal/blob"
"github.com/danmestas/go-libfossil/internal/content"
"github.com/danmestas/go-libfossil/internal/deck"
libfossil "github.com/danmestas/go-libfossil/internal/fsltype"
"github.com/danmestas/go-libfossil/internal/leaf"
"github.com/danmestas/go-libfossil/internal/repo"
)
// checkDerived verifies consistency of derived tables: event, mlink, plink, tagxref, filename, leaf.
func checkDerived(r *repo.Repo, report *Report, cache *content.Cache) error {
if r == nil {
panic("verify: checkDerived: nil repo")
}
if report == nil {
panic("verify: checkDerived: nil report")
}
// detached collects the parents a P-card or a parent tag names that the
// check-in's plink no longer holds, which checkLeaves must not hold to a
// leaf verdict.
detached := make(map[libfossil.FslID]struct{})
if err := checkCheckins(r, report, cache, detached); err != nil {
return err
}
if err := checkLeaves(r, report, detached); err != nil {
return err
}
return nil
}
// checkCheckins verifies event and plink tables against checkin manifests.
func checkCheckins(r *repo.Repo, report *Report, cache *content.Cache, detached map[libfossil.FslID]struct{}) error {
if r == nil {
panic("verify: checkCheckins: nil repo")
}
if report == nil {
panic("verify: checkCheckins: nil report")
}
history, err := parentHistory(r.DB(), cache)
if err != nil {
return err
}
rows, err := r.DB().Query("SELECT rid, uuid FROM blob WHERE size >= 0")
if err != nil {
return fmt.Errorf("checkCheckins query blobs: %w", err)
}
defer rows.Close()
for rows.Next() {
var rid int64
var uuid string
if err := rows.Scan(&rid, &uuid); err != nil {
return fmt.Errorf("checkCheckins scan: %w", err)
}
if err := checkOneCheckin(r, report, libfossil.FslID(rid), uuid, cache, detached, history); err != nil {
return err
}
}
return rows.Err()
}
// checkOneCheckin verifies a single blob's derived data if it's a checkin.
func checkOneCheckin(
r *repo.Repo, report *Report, rid libfossil.FslID, uuid string, cache *content.Cache,
detached map[libfossil.FslID]struct{}, history map[string][][]string,
) error {
if r == nil {
panic("verify: checkOneCheckin: nil repo")
}
if report == nil {
panic("verify: checkOneCheckin: nil report")
}
data, err := cache.Expand(r.DB(), rid)
if err != nil {
// Not expandable - skip silently (might be corrupt, already reported)
return nil
}
d, err := deck.Parse(data)
if err != nil {
// Not a valid manifest - skip silently
return nil
}
if err := collectReparents(r.DB(), d, uuid, detached); err != nil {
return err
}
if d.Type != deck.Checkin {
return nil
}
// Check event table
if err := checkEvent(r.DB(), report, rid, uuid); err != nil {
return err
}
parents, err := checkinParents(r.DB(), rid, uuid, d.P, history)
if err != nil {
return err
}
if !slices.Equal(parents, d.P) {
for _, parentUUID := range d.P {
if err := checkPlink(r.DB(), report, rid, uuid, parentUUID, true, detached); err != nil {
return err
}
}
}
for _, parentUUID := range parents {
if err := checkPlink(r.DB(), report, rid, uuid, parentUUID, false, detached); err != nil {
return err
}
}
return nil
}
// parentHashes follows manifest_reparent_checkin's reparent_abort: a nonempty
// list of full hashes, with exactly one separator between hashes. Malformed
// values leave the previous ancestry untouched.
func parentHashes(value string) []string {
var parents []string
for len(value) > 0 {
i := 0
for i < len(value) && value[i] > ' ' {
i++
}
hash := value[:i]
if len(hash) != 40 && len(hash) != 64 {
return nil
}
for _, c := range hash {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return nil
}
}
parents = append(parents, hash)
if i == len(value) {
break
}
value = value[i+1:]
}
return parents
}
// parentHistory retains the possible earlier singleton overrides. A cancelled
// or malformed parent tag cannot tell us which earlier override had reached a
// crosslink-end pass, but the artifacts bound the possible retained ancestry.
func parentHistory(q db.Querier, cache *content.Cache) (map[string][][]string, error) {
var count int
if err := q.QueryRow(`SELECT count(*) FROM tagxref JOIN tag USING(tagid)
WHERE tagname='parent'`).Scan(&count); err != nil {
return nil, fmt.Errorf("parentHistory tags: %w", err)
}
if count == 0 {
return nil, nil
}
rows, err := q.Query("SELECT rid, uuid FROM blob WHERE size >= 0")
if err != nil {
return nil, fmt.Errorf("parentHistory blobs: %w", err)
}
defer rows.Close()
history := make(map[string][][]string)
for rows.Next() {
var rid libfossil.FslID
var uuid string
if err := rows.Scan(&rid, &uuid); err != nil {
return nil, fmt.Errorf("parentHistory scan: %w", err)
}
data, err := cache.Expand(q, rid)
if err != nil {
continue
}
d, err := deck.Parse(data)
if err != nil {
continue
}
for _, tag := range d.T {
if tag.Name != "parent" || tag.Type != deck.TagSingleton {
continue
}
parents := parentHashes(tag.Value)
if len(parents) == 0 {
continue
}
target := tag.UUID
if target == "*" {
target = uuid
}
history[target] = append(history[target], parents)
}
}
return history, rows.Err()
}
func checkinParents(q db.Querier, rid libfossil.FslID, uuid string, original []string, history map[string][][]string) ([]string, error) {
var tagtype int
var value sql.NullString
err := q.QueryRow(`SELECT tagtype, value FROM tagxref JOIN tag USING(tagid)
WHERE tagname='parent' AND rid=?`, rid).Scan(&tagtype, &value)
if err == sql.ErrNoRows {
return original, nil
}
if err != nil {
return nil, fmt.Errorf("checkinParents tag: %w", err)
}
if parents := parentHashes(value.String); tagtype == 1 && len(parents) > 0 {
return parents, nil
}
// A cancel, propagating tag, or malformed singleton does not rewrite
// ancestry. Accept a retained override only if every parent link survives.
for _, parents := range history[uuid] {
complete := true
for _, parent := range parents {
var count int
err := q.QueryRow(`SELECT count(*) FROM plink JOIN blob ON blob.rid=plink.pid
WHERE cid=? AND uuid=?`, rid, parent).Scan(&count)
if err != nil {
return nil, fmt.Errorf("checkinParents retained link: %w", err)
}
complete = complete && count > 0
}
if complete {
return parents, nil
}
}
return original, nil
}
// collectReparents adds to detached each parent that a parent tag in d names
// and its target's plink does not hold. A later parent tag replaces an
// earlier one's tagxref row, so only the artifacts still record a parent an
// earlier reparent linked and a later one dropped.
func collectReparents(q db.Querier, d *deck.Deck, uuid string, detached map[libfossil.FslID]struct{}) error {
if q == nil {
panic("verify: collectReparents: nil querier")
}
if d == nil {
panic("verify: collectReparents: nil deck")
}
if detached == nil {
panic("verify: collectReparents: nil detached")
}
for _, t := range d.T {
if t.Name != "parent" || t.Type != deck.TagSingleton {
continue
}
target := t.UUID
if target == "*" {
target = uuid
}
cid, ok := blob.Exists(q, target)
if !ok {
continue
}
for _, parent := range parentHashes(t.Value) {
pid, ok := blob.Exists(q, parent)
if !ok {
continue
}
var count int
err := q.QueryRow("SELECT COUNT(*) FROM plink WHERE pid = ? AND cid = ?", pid, cid).Scan(&count)
if err != nil {
return fmt.Errorf("collectReparents query: %w", err)
}
if count == 0 {
detached[pid] = struct{}{}
}
}
}
return nil
}
// checkEvent verifies that a checkin has a corresponding event row.
func checkEvent(q db.Querier, report *Report, rid libfossil.FslID, uuid string) error {
if q == nil {
panic("verify: checkEvent: nil querier")
}
if report == nil {
panic("verify: checkEvent: nil report")
}
var count int
err := q.QueryRow("SELECT COUNT(*) FROM event WHERE objid = ?", rid).Scan(&count)
if err != nil {
return fmt.Errorf("checkEvent query: %w", err)
}
if count == 0 {
report.addIssue(Issue{
Kind: IssueEventMissing,
RID: rid,
UUID: uuid,
Table: "event",
Message: fmt.Sprintf("checkin rid=%d uuid=%s missing event row", rid, uuid),
})
}
return nil
}
// checkPlink verifies that a parent relationship has a corresponding plink row,
// adding a parent that lacks one to detached. A missing row is an issue only
// when the child is not reparented.
func checkPlink(
q db.Querier, report *Report, cid libfossil.FslID, childUUID string, parentUUID string,
reparented bool, detached map[libfossil.FslID]struct{},
) error {
if q == nil {
panic("verify: checkPlink: nil querier")
}
if report == nil {
panic("verify: checkPlink: nil report")
}
if detached == nil {
panic("verify: checkPlink: nil detached")
}
pid, ok := blob.Exists(q, parentUUID)
if !ok {
// Parent doesn't exist in blob table - might be phantom
return nil
}
var count int
err := q.QueryRow("SELECT COUNT(*) FROM plink WHERE pid = ? AND cid = ?", pid, cid).Scan(&count)
if err != nil {
return fmt.Errorf("checkPlink query: %w", err)
}
if count == 0 {
detached[pid] = struct{}{}
if reparented {
return nil
}
report.addIssue(Issue{
Kind: IssuePlinkMissing,
RID: cid,
UUID: childUUID,
Table: "plink",
Message: fmt.Sprintf("checkin rid=%d uuid=%s missing plink to parent %s", cid, childUUID, parentUUID),
})
}
return nil
}
// checkLeaves reports the leaf-table errors leaf.Audit can decide. Section 7.3
// lets the table depend on the order artifacts arrived in, so Verify does not
// recompute one expected table: it reports a row that a recheck has removed
// and a tagged check-in that a recheck has inserted, and a row no recheck
// writes. detached are the parents a reparent may have taken a child from, as
// checkPlink and collectReparents found them.
func checkLeaves(r *repo.Repo, report *Report, detached map[libfossil.FslID]struct{}) error {
if r == nil {
panic("verify: checkLeaves: nil repo")
}
if report == nil {
panic("verify: checkLeaves: nil report")
}
f, err := leaf.Audit(r.DB(), detached)
if err != nil {
return fmt.Errorf("checkLeaves: %w", err)
}
for _, rid := range f.Stale {
report.addIssue(Issue{
Kind: IssueLeafIncorrect,
RID: rid,
Table: "leaf",
Message: fmt.Sprintf("leaf rid=%d has a same-branch child that carries a branch tag", rid),
})
}
for _, rid := range f.Stray {
report.addIssue(Issue{
Kind: IssueLeafIncorrect,
RID: rid,
Table: "leaf",
Message: fmt.Sprintf("leaf rid=%d is no check-in a leaf recheck reaches", rid),
})
}
for _, rid := range f.Missing {
report.addIssue(Issue{
Kind: IssueLeafIncorrect,
RID: rid,
Table: "leaf",
Message: fmt.Sprintf("leaf missing rid=%d, which carries a branch tag and has no same-branch child", rid),
})
}
return nil
}