// Package external implements the operations on documents somebody else // published that we depend on. // // Every act is a fetch or a comparison. Nothing here repairs anything: a changed // external is a candidate, not a replacement, and somebody decides. package external import ( "errors" "fmt" "io" "io/fs" "net/http" "net/url" "os" "path" "path/filepath" "strings" "time" "git.hypertheory-labs.dev/loom/loom-cli/internal/config" "git.hypertheory-labs.dev/loom/loom-cli/internal/lock" ) var client = &http.Client{Timeout: 30 * time.Second} // FindRoot walks up from dir looking for the .loom directory that marks a // repository using loom. func FindRoot(dir string) (string, error) { d, err := filepath.Abs(dir) if err != nil { return "", err } for { if fi, err := os.Stat(filepath.Join(d, ".loom")); err == nil && fi.IsDir() { return d, nil } parent := filepath.Dir(d) if parent == d { return "", errors.New("no .loom directory found in this directory or any parent") } d = parent } } // localPath derives where an adopted document is kept from the URL it came from. // // The path is for a person: ///. It deliberately // does not encode the route, the branch, or .loom/published/ — which is why it // cannot be turned back into a URL, and why the lock records the origin. func localPath(raw string) (string, error) { u, err := url.Parse(raw) if err != nil { return "", err } if u.Host == "" || u.Scheme == "" { return "", fmt.Errorf("not an absolute URL: %s", raw) } segs := strings.Split(strings.Trim(u.Path, "/"), "/") if len(segs) < 3 { return "", fmt.Errorf("cannot tell owner and repository from %s — pass --path", raw) } base := segs[len(segs)-1] if base == "" { return "", fmt.Errorf("no file name in %s", raw) } return path.Join(u.Host, segs[0], segs[1], base), nil } // request builds a GET carrying the host's token, if we have one. func request(cfg *config.Config, method, raw string, ifNoneMatch string) (*http.Request, error) { req, err := http.NewRequest(method, raw, nil) if err != nil { return nil, err } if t := cfg.TokenFor(req.URL.Host); t != "" { req.Header.Set("Authorization", "token "+t) } if ifNoneMatch != "" { req.Header.Set("If-None-Match", ifNoneMatch) } return req, nil } // Add fetches a document, writes it into .loom/externals/, and records its lock. // // It does not create a .usages.md: an empty facet asserts that we have something // to say and we do not. func Add(root, raw, override string, out io.Writer) error { cfg, err := config.Load() if err != nil { return err } rel := override if rel == "" { if rel, err = localPath(raw); err != nil { return err } } req, err := request(cfg, http.MethodGet, raw, "") if err != nil { return err } resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() switch resp.StatusCode { case http.StatusOK: case http.StatusNotFound: return fmt.Errorf("404 unresolved: %s was withdrawn, or this credential cannot see it — "+ "over HTTP these are the same response", raw) default: return fmt.Errorf("%s: %s", resp.Status, raw) } body, err := io.ReadAll(resp.Body) if err != nil { return err } etag := resp.Header.Get("ETag") dest := filepath.Join(root, lock.Dir, filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return err } if err := os.WriteFile(dest, body, 0o644); err != nil { return err } locks, err := lock.Load(root) if err != nil { return err } locks.Put(lock.Record{Path: rel, URL: resp.Request.URL.String(), ETag: etag}) if err := locks.Save(); err != nil { return err } fmt.Fprintf(out, "adopted %s\n", rel) fmt.Fprintf(out, " from %s\n", resp.Request.URL) if etag == "" { fmt.Fprintf(out, " etag (none served — check cannot ask conditionally)\n") } else { fmt.Fprintf(out, " etag %s\n", etag) } return nil } // Status is what check found for one document. type Status struct { Path string Result string Detail string } // Check asks every publisher whether their copy has moved. // // It reports and does nothing else. A changed document is a candidate, not a // replacement. func Check(root string, out io.Writer) error { cfg, err := config.Load() if err != nil { return err } locks, err := lock.Load(root) if err != nil { return err } seen := map[string]bool{} var results []Status for _, rec := range locks.All() { seen[rec.Path] = true results = append(results, checkOne(cfg, root, rec)) } // Documents in the tree with no lock. Never adopt whatever the remote is // currently serving as the lock: that asserts the local copy is the one // being served, which is the thing we were about to check. unlocked, err := unlockedDocs(root, seen) if err != nil { return err } for _, p := range unlocked { results = append(results, Status{Path: p, Result: "unlocked", Detail: "fetched by hand; run `loomctl external add` to lock it"}) } if len(results) == 0 { fmt.Fprintln(out, "no adopted documents") return nil } w := 0 for _, r := range results { if len(r.Path) > w { w = len(r.Path) } } for _, r := range results { fmt.Fprintf(out, "%-*s %-10s %s\n", w, r.Path, r.Result, r.Detail) } return nil } func checkOne(cfg *config.Config, root string, rec lock.Record) Status { if _, err := os.Stat(filepath.Join(root, lock.Dir, filepath.FromSlash(rec.Path))); errors.Is(err, fs.ErrNotExist) { return Status{rec.Path, "missing", "locked, but the local copy is gone"} } if rec.ETag == "" { return Status{rec.Path, "no-etag", "publisher served none; freshness cannot be asked"} } req, err := request(cfg, http.MethodGet, rec.URL, rec.ETag) if err != nil { return Status{rec.Path, "error", err.Error()} } resp, err := client.Do(req) if err != nil { return Status{rec.Path, "error", err.Error()} } defer resp.Body.Close() io.Copy(io.Discard, resp.Body) switch resp.StatusCode { case http.StatusNotModified: return Status{rec.Path, "same", ""} case http.StatusOK: return Status{rec.Path, "CHANGED", "upstream moved — the new copy is a candidate, not a replacement"} case http.StatusGone: return Status{rec.Path, "GONE", "410 — follow whatever the response points at"} case http.StatusNotFound: return Status{rec.Path, "404", "withdrawn, or access lost — over HTTP these are the same response"} default: return Status{rec.Path, resp.Status, ""} } } // unlockedDocs finds adopted documents that no lock covers. Facets we wrote // ourselves are not adopted documents and are skipped. func unlockedDocs(root string, locked map[string]bool) ([]string, error) { base := filepath.Join(root, lock.Dir) var out []string err := filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error { if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil } return err } if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") { return nil } rel, err := filepath.Rel(base, p) if err != nil { return err } rel = filepath.ToSlash(rel) if locked[rel] || isFacet(rel) { return nil } out = append(out, rel) return nil }) return out, err } // isFacet reports whether a path is something we wrote beside an adopted // document rather than the document itself: x.usages.md, x.gaps.md, x.notes.md. func isFacet(rel string) bool { base := strings.TrimSuffix(path.Base(rel), ".md") i := strings.LastIndex(base, ".") if i < 0 { return false } switch base[i+1:] { case "usages", "gaps", "notes": return true } return false }