Add unity-sync, a mirroring CLI for the Unity Asset Store - #1
Merged
Conversation
The offline suite runs against real SearchMyAssets responses, and the store returns a per-row entitlement id alongside every product. Nothing in this tool reads it, so the scrubber deletes the field rather than substituting a value -- which also leaves each fixture shaped exactly like what the pinned query asks for. Raw captures stay out of the repo; only the scrubbed output is committed. The guard test walks whatever is in testdata rather than a fixed list, so it also covers fixtures added later by someone who never read the scrubber. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
The API returns id, productId and itemId for every product. Only the first is
the one /api/downloads/{id} takes and the one the package's own gzip metadata
carries; the 12-digit productId is accepted by no endpoint here. Asset omits it
entirely so it cannot be reached for by mistake.
Slugs are derived, not identifying: a rename changes them, which is why they key
the lockfile and the cache tree for readability while classification matches on
the id. Both slug forms fall back when a name folds to nothing under ASCII
folding, because an empty path segment would collapse the cache layout to two
components and empty quarry's pack facet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
session_source has no default value. Every other tool of this shape defaults to reading a browser cookie store, but the Asset Store's credential is a session cookie that no cookie database holds, so a browser default would make the out-of-box run fail with a diagnostic pointing at the wrong thing. Concurrency defaults to 2 rather than the template's 4: packages here reach 23 GB and the store is someone else's infrastructure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
An expired Asset Store session arrives as HTTP 500 with an empty GraphqlError. On status alone that is indistinguishable from a server hiccup, so the single most common failure this tool will see would be retried through the whole backoff schedule before the user is finally told to re-paste a session. retry.Permanent lets the caller settle it from the body and stop at once, while Retryable keeps the ordinary status policy for everything else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Every .unitypackage carries a JSON descriptor with the product id and version id in the gzip FEXTRA field. It is not the comment field, which is empty on every real package, and gzip.Header.Comment would therefore read blank forever while looking correct. The subfield walk is driven by each subfield's own length rather than a fixed prefix. Every package sampled ends its descriptor by byte 338, but XLEN is a uint16, and a prefix-limited reader would report "no metadata" for a package that has some -- quietly downgrading the hard "the store served a different asset" check into the tolerated warning path. The test puts a 40 KB subfield in front of the descriptor to keep that honest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Measured against the live store: _csrf plus LS alone returns the full owned-asset list, a junk or absent NextAuth session token changes nothing, and removing LS turns any user-scoped query into an opaque HTTP 500 that reads like a server fault. So LS is the credential, and its absence is reported before a request goes out rather than left to that 500. Two parsing details are load-bearing. A cookies.txt marks HttpOnly rows with a #HttpOnly_ prefix on the record itself, and LS is HttpOnly, so treating every '#' line as a comment drops precisely the credential. And the storefront mixes cookie scopes within a single response, so the whole unity.com family is accepted rather than one host guessed -- which scope LS uses is not something any reachable endpoint reveals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
…kage The response-level guards live here because they can all be decided before any bytes are kept: no redirect is followed, any Content-Encoding is refused, and the content type must be an octet-stream. Each of these has a specific failure behind it. An unauthenticated download 302s to Unity's OAuth page, so following redirects would write a sign-in page into the cache under a .unitypackage name. The endpoint honours Accept-Encoding: gzip by gzipping the already-gzipped package, and Go does not transparently decode an encoding the caller asked for, so the cache would receive a double-gzipped blob with no readable metadata. x-requested-with is what makes a failed API call answer with the JSON error that carries the diagnosis rather than a 302 to an HTML error page. It, the identity encoding, the User-Agent and the query document are all invisible when missing -- a permissive server answers fine -- so the tests assert what the client sends, not just what it does with the reply. The enumeration compares raw rows against the store's own total before deduplicating, so a duplicate entitlement row cannot masquerade as a short walk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Store leaves the bytes in a temp file beside their destination and hands back the digest and exact size; the caller runs its checks and then commits or discards. A single-phase write would leave a rejected body at the real cache path for a window, and an interrupt there strands it where the next run's adopt scan takes it for genuine. The layout is three segments because quarry reads its vendor facet from the first path segment and its pack facet from the second, filling the latter only when a path has at least three parts -- a flat tree indexes every package with both facets empty. That is also why a rename moves the directory rather than the file, and why an emptied directory is pruned. Adoption scans by the descriptor inside each file rather than probing a derived path, since the case it exists for is a file that is not where the layout would put it. It skips dotfiles, so an abandoned partial cannot be adopted: a partial can be large enough to clear a size floor while still carrying an intact descriptor. Relocation refuses an occupied destination, because the caller records the digest of whatever ends up there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Each lockfile entry has two halves. The advertised half refreshes every run for every owned asset, so a deprecation or a rename shows up in the diff even for assets the run never downloaded. The resolution half describes the file on disk and is rewritten only when a run resolves that asset. resolvedVersionId is the diff key, not version.id. Keeping them apart is what stops a refreshed advertised id from being written beside an older file: that pairing would mark every unresolved asset as already current and it would never re-download. sizeBytes is likewise always the received count and never the advertised one, which runs 0-16 bytes higher. There is no run timestamp. Stamping one would dirty a committed file on every no-op run, burying the changelog the file exists to be. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
The manifest is the one file in the system a user curates by hand, so every destructive edge is closed here. Entries key on the store product id, not the name, because a publisher rename would otherwise silently disable an asset. An unknown TOML key is an error rather than a shrug, since Save re-encodes from the struct and would delete it on the next write. Reconcile reports the entries it drops instead of removing them quietly, and refuses outright to rewrite a non-empty manifest against an empty owned set -- a session with a different active org looks exactly like that. UnknownIDs exists so status and sync can mention a refunded or mistyped id. Neither writes the manifest; only select does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
The semantic guards live here because they need both the stored bytes and the enumeration metadata: the store layer sees only responses, the cache layer only bytes. Each runs against the temp file, and a failure discards it, so a rejected body never reaches a real cache path. A short body is ambiguous -- truncation or a republish that moved the advertised size -- so one re-read of that product settles it. The discriminator is deliberately not "the delivered version id differs from the advertised one": for some products that difference is a steady state, and keying on it would switch the floor off permanently for exactly the assets most likely to hide a truncation. Failures are per-asset. The pool cancels early only for a run-fatal error, because otherwise one delisted package aborts a 75 GB mirror on every future run. A permanently pulled asset is reported but does not make the run exit non-zero; a corrupt body or a transport error does, since re-running may fix it. The lockfile is written after each successful download, and entries the run did not resolve keep both their prior resolution and their prior key -- re-keying one while leaving its file in place would put the two permanently out of step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
select is the only command that writes the manifest, so the two ways a save can destroy a curated allowlist are refused here rather than absorbed. A POST without this run's token comes from a page an earlier run served, against a different library. A save that would deselect everything at once is refused unless nothing was selected to begin with, which is the legitimate first-run case. Thumbnail URLs arrive protocol-relative and are made absolute: on a page served from http://localhost they would otherwise resolve to http:// and break. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
The repository is private, so the release binary comes from the GitHub asset API, which answers with a 302 to a signed CDN URL. The Asset Store clients ban redirects because following one there writes a sign-in page into the cache; inheriting that ban here would break updates outright, and a stub server that never redirects would not notice. The test therefore redirects for real. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
flag.Parse stops at the first non-flag argument, so an unchecked positional swallows every flag after it: `sync some-asset --dry-run` would download the delta and rewrite the lockfile. Every subcommand rejects positionals and points at --only instead; update is the sole exception, since it takes a version. list reads only the lockfile, so it works with no session and no network. The two ways a run can fail before touching the network -- no session configured, or a session with no LS cookie -- are named with the remedy rather than surfacing as an opaque 500 later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
release.yml calls ci.yml rather than restating its steps, so the two cannot drift into a tag being cut on a weaker bar than a merge. The suite runs under -race because the syncer fans downloads out under a semaphore and persists the lockfile from each goroutine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
docs/design.md records the measured behaviour rather than the expected: which cookie is the credential, which route issues the CSRF token, that an unauthenticated download 302s to an OAuth page, that the endpoint gzips an already-gzipped package if asked, and that advertised and delivered version ids disagree for some products. Each of those is the reason a specific guard is shaped the way it is, so the reasoning stays attached to the fact. The README leads with the paste-a-session workflow instead of apologising for it: no browser cookie database holds the cookie the store checks, so it is the only thing that can work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Three were substantive. Downloads had no retry at all, though the design specifies one and depends on each attempt starting from a fresh temp file and hasher; retry now wraps the fetch and the write together. The advertised-versus-delivered version mismatch was recorded but never mentioned, so the one case where the store serves a build it does not advertise passed in silence. And a republish discovered mid-download failed the asset instead of warning, inverting the outcome for the single legitimate way to fall below the size floor -- it now stores nothing, warns, and leaves the new build for the next run. The rest are alignment: the download request was missing x-requested-with; a stale CSRF token got zero retries where one re-bootstrap is the obvious remedy; the pool did not stop on an expired session, so every remaining asset failed individually; `unity-sync version foo` exited 0; the PII guard skipped non-JSON fixtures; and the pinned query asks for one image size while the fixtures carried four, so the scrubber now drops the rest. The failure-model tests move into audit_test.go files, the convention the template uses to mark which tests exist because something went wrong once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Six verify-column gaps, all cases where the code existed but nothing proved it. The two that matter are on the adopt path, which the plan calls the design's load-bearing case. A candidate stamped with a different build than the store advertises must be refused -- a wrong adopt is silent and permanent, where a redundant download is loud -- and a candidate found off its derived path must be relocated before its cachePath is recorded, or quarry's facets stay on the old name and the file is stranded at the next version bump. Live QA cannot reach the second one: its subject already sits at the derived path, where relocation is deliberately a no-op. The pinned query document is now compared whole against a golden file rather than by substring. Its field set is a privacy boundary as much as a correctness one, and a substring check would notice neither a lost field nor a regained one. `select`, `status` and `sync` gain command-layer tests by narrowing what they take to the interface they use, so their happy paths are exercised instead of only their failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
The serious one. When cheap verification failed, classify fell through to the adopt scan, which rescanned the tree and matched the very file that had just failed: a truncation or a mid-file flip leaves the descriptor intact and a small truncation clears the size floor, so the damaged bytes were re-hashed and their digest recorded as the asset's truth. That is exactly the outcome the two-phase write, the floor and the metadata gate exist to prevent, reached through the one door that skips all three. The failing path is now excluded from the scan, so the asset re-downloads. Three smaller ones. A rename that also bumped the version downloaded to the new derived path and left the old directory holding a superseded copy. An expired session and a pulled asset each burned a retry with a two-second backoff, though neither improves on a second attempt. And `status` with nothing enabled reported "0 assets considered" rather than the owned count, which is the number the user needs before running select. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
A 23 GB download previously printed one line before the transfer began and nothing until it ended. Progress is now counted off the body as it streams, rate-limited to a line a second, from the layer that knows the asset's name and advertised size. Cancelling the pool now reaches an in-flight transfer too, not only queued ones: once a session is known dead, continuing to pull gigabytes under it is waste. Two documentation claims were stale rather than wrong-by-omission. The README said the tool never deletes a package it mirrored, which stopped being true when the rename fix started removing an asset's own superseded copy; and the design doc never described adoption at all, including the exclusion that keeps a damaged file from being adopted back in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Only a 500 whose GraphqlError carries an empty message means the session died; that shape short-circuits the backoff so a stale paste is named at once. A 5xx that says what went wrong is an ordinary server error, and returning it as permanent turned a transient upstream outage into an immediate failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
The CSRF token can expire between the bootstrap and the call that spends it, so the client re-bootstraps and retries once. Nothing asserted that, only that a permanently mismatching server surfaces ErrCSRF, which the retry path also satisfies. The memoized verification probe becomes a named function so the property that matters can be tested directly. Counting hashes through Run was not observable without a seam that only a test would use, and a test that cannot observe the thing it names is worse than none. Slugs fold runs of non-ASCII to one separator rather than transliterating, which is what the store does too; that is now pinned rather than described. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
The class tally said "undownloadable 1" against 176 owned assets and stopped there, so the one fact the user can act on — which package — was missing. The summary already names the other two exceptional cases per asset, and the README already promised delisted assets are reported. Found running the live QA against the real account, where a genuinely disabled asset produced a count and no name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Two ways a run could get permanently stuck: A cached file that failed verification stayed at the derived path while adoption tried to move a good copy of the same package onto it. Relocate refuses an occupied destination, so nothing resolved, the prior entry carried forward unchanged, and every later run refused identically. A download would have renamed straight over that file, so an adoption may too. The store client's cookie and CSRF token were written by a mid-flight re-bootstrap while the download pool read them, a data race on the credential itself. A torn Cookie value would surface as an unreproducible "session expired" partway through a sync. Both are pinned by tests that fail against the previous code: the adopt test reproduces the refusal, the concurrency test reports eight data races. And seven smaller ones: install.sh died silently with no credential, because a function ending in a failing test returns non-zero and set -e aborts the caller's assignment, so the diagnostic written for exactly that case could never print. The advisory size warning overwrote the version-mismatch warning, losing the only line that explains why the lockfile holds two different version ids. A refused save killed the select page, so its own "reload and choose again" was impossible to follow, and any open tab could end a selection with one cross-origin POST. The incremental lockfile save ran after releasing the lock, so two goroutines could reach the rename in the order opposite to how they built their snapshots, losing the record the write exists to keep. A non-404 4xx on the download endpoint was retried, against both the stated policy and the GraphQL path's own behaviour. Seven comments justified an absence or argued against a rejected alternative, which a reader of the file cannot check. The README undercounted enumeration by the terminator page, and described the cache as removing only one kind of file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
The adopt fix added a second reason a run deletes a cached file, and only the README said so. docs/design.md still opened "Nothing deletes a package the tool mirrored, with one exception", and CLAUDE.md points readers at that file as the authoritative one before they touch the cache. main.go and RemoveStale's own doc were wrong the same way. There is now a third reason, because adoption did not remove the entry's own superseded copy the way the download branch does. When the current build already sits at the derived path and the lockfile points somewhere else — a run killed between the commit and the incremental save leaves exactly that — the old file was orphaned: unreferenced by the new lockfile and named by nothing, since the summary only names assets that left the account. So the rule is stated once, as it now stands: a run removes a file only when it is replacing that same asset's own copy. Also from the review: a comment of mine that argued against a design nobody proposed, the same shape the previous commit deleted seven of; dropped assets printed in map order, so two runs finding the same thing disagreed; and a branch in SweepTemps that could not be reached, since the walk function returns nil for every error including the missing root. That case is now a test instead of an unreachable guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
An asset whose advertised version moved is Changed before the cache is asked anything, but the decision about which file to exclude from the adopt scan ran first and forced the probe anyway. Under --verify that probe is a full re-hash, so every out-of-date asset had its whole file read immediately before being replaced by the download. The decision now happens inside the adopt closure, which is only reached once that short-circuit has not fired. The adopt scan's exclude list was also compared as a raw string while every other cache entry point resolves its paths, so a lockfile spelling the same file differently would skip nothing and re-offer a file that just failed verification as something to adopt. Two error values still documented themselves as returned from Serve, which stopped being true when the refused-save path stopped tearing the server down. They are rendered into a 409 body now, and say so. Also: a tiebreak on the dropped-asset ordering, since two publishers can ship the same product name; a run that fails at the final lockfile write now still prints what the download pass did; build no longer takes an Options it never reads; and Progress says that it is called from every download goroutine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Pruning compared the library root as the user spelled it against paths that had been cleaned, so `--library ./lib` or a trailing slash left every emptied directory behind and nothing said so. quarry reads its pack facet from those directories, which is the reason they get pruned at all. The lockfile and the manifest are written through a temp file and renamed, so both landed as 0600 — owner-only, on the two files this design most wants committed and hand-edited. A fresh one is 0644 now and an existing one keeps whatever mode it has. An adopt-scan exclusion that could not be resolved was dropped, turning "do not adopt this file" into no exclusion at all, on the one path into the cache that skips the download guards. It now refuses every candidate instead, which falls back to a re-download where those guards apply. SweepTemps could not return a non-nil error, because its walk swallows all of them, so the fatal check on it was unreachable and a real walk failure would report zero reclaimed. Swallowing is right — one unreadable directory must not stop a 75 GB mirror — so the signature now says so. Nothing asserted the sweep happens either: deleting the call left the suite green. Also: the select page's URL went to stdout, against the rule that stdout is output and diagnostics are stderr; two exported error values that nothing returns are messages now; and Report.Freed was written and never read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
Every diagnostic in install.sh was unreachable. The script runs under `set -e` with `pipefail`, and each guard sits after an assignment from a pipeline, so a failing curl aborted the script before its own error message could print. A new user without a credential got exit 1 and not one word, on a private repo where that is the expected first outcome. Verified against a 404: silent before, named and exit 1 now. Nothing cleaned the temp directory on those paths either, since there was no trap, so each failure left a mktemp -d behind. The final `unity-sync version` check could not fail the install: it fell through to `err`, which is a printf and returns 0, so a broken binary exited 0 as the script's last command. The token also travelled in argv, where `ps` shows it to every local user for the length of both requests. It goes through a curl config on stdin now, which is what the Go client already does by setting the header in-process. This came in from synty-sync's installer, which the plan leaves alone, so the fix is here only. Also: the CI concurrency comment justified itself with a double-run that the triggers make impossible — push is restricted to main, so a PR branch fires one workflow, and the two would carry different refs anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A Go CLI that mirrors the assets you own on the Unity Asset Store into a local library,
downloading only what changed since the last run.
selectpicks what to mirror through alocal page,
statussays what a sync would change,syncfetches the delta,listprintsthe lockfile,
updatereplaces the binary from a release.Three files hold the state, deliberately apart: user config and the pasted session live
outside any project, the
unity-sync.tomlallowlist and itsunity-sync.lock.jsonlive inthe project that consumes the assets and get committed, and the packages themselves live in
an expendable cache at
<library>/<publisher>/<asset>/<asset>.unitypackage.51 files, all new; the repo was empty. Commits are narrative and read in dependency order,
with one deliberate exception: the fixture scrubber lands first, because everything after it
consumes fixtures and committing the raw captures even briefly would put entitlement ids in
git history where an edit cannot remove them.
Why
The Asset Store gives you no way to pull the packages you have bought outside the Editor,
and an account of a few hundred assets is tens of gigabytes with individual packages
reaching 23 GB. Re-downloading the world to find the one thing that changed is not viable,
so the whole design is about answering "what changed" cheaply and never trusting a file it
did not verify.
The store makes that harder than it sounds, and most of the code is a response to something
measured rather than assumed:
LScookie is the entire credential. Not the NextAuth session token, which neitherendpoint consults.
LSis a session cookie no browser writes to disk, which is why thetool asks for a paste instead of reading your cookie database. See DECISIONS.md item 1 in
the run notes: whether the zero-paste UX is worth chasing is a real open question.
LSreturns an HTTP 500 that reads like a server fault, so its absence isasserted locally before any request goes out.
redirect.
selfupdateis the one deliberate exception, since GitHub's asset API 302s toa signed CDN URL by design.
downloads ask for
identity.and the id it actually serves. Both are recorded, and the advertised one is the diff key,
because pairing a refreshed id with an unresolved entry would mark that entry current
forever.
Testing
The suite is fully offline:
go test -race ./...across 14 packages, againsthttptestservers and scrubbed fixtures.
go vet ./...andgofmt -l .are clean, and CI runs thesame three.
It was also run live against a real 176-asset account: select through save, first sync,
the two-version-id warning, an unchanged no-op run, a stale diff key forcing a re-download,
truncation caught by the cheap check, a mid-file flip caught only by
--verify, adoptionof an orphaned file with and without a lockfile entry, the version gate refusing to adopt a
mismatched one, a forced rename relocating the directory, a delisted asset reported without
failing the run, four concurrent downloads, and a corrupted
LSreported as an expiredsession with the lockfile untouched. All passed. The one finding it produced, a delisted
asset counted but never named, is fixed, and the live checks were re-run against the
finished binary.
The diff then went through five review rounds, which raised 38 findings; 34 were fixed and
4 judged not worth acting on. The ones worth knowing while reading it:
good copy of the same package existed elsewhere, the good copy had nowhere to land: the
damaged file still held the derived path, and
Relocaterefuses an occupied destination.Nothing resolved, the entry carried forward unchanged, and every later run failed the
same way.
writes
cookieandcsrfwhile the download pool reads them.--verify, every out-of-date asset was fully re-hashed immediately before beingreplaced by its own re-download.
landing as
0600.install.shwas unreachable: the script runs underset -ewithpipefail, and each guard sits after an assignment from a pipeline, so the first failurea new user hits printed nothing at all.
Each has a test that fails against the previous code, except the
--verifyone, which is aperformance property with no behavioural observable and is verified by reading.
Worth knowing: the review stopped at its round cap, not because a round came back clean.
Findings per round were 10, 7, 7, 9, 5. What changed across rounds is where they were. The
Go code stopped yielding anything that would hold a merge by round 4, and round 5's only
significant finding was in
install.sh, which no earlier round had read closely, so theshell and workflow files have had one careful reading rather than five.
Notes
install.shandupdateboth authenticate withghorGITHUB_TOKEN.test fails the build if PII lands in
testdata/.quarry, and the three-level cache layout exists so
its vendor and pack filters work against this tree.