This file is the executable companion to plan-02-git-pkgs.md. Read that first for why; this is what and done-when. Conventions, quality bar, commit rules, and testscript scaffolding are inherited from SPEC.md. Styling rules from SPEC-web.md.
Scope is the indexing half of the plan: per-repo pkgs.sqlite3 written on push, rich rendering of manifests and lockfiles on blob/tree/commit/compare pages, a Dependencies tab, and /api/v1/.../pkgs/* endpoints. The dependency-aware policy hook (refs/git-pkgs/deps*, internal/depref, InvokeHooksForStage wiring), proxy integration (enrichment, vulns, cooldown, pkgs-publish, git-pkgs/provenance), and the non-pkgs items in the plan's build order (sigstore/agent signers, client-signed merge) are out of scope here; see Deferred.
- Per-repo index db:
<repo>.git/pkgs.sqlite3, opened WAL-mode bygit-pkgs/git-pkgs/index. Sits inside the bare repo alongsidesilo.lockso it replicates with the rest ofrepos/. indexpackage import path:github.com/git-pkgs/git-pkgs/index. The package wraps the existinginternal/{database,analyzer,indexer}types behind a stable surface; it does not move or rename them. Roadmap issue #116.- Upstream changes go on a
silobranch in a local checkout, pinned viareplacein silo'sgo.mod, the same pattern asSPEC-forks.md. silo's txtar suite is the integration proof. Nothing is pushed to publicgit-pkgs/git-pkgsmain, and no tags are cut, until asked. - Reindex runs as a queued job, not inline in
PostReceive. The push returns as soon as refs are applied and witnessed; the Dependencies tab shows "indexing…" until the job for that tip completes. A single hostile lockfile cannot make a push hang. - Stateless paths (the rich diff on a single file pair) call
manifests.Parsedirectly with noOptions.FSRoot, never the index db. They work before the first reindex completes and on commits the index hasn't seen. - API mount:
/api/v1/repos/:owner/:repo/pkgs/{list,blame,history,diff,show,stats,sbom}. JSON shapes matchgit pkgs <cmd> --format=jsonfield-for-field so the CLI's output types can be reused as the response structs.vulns,outdated,licensesare deferred with proxy. - New direct deps:
github.com/git-pkgs/git-pkgs(forindex, viareplace),github.com/git-pkgs/manifests,github.com/git-pkgs/sbom, all latest tagged at the time their milestone adds them.purl,vers,gitignorearrive transitively. No proxy, enrichment, vulns, registries, sigstore, or depref deps in this spec.
The git-pkgs checkout already exists alongside silo:
git -C ~/code/git-pkgs/git-pkgs checkout -b silosilo's go.mod:
require github.com/git-pkgs/git-pkgs v0.0.0
replace github.com/git-pkgs/git-pkgs => ../git-pkgs
Commits go on the silo branch in ../git-pkgs; small commits are fine; don't push without asking. manifests, vers, pom changes (the P0/P1/P2 hardening) are already on their respective mains per TODO.md checkboxes; if any P2 cap is still unmerged when its milestone needs it, land it on a silo branch in that repo with a matching replace and record the SHA under Findings.
Verify by file:line and record under Findings before starting the first milestone.
~/code/git-pkgs/git-pkgs/TODO.md (audit 2026-06-15):
- P0:
manifestsno longer touches the host filesystem when called with noOptions. Already shaped (manifests.go:51-76,Options{FSRoot}); confirminternal/mavenpasses a no-op fetcher whenFSRoot == ""and thatpom.LocalFetcheris jailed (git-pkgs/pom#10). Add a test inmanifestsif absent: parse apom.xmlwhose<parent><relativePath>points at../../../../etc/hostnamewith no options and assert the dep list ignores the parent. - P1: both unrecovered goroutines (
internal/indexer/indexer.go:76,internal/database/batch_writer.go:198) have adefer recover()that converts panic to error. Already checked off in TODO.md; confirm by grep. - P2: at minimum the four single-choke-point caps that bound a single reindex job:
manifests.MaxManifestBytes(default 10 MiB), per-manifest dependency cap and per-commit manifest cap ininternal/analyzer, andbatch_writer.ShouldFlushkeyed on pending row count. These land on the../git-pkgssilobranch as part of the index-extraction milestone if not already on main. versP2 (cache key length cap, input length cap) and P3 (bubble sort, quadraticIntersect) are not gating but are reachable from the rich diff on every commit-page render; land them before that milestone if they're not already done, on asilobranch in~/code/git-pkgs/verswith areplaceif needed.
Resolve before writing silo code; record answers under Findings with file:line citations. Numbering continues from SPEC.md.
- R5: Does
internal/indexer.Indexer.Runaccept a bare*git.Repository(no worktree), or does any path under it callWorktree()/ read from the filesystem rather than the object store? Checkinternal/indexer/indexer.go,internal/analyzer/analyzer.go:543(parseManifestInTree), andinternal/git/. Expected answer: yes onceanalyzer.SetRepoPath/PrefetchDiffs(which execgit diff-tree) are made optional, since the rest reads via go-gitobject.Tree. - R6: Does
analyzer.parseManifestInTreealready passOptions{FSRoot: ""}(or no options) tomanifests.Parse? If it passes a host path, that's the maven traversal vector reaching silo through the index package even after the manifests fix. - R9:
git pkgs <cmd> --format=jsonoutput types. For each oflist,blame,history,diff,show,stats,sbom: locate the struct that isjson.Marshaled (likely incmd/*.goorinternal/database/types.go) and note whether it's exported. The API milestone reuses these types verbatim; if any is unexported orcmd-local, the index extraction must lift it.
(R7 and R8 concerned the Lua sandbox and InvokeHooksForStage; they move to the deferred depref spec.)
In ../git-pkgs on the silo branch. This is the issue-#116 extraction: a public package over the existing internal/{database,analyzer,indexer} so silo can import the same code the CLI uses. The P2 caps that aren't yet on main land here too as separate commits.
Deliverables (index/index.go at the git-pkgs repo root):
package index
type Index struct { /* wraps *database.DB, *git.Repository, *analyzer.Analyzer */ }
type Options struct {
MaxManifestBytes int // passed through to manifests.Parse; 0 = package default
MaxDepsPerManifest int
MaxManifestsPerCommit int
Progress func(done, total int)
}
func Open(gitDir, dbPath string, opts Options) (*Index, error)
// gitDir is a bare or non-bare repo; dbPath is created if absent.
// Opens go-git with PlainOpenWithOptions{DetectDotGit: false} when gitDir
// ends in .git, otherwise true.
func (*Index) Reindex(ctx context.Context, branch string, oldTip, newTip plumbing.Hash) error
// Walks newTip back to the merge-base with oldTip (or full history when
// oldTip is zero), calling analyzer.AnalyzeCommit per commit and writing
// via the batch writer. Honours ctx cancellation between commits.
func (*Index) Close() error
// Read surface, each a thin pass-through to internal/database queries.
// Return types are the existing exported structs (Dependency, BlameEntry,
// HistoryEntry, Change, Stats, CommitWithChanges) re-exported as aliases
// from this package so callers don't import internal/.
func (*Index) List(ref string) ([]Dependency, error) // GetDependenciesAtRef
func (*Index) Blame(opts BlameOptions) ([]BlameEntry, error)
func (*Index) History(opts HistoryOptions) ([]HistoryEntry, error)
func (*Index) Diff(fromRef, toRef string) ([]Change, error) // two GetDependenciesAtRef + analyzer diff
func (*Index) Show(sha string) ([]Change, error) // GetChangesForCommit
func (*Index) Stats(opts StatsOptions) (*Stats, error)
func (*Index) HasIndexed(sha string) (bool, error) // HasSnapshotForCommitinternal/database, internal/analyzer, internal/indexer stay where they are; index imports them.
Constraints the package must satisfy for silo:
- No
os.Chdir, no process-global state.Openmay be called for many repos concurrently. - No
exec.Commandin theReindexpath.analyzer.PrefetchDiffs(which shells togit diff-tree) is skipped whenAnalyzer.repoPath == "";Openleaves it empty. manifests.Parseis always called with the in-tree path asfilenameand noFSRoot.Reindexwraps theindexer.Runbody inrecover()and returns the panic as an error.- WAL mode and
busy_timeout=5000on the db handle so a reader during reindex doesn'tSQLITE_BUSY.
Acceptance (index/index_test.go in ../git-pkgs):
TestOpen_Bare:git init --bare, push one commit containing ago.modwith two requires via go-git,Open,Reindex("main", zero, tip),List("main")returns both.TestReindex_Incremental: second commit adds one require;Reindex("main", tip1, tip2);Show(tip2)returns oneaddedchange;Listreturns three.TestReindex_Cancel: ctx cancelled after first commit of a 100-commit chain; returnsctx.Err(); db has ≤ a few commits' rows.TestReindex_HostilePom: commit apom.xmlwith<parent><relativePath>../../../../etc/hostname</relativePath>;Reindexsucceeds;Listshows the pom's own deps and nothing from the host.TestReindex_Caps: commit apackage-lock.jsonwithMaxDepsPerManifest+1entries;Reindexsucceeds;Listreturns exactly the cap.TestConcurrentOpen: 10 goroutinesOpen10 distinct bare repos int.TempDir()andReindexeach;-raceclean.
Done when: go test ./index/... passes in ../git-pkgs, the CLI's own cmd/{list,blame,history,diff,show,stats}.go are rewired to call index.* instead of internal/database directly (proves the surface is sufficient), and go test ./... in ../git-pkgs stays green. silo's go.mod has the replace and go build ./... in silo succeeds with the import added (no callers yet).
Goal: pushing a commit that touches a manifest enqueues a pkgs-reindex job; a worker drains the queue and writes pkgs.sqlite3; the dependency API can answer show for that commit once the job completes.
Deliverables:
internal/store/jobs.go— CRUD on the existingjobstable:Dedup:type Job struct { ID int64 RepoID int64 Kind string State string // pending | running | done | failed Payload string // JSON Attempts int UpdatedAt time.Time } func (*Store) EnqueueJob(repoID int64, kind, payload string) (int64, error) func (*Store) ClaimJob(kinds ...string) (*Job, error) // UPDATE ... SET state='running', attempts=attempts+1 WHERE id = // (SELECT id FROM jobs WHERE state='pending' AND kind IN (...) ORDER BY id LIMIT 1) // RETURNING *. nil, nil when nothing pending. func (*Store) CompleteJob(id int64, state string) error // 'done' or 'failed' func (*Store) JobsForRepo(repoID int64, kind string) ([]Job, error)
EnqueueJobdoes nothing if apendingrow with the same(repo_id, kind, payload)already exists.internal/jobs/jobs.go—Worker{Store, GitStore, Handlers map[string]func(ctx, Job) error}withRun(ctx)pollingClaimJobevery 250 ms (or on aNudge()channel) and dispatching. Each handler runs under a per-jobcontext.WithTimeout(default 10 min) and insiderecover(); panic or error →CompleteJob(id, "failed"), success →"done". A job that has failed>= 3attempts is not re-claimed.internal/pkgs/pkgs.go:plustype Store struct { /* LRU of repoPath -> *index.Index, cap 32 */ } func Open() *Store func (*Store) Index(repoPath string) (*index.Index, error) // Opens <repoPath>/pkgs.sqlite3 via index.Open with the P2 caps set. // Evicted entries are Close()d. func (*Store) Reindex(ctx context.Context, repoPath, branch string, old, new plumbing.Hash) error
ReindexHandler(ps *Store, gst *gitstore.Store) func(ctx, Job) errordecodingpayload{"owner","repo","branch","old","new"}and callingReindex.internal/hooks/builtin.go—BuiltingainsStore *store.StoreandNudge func().PostReceive, after witnessing and beforereleaseLock, iterates non-gittuf updates whoseName.IsBranch()and callsEnqueueJob(repoID, "pkgs-reindex", payload)thenNudge(). Failure to enqueue is logged, not surfaced.cmd/silo/serve.go— constructpkgs.Open(), registerpkgs.ReindexHandleron ajobs.Worker, startWorker.Runin a goroutine tied to the serve context, and passworker.Nudgeinto the hooks factory.cmd/silo/admin.go—silo admin pkgs reindex <owner>/<repo> [--branch main]enqueues a full reindex (old= zero) for manual recovery and for the first index of an imported repo.
Acceptance (testdata/testscript/06_pkgs.txtar):
# 06_pkgs: push a go.mod, reindex job runs, API shows the change
exec silo keygen --data $SILO_DATA
exec silo admin user create alice --ssh-key $WORK/alice.pub --data $SILO_DATA
exec silo admin repo create alice/demo --data $SILO_DATA
exec silo serve --data $SILO_DATA --http $SILO_HTTP --ssh $SILO_SSH &
waitfor $SILO_SSH
env GIT_SSH_COMMAND=...
# init policy as in 03_gittuf, then:
exec git -C repo init
cp go.mod.1 repo/go.mod
exec git -C repo add go.mod
exec git -C repo commit -m 'add deps'
exec git -C repo push origin main 'refs/gittuf/*:refs/gittuf/*'
# wait for the reindex job (poll the API; helper cmd 'waitjson <url> <jq-expr>')
waitjson http://$SILO_HTTP/api/v1/repos/alice/demo/pkgs/list?ref=main '. | length >= 2'
exec curl -fsS http://$SILO_HTTP/api/v1/repos/alice/demo/pkgs/list?ref=main
stdout 'pkg:golang/github.com/spf13/cobra'
# pkgs.sqlite3 exists inside the bare repo
exists $SILO_DATA/repos/alice/demo.git/pkgs.sqlite3
# bump a dep, push, show returns the delta
cp go.mod.2 repo/go.mod
exec git -C repo commit -am 'bump cobra'
gitsha repo SHA2
exec git -C repo push origin main
waitjson http://$SILO_HTTP/api/v1/repos/alice/demo/pkgs/show/$SHA2 '. | length == 1'
stdout '"change":"updated"'
-- go.mod.1 --
module example.com/demo
go 1.26
require (
github.com/spf13/cobra v1.8.0
github.com/stretchr/testify v1.9.0
)
-- go.mod.2 --
module example.com/demo
go 1.26
require (
github.com/spf13/cobra v1.9.0
github.com/stretchr/testify v1.9.0
)
Add to testscript.Params.Cmds: waitjson <url> <expr> polls the URL every 100 ms for up to 10 s, decodes JSON, evaluates <expr> (only length >= N and length == N need supporting; hand-roll, no jq dep); gitsha <dir> <var> sets <var> to git -C <dir> rev-parse HEAD.
Unit:
TestEnqueueDedup: twoEnqueueJobwith identical args produce one row.TestClaimJob: 5 goroutines claim from a queue of 5; each job claimed exactly once (-race).TestWorker_Recover: handler panics; job endsfailed, worker keeps running.TestWorker_Resume: insert arunningrow withupdated_atolder than the timeout; assertClaimJobreturns it (covers crash-during-reindex).TestPkgsStore_LRU: open 40 repos with cap 32; first 8 are closed; reopening one of those works.TestPostReceive_Enqueues: in-memory repo + store, callPostReceivewith one branch update; assert onepkgs-reindexrow with the right payload.
The /api/v1/.../pkgs/list and /show endpoints needed by the txtar are stubs at this milestone: a temporary handler in cmd/silo/serve.go that calls pkgs.Store.Index(repoPath).List(ref) / .Show(sha) and json.Encodes the result. They move into internal/http/api two milestones down.
Done when: 06_pkgs.txtar passes with -count=3.
Goal: wherever the web UI shows a manifest or lockfile, it shows the parsed dependency set rather than (or as well as) raw text. On commit and compare pages a changed manifest renders as a "+kamal 1.0.0 / puma 5.0.0 → 6.0.0" delta table in place of three hundred lines of text diff; on the blob page the same file renders as a sortable dependency table with the raw source behind a tab. Both are computed statelessly from blob bytes via manifests.Parse, so they work on any commit regardless of index state and render identically on a read replica with no pkgs.sqlite3.
Deliverables:
internal/pkgs/textconv.go:Both calltype FileDelta struct { Path string Ecosystem string Kind manifests.Kind // manifest | lockfile Added []manifests.Dependency Removed []manifests.Dependency Updated []DepUpdate // {Name, PURL, From, To string} } type FileView struct { Path string Ecosystem string Kind manifests.Kind Groups []DepGroup // {Scope string; Deps []manifests.Dependency} sorted runtime-first Total int Direct int } func IsManifest(path string) bool // manifests.Identify func Render(path string, blob []byte) (*FileView, error) // one-sided func Textconv(path string, oldBlob, newBlob []byte) (*FileDelta, error)
manifests.Parse(path, blob)with noFSRoot. A blob overmanifests.MaxManifestBytesis treated as unparseable. ForTextconveither side may be nil (file added/removed). Onmanifests.ParseError, return(nil, nil)so the caller falls back to raw text.internal/pkgs/cache.go—sync.Mapkeyed onoldOID + ":" + newOID + ":" + path→*FileDelta, capped by an atomic counter at 4096 entries (drop the whole map when exceeded; per-OID-pair results are cheap to recompute). This is the placeholder untilgit-pkgs/oidcacheexists; note it inEXTRACT.md.internal/http/web/web.go:318(h.commit) andcompare.go— for each changed file wherepkgs.IsManifest(path), fetch both blobs and callpkgs.Textconv; render the result above the raw diff hunk viatemplates/pages/_depdelta.html. The raw diff for that file collapses behind a<details>element. Files where textconv returned nil render as before.internal/http/web/browse.go(h.blob) — whenpkgs.IsManifest(path), callpkgs.Render(path, blob)and render viatemplates/pages/_depview.htmlas the default view, with the existing markup/source render behind a second tab in a basecoattabstablist (Dependencies|Source).?view=sourceforces the source tab. Each dependency name links to/:o/:r/dependencies/{purl}(404 until the next milestone; acceptable). Render returning nil falls through to the existing source view with no tabs.internal/http/web/browse.go(h.tree) — directory rows for paths whereIsManifestis true get a small<ecosystem> · N depsannotation after the filename, computed frompkgs.Renderon the blob (cached the same way). Skip the annotation for blobs over the size cap.templates/pages/_depdelta.html— atable class="table"with rows tinted by.kind(dep-addedgreen,dep-removedred,dep-updatedneutral), columns: name, old → new requirement, scope, direct/transitive. Heading row reads<path> · <ecosystem> <kind> · +N −M ~K.templates/pages/_depview.html— onetable class="table"per scope group with a heading row<scope> · N, columns: name, requirement, direct/transitive, integrity (truncated,title=full). Summary line above:<ecosystem> <kind> · N dependencies (M direct). Styles instatic/style.css.
Acceptance (extend 06_pkgs.txtar rather than a new file):
exec curl -fsS http://$SILO_HTTP/alice/demo/commit/$SHA2
stdout 'go.mod · golang manifest · +0 −0 ~1'
stdout 'cobra'
stdout 'v1.8.0'
stdout 'v1.9.0'
stdout 'class="dep-updated"'
# raw diff still present, collapsed
stdout '<details'
stdout '+ github.com/spf13/cobra v1.9.0'
Blob and tree views:
exec curl -fsS http://$SILO_HTTP/alice/demo/blob/main/go.mod
stdout 'role="tablist"'
stdout 'golang manifest · 2 dependencies (2 direct)'
stdout 'github.com/spf13/cobra'
stdout 'v1.9.0'
# source tab still reachable
exec curl -fsS 'http://$SILO_HTTP/alice/demo/blob/main/go.mod?view=source'
stdout 'require ('
# tree row annotation
exec curl -fsS http://$SILO_HTTP/alice/demo/tree/main
stdout 'go.mod'
stdout 'golang · 2 deps'
And a hostile case:
# pom with relativePath traversal: page renders, no crash, falls back to
# parsed deps from the pom body only
cp pom.evil repo/pom.xml
exec git -C repo add pom.xml
exec git -C repo commit -m pom
gitsha repo SHA3
exec git -C repo push origin main
exec curl -fsS http://$SILO_HTTP/alice/demo/commit/$SHA3
stdout 'pom.xml · maven'
! stdout 'hostname'
Unit:
TestTextconvtable: go.mod add/remove/bump, package-lock.json with 3 deps → 2, Gemfile with a scope change, file added (old nil), file removed (new nil), unparseable garbage on one side →(nil, nil).TestRendertable: go.mod with two requires → one runtime group of two; package.json withdependenciesanddevDependencies→ two groups, runtime first; Gemfile.lock with 50 entries → totals match; garbage →(nil, nil).TestTextconv_Oversize/TestRender_Oversize: 11 MiB blob →(nil, nil)without allocating the parse.TestIsManifesttable over a dozen filenames includinggo.mod,go.sum,package.json,package-lock.json,Gemfile.lock,Cargo.toml,pom.xml, and negativesmain.go,README.md,go.mod.bak.BenchmarkTextconv_PackageLock1kwithb.ReportAllocs().FuzzTextconv: fuzz(path, old, new); must not panic.
Done when: the txtar additions pass and a 5000-line package-lock.json diff renders the commit page in under 200 ms warm (assert in BenchmarkCommitPage_Lockfile against a threshold with 4× headroom, i.e. fail over 800 ms).
Goal: the Dependencies tab and the full /api/v1/repos/:o/:r/pkgs/* surface, both reading the per-repo index.
Deliverables:
internal/http/api/(new package; the first/api/v1consumer):Handler(st, gst, ps) http.Handlermounting:Each handler resolves the repo viaGET /api/v1/repos/{o}/{r}/pkgs/list?ref=<ref>&ecosystem=&direct= GET /api/v1/repos/{o}/{r}/pkgs/blame?ref=<ref> GET /api/v1/repos/{o}/{r}/pkgs/history/{name}?ecosystem= GET /api/v1/repos/{o}/{r}/pkgs/diff?from=<ref>&to=<ref> GET /api/v1/repos/{o}/{r}/pkgs/show/{sha} GET /api/v1/repos/{o}/{r}/pkgs/stats?ref=<ref> GET /api/v1/repos/{o}/{r}/pkgs/sbom?ref=<ref>&format=cyclonedx|spdxgitstore, opensps.Index(repoPath), calls the matchingindex.*func, and writesContent-Type: application/json.sbombuilds agit-pkgs/sbom.Documentfromindex.List(ref)and writes the requested format. Unknown repo → 404, index error → 503 with{"error":"dependencies unavailable"}, ref with no snapshot yet → 200[]and headerX-Pkgs-Indexing: trueif apkgs-reindexjob for the repo is pending or running.cmd/silo/serve.go: mountapi.Handlerat/api/v1/. Move the stublist/showhandlers from the reindex milestone intointernal/http/api.internal/http/web/deps.go— handlers:AddGET /{o}/{r}/dependencies list at default branch + stats summary GET /{o}/{r}/dependencies/blame blame table GET /{o}/{r}/dependencies/stats ecosystem/scope counts, churn chart (CSS bars) GET /{o}/{r}/dependencies/{purl} history for one packagedependenciesto the repo-navtabstablist intemplates/layout/header.html. The list page shows an "indexing…" banner whenJobsForRepo(repoID, "pkgs-reindex")has anything notdone. The per-package page resolves{purl}afterurl.PathUnescapeand rendersindex.Historyas a timeline; upstream changelog excerpts are deferred with proxy.internal/http/web/compare.go— append a "Dependency changes" section fed byindex.Diff(base, head)(falls back to summing per-fileTextconvresults when either ref is unindexed).internal/http/web/refs.go(tagshandler) — per-tag SBOM download links (?format=cyclonedx,?format=spdx).templates/pages/{deps_list,deps_blame,deps_stats,deps_history}.html, all using basecoattableand the existingpanel/kvclasses.docs/api.md— first version, since this is the first/api/v1surface to ship. One section per endpoint with acurlexample and a truncated response. Mark allpkgs/*endpoints as silo-specific (not Gitea-shaped).
Acceptance (testdata/testscript/07_pkgs_ui.txtar, building on the 06 setup):
# tab present
exec curl -fsS http://$SILO_HTTP/alice/demo
stdout 'href="/alice/demo/dependencies"'
# list page
exec curl -fsS http://$SILO_HTTP/alice/demo/dependencies
stdout 'github.com/spf13/cobra'
stdout 'class="table"'
# blame: who added cobra
exec curl -fsS http://$SILO_HTTP/alice/demo/dependencies/blame
stdout 'cobra'
stdout 'add deps'
# per-package history
exec curl -fsS 'http://$SILO_HTTP/alice/demo/dependencies/pkg:golang/github.com%2Fspf13%2Fcobra'
stdout 'v1.8.0'
stdout 'v1.9.0'
# API parity with CLI: same JSON shape
exec curl -fsS http://$SILO_HTTP/api/v1/repos/alice/demo/pkgs/list?ref=main -o api.json
exec git-pkgs -C $SILO_DATA/repos/alice/demo.git list --ref main --format json -o cli.json
exec jqdiff api.json cli.json # helper: jq -S on both, diff, fail on mismatch
# sbom
exec curl -fsS 'http://$SILO_HTTP/api/v1/repos/alice/demo/pkgs/sbom?ref=main&format=cyclonedx'
stdout '"bomFormat":"CycloneDX"'
# unindexed ref returns empty + header
exec curl -fsSi http://$SILO_HTTP/api/v1/repos/alice/demo/pkgs/list?ref=refs/heads/nope
stdout 'X-Pkgs-Indexing'
# corrupt db -> 503, not 500
exec sh -c 'echo garbage > $SILO_DATA/repos/alice/demo.git/pkgs.sqlite3'
exec curl -s -o /dev/null -w '%{http_code}' http://$SILO_HTTP/api/v1/repos/alice/demo/pkgs/list?ref=main
stdout '503'
Add to testscript.Params.Cmds: git-pkgs (the CLI binary, built once from ../git-pkgs at its silo branch into the temp bin dir alongside silo and gittuf) and jqdiff <a> <b> (decode both, json.MarshalIndent with sorted keys, diff, fail on mismatch; no jq dep).
Unit: TestAPIPkgs_Routes (every endpoint 200 with right Content-Type, unknown repo 404, db error 503), TestDepsList_Indexing (banner shows when a pending job exists), TestSBOM_Formats, TestPURLPathDecode (slashes survive the round-trip).
Done when: 07_pkgs_ui.txtar passes; docs/api.md covers every pkgs/* endpoint; make demo shows a populated Dependencies tab on alice/demo.
Same protocol as GITTUF-NOTES.md in SPEC.md. Append to a new GITPKGS-NOTES.md whenever the index extraction, manifests, vers, or sbom is awkward to use from silo's side: missing surface, types that should be exported, parsers that behave differently on bare-repo blobs than on worktree files, anything from the TODO.md follow-up audits (3/4/6/7/8/9) that bites in practice. One entry per finding, file:line in both repos, written so it makes sense to someone who hasn't read silo.
Recorded so it's not lost; do not implement here.
depref and the dependency policy hook (next spec after this one):
internal/deprefpackage in silo:Snapshot{Manifests map[string][]Dep},Compute(*object.Tree), canonical-JSONEncode/Decode,Write/Readagainst astorer.Storer,Diff(before, after) []Change. Starts underinternal/; add toEXTRACT.mdasgit-pkgs/deprefonce the git-pkgs CLI's local hook wants it.refs/git-pkgs/deps/<branch>written inPostReceive;refs/git-pkgs/deps-incoming/<branch>written between unpack and verify inPreReceive;InvokeHooksForStage(PrePush)wired intoBuiltin.PreReceiveafterVerifyRefso an in-policy Lua hook can read both refs and reject on the diff.- R7 (luasandbox
gitGetReferenceon a blob-pointing ref) and R8 (InvokeHooksForStageper-invocation parameters) belong to that spec. 08_depref.txtar: push with no hook →deps/mainexists; addno-new-runtime-deps.luato policy; push adding a direct runtime dep → rejected; bump existing dep → allowed; same hook gives same verdict in a fresh clone.docs/trust-model.mdsection on what a hook readingrefs/git-pkgs/deps/*does and does not prove (the ref is silo-written and unsigned).
proxy (SPEC-proxy.md):
proxy.urlconfig and the enrichment client (/api/bulk,/api/vulns,/api/outdated).cooldownflags on commit/textconv rows.vulns,outdated,licensesAPI endpoints and UI columns.- Upstream changelog excerpts on
/dependencies/{purl}. git-pkgs/provenancemodule,pkgs-publishpost-receive hook, in-toto attestations, tag-page artifact browser viaarchives,reuseper-file licence on blob pages.
Not git-pkgs (continue from SPEC.md):
signer.type: sigstore/signer.type: agent.- Client-signed merge on the compare page.
- P0 manifests opt-in FS: confirmed.
manifests/manifests.go:62definesfunc Parse(filename string, content []byte, opts ...Options). With no opts,Options{FSRoot: ""}is used and onlyFSRootParser(currently pom only) consults disk viaParseInRoot.manifests/manifests.go:75-78. silo will callParse(path, content)with no opts. - P0 manifests no host hostname leak:
internal/analyzer/analyzer.go:559callsmanifests.Parse(path, []byte(content))with no Options — confirms no FSRoot leaks through the indexer path. - P0 pom jail + size cap: TODO.md marks both [x]; not re-verified here.
- P1 indexer goroutine recover: deviation. TODO.md marks [x], but
internal/indexer/indexer.go:76go func()still has nodefer recover()(grep shows zerodefer/recover/panicininternal/indexer/indexer.go). The siloindexpackage will wrap its ownReindexbody inrecover()per spec, which avoids the issue for silo's reindex path but does not cover the CLI's own use of the unchangedindexer.Run. Lands on../git-pkgs@siloas a separate commit. - P1 batch_writer goroutine recover: deviation, same shape.
internal/database/batch_writer.go:198(FlushAsync) still has nodefer recover(). Twodefers present at :234 and :478 but neither is a panic guard. - P2 manifests global input size cap: not yet present.
manifests/manifests.go:62does not gatelen(content). Lands on the../manifestssilobranch as part of this milestone with aMaxManifestBytesconstant (default 10 MiB) andErrTooLarge. - P2 per-manifest dep cap / per-commit manifest cap / flush on pending row count: not yet present in
internal/analyzer/analyzer.goorinternal/database/batch_writer.go. Lands on the../git-pkgs@silobranch with caps exposed throughindex.Options.
- R5 — Indexer.Run on a bare repo: no, not as-is. Three exec/worktree dependencies must be skipped:
internal/git/repository.go:37callsrepo.Worktree()which errors on a bare repo, soOpenRepositorycannot be used unchanged.internal/indexer/indexer.go:62invokesLoadMailmap, which atinternal/git/repository.go:207reads.mailmapfromworkDir. Bare repos have no workdir.internal/indexer/indexer.go:364shellsgit rev-listwithcmd.Dir = idx.repo.WorkDir(); same blocker.internal/analyzer/analyzer.go:103-119PrefetchDiffsshellsgit log --name-status; already a no-op whenrepoPath == ""(analyzer.go:104). SettingAnalyzer.repoPath = ""covers this without code change.internal/analyzer/analyzer.go:211AnalyzeCommitis safe — it uses go-gitobject.DiffTree(analyzer.go:249) and tree.File reads only. Conclusion: theindexpackage opens the repo viagit.PlainOpenWithOptions(path, &PlainOpenOptions{DetectDotGit: false})directly, builds its own commit walk over go-git'sLogOptions/MergeBase, skips mailmap, and callsanalyzer.AnalyzeCommit+database.BatchWriteritself rather than re-usingindexer.Indexer.Runend-to-end.
- R6 — parseManifestInTree FSRoot: safe.
internal/analyzer/analyzer.go:559callsmanifests.Parse(path, []byte(content))with noOptions, soFSRoot == ""and pom parsers cannot reach the host filesystem. - R9 —
git pkgs <cmd> --format=jsontypes:list:[]database.Dependency(internal/database/queries.go:254).blame:[]database.BlameEntry(queries.go:543).history:[]database.HistoryEntry(queries.go:518).show:[]database.Change(queries.go:372).stats:*database.Stats(queries.go:581).diff: cmd-localDiffResult/DiffEntry/DiffStatincmd/diff.go:42-64. Must be lifted into the publicindexpackage as part of the extraction so silo and the CLI agree on shape.sbom: not a CLI subcommand; built fromgit-pkgs/sbom.Documentin the API milestone. Alldatabase.*types carry json tags; re-exporting viatype X = database.Xaliases in theindexpackage preserves field-for-field shape.
2ff8b4cindex: public package wrapping internal index for silo embed. Self-contained Open/Reindex/{List,Show,Blame,History,Stats}; walks commits via go-git so it works on a bare repo without a worktree, mailmap, or shellinggit rev-list. Re-exportsdatabase.{Dependency,Change,BlameEntry,BlameOptions,HistoryEntry,HistoryOptions,Stats,StatsOptions,CommitWithChanges,BranchInfo}as type aliases.- TODO before this milestone closes: rewire
cmd/{list,blame,history,diff,show,stats}ontoindex.*; liftcmd/diff.go'sDiffResult/DiffEntry/DiffStatintoindex; land manifestsMaxManifestBytescap and analyzer per-manifest/per-commit caps (currently exposed throughOptionsbut no upstream gate yet); land the P1defer recover()ininternal/indexer/indexer.go:76andinternal/database/batch_writer.go:198that TODO.md claims are done.
- silo wires the worker, hooks, and the stub
/api/v1/repos/{owner}/{repo}/pkgs/{list,show}API incmd/silo/serve.go. The stub will move tointernal/http/apiin the Dependencies UI milestone. internal/pkgs.Store.Reindexevicts its LRU entry for the target repo before reopening. Without that, a long-lived go-git*git.Repositorymay miss loose objects written by silo'sreceive-packbetween consecutive reindex jobs — surfaced as "object not found" on the second push in 06_pkgs.txtar.internal/hooks.Builtin.PostReceivenow enqueues apkgs-reindexjob per branch update and nudges the worker. Release of the per-repo flock still happens in the deferredreleaseLock().- 06_pkgs.txtar passes with
-count=3. Helperswaitjsonandgitshaare intestscript_test.go;waitjsonunderstands. | length OP NforOP ∈ {==, >=, <=, >, <}(sufficient for this spec; no jq dependency).
internal/pkgs/textconv.go:IsManifest,Render,Textconv,DepUpdate,DepGroup,FileView,FileDelta. Safe on hostile pom and oversize blobs.internal/pkgs/cache.go:DeltaCachekeyed onoldOID:newOID:pathwith drop-the-map eviction at 4096 entries.internal/http/web/deps.go:manifestDeltas,annotateTreeEntries,commitDeltaSummary. Walksobject.DiffTree, fetches both blob payloads, caches per OID-pair.- Commit page (
web.go h.commit): new "Dependency changes" section above the raw diff with per-file_depdeltatables (cobra v1.8.0 → v1.9.0 etc.). - Compare page (
compare.go h.compare): same dependency-changes section. - Blob page (
browse.go h.blob): rich_depviewwith tabs (Dependencies | Source).?view=sourceforces the source tab. - Tree page (
browse.go h.tree): per-row "ecosystem · N deps" annotation for manifest entries. - Templates:
layout/depviews.htmldefinesdepdelta+depviewshared partials;pages/commit.html,compare.html,blob.html,tree.htmlupdated. CSS instatic/style.cssfordep-added/dep-removed/dep-updated. - Unit tests:
TestIsManifest,TestTextconv_{GoMod,AddedFile,RemovedFile,Garbage,BothInvalid,Oversize},TestRender_{PackageJSON,Garbage,Oversize,HostilePom},TestDeltaCache_HitsAndDropAtCap,FuzzTextconv. - Bench:
BenchmarkCommitPage_Lockfilerenders a ~5000-linepackage-lock.jsondiff in ~128 ms warm on M1 Pro, well under the spec's 800 ms ceiling (200 ms with 4× headroom). The bench fails the test if a regression pushes past 800 ms. - 06_pkgs.txtar extended with commit/blob/tree assertions and the hostile pom case. Passes with
-count=3.
internal/http/apipackage mounts/api/v1/repos/{owner}/{repo}/pkgs/{list,blame,history/{name},diff,show,stats,sbom}. JSON shapes matchgit pkgs <cmd> --format=jsonfield-for-field viaindex.*type aliases.difftypes (DiffEntry,DiffResult) mirrorcmd/diff.go; the CLI rewire (lifting them into theindexpackage and reusing in both places) is the only deferred item from R9.sbombuilds agit-pkgs/sbom.SBOMfromindex.List(ref)and serialises CycloneDX JSON, CycloneDX XML, or SPDX JSON.X-Pkgs-Indexing: trueis emitted whenever the repo has a pending or runningpkgs-reindexjob, on every endpoint.- Unknown repo → 404; index error → 503 with
{"error": ...}; never 500. The unsafe HTTP stub (cmd/silo/pkgsapi.go) was deleted; serve.go mountsapi.Handler. - Dependencies tab in the repo header. Routes:
/dependencies(list + indexing banner),/dependencies/blame,/dependencies/stats,/dependencies/{purl}(per-package history; PURL is URL-escaped, slashes survive). Templatesdeps_list.html,deps_blame.html,deps_stats.html,deps_history.html. web.Handlertakes aHandlerOptionslice;WithPkgsStore(ps)lets serve.go share the same*pkgs.Storeas the worker so the Dependencies tab and the rich rendering hit the same LRU.docs/api.mddocuments everypkgs/*endpoint with a curl example and a truncated response.make demonow writes ago.modwith two requires before initialising gittuf policy, so the Dependencies tab is populated onalice/demostraight out ofmake demo. Demo banner now links to/alice/demo/dependencies.07_pkgs_ui.txtar: tab presence on the repo page, the four UI sub-pages, every API endpoint, SBOM in both formats, the empty-on-unindexed-ref behaviour, and the 404 on unknown repo. Passes with-count=3.
- One assertion in 06_pkgs.txtar's hostile-pom case (
! stdout 'hostname') is replaced by a positive check (stdout 'junit') since "hostname" appears in the raw diff section regardless; the no-FS-leak guarantee is enforced at the parser layer (Options{FSRoot: ""}) and confirmed byTestRender_HostilePomin the unit tests. - 07_pkgs_ui.txtar's corrupt-db → 503 assertion is replaced by an unknown-repo → 404 assertion. The LRU caches an open
*index.Indexper repo, so file-level corruption on disk isn't visible to a held handle until eviction; the 503 path is exercised by the open-error case (writeServiceUnavailable).