Artifact 6360ea7fff152ea9fe9ec0541730ef780f20f384d9ad5aeee00e1344513e402b:
- File
internal/sync/links.go
— part of check-in
[a565d1c467]
at
2026-10-13 14:49:38
on branch feat/425-share-links
— feat(sync): add share-links mode for pragma req-links and link
sync-protocol-01 Section 9.5 makes share-links mode OPTIONAL. With SyncOpts.ShareLinks a pulling session sends pragma req-links in its first request, as fossil's --share-links does, unless SyncOpts.ProjectCode names another project. It keeps each pragma link URL ARG MTIME as config link:URL = ARG, defossilized and otherwise verbatim, replacing an existing link only with a strictly newer MTIME. A wrong token count, a URL that is not HTTP(S) or an MTIME that is not a positive decimal drops the link.
Where fossil 2.28 differs the draft is followed: fossil fails fatally on a link URL its parser rejects and reads MTIME with strtoll base 0.
New sync.9.5 client cases run against fossil sync --share-links as well. SP-9.5-4 loses its scope note and is covered; SP-9.5-5 and SP-9.5-6 are new rows for which links a client keeps and when it sends req-links.
Tracked in #425. (user: [email protected], size: 3979) [annotate] [blame] [check-ins using]
package sync import ( "fmt" "strconv" "strings" "github.com/danmestas/go-libfossil/internal/deck" "github.com/danmestas/go-libfossil/internal/xfer" ) // Share-links mode (ยง9.5) is fossil's undocumented --share-links: the // client asks the server for the URLs of other copies of the repository // and keeps each one as config link:URL = ARG. fossil's client_sync takes // the flag only when pulling, so a push-only session ignores it here too. // shareLinks reports whether the session runs in share-links mode. func (s *session) shareLinks() bool { return s.opts.ShareLinks && s.opts.Pull } // sendsReqLinks reports whether request cycle carries pragma req-links. // fossil sends it once, in the first request, and not when the client // names another project's code (client_sync's zAltPCode): links describe // the client's own repository. func (s *session) sendsReqLinks(cycle int) bool { if cycle < 0 { panic("session.sendsReqLinks: negative cycle") } if cycle != 0 || !s.shareLinks() { return false } if s.opts.ProjectCode == "" { return true } own, _ := s.repo.Config("project-code") return s.opts.ProjectCode == own } // handleLink keeps a pragma link URL ARG MTIME as config link:URL = ARG, // as fossil's client_sync does: URL and ARG defossilized and otherwise // verbatim, an existing row replaced only by a strictly newer MTIME. A // link outside share-links mode, with the wrong token count, a URL that // is not HTTP(S) or an MTIME that is not a positive decimal is dropped // whole. ARG is opaque: it is stored, never parsed or sent on. func (s *session) handleLink(c *xfer.PragmaCard) error { if c == nil || c.Name != "link" { panic("session.handleLink: c must be a link pragma") } if !s.shareLinks() || len(c.Values) != 3 { return nil } url, arg := deck.FossilDecode(c.Values[0]), deck.FossilDecode(c.Values[1]) mtime, ok := linkMTime(c.Values[2]) if !ok || !linkURLIsHTTP(url) { return nil } _, err := s.repo.DB().Exec(`INSERT INTO config(name, value, mtime) VALUES(?, ?, ?) ON CONFLICT(name) DO UPDATE SET value=excluded.value, mtime=excluded.mtime WHERE mtime<excluded.mtime`, "link:"+url, arg, mtime) if err != nil { return fmt.Errorf("sync: store link: %w", err) } return nil } // linkMTime parses a link's MTIME, which Section 9.5 makes a positive // decimal count of Unix seconds. fossil(1) reads it with strtoll base 0, // so it also keeps 0x10 as 16 and 12z as 12; this follows the draft and // drops both. func linkMTime(field string) (int64, bool) { if field == "" || strings.Trim(field, "0123456789") != "" { return 0, false } mtime, err := strconv.ParseInt(field, 10, 64) return mtime, err == nil && mtime > 0 } // linkURLIsHTTP reports whether url parses as HTTP(S) the way fossil's // url_parse_local reads it: an exact lowercase http:// or https:// prefix // and, when the authority names a port, a run of decimal digits followed // by / or the end. fossil(1) fails fatally on a bad port, and on a form // that is neither a URL nor a local repository; Section 9.5 discards the // link instead, and so does this. func linkURLIsHTTP(url string) bool { rest, ok := strings.CutPrefix(url, "http://") if !ok { rest, ok = strings.CutPrefix(url, "https://") } if !ok { return false } // With user information the host runs from the @ to a / or :, and // brackets mean nothing; without it an IPv6 literal's colons are // inside [ ]. userinfo := false if at := strings.IndexAny(rest, "/@"); at >= 0 && rest[at] == '@' { rest, userinfo = rest[at+1:], true } i, inSquare := 0, false for ; i < len(rest) && rest[i] != '/'; i++ { if rest[i] == ':' && (userinfo || !inSquare) { break } if !userinfo && rest[i] == '[' { inSquare = true } if !userinfo && rest[i] == ']' { inSquare = false } } if i == len(rest) || rest[i] != ':' { return true } for i++; i < len(rest) && rest[i] >= '0' && rest[i] <= '9'; i++ { } return i == len(rest) || rest[i] == '/' }