Artifact e0093e1d774185c711bc5020d79c4c7ac396c0a737d468c70ea4572283dab2ac:
- File
internal/manifest/attachment_write.go
— part of check-in
[cfc0ab51e8]
at
2026-10-29 00:15:24
on branch feat/attachments
— fix(attachments): stamp each attachment write after the latest for its name
A default-time deletion could share the addition's millisecond, leaving both rows latest: the deleted file stayed listed but could not be read. A synced attachment stamped in the future made a default-time deletion a silent no-op. Every write is now stamped strictly after the latest for its name -- now, or a millisecond after it -- and an explicit earlier or equal time is refused, for additions as for deletions.
The ambiguity error names the kinds that matched, and an external name of "." or ".." is refused up front. (user: [email protected], size: 11017) [annotate] [blame] [check-ins using] [more...]
package manifest import ( "database/sql" "errors" "fmt" "strings" "time" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/deck" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/seedcheck" ) // ErrNoAttachTarget is returned by ResolveAttachTarget when no ticket, wiki // page or tech note answers to the name. var ErrNoAttachTarget = errors.New("no ticket, wiki page or tech note by that name") // AttachmentWrite is one attachment artifact: the file name bound to a // target, and the file's bytes, or none to delete the binding. type AttachmentWrite struct { // Target is the full ticket id, wiki page name or tech note id. Target string // Name is the file name as given; only its final path component is kept. Name string // Content is stored as the attachment's blob. Nil writes a deletion. Content []byte // Mimetype, when not empty, is written as the N card. Mimetype string Comment string User string // Time is the D card; zero means now. Time time.Time } // WriteAttachment stores the artifacts fossil's attach_commit stores for an // addition (src/attach.c): Content as one ordinary blob, then the control // artifact whose A card names it, crosslinked in the same transaction and // queued for sync. A deletion is the control artifact /ainfo writes: an A // card with no source and no C card. It returns the control artifact's rid // and hash. // // Content that parses as an artifact is compressed and its name gains ".gz", // as fossil does, so an attached file is never linked as a control artifact. // // The artifact must become the latest for its name on its target, so it is // stamped strictly after the one that is latest now: a zero w.Time means now // or a millisecond after that one, whichever is later, and an explicit // w.Time no later than it is refused. Fossil marks every artifact sharing the // latest time as latest, so a tie would leave both an addition and its // deletion current. func WriteAttachment(r *repo.Repo, w AttachmentWrite) (libfossil.FslID, string, error) { if r == nil { panic("manifest.WriteAttachment: r must not be nil") } if w.Target == "" || w.User == "" { panic("manifest.WriteAttachment: w.Target and w.User must not be empty") } if w.Content == nil && (w.Comment != "" || w.Mimetype != "") { panic("manifest.WriteAttachment: a deletion carries no comment or mimetype") } uid, err := r.LocalUID(w.User) if err != nil { return 0, "", fmt.Errorf("manifest.WriteAttachment: %w", err) } var rid libfossil.FslID var uuid string err = r.WithReceipt(&db.Receipt{UID: uid}).WithTx(func(tx *db.Tx) error { d := &deck.Deck{ Type: deck.Attachment, A: &deck.AttachmentCard{Filename: attachName(w.Name), Target: w.Target}, C: strings.TrimSpace(w.Comment), N: w.Mimetype, U: deck.User(w.User), } stored := []libfossil.FslID{} if w.Content != nil { srcRID, err := storeAttachedFile(tx, d, w.Content) if err != nil { return err } stored = append(stored, srcRID) } // The name is final only now: storing the file may add ".gz". if d.D, err = attachmentTime(tx, w.Target, d.A.Filename, w.Time); err != nil { return err } rid, uuid, err = storeAttachmentControl(tx, d) if err != nil { return err } _, err = seedcheck.JudgeFilled(tx, append(stored, rid)) return err }) if err != nil { return 0, "", fmt.Errorf("manifest.WriteAttachment: %w", err) } return rid, uuid, nil } // attachmentTime is the D card for the next artifact on target's name: // want, or now when want is zero, and strictly after the current latest. func attachmentTime(q db.Querier, target, name string, want time.Time) (time.Time, error) { prev, found, err := LatestAttachment(q, target, name) if err != nil { return time.Time{}, err } if !want.IsZero() { when := libfossil.CardTime(want) if found && !when.After(libfossil.CardTime(prev.Time)) { return time.Time{}, fmt.Errorf("time %s is not after %s, the latest for %q", when.Format(time.RFC3339Nano), libfossil.CardTime(prev.Time).Format(time.RFC3339Nano), name) } return when, nil } when := libfossil.CardTimeNow(time.Now()) if found { if next := libfossil.CardTime(prev.Time).Add(time.Millisecond); !when.After(next) { when = next } } return when, nil } // attachName is fossil's: the part after the last '/' or '\', or "unknown" // when that is empty. func attachName(name string) string { if i := strings.LastIndexAny(name, `/\`); i >= 0 { name = name[i+1:] } if name == "" { return "unknown" } return name } // storeAttachedFile stores content as one ordinary blob and sets d's A-card // source to it, compressing an artifact-shaped file as attach_commit does. func storeAttachedFile(tx *db.Tx, d *deck.Deck, content []byte) (libfossil.FslID, error) { if _, err := deck.Parse(content); err == nil { packed, err := blob.Compress(content) if err != nil { return 0, fmt.Errorf("compress attachment: %w", err) } content = packed d.A.Filename += ".gz" } rid, uuid, err := blob.Store(tx, content) if err != nil { return 0, fmt.Errorf("store attachment: %w", err) } if _, err := tx.Exec("INSERT OR IGNORE INTO unsent(rid) VALUES(?)", rid); err != nil { return 0, fmt.Errorf("attachment unsent: %w", err) } d.A.Source = uuid return rid, nil } // storeAttachmentControl stores d and links it as a sweep would on arrival. func storeAttachmentControl(tx *db.Tx, d *deck.Deck) (libfossil.FslID, string, error) { data, err := d.Marshal() if err != nil { return 0, "", fmt.Errorf("marshal attachment artifact: %w", err) } // Link what a reader will parse, not the deck it was built from; a name // fossil would refuse fails here, before anything is stored. parsed, err := deck.Parse(data) if err != nil { return 0, "", fmt.Errorf("attachment artifact does not parse: %w", err) } if parsed.Type != deck.Attachment { panic("manifest.storeAttachmentControl: marshalled deck is not an attachment artifact") } rid, uuid, err := blob.Store(tx, data) if err != nil { return 0, "", fmt.Errorf("store attachment artifact: %w", err) } if err := crosslinkAttachment(tx, rid, parsed); err != nil { return 0, "", err } if _, err := tx.Exec("INSERT OR IGNORE INTO unsent(rid) VALUES(?)", rid); err != nil { return 0, "", fmt.Errorf("attachment control unsent: %w", err) } return rid, uuid, nil } // ResolveAttachTarget returns the full name of what target names: a wiki // page by its exact name, or a ticket or tech note by its id or a unique // prefix of it, as fossil's /attachadd resolves its tkt=, page= and // technote= parameters. Fossil takes the kind from which parameter is set; // here it is inferred, so a name that two kinds answer to is refused as // ambiguous rather than guessed at. func ResolveAttachTarget(q db.Querier, target string) (string, error) { if target == "" { return "", fmt.Errorf("attachment target is empty") } var found, kinds []string var one int switch err := q.QueryRow("SELECT 1 FROM tag WHERE tagname=?", "wiki-"+target).Scan(&one); { case err == nil: found, kinds = append(found, target), append(kinds, "wiki page "+target) case !errors.Is(err, sql.ErrNoRows): return "", fmt.Errorf("resolve attachment target: %w", err) } if prefix := strings.ToLower(target); len(prefix) <= 64 && isLowerHex(prefix) { for _, kind := range [][2]string{{"tkt-", "ticket "}, {"event-", "tech note "}} { ids, err := tagsWithPrefix(q, kind[0], prefix) if err != nil { return "", err } for _, id := range ids { found, kinds = append(found, id), append(kinds, kind[1]+id) } } } switch len(found) { case 0: return "", fmt.Errorf("attachment target %q: %w", target, ErrNoAttachTarget) case 1: return found[0], nil } return "", fmt.Errorf("attachment target %q is ambiguous: %s", target, strings.Join(kinds, ", ")) } // tagsWithPrefix returns up to two ids whose tag is kind followed by an id // starting with prefix; two is enough to tell unique from ambiguous. func tagsWithPrefix(q db.Querier, kind, prefix string) ([]string, error) { rows, err := q.Query("SELECT substr(tagname, ?) FROM tag WHERE tagname GLOB ? LIMIT 2", len(kind)+1, kind+prefix+"*") if err != nil { return nil, fmt.Errorf("resolve attachment target: %w", err) } defer rows.Close() var ids []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, fmt.Errorf("resolve attachment target: %w", err) } ids = append(ids, id) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("resolve attachment target: %w", err) } return ids, nil } // AttachmentRow is one row of the attachment table, with the hash of its // control artifact and the size of the blob it names (-1 while that blob is // a phantom, 0 for a deletion). type AttachmentRow struct { Artifact string Source string Name string Comment string User string Time time.Time Size int64 } const attachmentRowSQL = `SELECT b.uuid, coalesce(a.src, ''), a.filename, coalesce(a.comment, ''), coalesce(a.user, ''), CAST(a.mtime AS REAL), coalesce((SELECT size FROM blob WHERE uuid=a.src), -1) FROM attachment a JOIN blob b ON b.rid=a.attachid` // CurrentAttachments returns target's current attachments, the newest // first: the latest artifact for each name, unless that latest artifact // deletes it. This is the set fossil's attachment_list shows. func CurrentAttachments(q db.Querier, target string) ([]AttachmentRow, error) { return attachmentRows(q, attachmentRowSQL+ " WHERE a.isLatest AND a.src!='' AND a.target=? ORDER BY a.mtime DESC, a.filename", target) } // LatestAttachment returns the latest artifact for name on target, which is // a deletion when its Source is empty. found is false when there is none. func LatestAttachment(q db.Querier, target, name string) (row AttachmentRow, found bool, err error) { rows, err := attachmentRows(q, attachmentRowSQL+ " WHERE a.isLatest AND a.target=? AND a.filename=? ORDER BY a.mtime DESC, a.attachid DESC LIMIT 1", target, name) if err != nil || len(rows) == 0 { return AttachmentRow{}, false, err } return rows[0], true, nil } func attachmentRows(q db.Querier, query string, args ...any) ([]AttachmentRow, error) { rows, err := q.Query(query, args...) if err != nil { return nil, fmt.Errorf("attachments: %w", err) } defer rows.Close() var out []AttachmentRow for rows.Next() { var a AttachmentRow var mtime float64 if err := rows.Scan(&a.Artifact, &a.Source, &a.Name, &a.Comment, &a.User, &mtime, &a.Size); err != nil { return nil, fmt.Errorf("attachments: %w", err) } if a.Source == "" { a.Size = 0 } a.Time = libfossil.JulianToTime(mtime) out = append(out, a) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("attachments: %w", err) } return out, nil }