From bafbab12614df73c5618219cbb6f8eae99e543b1 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 11:26:56 -0700 Subject: [PATCH 01/28] Scrub the captured store responses before anything can consume them 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- cmd/scrubfixtures/main.go | 53 + go.mod | 3 + go.sum | 0 internal/fixtures/guard_test.go | 71 + internal/fixtures/scrub.go | 76 + internal/fixtures/scrub_test.go | 64 + testdata/store/my_assets_p0.json | 2111 ++++++++++++++++++++++ testdata/store/my_assets_p1.json | 1607 ++++++++++++++++ testdata/store/my_assets_p2.json | 10 + testdata/store/package_header_sample.bin | Bin 0 -> 1024 bytes 10 files changed, 3995 insertions(+) create mode 100644 cmd/scrubfixtures/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/fixtures/guard_test.go create mode 100644 internal/fixtures/scrub.go create mode 100644 internal/fixtures/scrub_test.go create mode 100644 testdata/store/my_assets_p0.json create mode 100644 testdata/store/my_assets_p1.json create mode 100644 testdata/store/my_assets_p2.json create mode 100644 testdata/store/package_header_sample.bin diff --git a/cmd/scrubfixtures/main.go b/cmd/scrubfixtures/main.go new file mode 100644 index 0000000..33bbc19 --- /dev/null +++ b/cmd/scrubfixtures/main.go @@ -0,0 +1,53 @@ +// Command scrubfixtures regenerates testdata/store from the raw captures kept outside +// version control. Run it from the repo root; it is the only supported way to change +// those fixtures, because hand-editing them is how account data gets committed. +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/curbol/unity-sync/internal/fixtures" +) + +func main() { + from := flag.String("from", ".longrun/captures", "directory holding the raw captures") + to := flag.String("to", "testdata/store", "directory to write scrubbed fixtures into") + flag.Parse() + + if err := run(*from, *to); err != nil { + fmt.Fprintln(os.Stderr, "scrubfixtures:", err) + os.Exit(1) + } +} + +func run(from, to string) error { + matches, err := filepath.Glob(filepath.Join(from, "*.json")) + if err != nil { + return err + } + if len(matches) == 0 { + return fmt.Errorf("no captures in %s (they are git-excluded; see docs/design.md)", from) + } + if err := os.MkdirAll(to, 0o755); err != nil { + return err + } + for _, src := range matches { + raw, err := os.ReadFile(src) + if err != nil { + return err + } + clean, err := fixtures.Scrub(raw) + if err != nil { + return fmt.Errorf("%s: %w", src, err) + } + dst := filepath.Join(to, filepath.Base(src)) + if err := os.WriteFile(dst, clean, 0o644); err != nil { + return err + } + fmt.Printf("wrote %s (%d bytes)\n", dst, len(clean)) + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..636a39c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/curbol/unity-sync + +go 1.26 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/internal/fixtures/guard_test.go b/internal/fixtures/guard_test.go new file mode 100644 index 0000000..7e0cd4f --- /dev/null +++ b/internal/fixtures/guard_test.go @@ -0,0 +1,71 @@ +package fixtures_test + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// The store will return several account-identifying fields if a query asks for them, +// and a capture taken with a wider query would carry them into testdata. The pinned +// query asks for none of them, so their appearance in a committed fixture means either +// the query grew or a fixture was hand-edited from another source. +var forbiddenFields = []string{ + "assignFrom", + "grantTime", + "orderId", + "organizations", + "userOverview", +} + +var forbiddenPatterns = []struct { + name string + re *regexp.Regexp +}{ + {"an email address", regexp.MustCompile(`[\w.+-]+@[\w-]+\.[\w.]+`)}, + {"a session cookie", regexp.MustCompile(`(?i)(__Secure-next-auth|_csrf\s*=|\bLS\s*=)`)}, + // Entitlement ids are 14-digit runs. Product ids are 5-6 digits and version ids 6-7, + // so this cannot collide with the catalogue data the fixtures are for. + {"a 14-digit entitlement id", regexp.MustCompile(`\b\d{14}\b`)}, +} + +// TestCommittedFixturesCarryNoAccountData fails the build rather than the review when +// account data reaches testdata. It walks whatever is committed, so it also covers +// fixtures added later by someone who never read the scrubber. +func TestCommittedFixturesCarryNoAccountData(t *testing.T) { + root := filepath.Join("..", "..", "testdata") + seen := 0 + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".json") { + return nil + } + seen++ + raw, err := os.ReadFile(path) + if err != nil { + return err + } + body := string(raw) + for _, field := range forbiddenFields { + if strings.Contains(body, `"`+field+`"`) { + t.Errorf("%s: contains account-identifying field %q", path, field) + } + } + for _, p := range forbiddenPatterns { + if m := p.re.FindString(body); m != "" { + t.Errorf("%s: contains %s", path, p.name) + } + } + return nil + }) + if err != nil { + t.Fatalf("walk testdata: %v", err) + } + if seen == 0 { + t.Fatal("no JSON fixtures found; the guard would pass vacuously") + } +} diff --git a/internal/fixtures/scrub.go b/internal/fixtures/scrub.go new file mode 100644 index 0000000..e1f9906 --- /dev/null +++ b/internal/fixtures/scrub.go @@ -0,0 +1,76 @@ +// Package fixtures turns raw Asset Store captures into the PII-free JSON that the +// offline test suite runs against. Captures are never committed; the scrubbed output +// is. The account-identifying field the store returns is the per-row entitlement id, +// which nothing in unity-sync reads, so the scrub deletes the field outright rather +// than substituting a value — that also keeps a fixture shaped exactly like what the +// pinned query asks for. +package fixtures + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// entitlementField is the per-row id the store returns alongside each product. It +// identifies the grant, not the asset, and no code path in this tool uses it. +const entitlementField = "id" + +// Scrub rewrites one captured `searchMyAssets` batch response into fixture form, +// preserving key order-independent structure and stable indentation so the committed +// file diffs cleanly. It fails rather than guessing when the payload is not the batch +// shape the client actually parses. +func Scrub(raw []byte) ([]byte, error) { + var batch []map[string]any + if err := json.Unmarshal(raw, &batch); err != nil { + return nil, fmt.Errorf("capture is not a GraphQL batch array: %w", err) + } + if len(batch) == 0 { + return nil, fmt.Errorf("capture holds no operations") + } + for _, op := range batch { + results, err := resultRows(op) + if err != nil { + return nil, err + } + for _, row := range results { + m, ok := row.(map[string]any) + if !ok { + return nil, fmt.Errorf("result row is %T, want object", row) + } + delete(m, entitlementField) + } + } + var out bytes.Buffer + enc := json.NewEncoder(&out) + enc.SetIndent("", " ") + if err := enc.Encode(batch); err != nil { + return nil, err + } + return out.Bytes(), nil +} + +// resultRows walks to searchMyAssets.results, tolerating the terminator page whose +// results list is empty but not a payload missing the path entirely. +func resultRows(op map[string]any) ([]any, error) { + data, ok := op["data"].(map[string]any) + if !ok { + return nil, fmt.Errorf("operation has no data object") + } + search, ok := data["searchMyAssets"].(map[string]any) + if !ok { + return nil, fmt.Errorf("operation data has no searchMyAssets object") + } + raw, present := search["results"] + if !present { + return nil, fmt.Errorf("searchMyAssets has no results field") + } + if raw == nil { + return nil, nil + } + rows, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("searchMyAssets.results is %T, want array", raw) + } + return rows, nil +} diff --git a/internal/fixtures/scrub_test.go b/internal/fixtures/scrub_test.go new file mode 100644 index 0000000..dcddb8b --- /dev/null +++ b/internal/fixtures/scrub_test.go @@ -0,0 +1,64 @@ +package fixtures_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/curbol/unity-sync/internal/fixtures" +) + +const oneRowCapture = `[{"data":{"searchMyAssets":{"total":1,"results":[ + {"id":"20066949412942","product":{"id":"115488","name":"Quick Outline"}} +]}}}]` + +func TestScrubDropsTheEntitlementIdAndKeepsTheProduct(t *testing.T) { + out, err := fixtures.Scrub([]byte(oneRowCapture)) + if err != nil { + t.Fatalf("Scrub: %v", err) + } + if strings.Contains(string(out), "20066949412942") { + t.Error("scrubbed output still carries the entitlement id") + } + + var batch []map[string]any + if err := json.Unmarshal(out, &batch); err != nil { + t.Fatalf("scrubbed output is not valid JSON: %v", err) + } + rows := batch[0]["data"].(map[string]any)["searchMyAssets"].(map[string]any)["results"].([]any) + row := rows[0].(map[string]any) + if _, present := row["id"]; present { + t.Error("row still has an id field") + } + product, ok := row["product"].(map[string]any) + if !ok { + t.Fatal("row lost its product object") + } + if product["id"] != "115488" { + t.Errorf("product id = %v, want 115488 (the product id must survive)", product["id"]) + } +} + +// The terminator page has an empty results list. Scrubbing it must succeed, because it +// is a fixture the pagination tests need. +func TestScrubAcceptsAnEmptyResultsPage(t *testing.T) { + if _, err := fixtures.Scrub([]byte(`[{"data":{"searchMyAssets":{"total":176,"results":[]}}}]`)); err != nil { + t.Fatalf("Scrub on terminator page: %v", err) + } +} + +func TestScrubRefusesPayloadsItDoesNotUnderstand(t *testing.T) { + for name, body := range map[string]string{ + "not an array": `{"data":{}}`, + "empty batch": `[]`, + "no data": `[{"errors":[]}]`, + "no searchMyAssets": `[{"data":{"product":{}}}]`, + "no results field": `[{"data":{"searchMyAssets":{"total":0}}}]`, + } { + t.Run(name, func(t *testing.T) { + if _, err := fixtures.Scrub([]byte(body)); err == nil { + t.Error("Scrub accepted a payload it cannot have scrubbed correctly") + } + }) + } +} diff --git a/testdata/store/my_assets_p0.json b/testdata/store/my_assets_p0.json new file mode 100644 index 0000000..2d96a72 --- /dev/null +++ b/testdata/store/my_assets_p0.json @@ -0,0 +1,2111 @@ +[ + { + "data": { + "searchMyAssets": { + "results": [ + { + "product": { + "currentVersion": { + "id": "1344228", + "name": "1.10.4", + "publishedDate": "2026-08-18T02:50:14Z" + }, + "downloadSize": "410768352", + "id": "234255", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/3e30e9a1-1890-41c0-932b-b67e120602c1.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/881019ea-8af0-4d97-910f-08f50e4e22c2.png" + }, + "name": "POLYGON - Meadow Forest - Nature Biomes - 3D Environment Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1450672", + "name": "3.6.22", + "publishedDate": "2026-08-17T14:49:20Z" + }, + "downloadSize": "381108720", + "id": "271742", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/82ed0f53-62ab-4ca3-9d17-49d31f220985.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/c32b83ff-43d3-40fe-b0e2-f47f4b886bf0.png" + }, + "name": "COZY: Stylized Weather 3", + "publisher": { + "id": "40676", + "name": "Distant Lands" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1451476", + "name": "1.9.2", + "publishedDate": "2026-08-18T10:34:11Z" + }, + "downloadSize": "124330544", + "id": "137126", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a7d5bd17-2075-464c-a4bf-6e40c6f201b3.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/e895e0c0-5864-4419-b003-7dd31d90f8dc.png" + }, + "name": "POLYGON - Prototype Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1455206", + "name": "1.03", + "publishedDate": "2026-08-19T20:54:18Z" + }, + "downloadSize": "62329520", + "id": "258357", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/cb313299-a07b-4bca-abad-35e1e79b4c47.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/540cf39e-7c51-4116-9cd2-e0a200ba2aa6.png" + }, + "name": "STYLIZED Fantasy Forge \u0026 Armory - Low Poly 3D Art", + "publisher": { + "id": "82433", + "name": "Daniel Mistage" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1455240", + "name": "1.22", + "publishedDate": "2026-08-15T11:11:09Z" + }, + "downloadSize": "45844352", + "id": "249203", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/e96f0b99-6c0f-4448-bf99-9b28d30abe69.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/d89ebf71-4ec1-45ef-a2fd-d23e607432d8.png" + }, + "name": "STYLIZED Fantasy Armory - Low Poly 3D Art", + "publisher": { + "id": "82433", + "name": "Daniel Mistage" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1451246", + "name": "1.4.1", + "publishedDate": "2026-08-18T03:19:10Z" + }, + "downloadSize": "118370384", + "id": "168372", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/651124a0-ca89-4026-ab29-c437a9b792e6.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/842d003c-538f-4cb5-8457-4e260a04c181.png" + }, + "name": "POLYGON - Particle FX Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1454706", + "name": "1.0", + "publishedDate": "2022-01-16T08:28:12Z" + }, + "downloadSize": "1384007920", + "id": "211348", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/cced39f3-f5d4-42c0-9a2f-1f8f872aa1e9.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/f85b17c7-b4e2-4864-b6b9-7e7d6a91ae02.png" + }, + "name": "Wyrms", + "publisher": { + "id": "10181", + "name": "Dmitriy Dryzhak" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1451100", + "name": "1.2.1", + "publishedDate": "2026-08-17T23:49:09Z" + }, + "downloadSize": "170034608", + "id": "120152", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/b76e074a-97ee-4c38-a932-7dc00fb644b2.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/9fa2e075-eb09-4b6b-a28f-d0faa6253007.png" + }, + "name": "POLYGON - Nature Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1455186", + "name": "1.04", + "publishedDate": "2026-08-19T21:09:09Z" + }, + "downloadSize": "49821984", + "id": "260824", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/54616356-7153-4243-8b38-814e82bbed49.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a337b2bd-7fd5-4382-b5dc-9a6759305fd1.png" + }, + "name": "STYLIZED Fantasy Market - Low Poly 3D Art", + "publisher": { + "id": "82433", + "name": "Daniel Mistage" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1353434", + "name": "1.6.2", + "publishedDate": "2026-08-18T04:04:10Z" + }, + "downloadSize": "148676576", + "id": "92579", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/3737e35a-dc8f-48bb-b171-378daa3596c2.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/497ec2f3-4f11-4809-a8c8-ad0690e62cfd.png" + }, + "name": "POLYGON - Pirate Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1451602", + "name": "1.2.1", + "publishedDate": "2026-08-18T10:34:12Z" + }, + "downloadSize": "114664160", + "id": "156819", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/444064ed-5d44-4898-8c21-9ca95259e6ec.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6e243fac-4f94-471a-aab5-a129fe57f0f6.png" + }, + "name": "POLYGON - Starter Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1450236", + "name": "1.4.1", + "publishedDate": "2026-08-17T07:33:10Z" + }, + "downloadSize": "164470832", + "id": "143468", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/1bebcb09-0716-4781-8335-bb164f5c099e.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/629f4436-4f44-4921-aca5-814597e682ad.png" + }, + "name": "POLYGON - Modular Fantasy Hero Characters Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1343060", + "name": "1.10.4", + "publishedDate": "2026-08-18T01:51:09Z" + }, + "downloadSize": "261514240", + "id": "234254", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/7cc1c328-b91b-49cb-92e8-de3affd3c026.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/1ad28ece-190c-4ef3-989a-f8d784f61c5a.png" + }, + "name": "POLYGON - Swamp Marshland - Nature Biomes - 3D Environment Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1450152", + "name": "1.2.2", + "publishedDate": "2026-08-17T04:03:11Z" + }, + "downloadSize": "127717968", + "id": "202117", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/6a6fea8e-786b-42b9-92cb-5c29a39cfb4b.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/e44eb0c6-cc18-40f4-a984-b451aa3b2847.png" + }, + "name": "POLYGON - Icons Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1451122", + "name": "1.10.5", + "publishedDate": "2026-08-18T02:06:10Z" + }, + "downloadSize": "280778352", + "id": "234253", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/8459f88a-77aa-4f84-962a-acebd41db2f1.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/17df8081-ecbe-4ae9-b6d7-a9d625ab67e2.png" + }, + "name": "POLYGON - Tropical Jungle - Nature Biomes - 3D Environment Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1451572", + "name": "1.7.1", + "publishedDate": "2026-08-18T15:49:18Z" + }, + "downloadSize": "128628848", + "id": "89551", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/dc8ef8cf-bc0d-45a8-b8cc-79b60627172a.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/fa945e09-1bed-4103-9d8a-5bdd652e34cc.png" + }, + "name": "POLYGON - Samurai Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1450188", + "name": "1.6.2", + "publishedDate": "2026-08-17T05:18:12Z" + }, + "downloadSize": "138812272", + "id": "83694", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d1b94c94-8250-46b1-987d-96727c5a12d7.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/29ce21ee-922b-4328-b2f7-3f78ed670230.png" + }, + "name": "POLYGON - Knights Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1443860", + "name": "3.8.0", + "publishedDate": "2026-08-18T11:04:24Z" + }, + "downloadSize": "15232192", + "id": "104017", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/f3e7ecac-5ac1-4c38-9828-7beb72a46ad2.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/380870e7-418c-439e-9631-ff31faf61238.png" + }, + "name": "Polyverse Skies | Low Poly Skybox Shaders", + "publisher": { + "id": "20529", + "name": "BOXOPHOBIC" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1442968", + "name": "4.9.12", + "publishedDate": "2026-08-09T21:36:10Z" + }, + "downloadSize": "161756256", + "id": "143368", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/1dbc3e78-fcd1-493c-9c69-34afa00c4df2.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/c516c43b-9b15-4e82-bf16-e70d22fdf7cb.png" + }, + "name": "Flat Kit: Toon Shading and Water", + "publisher": { + "id": "16150", + "name": "Dustyroom" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1346974", + "name": "1.6.1", + "publishedDate": "2026-08-17T00:18:11Z" + }, + "downloadSize": "114824416", + "id": "97186", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/5f8f8032-20c0-4cdc-871a-d12172a76af2.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/0b7ee5a6-1ed2-4cd5-b368-f446affa6775.png" + }, + "name": "POLYGON - Fantasy Characters Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1450234", + "name": "1.7.1", + "publishedDate": "2026-08-17T06:18:12Z" + }, + "downloadSize": "121372240", + "id": "122084", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/8e1f306e-70c4-4da9-a820-8dfe861a0196.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6e1805c7-e18a-4ccf-9107-46f6d03e709e.png" + }, + "name": "POLYGON MINI - Fantasy Character Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1450036", + "name": "1.4.2", + "publishedDate": "2026-08-17T01:02:10Z" + }, + "downloadSize": "119597840", + "id": "118399", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/159204cf-17a4-49bc-bf86-725ed2a99bab.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/07bdc399-3221-4fb3-b017-06a67fe031c3.png" + }, + "name": "POLYGON - Fantasy Rivals Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1344268", + "name": "1.4.1", + "publishedDate": "2026-08-17T06:33:10Z" + }, + "downloadSize": "119818704", + "id": "96800", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/4b8bac7a-031d-47a5-8080-ae7ffa6d0c97.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/857b27ab-7c0a-4f05-b1bc-7b9a35fa054a.png" + }, + "name": "POLYGON MINI - Fantasy Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1343046", + "name": "1.5.1", + "publishedDate": "2026-08-18T04:34:10Z" + }, + "downloadSize": "132651904", + "id": "85664", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/9013b7aa-ebc9-4326-8279-38a47314d1be.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/053ac792-3f6d-4ff4-ac05-d34d08ba7aa4.png" + }, + "name": "POLYGON - Vikings Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1446094", + "name": "1.6.1", + "publishedDate": "2026-08-13T07:00:46Z" + }, + "downloadSize": "232818896", + "id": "224020", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/7184c193-4874-433b-a0c1-fc786d830f20.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/ac5c83c0-716c-4b74-b9d7-f52fad39984b.png" + }, + "name": "POLYGON - Ancient Empire Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1445746", + "name": "6.0.5", + "publishedDate": "2026-07-13T17:47:09Z" + }, + "downloadSize": "94958240", + "id": "133704", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/81e52d4c-b832-4439-86ef-08e9b987185f.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6dd90315-d360-4f76-8069-4571aa3a9b61.png" + }, + "name": "RPG VFX Bundle", + "publisher": { + "id": "28391", + "name": "Hovl Studio" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1450038", + "name": "1.12.5", + "publishedDate": "2026-08-17T03:33:10Z" + }, + "downloadSize": "278496384", + "id": "164532", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/71434092-b423-4adb-a3f2-bc5e26644de7.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/7e90c4ff-c0ef-482d-9400-4b2573657da9.png" + }, + "name": "POLYGON - Fantasy Kingdom Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1442862", + "name": "2.17", + "publishedDate": "2026-08-09T19:21:16Z" + }, + "downloadSize": "47344", + "id": "282348", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/f9746116-f3c2-4311-8a2e-2dd8f0d19ca5.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/683d7f6c-b78e-4d92-80dd-2f6731547265.png" + }, + "name": "RPG Animations Pack(Bundle)", + "publisher": { + "id": "99592", + "name": "DoubleL" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1449170", + "name": "1.3.4", + "publishedDate": "2026-08-16T21:47:09Z" + }, + "downloadSize": "10914544", + "id": "182328", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/805178bb-184b-4aac-9087-4e51a57a5d05.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/44005ad2-d1ab-4c3c-ba1d-57d6c25a4fde.png" + }, + "name": "Grabbit - Editor Physics Transforms", + "publisher": { + "id": "36143", + "name": "Jungle" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1447634", + "name": "1.10.2", + "publishedDate": "2026-08-14T05:52:10Z" + }, + "downloadSize": "163896224", + "id": "102677", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/482776c2-114c-4f3f-940b-85780fdd83c0.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/1564c479-107d-4789-83fb-8ea4689cc39e.png" + }, + "name": "POLYGON - Dungeons Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1442808", + "name": "1.10", + "publishedDate": "2026-08-09T19:21:10Z" + }, + "downloadSize": "297530528", + "id": "309179", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/0a10a705-3c8b-45eb-8359-21f045174355.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/4b877d35-7e95-4009-9a0c-6cf7b3c8e8e4.png" + }, + "name": "RPG_Animations - Two Hand Base", + "publisher": { + "id": "99592", + "name": "DoubleL" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1447640", + "name": "1.3.2", + "publishedDate": "2026-08-14T05:22:11Z" + }, + "downloadSize": "114301584", + "id": "143026", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/7b4fa47b-0b0c-4408-8b7b-5717b5ec3dd2.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/e21f218a-49b9-4a02-9d36-6227610d3a65.png" + }, + "name": "POLYGON - Fantasy Dungeon Map - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1447646", + "name": "1.4.5", + "publishedDate": "2026-08-14T07:52:31Z" + }, + "downloadSize": "186585328", + "id": "189093", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a49e8c6b-8490-4f04-9a00-d55f00e4ed1c.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/0d96a9df-dcf7-4ac4-8062-a85cb18483f7.png" + }, + "name": "POLYGON - Dungeon Realms Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1442810", + "name": "1.6", + "publishedDate": "2026-08-09T19:36:11Z" + }, + "downloadSize": "266475040", + "id": "309180", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/4b731cb5-1165-4a76-ba9c-bcfcc8e3e34c.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/785bee30-ea83-4981-8b66-26c9c0b22c54.png" + }, + "name": "RPG_Animations - Two Hand Up", + "publisher": { + "id": "99592", + "name": "DoubleL" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1344704", + "name": "6.0", + "publishedDate": "2026-07-19T14:48:11Z" + }, + "downloadSize": "316587600", + "id": "183370", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/04a3b4f5-c0b9-4820-a9bf-390c7a003209.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/0f44cacc-0406-4a7b-bb8a-63859b223e54.png" + }, + "name": "Feel", + "publisher": { + "id": "10305", + "name": "More Mountains" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1422362", + "name": "4.0.2.3", + "publishedDate": "2026-07-22T13:55:09Z" + }, + "downloadSize": "6290368", + "id": "89041", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/c1a2ba24-5397-48af-8dac-ebae07291b9e.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6497b8ed-fee7-4580-9507-54690d70fec4.png" + }, + "name": "Odin Inspector and Serializer", + "publisher": { + "id": "3727", + "name": "Sirenix" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1419322", + "name": "3.5.27", + "publishedDate": "2026-07-18T09:35:09Z" + }, + "downloadSize": "256992", + "id": "768", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/013887ed-eaa6-4fdf-bcb6-151c76a1f6d0.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3f104c5c-f7eb-470b-8201-168e80f305bf.png" + }, + "name": "Easy Save - The Complete Save Game \u0026 Data Serializer System", + "publisher": { + "id": "486", + "name": "Moodkie" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1423508", + "name": "2.6.0", + "publishedDate": "2026-07-21T13:47:10Z" + }, + "downloadSize": "219234432", + "id": "154574", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d2d0a691-a898-4198-aa22-61234815f215.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/cfa4737f-10de-4074-a1b3-8b73e9041528.png" + }, + "name": "Ultimate Clean GUI Pack", + "publisher": { + "id": "18519", + "name": "gamevanilla" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1413752", + "name": "1.8.2", + "publishedDate": "2026-07-13T07:47:21Z" + }, + "downloadSize": "124846384", + "id": "80585", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/51bf7a4b-5dd6-4b14-8755-3fd956310c6f.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/952cc307-b8a3-4822-9f37-217dec345be2.png" + }, + "name": "POLYGON - Adventure Pack - Art by Synty", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1393792", + "name": "1.0.430", + "publishedDate": "2026-06-23T11:24:13Z" + }, + "downloadSize": "603360", + "id": "32416", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/fd052e88-d81f-46d3-ac8f-b2c2214f9376.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/cf534981-836c-4f21-a66a-f9e7e504bbf7.png" + }, + "name": "DOTween Pro", + "publisher": { + "id": "1341", + "name": "Demigiant" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1407502", + "name": "1.31.1", + "publishedDate": "2026-06-13T19:07:09Z" + }, + "downloadSize": "23164235200", + "id": "89126", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a1a68cd3-ba5c-4998-b926-326c2c19b118.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/fa39240f-65fb-43aa-bd99-35023d0c7112.png" + }, + "name": "Total Music Collection", + "publisher": { + "id": "4078", + "name": "Andrew Sitkov Music" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1393790", + "name": "1.3.030", + "publishedDate": "2026-06-23T10:54:10Z" + }, + "downloadSize": "243120", + "id": "27676", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/b8d0dbfb-d982-4b07-bc8e-d4797fc3085c.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/778803bc-a2db-49ff-92e2-609bbf31ac9f.png" + }, + "name": "DOTween (HOTween v2)", + "publisher": { + "id": "1341", + "name": "Demigiant" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1401782", + "name": "1.5", + "publishedDate": "2026-07-01T18:58:11Z" + }, + "downloadSize": "79283824", + "id": "309183", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/14b60e25-7604-4533-b279-e590a6c46720.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/ba395a76-e85e-4d0c-b0da-07ca9d3d9f38.png" + }, + "name": "RPG_Animations - Action \u0026 Dead Pose", + "publisher": { + "id": "99592", + "name": "DoubleL" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1389838", + "name": "3.0.5", + "publishedDate": "2026-03-20T11:41:12Z" + }, + "downloadSize": "2836958224", + "id": "132195", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/6bd2bbc7-f525-4c28-bc69-1756a20fa046.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/f64a8b5f-8e7f-4728-8006-706a0515e050.png" + }, + "name": "Meadow Environment - Dynamic Nature", + "publisher": { + "id": "6887", + "name": "NatureManufacture" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1403586", + "name": "2.11", + "publishedDate": "2026-07-03T05:10:23Z" + }, + "downloadSize": "127113344", + "id": "15567", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/e0372c47-2510-4302-b5ce-5fcb420afcf0.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/4137b417-21a8-4971-bb31-4430c32cca00.png" + }, + "name": "BIRDS PACK", + "publisher": { + "id": "265", + "name": "PROTOFACTOR, INC" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1393638", + "name": "2.6.4", + "publishedDate": "2026-06-23T07:09:09Z" + }, + "downloadSize": "135255472", + "id": "203178", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/72aa860f-dcb9-4fb5-8af0-713a760b7369.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/247103b7-9b85-48d3-a99d-ad5fc9203d9b.png" + }, + "name": "Quibli: Anime Shaders and Tools", + "publisher": { + "id": "16150", + "name": "Dustyroom" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1384352", + "name": "1.6", + "publishedDate": "2026-06-19T21:47:09Z" + }, + "downloadSize": "179761360", + "id": "217205", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/70f68dea-a4e5-4609-8098-214a040ffbcb.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/ea71498b-df62-4322-ba53-9981a0b4b72b.png" + }, + "name": "Ivy Studio - Procedural vine generation", + "publisher": { + "id": "6503", + "name": "ARTnGAME" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1394542", + "name": "1.3.2", + "publishedDate": "2026-06-24T03:42:17Z" + }, + "downloadSize": "1792112", + "id": "159992", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/cf9d1019-318c-4704-b3df-1c183479119a.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/54fa1ee6-a54d-4a77-832b-588f0d5f9479.png" + }, + "name": "Jupiter - Procedural Sky Shader \u0026 Day Night Cycle", + "publisher": { + "id": "17305", + "name": "Pinwheel Studio" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1389544", + "name": "32.2.2", + "publishedDate": "2026-06-18T21:36:11Z" + }, + "downloadSize": "34980432", + "id": "225934", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/afd9584f-a6c4-4e49-8785-8bc0fa1bdd97.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/95be70a7-172e-4405-ba62-d873e18ab64d.png" + }, + "name": "Radiant Global Illumination", + "publisher": { + "id": "15018", + "name": "Kronnect" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1381398", + "name": "6.23", + "publishedDate": "2026-06-11T20:21:09Z" + }, + "downloadSize": "23453600", + "id": "158988", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/291df5c4-0f15-475b-9197-5e3cdc3bd505.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/87d9a20e-2f07-4887-9f2f-619b6c1134b3.png" + }, + "name": "Sprite Shaders Ultimate", + "publisher": { + "id": "45819", + "name": "Ekincan Tas" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1387780", + "name": "1.2", + "publishedDate": "2026-06-17T14:26:13Z" + }, + "downloadSize": "68256368", + "id": "309181", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/65e4df5a-ae9a-4191-8872-458db4247b69.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/9b294624-2a69-4f1c-9579-46ddac78f6de.png" + }, + "name": "RPG_Animations - NPC Actions", + "publisher": { + "id": "99592", + "name": "DoubleL" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1383192", + "name": "1.6", + "publishedDate": "2026-06-13T10:28:11Z" + }, + "downloadSize": "256785136", + "id": "309182", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/6143000b-38d0-475d-aec8-e3df355f590c.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/adbc82f3-cb1d-4c3a-86cb-8cf7d16df4b5.png" + }, + "name": "RPG_Animations - Torch,Lantern,Enemy Attack,Hit,Magic", + "publisher": { + "id": "99592", + "name": "DoubleL" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1388338", + "name": "4.4.2", + "publishedDate": "2026-06-18T00:56:22Z" + }, + "downloadSize": "14146000", + "id": "230509", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/b5cb8df4-bcfe-4535-826a-d5a723dd4516.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/99964151-b71a-450b-9a26-60569b0bcac0.png" + }, + "name": "Flexalon Pro: 3D \u0026 UI Layouts", + "publisher": { + "id": "72095", + "name": "Virtual Maker" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1349310", + "name": "1.6", + "publishedDate": "2026-05-28T12:12:10Z" + }, + "downloadSize": "3503953968", + "id": "229460", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/f12c9582-f123-41e4-ad68-c94cfcaab11c.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/8f75c6cd-a955-487c-9466-922b4ea7b737.png" + }, + "name": "6200 Fantasy RPG Icons Pack", + "publisher": { + "id": "41787", + "name": "CraftPix" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1378814", + "name": "1.0.2", + "publishedDate": "2026-06-09T14:17:10Z" + }, + "downloadSize": "5485024", + "id": "148408", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/eb16579b-099f-41e0-9f27-13e4c20398c3.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/8834c4b6-04b5-4223-868f-d74058652051.png" + }, + "name": "IK Helper Tool", + "publisher": { + "id": "36307", + "name": "Kevin Iglesias" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1373424", + "name": "2.02", + "publishedDate": "2026-06-04T04:06:11Z" + }, + "downloadSize": "10904544", + "id": "194727", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a98758b0-b204-4925-8c4b-eecc6faf847f.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/d8e0529c-9387-485f-9ae8-cc36be902091.png" + }, + "name": "Pixelate - Pixel Art Converter", + "publisher": { + "id": "42030", + "name": "Tom Black" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1361204", + "name": "1.9", + "publishedDate": "2026-05-23T07:57:09Z" + }, + "downloadSize": "199251504", + "id": "323439", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/55d9173a-d0fa-42f0-8f91-35b73d862149.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/cd07a668-193e-4f3f-8b67-09d36cde71a5.png" + }, + "name": "Base Move Animation", + "publisher": { + "id": "99592", + "name": "DoubleL" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1351146", + "name": "4.2.0", + "publishedDate": "2026-03-05T14:18:14Z" + }, + "downloadSize": "55913936", + "id": "50282", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/ce67dc83-3d7b-4a3b-9b29-b237f8c4743a.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/c311904a-5d51-4ee7-aa53-89ee2b9e7022.png" + }, + "name": "Turn Based Strategy Framework", + "publisher": { + "id": "17793", + "name": "Crooked Head" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1343516", + "name": "2.0", + "publishedDate": "2026-05-06T12:18:19Z" + }, + "downloadSize": "210158944", + "id": "162341", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/833ca243-3b92-482e-9231-8978dd9ee8b2.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/e25b18b8-9afe-45b5-b0b3-48da849d20a4.png" + }, + "name": "Human Mega Animations Pack", + "publisher": { + "id": "36307", + "name": "Kevin Iglesias" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1337694", + "name": "2.5.1", + "publishedDate": "2026-04-30T09:21:11Z" + }, + "downloadSize": "53836656", + "id": "157744", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/332cae64-6b71-4eaf-9a90-b7a0b385eb12.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/00a7471d-99ae-4203-8316-4aefd00e3a84.png" + }, + "name": "Human Basic Motions", + "publisher": { + "id": "36307", + "name": "Kevin Iglesias" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1353276", + "name": "1.2.48", + "publishedDate": "2026-05-15T00:08:11Z" + }, + "downloadSize": "31302416", + "id": "135594", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/1b6fb1eb-c281-440e-a1e3-53293e2cff84.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/4559f61c-3507-4eec-ab7e-8f3ce317c698.png" + }, + "name": "Boing Kit: Dynamic Bouncy Bones, Grass, and More", + "publisher": { + "id": "33759", + "name": "Long Bunny Labs" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1342458", + "name": "1.10", + "publishedDate": "2026-05-05T14:03:10Z" + }, + "downloadSize": "327960496", + "id": "309177", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/091ba98d-0d84-4992-8186-49c1c565c970.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/cb4ebbe5-0725-4126-94ba-961a4eeaeb87.png" + }, + "name": "RPG_Animations - One Hand Base", + "publisher": { + "id": "99592", + "name": "DoubleL" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1341510", + "name": "2.4.0", + "publishedDate": "2024-12-02T15:29:14Z" + }, + "downloadSize": "21483104", + "id": "108753", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/b461e6a2-9779-4fc8-8314-55d64af010d3.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/fbefb7dc-1b57-449e-9532-39e94c8a51e4.png" + }, + "name": "SC Post Effects Pack (for Unity 2020-2023)", + "publisher": { + "id": "15580", + "name": "Staggart Creations" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1342510", + "name": "1.10", + "publishedDate": "2026-05-06T15:51:22Z" + }, + "downloadSize": "302579376", + "id": "309178", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/bfe59621-6914-4c15-bb55-31d5067bbe9a.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3f5aeb12-ea15-4191-8ecc-a540e22bdb0d.png" + }, + "name": "RPG_Animations - One Hand Up", + "publisher": { + "id": "99592", + "name": "DoubleL" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1322324", + "name": "10.0", + "publishedDate": "2025-10-15T23:40:18Z" + }, + "downloadSize": "258868928", + "id": "54733", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/45d0f2a1-9610-4aba-aa2e-940f873c3205.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/f27170ce-59eb-419a-8ccc-78d7cbf232bb.png" + }, + "name": "Low Poly Ultimate Pack", + "publisher": { + "id": "19123", + "name": "polyperfect" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1327366", + "name": "3.7", + "publishedDate": "2026-04-19T11:32:10Z" + }, + "downloadSize": "38446560", + "id": "157187", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/0752c9a4-faa0-465a-9a2d-bee79c8b7dec.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/e51e38cb-3dc6-4355-8a3b-94db4fae5c76.png" + }, + "name": "Easy Performant Outline 2D | 3D (URP / HDRP)", + "publisher": { + "id": "21614", + "name": "Pirate Parrot" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1341518", + "name": "1.10", + "publishedDate": "2026-05-04T13:38:11Z" + }, + "downloadSize": "245836528", + "id": "309184", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/4806cab3-03a0-4106-bbfb-5bc56742b828.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/2f9cbca5-c5c2-4d57-896a-76436eb458b3.png" + }, + "name": "RPG_Animations - Bow", + "publisher": { + "id": "99592", + "name": "DoubleL" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1322240", + "name": "4.1.1", + "publishedDate": "2025-08-14T00:01:12Z" + }, + "downloadSize": "130309120", + "id": "93089", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/99b85c07-c6eb-405c-8876-d9a98af6d298.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/65c27d72-90da-4717-adef-bb8fab369cd2.png" + }, + "name": "Low Poly Animated Animals", + "publisher": { + "id": "19123", + "name": "polyperfect" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1321388", + "name": "3.02", + "publishedDate": "2025-11-30T17:12:33Z" + }, + "downloadSize": "69129536", + "id": "156748", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d67756ba-e43a-48a1-9e3f-7b1547bdc345.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/b4777594-7859-4ffc-ac18-19cbffad6930.png" + }, + "name": "Low Poly Animated People", + "publisher": { + "id": "19123", + "name": "polyperfect" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1317166", + "name": "1.32.1", + "publishedDate": "2026-04-10T11:54:07Z" + }, + "downloadSize": "38718320", + "id": "177023", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/19bb215c-c479-408f-9ab2-717746cfaa08.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/12904059-22ab-4ec2-a022-14795e18fc7f.png" + }, + "name": "Map Graph", + "publisher": { + "id": "39394", + "name": "Insane Scatterbrain" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1326074", + "name": "2.5.18", + "publishedDate": "2026-04-18T08:47:09Z" + }, + "downloadSize": "1986960", + "id": "205336", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/708ca4ee-ee2f-43c9-b81d-767bbd9f09ea.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/4afcc942-a01a-44b6-9d3f-c01dc145ee03.png" + }, + "name": "SensorToolkit 2", + "publisher": { + "id": "22315", + "name": "Micosmo" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1312902", + "name": "2.6.2", + "publishedDate": "2026-04-07T14:22:27Z" + }, + "downloadSize": "96192", + "id": "112837", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/5f3af86b-8b92-4dbd-892a-e77b9325d32e.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/63934f4d-23d7-43ca-9941-fd29beaa0d15.png" + }, + "name": "Asset Usage Detector", + "publisher": { + "id": "22792", + "name": "yasirkula" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1292966", + "name": "1.5", + "publishedDate": "2026-03-14T11:24:29Z" + }, + "downloadSize": "31130656", + "id": "137259", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/413159b3-8398-4928-aa8d-217554b9e257.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a90f91f4-e27c-449f-be64-aa8067273389.png" + }, + "name": "Quirky Series - Animals Mega Pack Vol 1", + "publisher": { + "id": "19311", + "name": "Omabuarts Studio" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1301960", + "name": "1.5", + "publishedDate": "2026-03-23T09:56:14Z" + }, + "downloadSize": "29964416", + "id": "183280", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/db99f677-ae99-47d8-a255-53e28158f16b.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/dd9b99c6-4b76-48cb-b1a4-aa4bbff662f3.png" + }, + "name": "Quirky Series - Animals Mega Pack Vol 2", + "publisher": { + "id": "19311", + "name": "Omabuarts Studio" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1258142", + "name": "1.0", + "publishedDate": "2026-02-12T12:50:23Z" + }, + "downloadSize": "1216250880", + "id": "152412", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/e8054c99-33ea-455e-92aa-93fb19971da9.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a9f8c83e-0c18-4a35-8b23-25ec6d27db7c.png" + }, + "name": "Demonic UI 8k + Icons", + "publisher": { + "id": "38930", + "name": "PONETI" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1234028", + "name": "7.2.2", + "publishedDate": "2026-01-20T06:43:16Z" + }, + "downloadSize": "154211504", + "id": "63772", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/910f37f4-cc6d-45e4-8e58-c631efd36d98.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/dbc04e94-67cb-42e7-87c6-819146a48967.png" + }, + "name": "RPG Character Mecanim Animation Pack", + "publisher": { + "id": "3415", + "name": "Explosive" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1258132", + "name": "1.01", + "publishedDate": "2026-02-12T22:05:13Z" + }, + "downloadSize": "610242160", + "id": "193841", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/44987cf1-eeb6-488f-817e-7aa59baa7717.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3c71011f-adfe-4190-8f27-e4daa7bc3f74.png" + }, + "name": "Classic Fantasy RPG - UI Kit", + "publisher": { + "id": "38930", + "name": "PONETI" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1267174", + "name": "1.6", + "publishedDate": "2026-02-04T10:01:06Z" + }, + "downloadSize": "25414870", + "id": "183075", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a5b40915-53a9-4e6d-b1e1-6bffb06114f8.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/b2af1f9b-9e5d-41cd-8120-91af030eeb72.png" + }, + "name": "Unity Learn | Foundations of Real-Time Audio | URP", + "publisher": { + "id": "1", + "name": "Unity Technologies" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1257540", + "name": "1.03", + "publishedDate": "2026-02-12T01:37:10Z" + }, + "downloadSize": "2807614464", + "id": "180688", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/4709fe85-fc7a-4402-9787-11a21acc77d3.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/c2c3f43d-c445-42d6-957a-d572e631f64c.png" + }, + "name": "Medieval Kingdom UI", + "publisher": { + "id": "38930", + "name": "PONETI" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1225076", + "name": "1.99", + "publishedDate": "2026-01-12T13:09:39Z" + }, + "downloadSize": "29543760", + "id": "111398", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/cbf4e129-14d8-450e-827e-c1dcd15cf327.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/ab225426-93f1-4771-bc6e-61c07443e66f.png" + }, + "name": "Character Creator 2D", + "publisher": { + "id": "34299", + "name": "Simpleton" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1214888", + "name": "1.3", + "publishedDate": "2026-01-02T16:14:16Z" + }, + "downloadSize": "57083264", + "id": "135882", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/59d3772c-9318-458e-9845-d2080709488d.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/7969defd-9f66-4bfe-971d-94ead7d16503.png" + }, + "name": "Gothic UI", + "publisher": { + "id": "38930", + "name": "PONETI" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1216840", + "name": "1.2", + "publishedDate": "2026-01-05T09:29:12Z" + }, + "downloadSize": "50304", + "id": "319643", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/0447c923-a682-4705-b83f-332e25e4cd22.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/432f1fb1-c37d-4c20-b81e-4c3686b931ca.png" + }, + "name": "Easy FBX Anim Modifier", + "publisher": { + "id": "99592", + "name": "DoubleL" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1218204", + "name": "1.0", + "publishedDate": "2018-08-02T22:51:19Z" + }, + "downloadSize": "404090272", + "id": "123499", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/47761ceb-3c7d-4bd2-ab16-ea3daa73ddf4.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/788cfe1b-dc4c-434f-8ae0-dda3674802c6.png" + }, + "name": "Citadel RPG GUI", + "publisher": { + "id": "5183", + "name": "Kodiak Graphics" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1215720", + "name": "22.1.3", + "publishedDate": "2025-08-05T15:08:15Z" + }, + "downloadSize": "19373744", + "id": "134149", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/77d71f38-f78a-4815-8ca2-276e4966c828.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/5e77cd21-c32b-437c-95a2-ff69748fd193.png" + }, + "name": "Highlight Plus - All in One Outline \u0026 Selection Effects", + "publisher": { + "id": "15018", + "name": "Kronnect" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "1156730", + "name": "1.0", + "publishedDate": "2019-12-18T08:05:10Z" + }, + "downloadSize": "7021840", + "id": "159068", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a7c50e2b-52c5-4959-9c78-930b00d0013b.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/2deaf83f-722d-4284-bf41-841a42321cfd.png" + }, + "name": "GUI Parts", + "publisher": { + "id": "38930", + "name": "PONETI" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1158758", + "name": "2.1", + "publishedDate": "2025-11-05T00:02:14Z" + }, + "downloadSize": "383190928", + "id": "112002", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d673d1ea-3fc8-4204-beaa-5ca32d11cd8a.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3bf08591-4a60-4838-acb0-f8225b9ec6aa.png" + }, + "name": "Sci-Fi Turret Constructor", + "publisher": { + "id": "12193", + "name": "Slava Z." + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1129684", + "name": "2.1.1", + "publishedDate": "2025-05-07T04:35:46Z" + }, + "downloadSize": "6056048", + "id": "47302", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/02ed0fba-ca47-4472-9ad1-878715518267.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/89ceeeb0-f8f2-468f-8a5a-f59cc5ffb1d6.png" + }, + "name": "Crossbow Warrior Mecanim Animation Pack", + "publisher": { + "id": "3415", + "name": "Explosive" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1106040", + "name": "1.0.9", + "publishedDate": "2025-08-29T18:56:15Z" + }, + "downloadSize": "1040271056", + "id": "215197", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/62c34864-5a5f-4299-8a43-2f6a80203c0a.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3e3599d8-d9de-4152-92e5-46bdbfaa4706.png" + }, + "name": "Toon Fantasy Nature", + "publisher": { + "id": "18116", + "name": "SICS Games" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1146608", + "name": "1.0.1", + "publishedDate": "2025-05-03T08:36:12Z" + }, + "downloadSize": "52608", + "id": "190683", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/852e8288-c5a5-413e-b2ea-4caac32b7c80.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/0443bc51-6608-4849-8156-e65928e46f82.png" + }, + "name": "Warrior Pack Super Bundle", + "publisher": { + "id": "3415", + "name": "Explosive" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1129476", + "name": "2.6.1", + "publishedDate": "2025-05-07T05:05:13Z" + }, + "downloadSize": "6669808", + "id": "35101", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/ef7bc7ea-3c9b-4dd3-964f-33256fe17666.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/aa08f459-41e1-4ade-8e21-c09531e17655.png" + }, + "name": "Karate Warrior Mecanim Animation Pack", + "publisher": { + "id": "3415", + "name": "Explosive" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1127882", + "name": "2.0.1", + "publishedDate": "2025-05-07T04:35:34Z" + }, + "downloadSize": "6311296", + "id": "42286", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/b0f6d61f-41be-4d0a-8fd3-f44828b6007f.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/e9b295a8-585f-4c4c-a045-2fa73d436c79.png" + }, + "name": "2-Handed Warrior Mecanim Animation Pack", + "publisher": { + "id": "3415", + "name": "Explosive" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1129470", + "name": "2.4.1", + "publishedDate": "2025-05-07T04:35:40Z" + }, + "downloadSize": "5948256", + "id": "35577", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/341ec3ad-268e-4e75-8289-1a5c9713c144.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3fc9921f-3766-4cb4-92c8-c3d371826851.png" + }, + "name": "Brute Warrior Mecanim Animation Pack", + "publisher": { + "id": "3415", + "name": "Explosive" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1129670", + "name": "2.8.1", + "publishedDate": "2025-05-07T10:35:17Z" + }, + "downloadSize": "8427008", + "id": "35814", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/05e1b5ff-b072-43f7-a5e2-299163d1b4e7.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3b435b1d-6ddc-4691-b011-d4ea8450c163.png" + }, + "name": "Sorceress Warrior Mecanim Animation Pack", + "publisher": { + "id": "3415", + "name": "Explosive" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1129674", + "name": "2.3.1", + "publishedDate": "2025-05-07T04:51:34Z" + }, + "downloadSize": "6106288", + "id": "43153", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/5f11d518-c1a3-4645-9e3b-8e7e4df896db.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/56dc3ae6-85b5-4aae-a4f0-c44578682b85.png" + }, + "name": "Swordsman Warrior Mecanim Animation Pack", + "publisher": { + "id": "3415", + "name": "Explosive" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1130712", + "name": "3.2.1", + "publishedDate": "2025-05-07T04:51:27Z" + }, + "downloadSize": "10606272", + "id": "35307", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/ba174c19-85b7-440b-bc91-d9620121e1a7.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/027c15f6-ce99-4eb5-85c2-46162b04d6e8.png" + }, + "name": "Ninja Warrior Mecanim Animation Pack", + "publisher": { + "id": "3415", + "name": "Explosive" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1127880", + "name": "2.2.1", + "publishedDate": "2025-05-07T04:35:27Z" + }, + "downloadSize": "6177360", + "id": "41714", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/3fce4ace-84a7-45de-941c-cdd39469e3bc.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/b942e066-73b9-4774-a1ad-ae92e1714e58.png" + }, + "name": "Archer Warrior Mecanim Animation Pack", + "publisher": { + "id": "3415", + "name": "Explosive" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1129478", + "name": "2.7.1", + "publishedDate": "2025-05-07T04:51:11Z" + }, + "downloadSize": "7166592", + "id": "38814", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/8e26d2a0-467b-47aa-989b-91c2a47fe6f5.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/7d486bbe-371d-42c5-87fc-5586af73925f.png" + }, + "name": "Knight Warrior Mecanim Animation Pack", + "publisher": { + "id": "3415", + "name": "Explosive" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1129472", + "name": "2.1.1", + "publishedDate": "2025-05-07T04:35:15Z" + }, + "downloadSize": "6568624", + "id": "46860", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/ce46063b-07bc-4cb7-bc01-d760db493c8a.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/512458a2-9782-40dc-a2e6-5b7895b88975.png" + }, + "name": "Hammer Warrior Mecanim Animation Pack", + "publisher": { + "id": "3415", + "name": "Explosive" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1129480", + "name": "2.4.1", + "publishedDate": "2025-05-07T04:51:19Z" + }, + "downloadSize": "6424448", + "id": "39519", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/cb70bba6-8d86-47c2-8c32-ec4ff3b0c842.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/acd458c4-960d-456e-a250-626bb4e0cec1.png" + }, + "name": "Mage Warrior Mecanim Animation Pack", + "publisher": { + "id": "3415", + "name": "Explosive" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1129672", + "name": "2.2.1", + "publishedDate": "2025-05-07T04:51:29Z" + }, + "downloadSize": "6182048", + "id": "46399", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a3b83321-02d8-49c6-8a73-ad5c79e44112.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/d2ac582e-b6d1-4997-bda3-ca1368e4516a.png" + }, + "name": "Spearman Warrior Mecanim Animation Pack", + "publisher": { + "id": "3415", + "name": "Explosive" + }, + "state": "published" + } + } + ], + "total": 176 + } + } + } +] diff --git a/testdata/store/my_assets_p1.json b/testdata/store/my_assets_p1.json new file mode 100644 index 0000000..0d7ca8d --- /dev/null +++ b/testdata/store/my_assets_p1.json @@ -0,0 +1,1607 @@ +[ + { + "data": { + "searchMyAssets": { + "results": [ + { + "product": { + "currentVersion": { + "id": "1095832", + "name": "4.0", + "publishedDate": "2025-08-06T16:53:13Z" + }, + "downloadSize": "211058768", + "id": "64248", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/270c8150-e1e0-4ab7-984a-8dde4763cd01.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/e4bf5216-4c2f-4720-940a-0f21979d149e.png" + }, + "name": "Little Dragons: Tiger", + "publisher": { + "id": "16163", + "name": "MalberS Animations" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1113190", + "name": "3.5.2", + "publishedDate": "2025-09-10T11:12:15Z" + }, + "downloadSize": "63142032", + "id": "266535", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/ce4f0d8d-47ef-4aa4-b61b-8ba77ba3da20.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/d680075a-394b-4ec6-b757-377a24ced8b8.png" + }, + "name": "Body Proportions", + "publisher": { + "id": "59523", + "name": "OnlyNew Studio" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1080711", + "name": "1.0.1", + "publishedDate": "2025-06-23T08:52:15Z" + }, + "downloadSize": "2903760", + "id": "148410", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/e54628cb-4973-4fe2-92b6-589cce49a99e.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/276b62dd-b4dd-4b81-a261-967e651e90fd.png" + }, + "name": "RPG Poly Pack - Lite", + "publisher": { + "id": "42095", + "name": "Gigel3d" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1094273", + "name": "1.0.0", + "publishedDate": "2024-03-20T09:34:05Z" + }, + "downloadSize": "12640", + "id": "262163", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/c4e520f4-e052-4b68-8855-2f314174f2f9.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6e2d5d2a-4487-4080-a729-0938710fbe10.png" + }, + "name": "UI Toolkit Bundle 1", + "publisher": { + "id": "37829", + "name": "KAMGAM" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1069514", + "name": "1.0", + "publishedDate": "2025-06-10T09:44:12Z" + }, + "downloadSize": "159429792", + "id": "179083", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/e3edfe3c-9c93-4562-ac88-a5a33056cb8e.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/31f22bf7-395a-4cbd-a20d-f52776445348.png" + }, + "name": "Monsters Ultimate Pack 02 Cute Series", + "publisher": { + "id": "3867", + "name": "Meshtint Studio" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "637666", + "name": "1.6", + "publishedDate": "2021-08-26T15:57:14Z" + }, + "downloadSize": "87409280", + "id": "61157", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/06ba4f3d-0574-4eee-8fa7-f38e4bf02069.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/2e3bc354-323c-459d-baca-ac98b9e4e530.png" + }, + "name": "Underwater FX", + "publisher": { + "id": "2575", + "name": "Rivermill Studios" + }, + "state": "disabled" + } + }, + { + "product": { + "currentVersion": { + "id": "643695", + "name": "1.0", + "publishedDate": "2021-05-17T09:41:09Z" + }, + "downloadSize": "121657712", + "id": "193760", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d0c86a49-3132-4a72-a0dd-d6a2a44edffe.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/1a0a6a99-02ac-488d-a385-5c41b67cfe50.png" + }, + "name": "Fantasy Sounds Bundle", + "publisher": { + "id": "16881", + "name": "Cafofo" + }, + "state": "disabled" + } + }, + { + "product": { + "currentVersion": { + "id": "1078634", + "name": "1.0", + "publishedDate": "2018-10-01T13:07:06Z" + }, + "downloadSize": "625728", + "id": "127775", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/673aaec2-ac97-4cba-8841-d415dc1319f5.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/27d23e8e-6446-4e05-8277-2a8d7b64d32f.png" + }, + "name": "Low Poly Fantasy Warrior", + "publisher": { + "id": "38620", + "name": "asoliddev" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1058207", + "name": "3.42", + "publishedDate": "2025-05-01T21:20:15Z" + }, + "downloadSize": "399312", + "id": "96925", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/7843f91c-727a-4f67-a1b0-0456d1e2d766.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/809f22b5-3b8a-466c-ace1-13d9ae3e545f.png" + }, + "name": "Smooth Sync", + "publisher": { + "id": "20301", + "name": "Noble Whale Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1063362", + "name": "2.0.0", + "publishedDate": "2024-04-15T22:16:13Z" + }, + "downloadSize": "548052304", + "id": "163280", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/2df4583b-7396-42a0-8047-549d94df4f12.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/9d0f079a-ff91-4e84-ad85-d7e947c0f9d5.png" + }, + "name": "5000 Fantasy Icons", + "publisher": { + "id": "38930", + "name": "PONETI" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1017477", + "name": "1.2.1", + "publishedDate": "2025-02-03T18:45:11Z" + }, + "downloadSize": "5410304", + "id": "205222", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/9b22ca9b-da0b-498e-b784-43f8363f6942.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/9bd43fdc-bee1-4832-9f07-4e461ae6b1f2.png" + }, + "name": "Pixel Art Full GUI / UI Kit + 151 icons!", + "publisher": { + "id": "5245", + "name": "HONETi" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1016949", + "name": "1.0.3", + "publishedDate": "2025-01-29T17:02:22Z" + }, + "downloadSize": "3040904275", + "id": "213197", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/5356fafe-5b56-44a5-b8fb-ab11014b372e.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/0f944911-416f-474a-9073-fc872015a707.png" + }, + "name": "Unity Terrain - URP Demo Scene", + "publisher": { + "id": "1", + "name": "Unity Technologies" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1056339", + "name": "0.1.0", + "publishedDate": "2023-09-14T14:37:11Z" + }, + "downloadSize": "48544", + "id": "262495", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a4fc501c-f8cb-4967-a9a5-b517afe79528.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/b747be4c-fca1-40b5-8f90-ad34df0f8846.png" + }, + "name": "Utility USS", + "publisher": { + "id": "89401", + "name": "Robby K" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1030373", + "name": "1.12.13", + "publishedDate": "2024-01-22T01:19:13Z" + }, + "downloadSize": "24521904", + "id": "160144", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/44487fdd-cbd0-40e8-b869-a11f4ce324db.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/54ff8831-e6df-4f10-8508-1c07ebe3ef84.png" + }, + "name": "Magica Cloth", + "publisher": { + "id": "36991", + "name": "Magica Soft" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "1047025", + "name": "1.5", + "publishedDate": "2025-04-06T09:59:11Z" + }, + "downloadSize": "50476272", + "id": "152053", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/182545f4-9e85-420a-ad3c-0190a4f459fe.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/e30cc1e2-eedb-4d96-b369-139bbf132115.png" + }, + "name": "RPG \u0026 MMO UI X", + "publisher": { + "id": "3533", + "name": "Evil" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "979796", + "name": "1.8.2", + "publishedDate": "2024-10-21T07:08:12Z" + }, + "downloadSize": "1883744", + "id": "177877", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/cea71fd4-26e7-4e92-a9e5-038bee3af03d.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/ecb49962-d7c2-4063-990f-7e5d3e32fec0.png" + }, + "name": "ProPixelizer - Realtime 3D Pixel Art", + "publisher": { + "id": "49852", + "name": "ElliotB256" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "998405", + "name": "1.692", + "publishedDate": "2024-12-09T13:43:00Z" + }, + "downloadSize": "272226032", + "id": "231178", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/67a3a721-b7bb-4123-b4f0-e8516dbdb9c1.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/c7c08c5e-f345-46cf-b7b3-9eb01cdf12d1.png" + }, + "name": "Dragon Crashers - UI Toolkit Sample project", + "publisher": { + "id": "1", + "name": "Unity Technologies" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "1008063", + "name": "4.0", + "publishedDate": "2025-01-07T17:11:32Z" + }, + "downloadSize": "238291072", + "id": "186580", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/aa10dbb4-894b-4469-afd4-1456ac486d05.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6b3bdab9-8714-4a93-9680-2b5737649c18.png" + }, + "name": "Easy AR : Make Awesome AR Apps Without Coding", + "publisher": { + "id": "47462", + "name": "Render Island" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "947306", + "name": "1.2.0", + "publishedDate": "2024-07-20T20:55:10Z" + }, + "downloadSize": "335387776", + "id": "146014", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/532f7d28-88c4-4d85-b037-8c5c6ec40165.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/820684d8-011d-4ec4-967a-76253d48f6d2.png" + }, + "name": "AllSky Free - 10 Sky / Skybox Set", + "publisher": { + "id": "3830", + "name": "rpgwhitelock" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "918644", + "name": "1.1", + "publishedDate": "2021-07-19T10:34:35Z" + }, + "downloadSize": "91462928", + "id": "160253", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/eb0c121c-b5ce-4843-b204-7373f2382cca.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/4a9c5254-9c3e-4161-b496-409ffefdcd2c.png" + }, + "name": "Classic RPG GUI", + "publisher": { + "id": "38930", + "name": "PONETI" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "965924", + "name": "1.4.1", + "publishedDate": "2024-09-13T17:03:09Z" + }, + "downloadSize": "44284368", + "id": "251843", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/0b5b7c99-f041-48b8-b2ff-18d47d0c041e.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/648debc1-9e39-4ed4-8f95-d704bc6e46d2.png" + }, + "name": "URP+ 2022 - Improved Universal Render Pipeline", + "publisher": { + "id": "46006", + "name": "ShadowShardTools" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "895314", + "name": "1.4.0", + "publishedDate": "2023-01-03T08:29:59Z" + }, + "downloadSize": "21649024", + "id": "221125", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/b1f51465-ed93-4728-b6e6-0c2fe30a5289.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/fcda904d-5100-470c-99a1-629ce2105f0b.png" + }, + "name": "Skill \u0026 Attack Indicators", + "publisher": { + "id": "35054", + "name": "DTT" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "928824", + "name": "1.0", + "publishedDate": "2022-10-14T11:24:16Z" + }, + "downloadSize": "928667344", + "id": "234071", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/53371ad8-3d29-4c3a-a01c-de11224fa869.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/5ffa31e9-ad6c-4732-ba5b-ba71a4fb27ac.png" + }, + "name": "Fantasy RPG Music Collection - Fallen Kingdom", + "publisher": { + "id": "55342", + "name": "Marcin Szmuc" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "875570", + "name": "1.1.2", + "publishedDate": "2023-11-28T16:07:12Z" + }, + "downloadSize": "1118524096", + "id": "89624", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/08a4b47e-e5f0-438b-923e-9b6b06aad55d.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3d1c2570-4f74-47f5-9e34-cc80bb0889fd.png" + }, + "name": "SciFi Space Base", + "publisher": { + "id": "3021", + "name": "Daelonik Artworks" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "905707", + "name": "1.0.2", + "publishedDate": "2023-11-15T23:00:18Z" + }, + "downloadSize": "2184784", + "id": "81692", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a33bce33-9728-4012-84bc-12784718a0a0.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/80d8b213-bfc7-4fad-8559-60bb18cb95c9.png" + }, + "name": "Simple Forest Animals - Cartoon Assets", + "publisher": { + "id": "5217", + "name": "Synty Studios" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "898246", + "name": "1.4", + "publishedDate": "2020-01-17T09:57:12Z" + }, + "downloadSize": "191259600", + "id": "136564", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/2fbdd5b6-e6ff-4430-a4ee-6adaa8b587d3.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/50b90022-5d84-4028-a2be-1f4784fd0baa.png" + }, + "name": "500 Skill Icons", + "publisher": { + "id": "38930", + "name": "PONETI" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "858190", + "name": "1.3", + "publishedDate": "2023-06-27T10:15:28Z" + }, + "downloadSize": "95393792", + "id": "221389", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/32980f7f-a5c9-43cd-a223-e4c916c63d7c.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/d2ebdca7-60b8-4529-a2b3-5abb933f3436.png" + }, + "name": "Brute Force - Snow \u0026 Ice Shader", + "publisher": { + "id": "52742", + "name": "BRUTE FORCE STUDIO" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "872804", + "name": "1.2", + "publishedDate": "2023-06-16T21:47:12Z" + }, + "downloadSize": "32205840", + "id": "207966", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/b4f8d461-740c-4dca-808b-5729446367cc.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/273a2497-efe9-43c4-ae58-fd028269798b.png" + }, + "name": "Shiny Items VFX for URP", + "publisher": { + "id": "57633", + "name": "Paul Jewell" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "856476", + "name": "1.9.5", + "publishedDate": "2023-08-01T08:40:16Z" + }, + "downloadSize": "2299743520", + "id": "115747", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/37870577-4415-47aa-8f47-1be72b29d1c1.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/2297589a-1306-4ed0-a48b-ef19207cef5c.png" + }, + "name": "Unity Learn | 3D Game Kit", + "publisher": { + "id": "1", + "name": "Unity Technologies" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "847756", + "name": "1.1", + "publishedDate": "2023-09-02T19:30:26Z" + }, + "downloadSize": "607672928", + "id": "213593", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/db3b0011-df23-4f28-80b8-192ce31c5aac.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/dec49d89-714f-4aa2-9c1a-b10b0f874e0e.png" + }, + "name": "Fantasy Map Creator", + "publisher": { + "id": "38930", + "name": "PONETI" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "888774", + "name": "1.0", + "publishedDate": "2019-03-27T13:25:08Z" + }, + "downloadSize": "1338836112", + "id": "142554", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/c195214d-c4da-48e7-8771-4da1134227f9.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/44dcbfa1-2a21-452f-a85e-9a1e5feab231.png" + }, + "name": "Forest animals", + "publisher": { + "id": "12623", + "name": "Red Deer" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "878127", + "name": "1.34", + "publishedDate": "2023-12-15T13:55:11Z" + }, + "downloadSize": "356912", + "id": "235062", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/82ac7317-d958-4d19-a542-91b8e58affbd.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/592efcd1-bca5-4efd-a6f3-77b506a1948d.png" + }, + "name": "Exporter for Unreal to Unity 2023", + "publisher": { + "id": "26243", + "name": "Ciprian Stanciu" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "895993", + "name": "1.6", + "publishedDate": "2024-02-20T15:31:44Z" + }, + "downloadSize": "461868736", + "id": "191447", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/b23887d6-c1d5-4dd8-a129-30c6e825fe1d.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/dd972b1b-c6d2-4518-abab-83610fc05aba.png" + }, + "name": "5592 Fantasy RPG Icons", + "publisher": { + "id": "41787", + "name": "CraftPix" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "799231", + "name": "1.0", + "publishedDate": "2023-04-03T13:03:13Z" + }, + "downloadSize": "82775552", + "id": "198411", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d3ba04fc-c42b-4cbb-9f03-bc194a48e3d1.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3ff9f3e5-5f90-4bd3-a9f5-8de61174630e.png" + }, + "name": "Party Monster Rumble PBR", + "publisher": { + "id": "23554", + "name": "Dungeon Mason" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "830668", + "name": "2.0", + "publishedDate": "2023-07-10T14:08:52Z" + }, + "downloadSize": "326481040", + "id": "64768", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/75f058a2-a078-4450-8903-599eafb9e391.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/4125a11c-e529-4fe1-980a-75a5cb842fdb.png" + }, + "name": "Skill And Ability Icons", + "publisher": { + "id": "21903", + "name": "Yusuf Artun" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "848851", + "name": "1.1.1", + "publishedDate": "2023-09-06T16:07:11Z" + }, + "downloadSize": "8264192", + "id": "161366", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/2239075a-2d86-4777-a0ae-5551e3a3fc8d.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/19ae8c50-d16c-4b3c-b276-88f4fdc591f1.png" + }, + "name": "Seamless Texture Generator", + "publisher": { + "id": "37262", + "name": "FImpossible Creations" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "743936", + "name": "1.07", + "publishedDate": "2023-07-10T14:28:13Z" + }, + "downloadSize": "28805888", + "id": "154032", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/04ef01fc-5081-4a81-ab3f-6d5f4b764be6.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/1411cc37-cc9b-420d-95ab-9f899d84b867.png" + }, + "name": "Powerslide Kart Physics", + "publisher": { + "id": "4634", + "name": "JustInvoke" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "822628", + "name": "1.7.6a", + "publishedDate": "2023-06-14T03:10:18Z" + }, + "downloadSize": "23661984", + "id": "44361", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/fc02181b-e961-44bf-8ddb-be4b0591d282.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/cfe8a505-1adf-460d-bef4-27cb2d41c9ea.png" + }, + "name": "ProTips - Tooltip System", + "publisher": { + "id": "7702", + "name": "ModelShark Studio" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "773259", + "name": "2.1", + "publishedDate": "2023-01-13T22:52:11Z" + }, + "downloadSize": "6291278720", + "id": "151756", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/6c7c13dd-3a89-471f-a8cc-04fa8ccb478a.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/b3772630-55e9-4a53-ab7c-73e3d7ddf618.png" + }, + "name": "Ultimate Sound FX Bundle", + "publisher": { + "id": "39089", + "name": "Sidearm Studios" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "773585", + "name": "1.0", + "publishedDate": "2023-01-14T22:41:11Z" + }, + "downloadSize": "130365232", + "id": "176744", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d2580ec5-7e6d-4f98-bd7e-543211fb076f.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a0ee1a91-3926-4b05-b955-eed7630ad2b0.png" + }, + "name": "Monster Sounds Pack", + "publisher": { + "id": "16881", + "name": "Cafofo" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "659998", + "name": "1.0.6", + "publishedDate": "2023-02-14T11:13:13Z" + }, + "downloadSize": "972558697", + "id": "208063", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/218a67d8-17cf-4d0f-8994-68eb272eb9f5.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/53239341-b683-41d4-83d8-3b270a763d31.png" + }, + "name": "Interactive Guide: Cross-Platform UI", + "publisher": { + "id": "1", + "name": "Unity Technologies" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "683375", + "name": "1.1", + "publishedDate": "2022-03-07T16:46:24Z" + }, + "downloadSize": "33824", + "id": "115488", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/c0cfbe0b-2771-4b8d-a793-ac4c08f0b9ad.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3c58effc-acb7-48f1-9da3-e760bba1afdc.png" + }, + "name": "Quick Outline", + "publisher": { + "id": "35813", + "name": "Chris Nolet" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "665322", + "name": "4.3", + "publishedDate": "2022-01-04T09:32:16Z" + }, + "downloadSize": "288215056", + "id": "103633", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/f272c42c-d5d0-42d4-b048-31b7c061c298.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/0ff283e5-baff-4a39-9854-3e46bec419eb.png" + }, + "name": "Skybox Series Free", + "publisher": { + "id": "31837", + "name": "Avionx" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "699375", + "name": "1.1.1", + "publishedDate": "2022-04-29T06:06:15Z" + }, + "downloadSize": "1799677570", + "id": "29140", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/23942802-9210-4612-b449-3691f0b65a39.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/b48a0552-cab7-479d-b73c-801ce3a16bf5.png" + }, + "name": "Viking Village URP", + "publisher": { + "id": "1", + "name": "Unity Technologies" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "737537", + "name": "1.5.3", + "publishedDate": "2022-09-15T16:17:12Z" + }, + "downloadSize": "2956018592", + "id": "65780", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/2ccb161c-750f-4fa2-a56d-a7a0811b255b.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/735b81ed-59ce-4ae4-a213-c148773e2527.png" + }, + "name": "Eternal Temple", + "publisher": { + "id": "12379", + "name": "Mana Station" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "761161", + "name": "1.0", + "publishedDate": "2022-11-27T21:00:17Z" + }, + "downloadSize": "348447648", + "id": "154257", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/65e6220e-d6ec-4494-89f8-66d7c25600db.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/1e9faf26-3081-477f-82ba-6f8a96189774.png" + }, + "name": "Sci-Fi Sound Pack", + "publisher": { + "id": "16881", + "name": "Cafofo" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "712205", + "name": "1.5.0", + "publishedDate": "2022-06-16T11:23:14Z" + }, + "downloadSize": "336057360", + "id": "73563", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/5f7f6ae1-2f72-43b9-a415-f3ccfcca9325.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/2c18f034-f017-4320-b66e-9637559cb62b.png" + }, + "name": "Lighting Optimisation Tutorial", + "publisher": { + "id": "1", + "name": "Unity Technologies" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "778177", + "name": "1.0", + "publishedDate": "2023-01-29T21:28:12Z" + }, + "downloadSize": "101036256", + "id": "201671", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/fa02dc3f-fb12-46a4-8bfd-32d6328ecc7f.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/b2245728-ec37-47ef-b125-6813a4d1833f.png" + }, + "name": "Human Vocal Sounds (PRO)", + "publisher": { + "id": "16881", + "name": "Cafofo" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "729133", + "name": "1.2.1", + "publishedDate": "2022-08-15T14:24:14Z" + }, + "downloadSize": "6956576", + "id": "162025", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/f7e40afa-de9d-48b2-960f-c8d10e9f55e2.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/f98cab8f-151b-48ad-9a31-894593cda6a5.png" + }, + "name": "Stylized Water For URP", + "publisher": { + "id": "4931", + "name": "Alexander Ameye" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "758521", + "name": "1.0", + "publishedDate": "2022-11-22T23:02:13Z" + }, + "downloadSize": "4681200", + "id": "151985", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/2fa34eba-b815-4821-810d-f9130b7c0fa8.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a8940c88-062c-4d9e-be01-0f5c3563ef45.png" + }, + "name": "Weapon Sounds", + "publisher": { + "id": "16881", + "name": "Cafofo" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "758522", + "name": "1.0", + "publishedDate": "2022-11-22T23:00:19Z" + }, + "downloadSize": "3630992", + "id": "153777", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/dd917882-b9bd-4ac0-b80f-a4b24b43ce1d.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/1d9da85a-c559-4bb9-b67e-fc6b4f6a194c.png" + }, + "name": "Shield Sounds", + "publisher": { + "id": "16881", + "name": "Cafofo" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "694429", + "name": "2.0.0", + "publishedDate": "2022-04-13T14:18:15Z" + }, + "downloadSize": "209465539", + "id": "174461", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/3dcea019-4a60-42bb-8c71-2aead0833950.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a6c909f0-6db2-42a4-9044-70cee8623d0d.png" + }, + "name": "Tiling Textures - 3D Microgame Add-Ons", + "publisher": { + "id": "1", + "name": "Unity Technologies" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "703276", + "name": "5.1.0", + "publishedDate": "2022-05-16T11:17:13Z" + }, + "downloadSize": "133409047", + "id": "25422", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/38197ea1-3efb-4722-acbd-b24cc6c56283.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/ee3d744e-6d87-4dd3-8815-9e2aa85f6777.png" + }, + "name": "Shader Calibration Scene", + "publisher": { + "id": "1", + "name": "Unity Technologies" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "649555", + "name": "1.0", + "publishedDate": "2020-05-07T10:24:11Z" + }, + "downloadSize": "77948704", + "id": "165660", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d68eac6c-faa9-4582-8a90-8d59a4fcb60b.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/d4f24e01-7f9d-4935-9b03-5197e02abf35.png" + }, + "name": "Footsteps Sound Pack", + "publisher": { + "id": "16881", + "name": "Cafofo" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "563902", + "name": "1.0", + "publishedDate": "2020-10-14T15:46:16Z" + }, + "downloadSize": "75408912", + "id": "180881", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/5197e910-6cbb-49e0-a5da-eb1b4657d276.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/085cc0a8-1324-4dc8-9d37-86627ed65709.png" + }, + "name": "300+ RPG Flat Icons Pack", + "publisher": { + "id": "34661", + "name": "Kat Grabowska" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "709653", + "name": "1.0", + "publishedDate": "2022-06-08T11:26:21Z" + }, + "downloadSize": "21301264", + "id": "224138", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/759d24ac-7bf3-4811-87fd-eb13a2186f8a.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3d826d36-dcd4-45a5-a0dd-ddbbd36377e9.png" + }, + "name": "RPG Flat Skills 05", + "publisher": { + "id": "67736", + "name": "A-ravlik" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "503516", + "name": "1.01", + "publishedDate": "2020-02-06T08:11:15Z" + }, + "downloadSize": "117497872", + "id": "82713", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/89ee1692-f2cc-4a03-8caf-23c73d4a86b5.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6e9c83dd-cfd3-4457-a128-bfbe3b11b994.png" + }, + "name": "Flat Skills Icons", + "publisher": { + "id": "13229", + "name": "REXARD" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "656436", + "name": "1.6.3", + "publishedDate": "2021-11-20T18:49:13Z" + }, + "downloadSize": "2987776", + "id": "80667", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/6cf09e1e-fbef-4b56-8b63-33210a9766e2.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/5865c88c-1b3a-48b2-8b3b-c392a8dcd32f.png" + }, + "name": "Soft Mask", + "publisher": { + "id": "26352", + "name": "Oleg Knyazev" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "568583", + "name": "1.2", + "publishedDate": "2020-11-04T18:22:16Z" + }, + "downloadSize": "20175488", + "id": "180105", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/e0d2d0cb-c0ec-4dc0-9fa0-15bed5263b58.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6407ee40-ca4f-40c9-b371-a8c1587481fe.png" + }, + "name": "Stylized RPG Cursors", + "publisher": { + "id": "47074", + "name": "Lid Games" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "633111", + "name": "1.0", + "publishedDate": "2021-05-31T11:51:11Z" + }, + "downloadSize": "893115456", + "id": "194725", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/802f1f16-b4e2-4401-9d95-7e6d880fd206.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/f4574f0a-bb29-41a2-80c3-f0d917ad33f4.png" + }, + "name": "Fantasy Music Pack Vol 1", + "publisher": { + "id": "16881", + "name": "Cafofo" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "627207", + "name": "1.0", + "publishedDate": "2021-06-23T12:49:10Z" + }, + "downloadSize": "160195248", + "id": "196149", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/7872fbe5-94a2-4466-bf2b-10cdcc1b9fe2.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/461be42e-63f2-464d-b357-3d337f5fa672.png" + }, + "name": "Fire Sounds Pack", + "publisher": { + "id": "16881", + "name": "Cafofo" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "544739", + "name": "1.2", + "publishedDate": "2020-07-22T07:23:39Z" + }, + "downloadSize": "14557680", + "id": "150771", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/0f154fcf-dcfd-47c8-bc7e-684efdaf4088.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/921b8cf0-7be6-43a6-a6e3-2d8cde538a8e.png" + }, + "name": "Classic UI icons", + "publisher": { + "id": "38930", + "name": "PONETI" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "482588", + "name": "1.0", + "publishedDate": "2019-10-17T09:21:09Z" + }, + "downloadSize": "655103888", + "id": "155819", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/4e967f9b-73f5-495d-b874-c65a4dec3037.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a16f1332-ed91-40ff-8e21-c13c46237481.png" + }, + "name": "Flat Icons Megapack", + "publisher": { + "id": "38930", + "name": "PONETI" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "610037", + "name": "1.7", + "publishedDate": "2020-08-26T12:22:16Z" + }, + "downloadSize": "9317856", + "id": "129638", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/2e11b202-08d4-426b-8678-19aa1b1fd904.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6665b031-8c41-4aab-a4b3-346c1703b3fa.png" + }, + "name": "Fast Mobile Post Processing: Color Correction(LUT), Blur, Bloom ( URP , VR , AR , LWRP )", + "publisher": { + "id": "39057", + "name": "Rufat's ShaderLab" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "536726", + "name": "1.5", + "publishedDate": "2020-06-22T09:29:20Z" + }, + "downloadSize": "1584368", + "id": "120049", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/3024dd49-6a4a-4c33-9979-5f50d11f1f8a.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/5a668076-acd7-4b52-9972-609650c5979b.png" + }, + "name": "Fantasy RPG Cursors (silver)", + "publisher": { + "id": "11690", + "name": "Leonid Deburger" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "534904", + "name": "1.0", + "publishedDate": "2020-06-30T08:47:10Z" + }, + "downloadSize": "1049008", + "id": "173127", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/fdf530d7-e7d1-42dd-8c89-969509439f85.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a12bace8-8c8f-48f6-90fd-aa20444ab0aa.png" + }, + "name": "Low Poly Hex Tiles Vol.2 (Dungeons)", + "publisher": { + "id": "28031", + "name": "InvasionFields" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "547620", + "name": "1.0", + "publishedDate": "2020-03-13T08:51:12Z" + }, + "downloadSize": "266485824", + "id": "163516", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/cac64d74-42ad-4a72-b27b-9af8bb1c8593.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/35b850b9-15e2-41b6-8194-a30d0107a680.png" + }, + "name": "Water Sounds Pack", + "publisher": { + "id": "16881", + "name": "Cafofo" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "550643", + "name": "1.0", + "publishedDate": "2019-03-20T10:07:09Z" + }, + "downloadSize": "25998768", + "id": "141915", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/4b3d8b8a-c296-46da-aa44-6a231630930a.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/ce59d9fb-a75f-487f-b303-8fb2cb9e19d4.png" + }, + "name": "Dragon Mobile UI", + "publisher": { + "id": "38930", + "name": "PONETI" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "544945", + "name": "1.2", + "publishedDate": "2020-07-23T08:29:11Z" + }, + "downloadSize": "27039984", + "id": "164168", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/90a00afb-1237-4a8c-8b99-053389948090.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/08cdfd01-8682-40ea-9e86-a2c2b21e34ad.png" + }, + "name": "Attribute Icons", + "publisher": { + "id": "38930", + "name": "PONETI" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "532727", + "name": "1.0", + "publishedDate": "2020-09-15T07:22:11Z" + }, + "downloadSize": "43239168", + "id": "171260", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/20ceaf71-3e41-467c-bcb4-365bfdc4bd2e.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a9ad2632-eeea-4c14-902e-96161819dd06.png" + }, + "name": "Rock Sounds Pack", + "publisher": { + "id": "16881", + "name": "Cafofo" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "453085", + "name": "1.2", + "publishedDate": "2019-06-26T07:26:12Z" + }, + "downloadSize": "674272", + "id": "136082", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/c1619ece-8466-49d2-a451-0f7342e5aaca.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/c8981f98-b1b1-4d5f-9995-a3e09f40184b.png" + }, + "name": "Bézier Path Creator", + "publisher": { + "id": "40478", + "name": "Sebastian Lague" + }, + "state": "deprecated" + } + }, + { + "product": { + "currentVersion": { + "id": "513402", + "name": "2.0", + "publishedDate": "2015-07-14T21:29:53Z" + }, + "downloadSize": "33239824", + "id": "15257", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/0f3027c8-bf0c-452c-a8f0-0608e6cb815c.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/23bda8fe-db8c-464a-9253-f48ae97e04f0.png" + }, + "name": "Gothic RPG Buttons", + "publisher": { + "id": "5183", + "name": "Kodiak Graphics" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "370031", + "name": "1.0", + "publishedDate": "2018-07-10T17:50:27Z" + }, + "downloadSize": "2563680", + "id": "120952", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d67d3093-5b3f-4ff0-bbb8-fadd278f7635.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/76a44737-ab2d-4e29-bd79-d51bdac4cb3d.png" + }, + "name": "Low Poly Hex Tiles Vol.1", + "publisher": { + "id": "28031", + "name": "InvasionFields" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "478972", + "name": "1.0", + "publishedDate": "2019-10-29T05:57:12Z" + }, + "downloadSize": "1038320", + "id": "156221", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/82a5f8f4-a05d-4b30-b689-74c1e7c39be7.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/2a64dd49-4ded-4340-9b91-46f1f57489d8.png" + }, + "name": "TBS Stylized Hex and Platforms", + "publisher": { + "id": "28031", + "name": "InvasionFields" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "352735", + "name": "1.6", + "publishedDate": "2018-04-13T17:49:33Z" + }, + "downloadSize": "281968", + "id": "69448", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/5c0f853d-f0bf-4da3-a356-84e5a9e2d3a5.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/ab0e423e-ab31-40b7-aa35-9f3d35823d18.png" + }, + "name": "Transition Blocks", + "publisher": { + "id": "23166", + "name": "ATesh Games" + }, + "state": "published" + } + }, + { + "product": { + "currentVersion": { + "id": "161469", + "name": "1.0", + "publishedDate": "2016-01-22T22:00:32Z" + }, + "downloadSize": "99312", + "id": "43321", + "mainImage": { + "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/199bd058-ca14-41c3-8e29-f8f39991f6a0.png", + "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/71c45fa0-df9f-4919-9957-e508dd77f146.png" + }, + "name": "RTS camera", + "publisher": { + "id": "12535", + "name": "Denis Sylkin" + }, + "state": "deprecated" + } + } + ], + "total": 176 + } + } + } +] diff --git a/testdata/store/my_assets_p2.json b/testdata/store/my_assets_p2.json new file mode 100644 index 0000000..685d6a3 --- /dev/null +++ b/testdata/store/my_assets_p2.json @@ -0,0 +1,10 @@ +[ + { + "data": { + "searchMyAssets": { + "results": [], + "total": 176 + } + } + } +] diff --git a/testdata/store/package_header_sample.bin b/testdata/store/package_header_sample.bin new file mode 100644 index 0000000000000000000000000000000000000000..c2412e176312d1fa384b3a8722aa97bb2f1e6bce GIT binary patch literal 1024 zcmb2|=3r?L6wG2`R$+8hQDCfA%E`>jR=3`~G(lYs`N=NDDNU1w-)4iwHw zOiIlGTI86QnVVPwatzRQXs*vJ$pN{=DX}D^Zq}>hfV<}4n-X;#g>f4W$9X+iqFl<14UMboY>2;vv_*3 zVA1n?LAy6UJs}(K4itF4@2CA{>(#n%&uqSY`QN_(aj(Drs+d>#Y-akl^yhnvZ8mS- zwr!j8o7$h-_>|qYZ2bIjqx8GW*Ot$Xm;QY%*X_rakDosu75KAx>(;IL*ZaJpZ*Sjx z*1NL)-_4}8x3_NGntl1)w!Ld@k7eIo7yJ5}#Cq9f&GnBDAF6da=q>g5y|C3a_hQ>^ zKeu(&SHAo|xqh3@`+Dv98w~b;P+Gru@7&mj@9S@y<=)=5J-GUR^zEka`}gj(y}^_7 z^?m)u|F54JpS-SqrTzJ{H%E*4I6iUCJ@PoQ)Zp?j39;CHA6!>8?s`{ex36yDT%|*Q zn+`EGHHS!dFwUK}FnF?k=raxQHV`~KkL!dM>%JzwYEO?R1_jJAtAwG#-=2J%WXnY3tE@0$_p+jD9DiKHgL>! zt&Q2eby3jy10NSNH65xDo^tn8cJl3G+frvwTWf5(Cs?$q4d}03tiO7=CTCV`JyshV z^Vf}yg*6~QW4fe|e!0@#kE_D2wfLR>F8NUW$#vC?l!EzE37d)&J`~J%$PzL>F*}*% z*%$85+@)VRzrPH#`k%dWNmc&}U;X&A6E5jUi)}T&n;h}uwdt#O;_sZd7KtpII$KI> z>Pu+>xfj>lPyM@e#c%${!|uO(Il@D-QdT5hRs{O8`TLYpyR%m$Upbq9>BxhC`v$IS x6@0f={r2Tp5Vw8NH7?$*r*^XR%f1$0QWGfkpZDsA#*{abQZkRkrQ{xn0|4WK*Ixht literal 0 HcmV?d00001 From 0197aa8bc95aceae86edac799ee63090007e25c3 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 11:28:06 -0700 Subject: [PATCH 02/28] Pin which of the store's three ids is the identity 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- internal/model/model.go | 107 +++++++++++++++++++++++++++++++++++ internal/model/model_test.go | 79 ++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 internal/model/model.go create mode 100644 internal/model/model_test.go diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..9616b51 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,107 @@ +// Package model holds the domain types shared across unity-sync and, more importantly, +// the identity rules everything else depends on: which of the store's several ids is +// the real one, and how an asset's slug is derived from it. +package model + +import ( + "regexp" + "strings" + "unicode" +) + +// State is the store's lifecycle value for a product. Deprecated assets still download; +// disabled ones answer 404, so they are owned but not fetchable. +type State string + +const ( + StatePublished State = "published" + StateDeprecated State = "deprecated" + StateDisabled State = "disabled" +) + +// Downloadable reports whether the store will serve bytes for an asset in this state. +// An unrecognised state is treated as downloadable: the store adding a value must not +// make a whole library unmirrorable, and a wrong guess surfaces as a loud 404. +func (s State) Downloadable() bool { return s != StateDisabled } + +// Publisher is the store's publisher record. Only the id is stable; the name can change +// and is used for display and for the cache's vendor directory. +type Publisher struct { + ID string + Name string +} + +// Version is one published build of an asset. ID is what a diff compares; Name is the +// publisher's display string and is not required to be orderly or even to change. +type Version struct { + ID string + Name string + PublishedDate string +} + +// Asset is one owned Asset Store product as the enumeration reports it. +// +// ID is the *store product id* — the value /api/downloads/{id} takes and the value the +// package's own metadata carries. The API also returns a `productId` field holding a +// different 12-digit number that no endpoint here accepts; this type deliberately does +// not carry it, so it cannot be reached for by mistake. +type Asset struct { + ID string + Name string + State State + Publisher Publisher + Version Version + + // AdvertisedSize is the store's `downloadSize`. It arrives as a JSON string and is + // only approximate — measured deltas run 0-16 bytes above the bytes delivered — so + // it bounds a transfer but never checksums one. + AdvertisedSize int64 + + // ThumbnailURL is protocol-relative as the store returns it (`//host/path`). + ThumbnailURL string +} + +// Slug is the asset's key in the lockfile and its directory name in the cache. The id +// suffix keeps it unique and stable across publisher renames; the name prefix keeps +// lockfile diffs and the cache tree readable. The slug is not the identity — a rename +// changes it — so everything that must survive a rename matches on ID instead. +func (a Asset) Slug() string { + base := slugify(a.Name) + if base == "" { + return a.ID + } + return base + "-" + a.ID +} + +// PublisherSlug is the cache's vendor directory. It carries no id suffix, so the +// directory reads as a name and quarry's vendor facet stays legible. A name written +// entirely in a non-Latin script folds to nothing, and an empty path segment would +// collapse the layout to two components and empty quarry's pack facet, so it falls back +// to the id. +func (a Asset) PublisherSlug() string { + if s := slugify(a.Publisher.Name); s != "" { + return s + } + if a.Publisher.ID != "" { + return "publisher-" + a.Publisher.ID + } + return "publisher-unknown" +} + +var nonSlug = regexp.MustCompile(`[^a-z0-9]+`) + +// slugify folds to lowercase ASCII and collapses everything else to single hyphens. +// Non-ASCII letters are dropped rather than transliterated, which is why callers need +// an empty-result fallback. +func slugify(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r < unicode.MaxASCII: + b.WriteRune(unicode.ToLower(r)) + default: + b.WriteByte(' ') + } + } + return strings.Trim(nonSlug.ReplaceAllString(b.String(), "-"), "-") +} diff --git a/internal/model/model_test.go b/internal/model/model_test.go new file mode 100644 index 0000000..aa84480 --- /dev/null +++ b/internal/model/model_test.go @@ -0,0 +1,79 @@ +package model_test + +import ( + "testing" + + "github.com/curbol/unity-sync/internal/model" +) + +func TestSlugMatchesTheStoresOwnSlugForOrdinaryNames(t *testing.T) { + for _, tc := range []struct { + name, id, want string + }{ + {"Quirky Series - Animals Mega Pack Vol.1", "137259", "quirky-series-animals-mega-pack-vol-1-137259"}, + {"Quick Outline", "115488", "quick-outline-115488"}, + {"RPG_Animations - Torch,Lantern,Enemy Attack,Hit,Magic", "309182", + "rpg-animations-torch-lantern-enemy-attack-hit-magic-309182"}, + {"UI Toolkit Bundle 1", "262163", "ui-toolkit-bundle-1-262163"}, + } { + a := model.Asset{ID: tc.id, Name: tc.name} + if got := a.Slug(); got != tc.want { + t.Errorf("Slug(%q) = %q, want %q", tc.name, got, tc.want) + } + } +} + +// A rename changes the slug by construction. The test exists to pin that the id +// survives inside it, since the id is what classification actually matches on. +func TestSlugChangesOnRenameButKeepsTheId(t *testing.T) { + before := model.Asset{ID: "309177", Name: "RPG_Animations - One Hand Base"} + after := model.Asset{ID: "309177", Name: "Ultimate One Hand Locomotion"} + if before.Slug() == after.Slug() { + t.Fatal("a rename must change the slug") + } + for _, s := range []string{before.Slug(), after.Slug()} { + if got := s[len(s)-len("309177"):]; got != "309177" { + t.Errorf("slug %q does not end in the product id", s) + } + } +} + +func TestSlugFallsBackToTheIdWhenTheNameFoldsAway(t *testing.T) { + a := model.Asset{ID: "424242", Name: "日本語アセット"} + if got := a.Slug(); got != "424242" { + t.Errorf("Slug() = %q, want the bare id; an empty slug would be an unsafe path element", got) + } +} + +func TestPublisherSlugCarriesNoIdSuffixButFallsBackToOne(t *testing.T) { + named := model.Asset{Publisher: model.Publisher{ID: "99592", Name: "DoubleL"}} + if got := named.PublisherSlug(); got != "doublel" { + t.Errorf("PublisherSlug() = %q, want %q — the vendor facet should read as a name", got, "doublel") + } + folded := model.Asset{Publisher: model.Publisher{ID: "12345", Name: "Кириллица"}} + if got := folded.PublisherSlug(); got != "publisher-12345" { + t.Errorf("PublisherSlug() = %q, want %q; an empty segment collapses the layout to two "+ + "components and empties quarry's pack facet", got, "publisher-12345") + } +} + +func TestSlugsAreUniquePerProductEvenWhenNamesCollide(t *testing.T) { + a := model.Asset{ID: "111", Name: "Forest Pack"} + b := model.Asset{ID: "222", Name: "Forest Pack!"} + if a.Slug() == b.Slug() { + t.Errorf("distinct products produced the same slug %q", a.Slug()) + } +} + +func TestOnlyDisabledIsUndownloadable(t *testing.T) { + for state, want := range map[model.State]bool{ + model.StatePublished: true, + model.StateDeprecated: true, // deprecated assets still serve bytes + model.StateDisabled: false, + model.State("retired-someday"): true, // an unknown state must not brick a library + } { + if got := state.Downloadable(); got != want { + t.Errorf("State(%q).Downloadable() = %v, want %v", state, got, want) + } + } +} From 246592f845e7e3df05e6fc1e457686a239ae11f6 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 11:29:09 -0700 Subject: [PATCH 03/28] Resolve user settings with no browser default and no baked-in paths 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- go.mod | 2 + go.sum | 2 + internal/config/config.go | 119 +++++++++++++++++++++++++++++++++ internal/config/config_test.go | 119 +++++++++++++++++++++++++++++++++ 4 files changed, 242 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/go.mod b/go.mod index 636a39c..9bcf9cd 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/curbol/unity-sync go 1.26 + +require github.com/BurntSushi/toml v1.6.0 diff --git a/go.sum b/go.sum index e69de29..f74b269 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0e781b3 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,119 @@ +// Package config resolves the user-scoped settings: where the session comes from, where +// the library lives, and how many downloads may run at once. Built-in defaults are +// overridden by config.toml in the XDG config dir, then by environment, then by flags. +// Project-scoped settings (the asset allowlist) live in the manifest, not here, and no +// machine-specific path is baked in. +package config + +import ( + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" +) + +// Config is the resolved user-scoped configuration. +type Config struct { + // SessionSource is a path to a pasted-curl file or a cookies.txt. There is + // deliberately no browser default: the Asset Store's credential is a session + // cookie no browser cookie database holds, so defaulting to one would make every + // out-of-box run fail with a confusing diagnostic. + SessionSource string + + // LibraryPath is where packages are mirrored. A user may point this at Unity's own + // Asset Store-5.x directory; whether the Editor recognises the layout is untested. + LibraryPath string + + // Concurrency bounds simultaneous downloads. Two by default: packages here reach + // 23 GB, and the store is someone else's infrastructure. + Concurrency int +} + +type fileConfig struct { + SessionSource string `toml:"session_source"` + LibraryPath string `toml:"library_path"` + Concurrency int `toml:"concurrency"` +} + +// ResolveDir picks the directory holding config.toml: an explicit flag, else +// $UNITY_SYNC_CONFIG_DIR, else $XDG_CONFIG_HOME/unity-sync, else ~/.config/unity-sync. +func ResolveDir(flag string) string { + if flag != "" { + return flag + } + if v := os.Getenv("UNITY_SYNC_CONFIG_DIR"); v != "" { + return v + } + if v := os.Getenv("XDG_CONFIG_HOME"); v != "" { + return filepath.Join(v, "unity-sync") + } + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, ".config", "unity-sync") + } + return "unity-sync" +} + +// defaultLibraryPath is $XDG_DATA_HOME/unity-sync, else ~/.local/share/unity-sync. App +// data rather than ~/.cache, so an OS cache cleaner cannot wipe a 75 GB mirror. +func defaultLibraryPath() string { + if v := os.Getenv("XDG_DATA_HOME"); v != "" { + return filepath.Join(v, "unity-sync") + } + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, ".local", "share", "unity-sync") + } + return "unity-library" +} + +func defaults() Config { + return Config{ + LibraryPath: defaultLibraryPath(), + Concurrency: 2, + } +} + +// Load merges built-in defaults, an optional config.toml in dir, then the environment +// (UNITY_SYNC_LIBRARY, UNITY_SYNC_SESSION). A missing config.toml is not an error; an +// unreadable or malformed one is. +func Load(dir string) (Config, error) { + c := defaults() + path := filepath.Join(dir, "config.toml") + if _, err := os.Stat(path); err == nil { + var fc fileConfig + if _, err := toml.DecodeFile(path, &fc); err != nil { + return Config{}, err + } + overlay(&c, fc) + } + if v := os.Getenv("UNITY_SYNC_LIBRARY"); v != "" { + c.LibraryPath = v + } + if v := os.Getenv("UNITY_SYNC_SESSION"); v != "" { + c.SessionSource = v + } + c.LibraryPath = expandHome(c.LibraryPath) + c.SessionSource = expandHome(c.SessionSource) + return c, nil +} + +func overlay(c *Config, fc fileConfig) { + if fc.SessionSource != "" { + c.SessionSource = fc.SessionSource + } + if fc.LibraryPath != "" { + c.LibraryPath = fc.LibraryPath + } + if fc.Concurrency > 0 { + c.Concurrency = fc.Concurrency + } +} + +func expandHome(p string) string { + if p == "~" || strings.HasPrefix(p, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, strings.TrimPrefix(p, "~")) + } + } + return p +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..14dfb5a --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,119 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/curbol/unity-sync/internal/config" +) + +// isolate clears every variable the resolver reads, so a developer's own environment +// cannot make these pass or fail. +func isolate(t *testing.T) { + t.Helper() + for _, k := range []string{"UNITY_SYNC_CONFIG_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", + "UNITY_SYNC_LIBRARY", "UNITY_SYNC_SESSION"} { + t.Setenv(k, "") + os.Unsetenv(k) + } + t.Setenv("HOME", t.TempDir()) +} + +func TestResolveDirPrecedence(t *testing.T) { + isolate(t) + home := os.Getenv("HOME") + + if got, want := config.ResolveDir(""), filepath.Join(home, ".config", "unity-sync"); got != want { + t.Errorf("bare default = %q, want %q", got, want) + } + t.Setenv("XDG_CONFIG_HOME", "/xdg") + if got, want := config.ResolveDir(""), filepath.Join("/xdg", "unity-sync"); got != want { + t.Errorf("XDG_CONFIG_HOME = %q, want %q", got, want) + } + t.Setenv("UNITY_SYNC_CONFIG_DIR", "/envdir") + if got := config.ResolveDir(""); got != "/envdir" { + t.Errorf("UNITY_SYNC_CONFIG_DIR = %q, want /envdir (env beats XDG)", got) + } + if got := config.ResolveDir("/flagdir"); got != "/flagdir" { + t.Errorf("flag = %q, want /flagdir (flag beats env)", got) + } +} + +func TestLoadDefaultsWhenNoFileExists(t *testing.T) { + isolate(t) + c, err := config.Load(t.TempDir()) + if err != nil { + t.Fatalf("Load with no config.toml: %v", err) + } + if c.Concurrency != 2 { + t.Errorf("Concurrency = %d, want 2", c.Concurrency) + } + if c.SessionSource != "" { + t.Errorf("SessionSource = %q, want empty: there is no browser default", c.SessionSource) + } + want := filepath.Join(os.Getenv("HOME"), ".local", "share", "unity-sync") + if c.LibraryPath != want { + t.Errorf("LibraryPath = %q, want %q", c.LibraryPath, want) + } +} + +func TestFileThenEnvPrecedence(t *testing.T) { + isolate(t) + dir := t.TempDir() + body := "session_source = \"/from/file.curl\"\nlibrary_path = \"/from/file/lib\"\nconcurrency = 7\n" + if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + + c, err := config.Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if c.SessionSource != "/from/file.curl" || c.LibraryPath != "/from/file/lib" || c.Concurrency != 7 { + t.Fatalf("file values not applied: %+v", c) + } + + t.Setenv("UNITY_SYNC_LIBRARY", "/from/env/lib") + t.Setenv("UNITY_SYNC_SESSION", "/from/env.curl") + c, err = config.Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if c.LibraryPath != "/from/env/lib" { + t.Errorf("LibraryPath = %q, want the env value to beat the file", c.LibraryPath) + } + if c.SessionSource != "/from/env.curl" { + t.Errorf("SessionSource = %q, want the env value to beat the file", c.SessionSource) + } + if c.Concurrency != 7 { + t.Errorf("Concurrency = %d, want the file value to survive (no env override exists)", c.Concurrency) + } +} + +func TestTildeExpands(t *testing.T) { + isolate(t) + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.toml"), + []byte("library_path = \"~/packages\"\n"), 0o600); err != nil { + t.Fatal(err) + } + c, err := config.Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if want := filepath.Join(os.Getenv("HOME"), "packages"); c.LibraryPath != want { + t.Errorf("LibraryPath = %q, want %q", c.LibraryPath, want) + } +} + +func TestMalformedConfigIsAnError(t *testing.T) { + isolate(t) + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte("concurrency = \"two\"\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := config.Load(dir); err == nil { + t.Error("Load accepted a malformed config.toml") + } +} From 946a7907fec7f43221532792c8e5b459bc417db8 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 11:30:07 -0700 Subject: [PATCH 04/28] Let a response body overrule its status code when deciding to retry 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- internal/retry/retry.go | 83 +++++++++++++++++++++++++++ internal/retry/retry_test.go | 108 +++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 internal/retry/retry.go create mode 100644 internal/retry/retry_test.go diff --git a/internal/retry/retry.go b/internal/retry/retry.go new file mode 100644 index 0000000..9a70b44 --- /dev/null +++ b/internal/retry/retry.go @@ -0,0 +1,83 @@ +// Package retry provides the backoff policy for store requests: retry what a later +// attempt might fix, stop immediately on what it cannot. +package retry + +import ( + "context" + "errors" + "fmt" + "time" +) + +// Policy configures Do. A zero Sleep uses time.Sleep, which tests replace. +type Policy struct { + Attempts int + Base time.Duration + Sleep func(time.Duration) +} + +// DefaultPolicy is used for store API calls. Downloads pass their own, because +// re-transferring a multi-gigabyte body is not the same kind of cheap as re-issuing a +// 2 KB query. +func DefaultPolicy() Policy { return Policy{Attempts: 4, Base: 500 * time.Millisecond} } + +// permanent marks an error that no further attempt can fix. +type permanent struct{ err error } + +func (p permanent) Error() string { return p.err.Error() } +func (p permanent) Unwrap() error { return p.err } + +// Permanent wraps err so Do returns it without retrying. The caller uses this when the +// *body* of a response settles the question that its status code left open — an expired +// session arrives as a 500, which the status alone would say to retry. +func Permanent(err error) error { + if err == nil { + return nil + } + return permanent{err} +} + +// Retryable reports whether an HTTP status is worth another attempt: rate limiting, +// request timeout, and server errors. Every other 4xx is the caller's fault and will +// not improve. +func Retryable(status int) bool { + switch { + case status == 429, status == 408: + return true + case status >= 500: + return true + default: + return false + } +} + +// Do calls fn until it returns nil, returns a Permanent error, or the attempts run out, +// sleeping base<<(n-1) between tries. A cancelled context stops it immediately. +func Do(ctx context.Context, p Policy, fn func(attempt int) error) error { + if p.Attempts < 1 { + p.Attempts = 1 + } + sleep := p.Sleep + if sleep == nil { + sleep = time.Sleep + } + var last error + for attempt := 1; attempt <= p.Attempts; attempt++ { + if err := ctx.Err(); err != nil { + return err + } + last = fn(attempt) + if last == nil { + return nil + } + var perm permanent + if errors.As(last, &perm) { + return perm.err + } + if attempt == p.Attempts { + break + } + sleep(p.Base << (attempt - 1)) + } + return fmt.Errorf("gave up after %d attempts: %w", p.Attempts, last) +} diff --git a/internal/retry/retry_test.go b/internal/retry/retry_test.go new file mode 100644 index 0000000..dd0af33 --- /dev/null +++ b/internal/retry/retry_test.go @@ -0,0 +1,108 @@ +package retry_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/curbol/unity-sync/internal/retry" +) + +// stubClock records what Do would have slept instead of sleeping. +type stubClock struct{ slept []time.Duration } + +func (s *stubClock) sleep(d time.Duration) { s.slept = append(s.slept, d) } + +func policy(attempts int, c *stubClock) retry.Policy { + return retry.Policy{Attempts: attempts, Base: 100 * time.Millisecond, Sleep: c.sleep} +} + +func TestBackoffDoublesAndStopsAtTheAttemptLimit(t *testing.T) { + clock := &stubClock{} + calls := 0 + err := retry.Do(context.Background(), policy(4, clock), func(int) error { + calls++ + return errors.New("boom") + }) + if err == nil { + t.Fatal("Do returned nil after exhausting attempts") + } + if calls != 4 { + t.Errorf("fn called %d times, want 4", calls) + } + want := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 400 * time.Millisecond} + if len(clock.slept) != len(want) { + t.Fatalf("slept %v, want %v (no sleep after the final attempt)", clock.slept, want) + } + for i := range want { + if clock.slept[i] != want[i] { + t.Errorf("sleep %d = %v, want %v", i, clock.slept[i], want[i]) + } + } +} + +func TestSuccessOnASecondAttemptSleepsOnce(t *testing.T) { + clock := &stubClock{} + err := retry.Do(context.Background(), policy(4, clock), func(attempt int) error { + if attempt == 1 { + return errors.New("transient") + } + return nil + }) + if err != nil { + t.Fatalf("Do = %v, want nil", err) + } + if len(clock.slept) != 1 { + t.Errorf("slept %v, want exactly one backoff", clock.slept) + } +} + +// The expired-session case: the status says 500, which Retryable would retry, but the +// body settles it. Permanent must short-circuit so the user is told immediately instead +// of after the full schedule. +func TestPermanentStopsImmediatelyAndUnwraps(t *testing.T) { + clock := &stubClock{} + sentinel := errors.New("session expired") + calls := 0 + err := retry.Do(context.Background(), policy(4, clock), func(int) error { + calls++ + return retry.Permanent(sentinel) + }) + if calls != 1 { + t.Errorf("fn called %d times, want 1", calls) + } + if len(clock.slept) != 0 { + t.Errorf("slept %v, want none", clock.slept) + } + if !errors.Is(err, sentinel) { + t.Errorf("Do = %v, want the wrapped sentinel to survive errors.Is", err) + } +} + +func TestCancelledContextStopsBeforeCalling(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + calls := 0 + err := retry.Do(ctx, policy(4, &stubClock{}), func(int) error { + calls++ + return errors.New("should not run") + }) + if calls != 0 { + t.Errorf("fn called %d times on a cancelled context, want 0", calls) + } + if !errors.Is(err, context.Canceled) { + t.Errorf("Do = %v, want context.Canceled", err) + } +} + +func TestRetryableCoversRateLimitsAndServerErrorsOnly(t *testing.T) { + for status, want := range map[int]bool{ + 200: false, 302: false, 400: false, 401: false, 403: false, 404: false, + 408: true, 429: true, 500: true, 502: true, 503: true, + } { + if got := retry.Retryable(status); got != want { + t.Errorf("Retryable(%d) = %v, want %v", status, got, want) + } + } +} From 48eb012783f386789c106847b842fe8f5dd66b7a Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 11:31:33 -0700 Subject: [PATCH 05/28] Read a package's identity from its gzip extra field, length-driven 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- internal/unitypackage/unitypackage.go | 86 ++++++++++++++ internal/unitypackage/unitypackage_test.go | 125 +++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 internal/unitypackage/unitypackage.go create mode 100644 internal/unitypackage/unitypackage_test.go diff --git a/internal/unitypackage/unitypackage.go b/internal/unitypackage/unitypackage.go new file mode 100644 index 0000000..f9aec0f --- /dev/null +++ b/internal/unitypackage/unitypackage.go @@ -0,0 +1,86 @@ +// Package unitypackage reads the metadata the Asset Store stamps into every +// .unitypackage. A package is gzip, and the store puts a JSON descriptor in the gzip +// FEXTRA field — not the comment field, which is empty on every real package. Reading +// it costs a header parse, so a cached file can be identified without decompressing +// gigabytes or hashing them. +package unitypackage + +import ( + "compress/gzip" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "os" +) + +// subfieldID is the extra-field id the store uses for its descriptor. +var subfieldID = [2]byte{'A', '$'} + +// ErrNoMetadata means the file is a readable gzip stream that carries no store +// descriptor. Callers treat this as unverifiable-by-metadata rather than as corruption, +// because the alternative is refusing to cache a package that may be perfectly good. +var ErrNoMetadata = errors.New("no Asset Store metadata in gzip extra field") + +// Metadata is the descriptor's fields, all of which the store encodes as JSON strings. +type Metadata struct { + ID string `json:"id"` + Title string `json:"title"` + Version string `json:"version"` + VersionID string `json:"version_id"` + UploadID string `json:"upload_id"` + UnityVersion string `json:"unity_version"` + PubDate string `json:"pubdate"` +} + +// Read parses the gzip header from r and returns the store descriptor. It reads only as +// far as the header, so passing a whole multi-gigabyte package costs nothing extra. +func Read(r io.Reader) (Metadata, error) { + zr, err := gzip.NewReader(r) + if err != nil { + return Metadata{}, fmt.Errorf("not a readable gzip stream: %w", err) + } + defer zr.Close() + return fromExtra(zr.Header.Extra) +} + +// ReadFile is Read against a path, reading only the header. +func ReadFile(path string) (Metadata, error) { + f, err := os.Open(path) + if err != nil { + return Metadata{}, err + } + defer f.Close() + return Read(f) +} + +// fromExtra walks the RFC 1952 subfield list. The walk is driven by each subfield's own +// length, never by an assumed prefix: XLEN is a uint16, so a descriptor may legitimately +// sit far past where every package sampled so far happens to end it, and a +// prefix-limited reader would silently report "no metadata" for such a package — +// downgrading the hard wrong-asset check into a warning. +func fromExtra(extra []byte) (Metadata, error) { + for i := 0; i+4 <= len(extra); { + id1, id2 := extra[i], extra[i+1] + length := int(binary.LittleEndian.Uint16(extra[i+2 : i+4])) + start := i + 4 + end := start + length + if end > len(extra) { + return Metadata{}, fmt.Errorf("extra subfield %q claims %d bytes but only %d remain", + string([]byte{id1, id2}), length, len(extra)-start) + } + if id1 == subfieldID[0] && id2 == subfieldID[1] { + var m Metadata + if err := json.Unmarshal(extra[start:end], &m); err != nil { + return Metadata{}, fmt.Errorf("metadata subfield is not valid JSON: %w", err) + } + if m.ID == "" { + return Metadata{}, fmt.Errorf("metadata subfield carries no product id") + } + return m, nil + } + i = end + } + return Metadata{}, ErrNoMetadata +} diff --git a/internal/unitypackage/unitypackage_test.go b/internal/unitypackage/unitypackage_test.go new file mode 100644 index 0000000..3f025a1 --- /dev/null +++ b/internal/unitypackage/unitypackage_test.go @@ -0,0 +1,125 @@ +package unitypackage_test + +import ( + "bytes" + "compress/gzip" + "encoding/binary" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/curbol/unity-sync/internal/unitypackage" +) + +// realHeader is the first kilobyte of an actual downloaded package, committed as a +// fixture. Everything else here is synthetic, so this is the only case proving the +// parser matches what the store really emits. +func realHeader(t *testing.T) []byte { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "testdata", "store", "package_header_sample.bin")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + return raw +} + +func TestReadsTheDescriptorFromARealPackageHeader(t *testing.T) { + m, err := unitypackage.Read(bytes.NewReader(realHeader(t))) + if err != nil { + t.Fatalf("Read: %v", err) + } + if m.ID != "323439" { + t.Errorf("ID = %q, want 323439", m.ID) + } + if m.VersionID != "1361204" { + t.Errorf("VersionID = %q, want 1361204", m.VersionID) + } + if m.Version != "1.9" { + t.Errorf("Version = %q, want 1.9", m.Version) + } + if m.UnityVersion != "6000.0.32f1" { + t.Errorf("UnityVersion = %q, want 6000.0.32f1", m.UnityVersion) + } +} + +// gzipWithExtra builds a valid gzip stream whose extra field holds the given subfields. +func gzipWithExtra(t *testing.T, subfields ...[]byte) []byte { + t.Helper() + var extra []byte + for _, s := range subfields { + extra = append(extra, s...) + } + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + zw.Header.Extra = extra + if _, err := zw.Write([]byte("payload")); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func subfield(id string, payload []byte) []byte { + out := []byte{id[0], id[1], 0, 0} + binary.LittleEndian.PutUint16(out[2:4], uint16(len(payload))) + return append(out, payload...) +} + +// XLEN is a uint16, so a descriptor can legitimately sit tens of kilobytes in. A reader +// that only inspects a fixed prefix passes every other case here and then silently +// reports "no metadata" in production, turning the hard wrong-asset check into a +// warning. +func TestFindsADescriptorFarPastAnyObservedOffset(t *testing.T) { + filler := subfield("XX", bytes.Repeat([]byte{0}, 40000)) + real := subfield("A$", []byte(`{"id":"999","version_id":"42"}`)) + m, err := unitypackage.Read(bytes.NewReader(gzipWithExtra(t, filler, real))) + if err != nil { + t.Fatalf("Read with a 40 KB leading subfield: %v", err) + } + if m.ID != "999" || m.VersionID != "42" { + t.Errorf("got %+v, want id 999 / version_id 42", m) + } +} + +func TestForeignSubfieldsAloneMeanNoMetadata(t *testing.T) { + body := gzipWithExtra(t, subfield("QQ", []byte("something else"))) + _, err := unitypackage.Read(bytes.NewReader(body)) + if !errors.Is(err, unitypackage.ErrNoMetadata) { + t.Errorf("Read = %v, want ErrNoMetadata", err) + } +} + +func TestNoExtraFieldMeansNoMetadata(t *testing.T) { + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + zw.Write([]byte("payload")) + zw.Close() + _, err := unitypackage.Read(bytes.NewReader(buf.Bytes())) + if !errors.Is(err, unitypackage.ErrNoMetadata) { + t.Errorf("Read = %v, want ErrNoMetadata", err) + } +} + +func TestTruncatedHeaderIsAnError(t *testing.T) { + full := realHeader(t) + if _, err := unitypackage.Read(bytes.NewReader(full[:20])); err == nil { + t.Error("Read accepted a truncated gzip header") + } +} + +func TestNonGzipIsAnError(t *testing.T) { + if _, err := unitypackage.Read(strings.NewReader("sign in")); err == nil { + t.Error("Read accepted a body that is not gzip at all") + } +} + +func TestDescriptorWithoutAProductIdIsRejected(t *testing.T) { + body := gzipWithExtra(t, subfield("A$", []byte(`{"version_id":"42"}`))) + if _, err := unitypackage.Read(bytes.NewReader(body)); err == nil { + t.Error("Read accepted a descriptor with no product id; the id gate depends on it") + } +} From 1113e90d3ed9b10ba2f55b710f83d283ff7c995f Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 11:32:57 -0700 Subject: [PATCH 06/28] Build the Cookie header around the one cookie the store checks 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- internal/session/session.go | 192 +++++++++++++++++++++++++++++++ internal/session/session_test.go | 156 +++++++++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 internal/session/session.go create mode 100644 internal/session/session_test.go diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..d44f9eb --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,192 @@ +// Package session turns a saved browser session into the Cookie header the Asset Store +// requires. The credential is the LS cookie: measured against the live store, _csrf plus +// LS alone returns a full owned-asset list, and the NextAuth session token is not +// consulted by either endpoint this tool uses. LS is a session cookie, so no browser +// cookie database ever holds it — which is why the only supported sources are a pasted +// curl command or a cookies.txt export. +package session + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// CredentialCookie is the one cookie the store actually checks. +const CredentialCookie = "LS" + +// cookieDomain is the family whose cookies a browser would send to the storefront. The +// storefront mixes scopes — the same response sets some cookies host-only on +// assetstore.unity.com and others with Domain=.unity.com — and which scope LS uses was +// never established, so the whole family is accepted rather than one host guessed. +const cookieDomain = "unity.com" + +// httpOnlyPrefix marks an HttpOnly record in a Netscape cookies.txt. Exporters write it +// on the line itself, so a parser that treats every '#' line as a comment drops exactly +// the credential cookies. +const httpOnlyPrefix = "#HttpOnly_" + +// ErrNoCredential means the source parsed but carries no LS cookie. It is reported +// before any request goes out, because the store answers a missing LS with an opaque +// HTTP 500 that reads like a server fault. +type ErrNoCredential struct{ Source string } + +func (e *ErrNoCredential) Error() string { + return fmt.Sprintf("session %s has no %s cookie: Unity keeps it in memory only, so a browser "+ + "cookie database never has it — re-copy the session from DevTools (Network > any "+ + "assetstore.unity.com request > Copy as cURL) while signed in", e.Source, CredentialCookie) +} + +// Resolve reads a session file and returns the Cookie header for the store. It also +// asserts the credential is present, whatever the source, so the diagnostic names the +// real problem instead of leaving it to a 500. +func Resolve(path string) (string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return "", err + } + pairs, err := parse(string(raw)) + if err != nil { + return "", fmt.Errorf("%s: %w", path, err) + } + if _, ok := pairs[CredentialCookie]; !ok { + return "", &ErrNoCredential{Source: path} + } + return join(pairs), nil +} + +// Discover looks for a session file in the user config dir, so a first run needs no +// flag once the file is in the obvious place. +func Discover(configDir string) (string, bool) { + for _, name := range []string{"session.curl", "cookies.txt"} { + p := filepath.Join(configDir, name) + if _, err := os.Stat(p); err == nil { + return p, true + } + } + return "", false +} + +func parse(content string) (map[string]string, error) { + if isCurlPaste(content) { + return fromCurl(content) + } + return fromCookiesTxt(content) +} + +// A cookie value can itself contain the opposite quote character, so each outer-quote +// style gets its own pattern; RE2 has no backreferences. +var ( + curlSingle = regexp.MustCompile(`(?i)(?:-H|--header)\s+'Cookie:\s*([^']*)'`) + curlDouble = regexp.MustCompile(`(?i)(?:-H|--header)\s+"Cookie:\s*([^"]*)"`) +) + +// isCurlPaste distinguishes a pasted command from a cookies.txt by structure, not by the +// word "curl" appearing somewhere: exported cookie files often carry a header comment +// mentioning curl, and treating that as a command sends it to a parser that can only +// fail. +func isCurlPaste(content string) bool { + if curlSingle.MatchString(content) || curlDouble.MatchString(content) { + return true + } + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + return strings.HasPrefix(line, "curl ") + } + return false +} + +func fromCurl(content string) (map[string]string, error) { + var header string + switch { + case curlSingle.MatchString(content): + header = curlSingle.FindStringSubmatch(content)[1] + case curlDouble.MatchString(content): + header = curlDouble.FindStringSubmatch(content)[1] + default: + return nil, fmt.Errorf("no Cookie header in the pasted curl command") + } + pairs := map[string]string{} + for _, part := range strings.Split(header, ";") { + name, value, ok := strings.Cut(strings.TrimSpace(part), "=") + if !ok || name == "" { + continue + } + pairs[name] = value + } + if len(pairs) == 0 { + return nil, fmt.Errorf("the pasted curl command's Cookie header is empty") + } + return pairs, nil +} + +func fromCookiesTxt(content string) (map[string]string, error) { + pairs := map[string]string{} + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + // "#HttpOnly_" is a record, not a comment. + line = strings.TrimPrefix(line, httpOnlyPrefix) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + f := strings.Split(line, "\t") + if len(f) < 7 { + continue + } + if !hostMatches(f[0]) { + continue + } + pairs[f[5]] = f[6] + } + if len(pairs) == 0 { + return nil, fmt.Errorf("no %s cookies found (is this a cookies.txt export for the right site?)", cookieDomain) + } + return pairs, nil +} + +func hostMatches(host string) bool { + host = strings.TrimPrefix(strings.TrimSpace(host), ".") + return host == cookieDomain || strings.HasSuffix(host, "."+cookieDomain) +} + +// join renders a deterministic header so two runs with the same session produce +// byte-identical requests. +func join(pairs map[string]string) string { + names := make([]string, 0, len(pairs)) + for n := range pairs { + names = append(names, n) + } + sort.Strings(names) + var b strings.Builder + for i, n := range names { + if i > 0 { + b.WriteString("; ") + } + b.WriteString(n) + b.WriteByte('=') + b.WriteString(pairs[n]) + } + return b.String() +} + +// WithCSRF returns header with its _csrf cookie replaced by token, adding it when +// absent. A pasted session usually carries a stale _csrf, and sending two would leave +// the store matching the header against whichever it picked. +func WithCSRF(header, token string) string { + pairs := map[string]string{} + for _, part := range strings.Split(header, ";") { + name, value, ok := strings.Cut(strings.TrimSpace(part), "=") + if !ok || name == "" { + continue + } + pairs[name] = value + } + pairs["_csrf"] = token + return join(pairs) +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go new file mode 100644 index 0000000..aff2766 --- /dev/null +++ b/internal/session/session_test.go @@ -0,0 +1,156 @@ +package session_test + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/curbol/unity-sync/internal/session" +) + +// Payloads are inlined rather than kept as testdata files: the repo's .gitignore matches +// *.curl and cookies.txt at any depth, so file fixtures would be silently uncommitted +// and CI would fail on missing testdata. +const curlPaste = `curl 'https://assetstore.unity.com/api/graphql/batch' \ + -H 'accept: application/json' \ + -H 'Cookie: _csrf=stale; LS=the-credential; DS=abc; activeOrgId=123' \ + --data-raw '[]'` + +// A real export marks HttpOnly rows with a #HttpOnly_ prefix on the record itself. +const cookiesTxt = "# Netscape HTTP Cookie File\n" + + "# This file was generated by a browser extension\n" + + "#HttpOnly_.unity.com\tTRUE\t/\tTRUE\t0\tLS\tthe-credential\n" + + "assetstore.unity.com\tFALSE\t/\tTRUE\t0\tDS\tabc\n" + + "example.com\tFALSE\t/\tFALSE\t0\tunrelated\tnope\n" + +func write(t *testing.T, name, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func TestResolveReadsAPastedCurlCommand(t *testing.T) { + got, err := session.Resolve(write(t, "session.curl", curlPaste)) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if !strings.Contains(got, "LS=the-credential") { + t.Errorf("header %q lost the credential", got) + } + if !strings.Contains(got, "DS=abc") { + t.Errorf("header %q dropped an ordinary cookie", got) + } +} + +// The credential is HttpOnly. A parser that treats every '#' line as a comment drops +// exactly the cookie that authenticates and then reports "no cookies found". +func TestCookiesTxtKeepsHttpOnlyRecords(t *testing.T) { + got, err := session.Resolve(write(t, "cookies.txt", cookiesTxt)) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if !strings.Contains(got, "LS=the-credential") { + t.Errorf("header %q dropped the #HttpOnly_ record", got) + } +} + +// The storefront sets some cookies host-only and others on Domain=.unity.com, and which +// scope LS uses was never established, so both must be accepted. +func TestCookiesTxtAcceptsTheWholeUnityFamilyAndNothingElse(t *testing.T) { + body := "#HttpOnly_assetstore.unity.com\tFALSE\t/\tTRUE\t0\tLS\thost-only\n" + + ".unity.com\tTRUE\t/\tFALSE\t0\twide\tyes\n" + + "evil.com\tFALSE\t/\tFALSE\t0\tleaked\tno\n" + got, err := session.Resolve(write(t, "cookies.txt", body)) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + for _, want := range []string{"LS=host-only", "wide=yes"} { + if !strings.Contains(got, want) { + t.Errorf("header %q is missing %q", got, want) + } + } + if strings.Contains(got, "leaked") { + t.Errorf("header %q carries a cookie from an unrelated site", got) + } +} + +func TestMissingCredentialIsNamedBeforeAnyRequest(t *testing.T) { + body := "assetstore.unity.com\tFALSE\t/\tTRUE\t0\tDS\tabc\n" + _, err := session.Resolve(write(t, "cookies.txt", body)) + var missing *session.ErrNoCredential + if !errors.As(err, &missing) { + t.Fatalf("Resolve = %v, want ErrNoCredential", err) + } + if !strings.Contains(err.Error(), "LS") { + t.Errorf("diagnostic %q does not name the missing cookie", err) + } +} + +// An export whose header comment mentions curl must still parse as a cookies.txt. +func TestCurlDetectionUsesStructureNotTheWord(t *testing.T) { + body := "# Generated for use with curl\n" + + "#HttpOnly_.unity.com\tTRUE\t/\tTRUE\t0\tLS\tthe-credential\n" + if _, err := session.Resolve(write(t, "cookies.txt", body)); err != nil { + t.Fatalf("Resolve: %v", err) + } +} + +func TestHeaderIsDeterministic(t *testing.T) { + p := write(t, "session.curl", curlPaste) + first, err := session.Resolve(p) + if err != nil { + t.Fatal(err) + } + for range 5 { + again, err := session.Resolve(p) + if err != nil { + t.Fatal(err) + } + if again != first { + t.Fatalf("header varies between reads:\n%q\n%q", first, again) + } + } +} + +func TestWithCSRFReplacesRatherThanAppends(t *testing.T) { + got := session.WithCSRF("LS=cred; _csrf=stale; DS=abc", "fresh") + if strings.Count(got, "_csrf=") != 1 { + t.Errorf("header %q has %d _csrf cookies, want exactly 1", got, strings.Count(got, "_csrf=")) + } + if !strings.Contains(got, "_csrf=fresh") { + t.Errorf("header %q kept the stale token", got) + } + if !strings.Contains(got, "LS=cred") { + t.Errorf("header %q lost the credential", got) + } + added := session.WithCSRF("LS=cred", "fresh") + if !strings.Contains(added, "_csrf=fresh") { + t.Errorf("header %q did not gain a token when none was present", added) + } +} + +func TestDiscoverPrefersSessionCurl(t *testing.T) { + dir := t.TempDir() + if _, ok := session.Discover(dir); ok { + t.Fatal("Discover found a session in an empty dir") + } + if err := os.WriteFile(filepath.Join(dir, "cookies.txt"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + got, ok := session.Discover(dir) + if !ok || filepath.Base(got) != "cookies.txt" { + t.Fatalf("Discover = %q, %v; want the cookies.txt", got, ok) + } + if err := os.WriteFile(filepath.Join(dir, "session.curl"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + got, _ = session.Discover(dir) + if filepath.Base(got) != "session.curl" { + t.Errorf("Discover = %q, want session.curl to win", got) + } +} From 3f32f84960f6dae739324993842c933b34da1c11 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 11:36:17 -0700 Subject: [PATCH 07/28] Talk to the store, refusing every response that only looks like a package 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- internal/store/store.go | 452 +++++++++++++++++++++++++++++++++++ internal/store/store_test.go | 357 +++++++++++++++++++++++++++ 2 files changed, 809 insertions(+) create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..112b291 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,452 @@ +// Package store is the Asset Store client: the batched GraphQL endpoint that lists what +// an account owns, and the endpoint that serves package bytes. It owns the +// response-level guards — refusing redirects, refusing a re-encoded body, checking the +// content type — and hands the body to the caller unbuffered, because a package here can +// be 23 GB. The checks that need the enumeration metadata belong to the syncer. +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "strconv" + "strings" + "time" + + "github.com/curbol/unity-sync/internal/model" + "github.com/curbol/unity-sync/internal/retry" +) + +const ( + defaultBase = "https://assetstore.unity.com" + + // csrfRoute issues the _csrf cookie. Not every storefront path does: "/" and + // "/publishers/{id}" answer 200 and set nothing, while this one answers 404 and + // sets the token. The uncached 404 routes are the ones that issue it. + csrfRoute = "/packages" + + graphQLPath = "/api/graphql/batch" + downloadPath = "/api/downloads/" + + pageSize = 100 +) + +var ( + // ErrExpiredSession is the one failure a user can act on. The store signals it two + // different ways: an empty GraphqlError inside an HTTP 500 on the API, and a 302 to + // the OAuth authorize URL on the download endpoint. + ErrExpiredSession = errors.New("session expired or missing; re-copy it from a signed-in browser") + + // ErrCSRF means the double-submit token did not match, i.e. the bootstrap failed. + ErrCSRF = errors.New("csrf token mismatch") + + // ErrNotDownloadable means the store has no bytes for this product any more. It is + // permanent: no re-run changes it. + ErrNotDownloadable = errors.New("asset is not downloadable") +) + +// searchDocument is pinned. Its field set is the tool's contract with the store, and it +// deliberately omits the per-row entitlement id and every other account-identifying +// field the API would return if asked. currentVersion.id is mandatory: it is the diff +// key, and losing it silently would break every classification. +const searchDocument = `query SearchMyAssets($page: Int, $pageSize: Int, $ids: [String!]) { + searchMyAssets(page: $page, pageSize: $pageSize, ids: $ids) { + total + results { + product { + id + name + state + downloadSize + currentVersion { id name publishedDate } + publisher { id name } + mainImage { icon75 } + } + } + } +}` + +// Client talks to one Asset Store host. +type Client struct { + http *http.Client + base string + cookie string + csrf string + agent string + retries retry.Policy +} + +// Option adjusts a Client for tests. +type Option func(*Client) + +// WithBaseURL points the client at a test server. +func WithBaseURL(u string) Option { return func(c *Client) { c.base = strings.TrimSuffix(u, "/") } } + +// WithRetryPolicy replaces the backoff policy, so tests need not sleep. +func WithRetryPolicy(p retry.Policy) Option { return func(c *Client) { c.retries = p } } + +// WithResponseHeaderTimeout shortens the header deadline so a test can prove the +// difference between bounding the headers and bounding the whole transfer. +func WithResponseHeaderTimeout(d time.Duration) Option { + return func(c *Client) { + c.http.Transport.(*http.Transport).ResponseHeaderTimeout = d + } +} + +// New builds a client for the given session Cookie header. +// +// The transport sets a response-header timeout rather than a whole-request timeout: a +// 23 GB body legitimately takes a long time, and a request deadline would kill it, while +// a server that never answers still needs bounding. +func New(cookieHeader, version string, opts ...Option) *Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.ResponseHeaderTimeout = 60 * time.Second + c := &Client{ + http: &http.Client{ + Transport: transport, + // No store request may follow a redirect: an unauthenticated download 302s + // to Unity's OAuth page, and following it would write a sign-in page into + // the cache under a .unitypackage name. + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }, + base: defaultBase, + cookie: cookieHeader, + agent: "unity-sync/" + version, + retries: retry.DefaultPolicy(), + } + for _, o := range opts { + o(c) + } + return c +} + +// Bootstrap obtains the _csrf token the GraphQL endpoint requires and folds it into the +// cookie header. Its own route answers 404 by design, so a non-2xx here is normal and a +// 3xx is not the expired-session signal it is everywhere else — but a response that +// issues no token is a hard failure, since proceeding guarantees ErrCSRF. +func (c *Client) Bootstrap(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+csrfRoute, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", c.agent) + req.Header.Set("Accept", "text/html,*/*") + req.Header.Set("Cookie", c.cookie) + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("csrf bootstrap: %w", err) + } + defer drain(resp) + + for _, ck := range resp.Cookies() { + if ck.Name == "_csrf" && ck.Value != "" { + c.csrf = ck.Value + c.cookie = withCSRF(c.cookie, ck.Value) + return nil + } + } + return fmt.Errorf("csrf bootstrap: %s issued no _csrf cookie (status %d)", csrfRoute, resp.StatusCode) +} + +// Enumerate walks every page of owned assets. It compares the raw row count against the +// store's own total *before* deduplicating, so a duplicate entitlement row cannot look +// like a short page, then dedups by product id. +func (c *Client) Enumerate(ctx context.Context) ([]model.Asset, error) { + var ( + assets []model.Asset + seen = map[string]bool{} + rawRows int + total = -1 + ) + for page := 0; ; page++ { + res, err := c.search(ctx, map[string]any{"page": page, "pageSize": pageSize}) + if err != nil { + return nil, err + } + total = res.Total + if len(res.Results) == 0 { + break + } + rawRows += len(res.Results) + for _, row := range res.Results { + a, err := row.Product.asset() + if err != nil { + return nil, fmt.Errorf("page %d: %w", page, err) + } + if seen[a.ID] { + continue + } + seen[a.ID] = true + assets = append(assets, a) + } + } + if total >= 0 && rawRows != total { + return nil, fmt.Errorf("enumeration collected %d rows but the store reports %d owned; "+ + "refusing to treat a short walk as the truth", rawRows, total) + } + return assets, nil +} + +// Lookup re-reads one product through the same pinned document, using the ids filter. It +// is the discriminator for a short download: if the advertised version or size has moved +// since enumeration, the publisher republished mid-transfer. +func (c *Client) Lookup(ctx context.Context, id string) (model.Asset, bool, error) { + res, err := c.search(ctx, map[string]any{"page": 0, "pageSize": 1, "ids": []string{id}}) + if err != nil { + return model.Asset{}, false, err + } + for _, row := range res.Results { + if row.Product.ID == id { + a, err := row.Product.asset() + return a, err == nil, err + } + } + return model.Asset{}, false, nil +} + +type searchResult struct { + Total int `json:"total"` + Results []struct { + Product product `json:"product"` + } `json:"results"` +} + +type product struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + DownloadSize string `json:"downloadSize"` + CurrentVersion struct { + ID string `json:"id"` + Name string `json:"name"` + PublishedDate string `json:"publishedDate"` + } `json:"currentVersion"` + Publisher struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"publisher"` + MainImage struct { + Icon75 string `json:"icon75"` + } `json:"mainImage"` +} + +// asset converts a decoded row, refusing anything the diff depends on being absent +// rather than defaulting it. +func (p product) asset() (model.Asset, error) { + if p.ID == "" { + return model.Asset{}, fmt.Errorf("product row has no id") + } + if p.CurrentVersion.ID == "" { + return model.Asset{}, fmt.Errorf("product %s has no currentVersion.id, which is the diff key", p.ID) + } + size, err := strconv.ParseInt(p.DownloadSize, 10, 64) + if err != nil && p.DownloadSize != "" { + return model.Asset{}, fmt.Errorf("product %s has unparseable downloadSize %q: %w", p.ID, p.DownloadSize, err) + } + return model.Asset{ + ID: p.ID, + Name: p.Name, + State: model.State(p.State), + Publisher: model.Publisher{ID: p.Publisher.ID, Name: p.Publisher.Name}, + Version: model.Version{ + ID: p.CurrentVersion.ID, + Name: p.CurrentVersion.Name, + PublishedDate: p.CurrentVersion.PublishedDate, + }, + AdvertisedSize: size, + ThumbnailURL: p.MainImage.Icon75, + }, nil +} + +type graphQLError struct { + ErrorCode string `json:"errorCode"` + Message string `json:"message"` +} + +func (c *Client) search(ctx context.Context, vars map[string]any) (searchResult, error) { + var out searchResult + err := retry.Do(ctx, c.retries, func(int) error { + res, err := c.searchOnce(ctx, vars) + if err != nil { + return err + } + out = res + return nil + }) + return out, err +} + +func (c *Client) searchOnce(ctx context.Context, vars map[string]any) (searchResult, error) { + body, err := json.Marshal([]map[string]any{{ + "query": searchDocument, + "variables": vars, + "operationName": "SearchMyAssets", + }}) + if err != nil { + return searchResult{}, retry.Permanent(err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+graphQLPath, strings.NewReader(string(body))) + if err != nil { + return searchResult{}, retry.Permanent(err) + } + req.Header.Set("User-Agent", c.agent) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json;charset=UTF-8") + req.Header.Set("Origin", c.base) + req.Header.Set("Referer", c.base+"/") + // Without this header the store answers a failed call with a 302 to an HTML error + // page instead of the JSON error that carries the diagnosis. + req.Header.Set("X-Requested-With", "XMLHttpRequest") + req.Header.Set("X-Source", "storefront") + req.Header.Set("Operations", "SearchMyAssets") + req.Header.Set("Accept-Encoding", "identity") + req.Header.Set("X-Csrf-Token", c.csrf) + req.Header.Set("Cookie", c.cookie) + + resp, err := c.http.Do(req) + if err != nil { + return searchResult{}, err + } + defer drain(resp) + + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + return searchResult{}, retry.Permanent(ErrExpiredSession) + } + payload, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil { + return searchResult{}, err + } + if resp.StatusCode == http.StatusBadRequest && strings.Contains(string(payload), "csrf token mismatch") { + return searchResult{}, retry.Permanent(ErrCSRF) + } + + var batch []struct { + Data struct { + Search *searchResult `json:"searchMyAssets"` + } `json:"data"` + Errors []graphQLError `json:"errors"` + } + if err := json.Unmarshal(payload, &batch); err != nil { + if retry.Retryable(resp.StatusCode) { + return searchResult{}, fmt.Errorf("status %d with a non-JSON body", resp.StatusCode) + } + return searchResult{}, retry.Permanent(fmt.Errorf("status %d with a non-JSON body: %w", resp.StatusCode, err)) + } + if len(batch) == 0 { + return searchResult{}, retry.Permanent(fmt.Errorf("empty GraphQL batch response")) + } + op := batch[0] + if len(op.Errors) > 0 { + // A missing or invalid credential arrives here as a 500 whose single error has + // an empty message. Every other error shape is reported as itself, and never as + // "you own nothing". + if resp.StatusCode == http.StatusInternalServerError && op.Errors[0].Message == "" { + return searchResult{}, retry.Permanent(ErrExpiredSession) + } + return searchResult{}, retry.Permanent(fmt.Errorf("store returned %s: %q", + op.Errors[0].ErrorCode, op.Errors[0].Message)) + } + if op.Data.Search == nil { + return searchResult{}, retry.Permanent(fmt.Errorf("response carries neither data nor errors")) + } + return *op.Data.Search, nil +} + +// Download is an open package body plus what the store called it. +type Download struct { + Body io.ReadCloser + + // Filename is parsed from Content-Disposition, which the store does not always + // send. It is recorded in the lockfile for reference and never determines a path. + Filename string +} + +// Fetch opens the package stream for one product, applying the response-level guards. +// The caller owns Body and must close it. +func (c *Client) Fetch(ctx context.Context, id string) (*Download, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+downloadPath+id, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", c.agent) + req.Header.Set("Accept", "*/*") + req.Header.Set("Referer", c.base+"/") + req.Header.Set("Cookie", c.cookie) + // Asking for identity is not hygiene: the endpoint honours Accept-Encoding: gzip by + // gzipping the already-gzipped package, and Go does not transparently decode an + // encoding the caller requested, so the cache would receive a double-gzipped blob + // with no readable metadata. + req.Header.Set("Accept-Encoding", "identity") + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + drain(resp) + return nil, ErrExpiredSession + } + if resp.StatusCode == http.StatusNotFound { + drain(resp) + return nil, ErrNotDownloadable + } + if resp.StatusCode != http.StatusOK { + drain(resp) + return nil, fmt.Errorf("download %s: status %d", id, resp.StatusCode) + } + if enc := resp.Header.Get("Content-Encoding"); enc != "" { + drain(resp) + return nil, fmt.Errorf("download %s: server re-encoded the body as %q despite identity", id, enc) + } + if err := checkOctetStream(resp.Header.Get("Content-Type")); err != nil { + drain(resp) + return nil, fmt.Errorf("download %s: %w", id, err) + } + return &Download{Body: resp.Body, Filename: dispositionFilename(resp.Header.Get("Content-Disposition"))}, nil +} + +func checkOctetStream(header string) error { + if header == "" { + return fmt.Errorf("response has no Content-Type") + } + mt, _, err := mime.ParseMediaType(header) + if err != nil { + return fmt.Errorf("unparseable Content-Type %q: %w", header, err) + } + if mt != "application/octet-stream" { + return fmt.Errorf("Content-Type is %q, not a package body", mt) + } + return nil +} + +func dispositionFilename(header string) string { + if header == "" { + return "" + } + _, params, err := mime.ParseMediaType(header) + if err != nil { + return "" + } + return params["filename"] +} + +func withCSRF(header, token string) string { + var kept []string + for _, part := range strings.Split(header, ";") { + part = strings.TrimSpace(part) + if part == "" || strings.HasPrefix(part, "_csrf=") { + continue + } + kept = append(kept, part) + } + return strings.Join(append(kept, "_csrf="+token), "; ") +} + +func drain(resp *http.Response) { + io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) + resp.Body.Close() +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..b782e98 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,357 @@ +package store_test + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/curbol/unity-sync/internal/retry" + "github.com/curbol/unity-sync/internal/store" +) + +const testCookie = "LS=cred; DS=abc" + +func fastRetries() store.Option { + return store.WithRetryPolicy(retry.Policy{Attempts: 2, Base: time.Millisecond, Sleep: func(time.Duration) {}}) +} + +// serve wires a handler and returns a bootstrapped client pointed at it. +func serve(t *testing.T, h http.HandlerFunc, opts ...store.Option) (*store.Client, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + opts = append([]store.Option{store.WithBaseURL(srv.URL), fastRetries()}, opts...) + return store.New(testCookie, "test", opts...), srv +} + +// csrfRouter answers the bootstrap route the way the real store does — 404 with a +// Set-Cookie — and delegates everything else. +func csrfRouter(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/packages" { + http.SetCookie(w, &http.Cookie{Name: "_csrf", Value: "issued-token", Path: "/"}) + w.WriteHeader(http.StatusNotFound) + return + } + next(w, r) + } +} + +func fixture(t *testing.T, name string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "testdata", "store", name)) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + return string(raw) +} + +func TestBootstrapAcceptsA404AndRequiresAToken(t *testing.T) { + c, _ := serve(t, csrfRouter(func(w http.ResponseWriter, r *http.Request) {})) + if err := c.Bootstrap(context.Background()); err != nil { + t.Fatalf("Bootstrap on a 404-that-sets-the-cookie: %v", err) + } + + // A response that issues no token must fail here rather than guaranteeing ErrCSRF later. + silent, _ := serve(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) }) + if err := silent.Bootstrap(context.Background()); err == nil { + t.Error("Bootstrap accepted a response that issued no _csrf cookie") + } +} + +// Everywhere else a 3xx means the session died. The bootstrap route is exempt: it is a +// storefront page, and a locale redirect there says nothing about the session. +func TestBootstrapIsExemptFromTheRedirectRule(t *testing.T) { + c, _ := serve(t, func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: "_csrf", Value: "issued-token", Path: "/"}) + w.Header().Set("Location", "/elsewhere") + w.WriteHeader(http.StatusFound) + }) + if err := c.Bootstrap(context.Background()); err != nil { + t.Errorf("Bootstrap on a 3xx that still issued a token: %v", err) + } +} + +func TestEnumerateWalksTheRealFixturesAndDedups(t *testing.T) { + pages := []string{ + fixture(t, "my_assets_p0.json"), + fixture(t, "my_assets_p1.json"), + fixture(t, "my_assets_p2.json"), + } + c, _ := serve(t, csrfRouter(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + page := 0 + switch { + case strings.Contains(string(body), `"page":1`): + page = 1 + case strings.Contains(string(body), `"page":2`): + page = 2 + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, pages[page]) + })) + if err := c.Bootstrap(context.Background()); err != nil { + t.Fatal(err) + } + + assets, err := c.Enumerate(context.Background()) + if err != nil { + t.Fatalf("Enumerate: %v", err) + } + if len(assets) != 176 { + t.Fatalf("got %d assets, want 176", len(assets)) + } + seen := map[string]bool{} + for _, a := range assets { + if seen[a.ID] { + t.Errorf("duplicate product id %s", a.ID) + } + seen[a.ID] = true + if a.Version.ID == "" { + t.Errorf("asset %s decoded without a version id", a.ID) + } + } + if !seen["115488"] { + t.Error("known product 115488 missing from the enumeration") + } +} + +func TestEnumerateRefusesAWalkShorterThanTheStoresOwnTotal(t *testing.T) { + c, _ := serve(t, csrfRouter(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + if strings.Contains(string(body), `"page":0`) { + io.WriteString(w, `[{"data":{"searchMyAssets":{"total":50,"results":[ + {"product":{"id":"1","name":"A","state":"published","downloadSize":"10", + "currentVersion":{"id":"9","name":"1.0"},"publisher":{"id":"p","name":"P"}}}]}}}]`) + return + } + io.WriteString(w, `[{"data":{"searchMyAssets":{"total":50,"results":[]}}}]`) + })) + c.Bootstrap(context.Background()) + if _, err := c.Enumerate(context.Background()); err == nil { + t.Error("Enumerate accepted 1 row against a reported total of 50") + } +} + +func TestStrictParsingRejectsAMissingVersionId(t *testing.T) { + c, _ := serve(t, csrfRouter(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `[{"data":{"searchMyAssets":{"total":1,"results":[ + {"product":{"id":"1","name":"A","state":"published","downloadSize":"10", + "currentVersion":{"name":"1.0"},"publisher":{"id":"p","name":"P"}}}]}}}]`) + })) + c.Bootstrap(context.Background()) + _, err := c.Enumerate(context.Background()) + if err == nil || !strings.Contains(err.Error(), "currentVersion.id") { + t.Errorf("Enumerate = %v, want a complaint about the missing diff key", err) + } +} + +func TestErrorMapping(t *testing.T) { + cases := []struct { + name string + handler http.HandlerFunc + want error + }{ + {"csrf mismatch", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, "csrf token mismatch") + }, store.ErrCSRF}, + {"expired session as an empty GraphqlError", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + io.WriteString(w, `[{"data":null,"errors":[{"errorCode":"GraphqlError","message":""}]}]`) + }, store.ErrExpiredSession}, + {"expired session as a redirect", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "/errors/unexpected") + w.WriteHeader(http.StatusFound) + }, store.ErrExpiredSession}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, _ := serve(t, csrfRouter(tc.handler)) + c.Bootstrap(context.Background()) + _, err := c.Enumerate(context.Background()) + if !errors.Is(err, tc.want) { + t.Errorf("Enumerate = %v, want %v", err, tc.want) + } + }) + } +} + +// A populated errors array with a 200 must never read as "you own nothing", which on a +// first run would look like a legitimate empty library. +func TestPopulatedErrorsArrayIsNotAnEmptyLibrary(t *testing.T) { + c, _ := serve(t, csrfRouter(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `[{"data":null,"errors":[{"errorCode":"Throttled","message":"slow down"}]}]`) + })) + c.Bootstrap(context.Background()) + assets, err := c.Enumerate(context.Background()) + if err == nil { + t.Fatalf("Enumerate = %d assets, nil error; want an error", len(assets)) + } + if !strings.Contains(err.Error(), "Throttled") { + t.Errorf("error %v does not report what the store said", err) + } +} + +func TestNonJSONSuccessIsAnError(t *testing.T) { + c, _ := serve(t, csrfRouter(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "sign in") + })) + c.Bootstrap(context.Background()) + if _, err := c.Enumerate(context.Background()); err == nil { + t.Error("Enumerate parsed an HTML body as a result set") + } +} + +// Every one of these headers is invisible when missing: the request still succeeds +// against a permissive server, so only an assertion catches an omission. +func TestClientSendsTheHeadersTheStoreNeeds(t *testing.T) { + var got http.Header + var body string + c, _ := serve(t, csrfRouter(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Clone() + raw, _ := io.ReadAll(r.Body) + body = string(raw) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `[{"data":{"searchMyAssets":{"total":0,"results":[]}}}]`) + })) + if err := c.Bootstrap(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := c.Enumerate(context.Background()); err != nil { + t.Fatal(err) + } + + for header, want := range map[string]string{ + "X-Requested-With": "XMLHttpRequest", + "Accept-Encoding": "identity", + "User-Agent": "unity-sync/test", + "X-Csrf-Token": "issued-token", + } { + if got.Get(header) != want { + t.Errorf("%s = %q, want %q", header, got.Get(header), want) + } + } + if cookie := got.Get("Cookie"); !strings.Contains(cookie, "_csrf=issued-token") { + t.Errorf("Cookie %q does not carry the token the header claims", cookie) + } + if strings.Count(got.Get("Cookie"), "_csrf=") != 1 { + t.Errorf("Cookie %q has more than one _csrf", got.Get("Cookie")) + } + // Losing this field from the document would break every classification silently. + if !strings.Contains(body, `currentVersion { id name publishedDate }`) { + t.Error("the pinned query no longer requests currentVersion.id") + } +} + +func TestFetchGuardsTheResponseBeforeAnyBytesAreKept(t *testing.T) { + pkg := "\x1f\x8b\x08\x00rest-of-a-package" + cases := []struct { + name string + handler http.HandlerFunc + wantErr error + wantSub string + }{ + {"re-encoded body", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Encoding", "gzip") + io.WriteString(w, pkg) + }, nil, "re-encoded"}, + {"wrong content type", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + io.WriteString(w, "") + }, nil, "Content-Type"}, + {"redirect to sign-in", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "https://api.unity.com/v1/oauth2/authorize") + w.WriteHeader(http.StatusFound) + }, store.ErrExpiredSession, ""}, + {"pulled asset", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, store.ErrNotDownloadable, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, _ := serve(t, tc.handler) + _, err := c.Fetch(context.Background(), "115488") + if err == nil { + t.Fatal("Fetch accepted a response it should have refused") + } + if tc.wantErr != nil && !errors.Is(err, tc.wantErr) { + t.Errorf("Fetch = %v, want %v", err, tc.wantErr) + } + if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) { + t.Errorf("Fetch = %v, want it to mention %q", err, tc.wantSub) + } + }) + } +} + +func TestFetchReturnsTheBodyAndTheStoresFilename(t *testing.T) { + c, _ := serve(t, func(w http.ResponseWriter, r *http.Request) { + if enc := r.Header.Get("Accept-Encoding"); enc != "identity" { + t.Errorf("download sent Accept-Encoding %q; gzip makes the store double-gzip the package", enc) + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", `attachment; filename="Quick Outline.unitypackage"`) + io.WriteString(w, "\x1f\x8b\x08\x04payload") + }) + dl, err := c.Fetch(context.Background(), "115488") + if err != nil { + t.Fatalf("Fetch: %v", err) + } + defer dl.Body.Close() + if dl.Filename != "Quick Outline.unitypackage" { + t.Errorf("Filename = %q", dl.Filename) + } + got, _ := io.ReadAll(dl.Body) + if !strings.HasPrefix(string(got), "\x1f\x8b") { + t.Errorf("body = %q, want the package bytes untouched", got) + } +} + +// The whole point of a response-header timeout is that a slow *body* is legitimate — a +// 23 GB package takes a while — while a server that never answers is not. Against +// kilobyte fixtures the two policies are indistinguishable, so this pins them apart. +func TestSlowBodyIsAllowedButSlowHeadersAreNot(t *testing.T) { + slowBody, _ := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + for range 4 { + time.Sleep(30 * time.Millisecond) + io.WriteString(w, "chunk") + w.(http.Flusher).Flush() + } + }, store.WithResponseHeaderTimeout(50*time.Millisecond)) + dl, err := slowBody.Fetch(context.Background(), "1") + if err != nil { + t.Fatalf("Fetch with a slow body: %v — a whole-request timeout would kill real downloads", err) + } + body, err := io.ReadAll(dl.Body) + dl.Body.Close() + if err != nil { + t.Fatalf("reading a slow body: %v", err) + } + if len(body) != 20 { + t.Errorf("read %d bytes, want 20", len(body)) + } + + slowHeaders, _ := serve(t, func(w http.ResponseWriter, r *http.Request) { + time.Sleep(300 * time.Millisecond) + w.Header().Set("Content-Type", "application/octet-stream") + }, store.WithResponseHeaderTimeout(50*time.Millisecond)) + if _, err := slowHeaders.Fetch(context.Background(), "1"); err == nil { + t.Error("Fetch waited indefinitely for headers") + } +} From 182e2972611a1006ef44e88b5c5ed8fdc4e1d1b7 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 11:38:38 -0700 Subject: [PATCH 08/28] Write to the cache in two phases so nothing unverified holds a real path 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- internal/cache/cache.go | 340 +++++++++++++++++++++++++++++++++++ internal/cache/cache_test.go | 292 ++++++++++++++++++++++++++++++ 2 files changed, 632 insertions(+) create mode 100644 internal/cache/cache.go create mode 100644 internal/cache/cache_test.go diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..d914c6a --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,340 @@ +// Package cache is the local package mirror. Its layout is three segments deep — +// //.unitypackage — because quarry derives 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 would index every package with +// both facets empty. +// +// Writes are two-phase on purpose: Store leaves the bytes in a temp file so the caller's +// semantic checks run before Commit renames anything into place. Nothing unverified ever +// occupies a real cache path, even briefly, because an interrupt in that window would +// strand a rejected body where the next run's adopt scan would take it for genuine. +package cache + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/curbol/unity-sync/internal/unitypackage" +) + +// tempPrefix marks an in-flight download. The sweep looks for it; the adopt scan +// deliberately does not consider it. +const tempPrefix = ".unity-sync-dl-" + +const packageExt = ".unitypackage" + +// RelPath is an asset's location relative to the library root, in forward slashes so the +// value is portable in a committed lockfile. +func RelPath(publisherSlug, assetSlug string) string { + return path.Join(publisherSlug, assetSlug, assetSlug+packageExt) +} + +// safeSegment rejects anything that is not a single, ordinary path element. Both slugs +// are derived from store-supplied names, so neither is trusted to be a bare word. +func safeSegment(kind, s string) error { + if s == "" || s == "." || s == ".." || strings.ContainsAny(s, `/\`) || strings.HasPrefix(s, ".") { + return fmt.Errorf("unsafe %s %q", kind, s) + } + for _, r := range s { + if r < 0x20 || r == 0x7f { + return fmt.Errorf("unsafe %s %q: contains a control character", kind, s) + } + } + return nil +} + +// resolve turns a lockfile-supplied relative path into an absolute one, refusing +// anything that would leave the library root. These values arrive from a file that is +// committed and travels between machines, so they are validated rather than trusted. +func resolve(root, rel string) (string, error) { + if rel == "" || path.IsAbs(rel) || filepath.IsAbs(rel) { + return "", fmt.Errorf("unsafe cache path %q", rel) + } + clean := filepath.Clean(filepath.FromSlash(rel)) + if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("unsafe cache path %q", rel) + } + return filepath.Join(root, clean), nil +} + +// Pending is a fully-written but uncommitted download. +type Pending struct { + RelPath string + SHA256 string + Size int64 + + tempPath string + final string +} + +// TempPath is where the bytes currently are, so the caller can inspect them before +// deciding to commit. +func (p *Pending) TempPath() string { return p.tempPath } + +// Store streams r into a temp file beside its eventual destination, hashing as it goes. +// It does not rename: the caller commits or discards. +func Store(root, publisherSlug, assetSlug string, r io.Reader) (*Pending, error) { + if err := safeSegment("publisher slug", publisherSlug); err != nil { + return nil, err + } + if err := safeSegment("asset slug", assetSlug); err != nil { + return nil, err + } + rel := RelPath(publisherSlug, assetSlug) + dir := filepath.Join(root, publisherSlug, assetSlug) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + tmp, err := os.CreateTemp(dir, tempPrefix+"*") + if err != nil { + return nil, err + } + h := sha256.New() + size, err := io.Copy(io.MultiWriter(tmp, h), r) + if err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return nil, err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return nil, err + } + if err := tmp.Close(); err != nil { + os.Remove(tmp.Name()) + return nil, err + } + return &Pending{ + RelPath: rel, + SHA256: hex.EncodeToString(h.Sum(nil)), + Size: size, + tempPath: tmp.Name(), + final: filepath.Join(dir, assetSlug+packageExt), + }, nil +} + +// Commit renames the pending bytes into place. +func (p *Pending) Commit() error { + if err := os.Rename(p.tempPath, p.final); err != nil { + os.Remove(p.tempPath) + return err + } + return nil +} + +// Discard removes the pending bytes. Callers use it whenever a check fails, so a +// rejected body never reaches a real cache path. +func (p *Pending) Discard() error { + err := os.Remove(p.tempPath) + if os.IsNotExist(err) { + return nil + } + return err +} + +// Verify is the cheap check: the file exists, its size is exactly what was recorded, and +// — when a delivered version id was recorded — its own metadata still says so. Exact +// recorded size is what makes truncation detectable without hashing, since the metadata +// block sits in the leading bytes and survives a truncation. +// +// An entry with no delivered id is verified on size alone. Requiring a metadata match +// there would make a package that simply has no descriptor re-download on every run. +func Verify(root, rel string, wantSize int64, wantDeliveredID string) bool { + full, err := resolve(root, rel) + if err != nil { + return false + } + fi, err := os.Stat(full) + if err != nil || fi.Size() != wantSize { + return false + } + if wantDeliveredID == "" { + return true + } + m, err := unitypackage.ReadFile(full) + if err != nil { + return false + } + return m.VersionID == wantDeliveredID +} + +// VerifyDeep re-hashes the file. It is opt-in because the library runs to tens of +// gigabytes, and it is the only check that sees a mid-file corruption. +func VerifyDeep(root, rel, wantSHA string) bool { + full, err := resolve(root, rel) + if err != nil { + return false + } + f, err := os.Open(full) + if err != nil { + return false + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return false + } + return hex.EncodeToString(h.Sum(nil)) == wantSHA +} + +// Hash returns a cached file's digest and size, for adopting a file the tool did not +// download itself. +func Hash(root, rel string) (string, int64, error) { + full, err := resolve(root, rel) + if err != nil { + return "", 0, err + } + f, err := os.Open(full) + if err != nil { + return "", 0, err + } + defer f.Close() + h := sha256.New() + size, err := io.Copy(h, f) + if err != nil { + return "", 0, err + } + return hex.EncodeToString(h.Sum(nil)), size, nil +} + +// Candidate is a package found on disk during an adopt scan. +type Candidate struct { + RelPath string + Size int64 + Metadata unitypackage.Metadata +} + +// Locate scans the library for a package whose own metadata claims the given product id. +// It scans rather than probing the derived path because the whole point of adoption is a +// file that is not where the current layout would put it — after a rename, say. +// +// Only files ending .unitypackage and not starting with a dot are considered, so an +// abandoned download temp can never be adopted: a partial can be large enough to clear a +// size floor while still carrying an intact descriptor. +// +// When several files claim the same product, the one already at preferRel wins, so an +// adopt that is really a no-op does not turn into a relocation conflict. +func Locate(root, productID, preferRel string) (Candidate, bool) { + var found []Candidate + filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + name := d.Name() + if strings.HasPrefix(name, ".") || !strings.HasSuffix(name, packageExt) { + return nil + } + m, err := unitypackage.ReadFile(p) + if err != nil || m.ID != productID { + return nil + } + fi, err := d.Info() + if err != nil { + return nil + } + rel, err := filepath.Rel(root, p) + if err != nil { + return nil + } + found = append(found, Candidate{RelPath: filepath.ToSlash(rel), Size: fi.Size(), Metadata: m}) + return nil + }) + if len(found) == 0 { + return Candidate{}, false + } + for _, c := range found { + if c.RelPath == preferRel { + return c, true + } + } + return found[0], true +} + +// Relocate moves a package to where the current layout puts it, creating parents and +// pruning directories the move empties. +// +// It is a no-op when the file is already there, and it refuses a destination holding a +// different file rather than renaming over it: the caller records the digest of whatever +// ends up at that path, so a silent overwrite would certify the wrong bytes. +func Relocate(root, fromRel, toRel string) error { + from, err := resolve(root, fromRel) + if err != nil { + return err + } + to, err := resolve(root, toRel) + if err != nil { + return err + } + if from == to { + return nil + } + if _, err := os.Stat(to); err == nil { + return fmt.Errorf("refusing to move %s onto %s: destination already holds a file", fromRel, toRel) + } else if !os.IsNotExist(err) { + return err + } + if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil { + return err + } + if err := os.Rename(from, to); err != nil { + return err + } + pruneEmptyParents(root, filepath.Dir(from)) + return nil +} + +// pruneEmptyParents removes directories the move emptied, walking up but never past the +// library root. +func pruneEmptyParents(root, dir string) { + for { + if dir == root || !strings.HasPrefix(dir, root+string(filepath.Separator)) { + return + } + entries, err := os.ReadDir(dir) + if err != nil || len(entries) > 0 { + return + } + if err := os.Remove(dir); err != nil { + return + } + dir = filepath.Dir(dir) + } +} + +// SweepTemps removes abandoned download temps anywhere in the tree, returning how many +// and how many bytes. It walks rather than scanning the root, because temps live beside +// their destinations, and it spares anything newer than the cutoff so a concurrent run's +// in-flight transfer survives. +func SweepTemps(root string, olderThan time.Time) (int, int64, error) { + var count int + var bytes int64 + err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() || !strings.HasPrefix(d.Name(), tempPrefix) { + return nil + } + fi, err := d.Info() + if err != nil || !fi.ModTime().Before(olderThan) { + return nil + } + if os.Remove(p) == nil { + count++ + bytes += fi.Size() + } + return nil + }) + if os.IsNotExist(err) { + return 0, 0, nil + } + return count, bytes, err +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..588b2b1 --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,292 @@ +package cache_test + +import ( + "bytes" + "compress/gzip" + "encoding/binary" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/curbol/unity-sync/internal/cache" +) + +// pkg builds a gzip stream carrying the store's descriptor, padded to size. +func pkg(t *testing.T, productID, versionID string, size int) []byte { + t.Helper() + descriptor := []byte(`{"id":"` + productID + `","version_id":"` + versionID + `"}`) + extra := []byte{'A', '$', 0, 0} + binary.LittleEndian.PutUint16(extra[2:4], uint16(len(descriptor))) + extra = append(extra, descriptor...) + + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + zw.Header.Extra = extra + zw.Write(bytes.Repeat([]byte("x"), 64)) + zw.Close() + out := buf.Bytes() + for len(out) < size { + out = append(out, 0) + } + return out +} + +func storeCommitted(t *testing.T, root, pub, asset string, body []byte) *cache.Pending { + t.Helper() + p, err := cache.Store(root, pub, asset, bytes.NewReader(body)) + if err != nil { + t.Fatalf("Store: %v", err) + } + if err := p.Commit(); err != nil { + t.Fatalf("Commit: %v", err) + } + return p +} + +func TestLayoutIsThreeSegmentsSoQuarryGetsBothFacets(t *testing.T) { + got := cache.RelPath("doublel", "quick-outline-115488") + want := "doublel/quick-outline-115488/quick-outline-115488.unitypackage" + if got != want { + t.Errorf("RelPath = %q, want %q", got, want) + } + if n := strings.Count(got, "/"); n != 2 { + t.Errorf("path has %d separators, want 2: quarry fills its pack facet only from a third segment", n+1) + } +} + +// The window between writing bytes and accepting them is where a rejected body would +// otherwise sit at a real cache path. +func TestStoreLeavesNothingAtTheRealPathUntilCommit(t *testing.T) { + root := t.TempDir() + body := pkg(t, "115488", "683375", 500) + p, err := cache.Store(root, "chris-nolet", "quick-outline-115488", bytes.NewReader(body)) + if err != nil { + t.Fatalf("Store: %v", err) + } + final := filepath.Join(root, filepath.FromSlash(p.RelPath)) + if _, err := os.Stat(final); !os.IsNotExist(err) { + t.Fatal("Store put bytes at the real path before they were checked") + } + if p.Size != int64(len(body)) { + t.Errorf("Size = %d, want %d", p.Size, len(body)) + } + if err := p.Commit(); err != nil { + t.Fatalf("Commit: %v", err) + } + if _, err := os.Stat(final); err != nil { + t.Fatalf("Commit did not place the file: %v", err) + } +} + +func TestDiscardRemovesTheTempAndLeavesNoFile(t *testing.T) { + root := t.TempDir() + p, err := cache.Store(root, "pub", "asset-1", bytes.NewReader(pkg(t, "1", "2", 300))) + if err != nil { + t.Fatal(err) + } + if err := p.Discard(); err != nil { + t.Fatalf("Discard: %v", err) + } + if _, err := os.Stat(p.TempPath()); !os.IsNotExist(err) { + t.Error("Discard left the temp file behind") + } + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(p.RelPath))); !os.IsNotExist(err) { + t.Error("Discard left a file at the real path") + } +} + +func TestVerifyUsesExactRecordedSizeAndMetadata(t *testing.T) { + root := t.TempDir() + body := pkg(t, "115488", "683375", 800) + p := storeCommitted(t, root, "pub", "quick-outline-115488", body) + + if !cache.Verify(root, p.RelPath, p.Size, "683375") { + t.Error("Verify rejected a file it just stored") + } + if cache.Verify(root, p.RelPath, p.Size+1, "683375") { + t.Error("Verify accepted a size that does not match the record") + } + if cache.Verify(root, p.RelPath, p.Size, "999999") { + t.Error("Verify accepted a file whose descriptor names another version") + } + + // Truncation is what the exact-size rule exists for: the descriptor sits in the + // leading bytes and survives it. + full := filepath.Join(root, filepath.FromSlash(p.RelPath)) + if err := os.Truncate(full, p.Size-100); err != nil { + t.Fatal(err) + } + if cache.Verify(root, p.RelPath, p.Size, "683375") { + t.Error("Verify accepted a truncated file") + } +} + +// A package with no descriptor is verified on size alone. Demanding a metadata match +// would make it re-download on every run, forever. +func TestVerifyFallsBackToSizeWhenNoDeliveredIdWasRecorded(t *testing.T) { + root := t.TempDir() + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + zw.Write([]byte("no descriptor here")) + zw.Close() + p := storeCommitted(t, root, "pub", "plain-1", buf.Bytes()) + + if !cache.Verify(root, p.RelPath, p.Size, "") { + t.Error("Verify rejected a descriptor-less package that matches its recorded size") + } + if cache.Verify(root, p.RelPath, p.Size+5, "") { + t.Error("Verify accepted a size mismatch even with no delivered id") + } +} + +func TestOnlyDeepVerifySeesAMidFileFlip(t *testing.T) { + root := t.TempDir() + body := pkg(t, "1", "2", 900) + p := storeCommitted(t, root, "pub", "asset-1", body) + full := filepath.Join(root, filepath.FromSlash(p.RelPath)) + + f, err := os.OpenFile(full, os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + f.WriteAt([]byte{0xFF}, 700) // past the descriptor, size unchanged + f.Close() + + if !cache.Verify(root, p.RelPath, p.Size, "2") { + t.Error("cheap verify should not see a mid-file flip; that is what makes it cheap") + } + if cache.VerifyDeep(root, p.RelPath, p.SHA256) { + t.Error("deep verify missed a mid-file flip") + } +} + +func TestLocateFindsAPackageByItsOwnIdAndIgnoresTemps(t *testing.T) { + root := t.TempDir() + storeCommitted(t, root, "pub-a", "asset-1", pkg(t, "111", "9", 400)) + storeCommitted(t, root, "pub-b", "asset-2", pkg(t, "222", "9", 400)) + + // An abandoned partial with an intact descriptor must never be adoptable. + tempDir := filepath.Join(root, "pub-c", "asset-3") + os.MkdirAll(tempDir, 0o755) + os.WriteFile(filepath.Join(tempDir, ".unity-sync-dl-999"), pkg(t, "333", "9", 400), 0o644) + + got, ok := cache.Locate(root, "222", "") + if !ok || !strings.Contains(got.RelPath, "asset-2") { + t.Errorf("Locate(222) = %+v, %v", got, ok) + } + if _, ok := cache.Locate(root, "333", ""); ok { + t.Error("Locate adopted an abandoned download temp") + } +} + +func TestLocatePrefersTheFileAlreadyAtTheDerivedPath(t *testing.T) { + root := t.TempDir() + derived := cache.RelPath("pub", "asset-1") + storeCommitted(t, root, "pub", "asset-1", pkg(t, "111", "9", 400)) + storeCommitted(t, root, "old-pub", "old-asset", pkg(t, "111", "9", 400)) + + got, ok := cache.Locate(root, "111", derived) + if !ok || got.RelPath != derived { + t.Errorf("Locate = %q, want the copy already at %q", got.RelPath, derived) + } +} + +func TestRelocateMovesTheDirectoryAndPrunesTheOldOne(t *testing.T) { + root := t.TempDir() + from := cache.RelPath("pub", "old-slug-111") + storeCommitted(t, root, "pub", "old-slug-111", pkg(t, "111", "9", 400)) + to := cache.RelPath("pub", "new-slug-111") + + if err := cache.Relocate(root, from, to); err != nil { + t.Fatalf("Relocate: %v", err) + } + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(to))); err != nil { + t.Fatalf("file is not at the new path: %v", err) + } + // quarry reads the pack facet from the directory, so the old directory must go. + if _, err := os.Stat(filepath.Join(root, "pub", "old-slug-111")); !os.IsNotExist(err) { + t.Error("the emptied old directory survived the move") + } +} + +func TestRelocateIsANoOpWhenAlreadyInPlace(t *testing.T) { + root := t.TempDir() + rel := cache.RelPath("pub", "asset-1") + storeCommitted(t, root, "pub", "asset-1", pkg(t, "111", "9", 400)) + if err := cache.Relocate(root, rel, rel); err != nil { + t.Errorf("Relocate onto itself = %v, want nil: this is the common adopt case", err) + } + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err != nil { + t.Errorf("the no-op relocation lost the file: %v", err) + } +} + +// The caller records the digest of whatever lands at the destination, so an overwrite +// here would certify the wrong bytes — with nothing else in the design watching. +func TestRelocateRefusesAnOccupiedDestination(t *testing.T) { + root := t.TempDir() + from := cache.RelPath("pub", "stray-111") + to := cache.RelPath("pub", "asset-111") + storeCommitted(t, root, "pub", "stray-111", pkg(t, "111", "9", 400)) + storeCommitted(t, root, "pub", "asset-111", pkg(t, "111", "9", 900)) + + if err := cache.Relocate(root, from, to); err == nil { + t.Fatal("Relocate silently overwrote an occupied destination") + } + fi, err := os.Stat(filepath.Join(root, filepath.FromSlash(to))) + if err != nil || fi.Size() != 900 { + t.Errorf("the destination file was disturbed: size %v, err %v", fi.Size(), err) + } + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(from))); err != nil { + t.Error("the source file was lost to a refused move") + } +} + +func TestSweepWalksTheTreeAndSparesInFlightTemps(t *testing.T) { + root := t.TempDir() + leaf := filepath.Join(root, "pub", "asset-1") + if err := os.MkdirAll(leaf, 0o755); err != nil { + t.Fatal(err) + } + stale := filepath.Join(leaf, ".unity-sync-dl-old") + fresh := filepath.Join(leaf, ".unity-sync-dl-live") + os.WriteFile(stale, bytes.Repeat([]byte("x"), 100), 0o644) + os.WriteFile(fresh, bytes.Repeat([]byte("x"), 50), 0o644) + old := time.Now().Add(-2 * time.Hour) + os.Chtimes(stale, old, old) + + cutoff := time.Now().Add(-time.Hour) + n, bytesFreed, err := cache.SweepTemps(root, cutoff) + if err != nil { + t.Fatalf("SweepTemps: %v", err) + } + // A root-only scan would report zero here while leaving a multi-gigabyte orphan. + if n != 1 || bytesFreed != 100 { + t.Errorf("swept %d files / %d bytes, want 1 / 100", n, bytesFreed) + } + if _, err := os.Stat(fresh); err != nil { + t.Error("the sweep deleted a temp newer than the cutoff, i.e. another run's transfer") + } +} + +func TestUnsafePathsAreRefused(t *testing.T) { + root := t.TempDir() + for _, seg := range []string{"", ".", "..", "a/b", `a\b`, ".hidden", "with\x00null"} { + if _, err := cache.Store(root, seg, "asset", bytes.NewReader([]byte("x"))); err == nil { + t.Errorf("Store accepted publisher slug %q", seg) + } + if _, err := cache.Store(root, "pub", seg, bytes.NewReader([]byte("x"))); err == nil { + t.Errorf("Store accepted asset slug %q", seg) + } + } + for _, rel := range []string{"", "/etc/passwd", "../escape.unitypackage", "a/../../escape"} { + if cache.Verify(root, rel, 1, "") { + t.Errorf("Verify accepted path %q", rel) + } + if _, _, err := cache.Hash(root, rel); err == nil { + t.Errorf("Hash accepted path %q", rel) + } + } +} From 2e699e5fd566e651d17bd19b370d596a73578e70 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 11:40:04 -0700 Subject: [PATCH 09/28] Split what the store advertises from what is actually on disk 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- internal/lockfile/lockfile.go | 141 ++++++++++++++++++++++++ internal/lockfile/lockfile_test.go | 167 +++++++++++++++++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 internal/lockfile/lockfile.go create mode 100644 internal/lockfile/lockfile_test.go diff --git a/internal/lockfile/lockfile.go b/internal/lockfile/lockfile.go new file mode 100644 index 0000000..e45eccd --- /dev/null +++ b/internal/lockfile/lockfile.go @@ -0,0 +1,141 @@ +// Package lockfile reads and writes unity-sync.lock.json, the committed record of what +// an account owns and what is mirrored. It lives beside the project manifest and is +// meant to be diffed: a month's diff should read like a changelog. +// +// Each entry has two halves that are deliberately not mixed. The advertised half is what +// the store currently says and is refreshed every run. The resolution half describes the +// file on disk and is rewritten only when a run actually resolves that asset. +package lockfile + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Version records one build. ID is what a diff compares. +type Version struct { + ID string `json:"id"` + Name string `json:"name"` + PublishedDate string `json:"publishedDate,omitempty"` +} + +// Publisher is carried for display and for the cache's vendor directory. +type Publisher struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Entry is one owned asset. Every owned asset gets one, whether or not it is selected, +// because the file is the record of what is *owned*, not of what happens to be mirrored. +type Entry struct { + // AssetID is the store product id. It is deliberately not called productId: the API + // has a field of that exact name holding a different, unusable value, and this is + // the one place a reader compares the two documents side by side. + AssetID string `json:"assetId"` + + // Advertised half, refreshed on every run for every owned asset. + Name string `json:"name"` + State string `json:"state"` + Publisher Publisher `json:"publisher"` + Version Version `json:"version"` + AdvertisedSize int64 `json:"advertisedSize"` + + // Resolution half, rewritten only when a run resolves this asset. + // + // Tracked means "bytes have been mirrored at some point", not "selected right now": + // an asset stays tracked after it is disabled in the manifest, because the file is + // still there. + Tracked bool `json:"tracked"` + + // ResolvedVersionID is the advertised id the cached file was fetched against, and + // it — not Version.ID — is what classification compares. Keeping them apart is what + // stops a refreshed advertised id from being written next to an older file and + // making it look current forever. + ResolvedVersionID string `json:"resolvedVersionId,omitempty"` + + // DeliveredVersionID is what the stored file's own descriptor says. It can differ + // from the advertised id: some products are advertised at one version and served at + // another, steadily. + DeliveredVersionID string `json:"deliveredVersionId,omitempty"` + + SizeBytes int64 `json:"sizeBytes,omitempty"` + SHA256 string `json:"sha256,omitempty"` + CachePath string `json:"cachePath,omitempty"` + + // DownloadedAt is when bytes were last fetched. An adopted entry leaves it empty: + // the tool found the file, it did not fetch it. + DownloadedAt string `json:"downloadedAt,omitempty"` + + // StoreFilename is what the store called the package. Recorded for reference; it + // never determines a path. + StoreFilename string `json:"storeFilename,omitempty"` +} + +// Lockfile is the whole document. +// +// There is deliberately no run timestamp. Stamping one would dirty a committed file on +// every no-op run, which is exactly the churn that buries the changelog. +type Lockfile struct { + Assets map[string]Entry `json:"assets"` +} + +// New returns an empty lockfile. +func New() Lockfile { return Lockfile{Assets: map[string]Entry{}} } + +// Load reads a lockfile, returning an empty one when the path does not exist. +func Load(path string) (Lockfile, error) { + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return New(), nil + } + if err != nil { + return Lockfile{}, fmt.Errorf("read lockfile: %w", err) + } + var lf Lockfile + if err := json.Unmarshal(raw, &lf); err != nil { + return Lockfile{}, fmt.Errorf("parse lockfile: %w", err) + } + if lf.Assets == nil { + lf.Assets = map[string]Entry{} + } + return lf, nil +} + +// Save writes the lockfile atomically. encoding/json sorts map keys, so the output is +// stable across runs and a diff shows only what actually changed. +func Save(path string, lf Lockfile) error { + raw, err := json.MarshalIndent(lf, "", " ") + if err != nil { + return err + } + raw = append(raw, '\n') + tmp, err := os.CreateTemp(filepath.Dir(path), ".unity-sync-lock-*") + if err != nil { + return err + } + name := tmp.Name() + if _, err := tmp.Write(raw); err != nil { + tmp.Close() + os.Remove(name) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(name) + return err + } + return os.Rename(name, path) +} + +// FindByAssetID returns the entry recorded for a product id, whatever key it sits under. +// Classification uses this rather than the key, so a renamed asset — whose key changes +// by construction — is still recognised as the same thing. +func (lf Lockfile) FindByAssetID(id string) (key string, e Entry, ok bool) { + for k, entry := range lf.Assets { + if entry.AssetID == id { + return k, entry, true + } + } + return "", Entry{}, false +} diff --git a/internal/lockfile/lockfile_test.go b/internal/lockfile/lockfile_test.go new file mode 100644 index 0000000..722078d --- /dev/null +++ b/internal/lockfile/lockfile_test.go @@ -0,0 +1,167 @@ +package lockfile_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/curbol/unity-sync/internal/lockfile" +) + +func sample() lockfile.Lockfile { + lf := lockfile.New() + lf.Assets["quick-outline-115488"] = lockfile.Entry{ + AssetID: "115488", + Name: "Quick Outline", + State: "published", + Publisher: lockfile.Publisher{ID: "37073", Name: "Chris Nolet"}, + Version: lockfile.Version{ID: "683375", Name: "1.1", PublishedDate: "2022-03-07T16:46:24Z"}, + AdvertisedSize: 33824, + Tracked: true, + ResolvedVersionID: "683375", + DeliveredVersionID: "683375", + SizeBytes: 33822, + SHA256: "abc123", + CachePath: "chris-nolet/quick-outline-115488/quick-outline-115488.unitypackage", + DownloadedAt: "2026-08-22T04:00:00Z", + StoreFilename: "Quick Outline.unitypackage", + } + lf.Assets["unowned-yet-999"] = lockfile.Entry{ + AssetID: "999", + Name: "Never Downloaded", + State: "published", + Version: lockfile.Version{ID: "1", Name: "1.0"}, + AdvertisedSize: 4096, + Tracked: false, + } + return lf +} + +func TestRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "unity-sync.lock.json") + if err := lockfile.Save(path, sample()); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := lockfile.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + want := sample() + for key, w := range want.Assets { + g, ok := got.Assets[key] + if !ok { + t.Fatalf("entry %q vanished", key) + } + if g != w { + t.Errorf("entry %q round-tripped as %+v, want %+v", key, g, w) + } + } +} + +func TestMissingFileLoadsEmpty(t *testing.T) { + lf, err := lockfile.Load(filepath.Join(t.TempDir(), "absent.json")) + if err != nil { + t.Fatalf("Load of a missing file = %v, want nil", err) + } + if len(lf.Assets) != 0 { + t.Errorf("got %d entries, want none", len(lf.Assets)) + } +} + +func TestSaveIsByteStableAcrossRuns(t *testing.T) { + dir := t.TempDir() + a := filepath.Join(dir, "a.json") + b := filepath.Join(dir, "b.json") + if err := lockfile.Save(a, sample()); err != nil { + t.Fatal(err) + } + if err := lockfile.Save(b, sample()); err != nil { + t.Fatal(err) + } + ra, _ := os.ReadFile(a) + rb, _ := os.ReadFile(b) + if string(ra) != string(rb) { + t.Error("two saves of the same content differ; the diff would be noise") + } +} + +// A run timestamp would dirty the committed file on every no-op run, which is the churn +// that buries the changelog the file exists to be. +func TestNoRunTimestampIsWritten(t *testing.T) { + path := filepath.Join(t.TempDir(), "lock.json") + if err := lockfile.Save(path, sample()); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(path) + for _, forbidden := range []string{"generatedAt", "updatedAt", "syncedAt"} { + if strings.Contains(string(raw), forbidden) { + t.Errorf("lockfile carries a run timestamp %q", forbidden) + } + } +} + +// sizeBytes must be what was received, never the advertised value: they differ by 0-16 +// bytes, and conflating them makes cheap verify fail forever. +func TestAdvertisedAndReceivedSizesAreSeparateFields(t *testing.T) { + path := filepath.Join(t.TempDir(), "lock.json") + if err := lockfile.Save(path, sample()); err != nil { + t.Fatal(err) + } + lf, err := lockfile.Load(path) + if err != nil { + t.Fatal(err) + } + e := lf.Assets["quick-outline-115488"] + if e.AdvertisedSize == e.SizeBytes { + t.Fatal("test fixture no longer distinguishes the two sizes") + } + if e.SizeBytes != 33822 || e.AdvertisedSize != 33824 { + t.Errorf("sizes crossed over: sizeBytes=%d advertisedSize=%d", e.SizeBytes, e.AdvertisedSize) + } +} + +func TestUntrackedEntriesOmitResolutionFields(t *testing.T) { + path := filepath.Join(t.TempDir(), "lock.json") + if err := lockfile.Save(path, sample()); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(path) + // The untracked entry must not carry empty resolution keys; omitempty keeps the + // committed file readable. + chunk := string(raw) + start := strings.Index(chunk, `"unowned-yet-999"`) + if start < 0 { + t.Fatal("untracked entry missing") + } + end := strings.Index(chunk[start:], "}") + block := chunk[start : start+end] + for _, field := range []string{"sha256", "cachePath", "downloadedAt", "resolvedVersionId"} { + if strings.Contains(block, field) { + t.Errorf("untracked entry carries %q", field) + } + } +} + +// A rename changes the key by construction, so lookups that must survive one go by id. +func TestFindByAssetIDIgnoresTheKey(t *testing.T) { + lf := sample() + key, e, ok := lf.FindByAssetID("115488") + if !ok { + t.Fatal("FindByAssetID missed a present entry") + } + if key != "quick-outline-115488" || e.Name != "Quick Outline" { + t.Errorf("FindByAssetID = %q, %+v", key, e) + } + if _, _, ok := lf.FindByAssetID("nope"); ok { + t.Error("FindByAssetID invented an entry") + } +} + +func TestCorruptLockfileIsAnError(t *testing.T) { + path := filepath.Join(t.TempDir(), "lock.json") + os.WriteFile(path, []byte("{not json"), 0o644) + if _, err := lockfile.Load(path); err == nil { + t.Error("Load accepted a corrupt lockfile") + } +} From 5d3d0580c34d75bbafc0d9aa36ba0a8a415c6cff Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 11:41:28 -0700 Subject: [PATCH 10/28] Key the allowlist on the asset id so a rename cannot reset a selection 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- internal/manifest/manifest.go | 186 +++++++++++++++++++++++++++++ internal/manifest/manifest_test.go | 170 ++++++++++++++++++++++++++ 2 files changed, 356 insertions(+) create mode 100644 internal/manifest/manifest.go create mode 100644 internal/manifest/manifest_test.go diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go new file mode 100644 index 0000000..dc489a9 --- /dev/null +++ b/internal/manifest/manifest.go @@ -0,0 +1,186 @@ +// Package manifest is the committed project manifest, unity-sync.toml: the allowlist of +// assets a project draws from. It is discovered by walking up from the working directory +// and lives with the consuming project, not with the tool, and it carries no account +// identity. +// +// Only `select` ever writes it. Reconcile and Save exist for that one command: a `sync` +// that rewrote the manifest would re-encode a hand-edited committed file on every run, +// and a wrong-org enumeration would delete the user's curated selections. +package manifest + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/BurntSushi/toml" + "github.com/curbol/unity-sync/internal/model" +) + +// FileName is what Discover looks for. +const FileName = "unity-sync.toml" + +// Entry is one asset's selection state. It keys on ID because that is the only stable +// identity: a publisher rename changes Name, and matching on the name would silently +// reset a selection to disabled. +type Entry struct { + ID string `toml:"id"` + Name string `toml:"name"` + Enabled bool `toml:"enabled"` +} + +// Manifest is the whole file. +type Manifest struct { + Assets []Entry `toml:"asset"` +} + +// Discover walks up from startDir looking for the manifest, returning its path and true +// on the first hit. +func Discover(startDir string) (string, bool) { + dir := startDir + for { + p := filepath.Join(dir, FileName) + if _, err := os.Stat(p); err == nil { + return p, true + } + parent := filepath.Dir(dir) + if parent == dir { + return "", false + } + dir = parent + } +} + +// LockPath is the lockfile that belongs beside a manifest. +func LockPath(manifestPath string) string { + return strings.TrimSuffix(manifestPath, ".toml") + ".lock.json" +} + +// Load reads the manifest, returning an empty one if the file does not exist. An unknown +// key is an error rather than a shrug: Save re-encodes from the struct, so a key that +// decodes to nothing here would be deleted from the user's file on the next write. +func Load(path string) (Manifest, error) { + var m Manifest + if _, err := os.Stat(path); os.IsNotExist(err) { + return m, nil + } + md, err := toml.DecodeFile(path, &m) + if err != nil { + return Manifest{}, err + } + if un := md.Undecoded(); len(un) > 0 { + keys := make([]string, 0, len(un)) + for _, k := range un { + keys = append(keys, k.String()) + } + return Manifest{}, fmt.Errorf("%s: unknown key(s): %s", path, strings.Join(keys, ", ")) + } + for i, e := range m.Assets { + if e.ID == "" { + return Manifest{}, fmt.Errorf("%s: [[asset]] #%d has no id", path, i+1) + } + } + return m, nil +} + +// Save writes the manifest atomically, entries sorted by id for a stable diff. +func Save(path string, m Manifest) error { + // Sort a copy: the value receiver shares the caller's backing array, so sorting in + // place would reorder their slice behind their back. + m.Assets = append([]Entry(nil), m.Assets...) + sort.Slice(m.Assets, func(i, j int) bool { return m.Assets[i].ID < m.Assets[j].ID }) + + tmp, err := os.CreateTemp(filepath.Dir(path), ".unity-sync-manifest-*") + if err != nil { + return err + } + name := tmp.Name() + if err := toml.NewEncoder(tmp).Encode(m); err != nil { + tmp.Close() + os.Remove(name) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(name) + return err + } + return os.Rename(name, path) +} + +// ErrWouldEmpty is returned by Reconcile when the owned set is empty but the manifest is +// not. An enumeration that legitimately returns nothing is indistinguishable from one +// made with the wrong org active, and this is the only file in the system a user curates +// by hand. +type ErrWouldEmpty struct{ Existing int } + +func (e *ErrWouldEmpty) Error() string { + return fmt.Sprintf("refusing to rewrite the manifest: the store reported no owned assets while "+ + "the manifest holds %d (a session with a different active org looks exactly like this)", e.Existing) +} + +// Reconcile rebuilds the allowlist against what is currently owned: existing selections +// are preserved by id, newly-owned assets are added disabled, and assets no longer owned +// drop out. It returns the entries it dropped so the caller can report them rather than +// removing them silently. +// +// Selection is opt-in: buying an asset must never cause it to download on the next run. +func (m *Manifest) Reconcile(owned []model.Asset) (dropped []Entry, err error) { + if len(owned) == 0 && len(m.Assets) > 0 { + return nil, &ErrWouldEmpty{Existing: len(m.Assets)} + } + prev := make(map[string]Entry, len(m.Assets)) + for _, e := range m.Assets { + prev[e.ID] = e + } + out := make([]Entry, 0, len(owned)) + stillOwned := make(map[string]bool, len(owned)) + for _, a := range owned { + stillOwned[a.ID] = true + out = append(out, Entry{ID: a.ID, Name: a.Name, Enabled: prev[a.ID].Enabled}) + } + for _, e := range m.Assets { + if !stillOwned[e.ID] { + dropped = append(dropped, e) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + m.Assets = out + return dropped, nil +} + +// EnabledIDs is the set a run may act on. +func (m Manifest) EnabledIDs() map[string]bool { + set := map[string]bool{} + for _, e := range m.Assets { + if e.Enabled { + set[e.ID] = true + } + } + return set +} + +// SetEnabled applies an id->enabled selection, as the select page returns it. +func (m *Manifest) SetEnabled(enabled map[string]bool) { + for i := range m.Assets { + m.Assets[i].Enabled = enabled[m.Assets[i].ID] + } +} + +// UnknownIDs returns manifest entries naming assets the account does not own. They are +// reported rather than ignored: a refunded asset, a typo, or an id invisible to the +// current session's org would otherwise produce complete silence from status and sync. +func (m Manifest) UnknownIDs(owned []model.Asset) []Entry { + ownedIDs := make(map[string]bool, len(owned)) + for _, a := range owned { + ownedIDs[a.ID] = true + } + var unknown []Entry + for _, e := range m.Assets { + if !ownedIDs[e.ID] { + unknown = append(unknown, e) + } + } + return unknown +} diff --git a/internal/manifest/manifest_test.go b/internal/manifest/manifest_test.go new file mode 100644 index 0000000..db7a7ef --- /dev/null +++ b/internal/manifest/manifest_test.go @@ -0,0 +1,170 @@ +package manifest_test + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/curbol/unity-sync/internal/manifest" + "github.com/curbol/unity-sync/internal/model" +) + +func asset(id, name string) model.Asset { return model.Asset{ID: id, Name: name} } + +func TestDiscoverWalksUpFromANestedDirectory(t *testing.T) { + root := t.TempDir() + nested := filepath.Join(root, "src", "game", "assets") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + want := filepath.Join(root, manifest.FileName) + if err := os.WriteFile(want, []byte("\n"), 0o644); err != nil { + t.Fatal(err) + } + got, ok := manifest.Discover(nested) + if !ok || got != want { + t.Errorf("Discover = %q, %v; want %q", got, ok, want) + } +} + +func TestDiscoverReportsAbsence(t *testing.T) { + if _, ok := manifest.Discover(t.TempDir()); ok { + t.Error("Discover invented a manifest") + } +} + +func TestLockPathSitsBesideTheManifest(t *testing.T) { + if got := manifest.LockPath("/p/unity-sync.toml"); got != "/p/unity-sync.lock.json" { + t.Errorf("LockPath = %q", got) + } +} + +// Save re-encodes from the struct, so a key that silently decodes to nothing would be +// deleted from the user's committed file on the next write. +func TestUnknownKeysAreRejected(t *testing.T) { + path := filepath.Join(t.TempDir(), manifest.FileName) + os.WriteFile(path, []byte("variant_includes = [\"Unity_*\"]\n"), 0o644) + if _, err := manifest.Load(path); err == nil { + t.Error("Load accepted an unknown key it would later delete") + } +} + +func TestEntriesWithoutAnIdAreRejected(t *testing.T) { + path := filepath.Join(t.TempDir(), manifest.FileName) + os.WriteFile(path, []byte("[[asset]]\nname = \"No Id\"\nenabled = true\n"), 0o644) + if _, err := manifest.Load(path); err == nil { + t.Error("Load accepted an entry with no id, which nothing could match") + } +} + +func TestNewlyOwnedAssetsArriveDisabled(t *testing.T) { + var m manifest.Manifest + if _, err := m.Reconcile([]model.Asset{asset("1", "A"), asset("2", "B")}); err != nil { + t.Fatal(err) + } + for _, e := range m.Assets { + if e.Enabled { + t.Errorf("asset %s arrived enabled; buying one must never download it", e.ID) + } + } +} + +// The manifest keys on id precisely so a rename cannot silently reset a selection. +func TestReconcilePreservesSelectionAcrossARename(t *testing.T) { + m := manifest.Manifest{Assets: []manifest.Entry{ + {ID: "309177", Name: "RPG_Animations - One Hand Base", Enabled: true}, + }} + if _, err := m.Reconcile([]model.Asset{asset("309177", "Ultimate One Hand Locomotion")}); err != nil { + t.Fatal(err) + } + if len(m.Assets) != 1 { + t.Fatalf("got %d entries, want 1", len(m.Assets)) + } + if !m.Assets[0].Enabled { + t.Error("a rename reset the selection") + } + if m.Assets[0].Name != "Ultimate One Hand Locomotion" { + t.Errorf("Name = %q, want the refreshed one", m.Assets[0].Name) + } +} + +func TestReconcileReportsDropsRatherThanHidingThem(t *testing.T) { + m := manifest.Manifest{Assets: []manifest.Entry{ + {ID: "1", Name: "Kept", Enabled: true}, + {ID: "2", Name: "Refunded", Enabled: true}, + }} + dropped, err := m.Reconcile([]model.Asset{asset("1", "Kept")}) + if err != nil { + t.Fatal(err) + } + if len(dropped) != 1 || dropped[0].ID != "2" { + t.Errorf("dropped = %+v, want the refunded entry", dropped) + } +} + +// A session with a different active org returns an empty or foreign owned set, and this +// is the one file a user curates by hand. +func TestReconcileRefusesToEmptyANonEmptyManifest(t *testing.T) { + m := manifest.Manifest{Assets: []manifest.Entry{{ID: "1", Name: "A", Enabled: true}}} + _, err := m.Reconcile(nil) + var wouldEmpty *manifest.ErrWouldEmpty + if !errors.As(err, &wouldEmpty) { + t.Fatalf("Reconcile = %v, want ErrWouldEmpty", err) + } + if len(m.Assets) != 1 { + t.Error("the refused reconcile mutated the manifest anyway") + } +} + +func TestEmptyOwnedSetIsFineForAnEmptyManifest(t *testing.T) { + var m manifest.Manifest + if _, err := m.Reconcile(nil); err != nil { + t.Errorf("Reconcile on a first run with nothing owned = %v, want nil", err) + } +} + +func TestUnknownIDsAreSurfaced(t *testing.T) { + m := manifest.Manifest{Assets: []manifest.Entry{ + {ID: "1", Name: "Owned", Enabled: true}, + {ID: "404", Name: "Typo", Enabled: true}, + }} + unknown := m.UnknownIDs([]model.Asset{asset("1", "Owned")}) + if len(unknown) != 1 || unknown[0].ID != "404" { + t.Errorf("UnknownIDs = %+v, want the entry the account does not own", unknown) + } +} + +func TestSaveLoadRoundTripSortsById(t *testing.T) { + path := filepath.Join(t.TempDir(), manifest.FileName) + m := manifest.Manifest{Assets: []manifest.Entry{ + {ID: "222", Name: "B", Enabled: false}, + {ID: "111", Name: "A", Enabled: true}, + }} + if err := manifest.Save(path, m); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := manifest.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(got.Assets) != 2 || got.Assets[0].ID != "111" { + t.Fatalf("round trip = %+v, want sorted by id", got.Assets) + } + if !got.Assets[0].Enabled || got.Assets[1].Enabled { + t.Errorf("selection flags did not survive: %+v", got.Assets) + } + if ids := got.EnabledIDs(); !ids["111"] || ids["222"] { + t.Errorf("EnabledIDs = %v", ids) + } +} + +func TestSaveDoesNotReorderTheCallersSlice(t *testing.T) { + m := manifest.Manifest{Assets: []manifest.Entry{{ID: "222"}, {ID: "111"}}} + if err := manifest.Save(filepath.Join(t.TempDir(), manifest.FileName), m); err != nil { + t.Fatal(err) + } + if m.Assets[0].ID != "222" { + t.Error("Save sorted the caller's slice in place") + } +} From 6423d846304f5bdbd40e7c9406b7b207af4e87e2 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 11:45:28 -0700 Subject: [PATCH 11/28] Orchestrate a run so a bad asset costs one asset, not the mirror 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- internal/syncer/syncer.go | 544 ++++++++++++++++++++++++++++ internal/syncer/syncer_test.go | 635 +++++++++++++++++++++++++++++++++ 2 files changed, 1179 insertions(+) create mode 100644 internal/syncer/syncer.go create mode 100644 internal/syncer/syncer_test.go diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go new file mode 100644 index 0000000..35a6a34 --- /dev/null +++ b/internal/syncer/syncer.go @@ -0,0 +1,544 @@ +// Package syncer orchestrates a run: enumerate, classify, download the delta, and +// rewrite the lockfile. It owns the checks that need both the stored bytes and the +// enumeration metadata — the store layer sees only responses, the cache layer only bytes. +package syncer + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "sync" + "time" + + "github.com/curbol/unity-sync/internal/cache" + "github.com/curbol/unity-sync/internal/lockfile" + "github.com/curbol/unity-sync/internal/manifest" + "github.com/curbol/unity-sync/internal/model" + "github.com/curbol/unity-sync/internal/store" + "github.com/curbol/unity-sync/internal/unitypackage" +) + +// ErrEmptyLibrary guards the committed record against a well-formed but wrong +// enumeration. A session whose active org differs returns a legitimately different owned +// set, and the lockfile lives in someone's project. +var ErrEmptyLibrary = errors.New("the store reported no owned assets while the lockfile holds entries; " + + "refusing to treat that as the truth (check which Unity organisation the session belongs to)") + +// Class is one asset's outcome for this run. +type Class int + +const ( + Unchanged Class = iota + New + Changed + DownloadNow // owned, previously recorded but never mirrored, now selected + CacheMissing + Adopted + Undownloadable +) + +func (c Class) String() string { + switch c { + case New: + return "new" + case Changed: + return "changed" + case DownloadNow: + return "download-now" + case CacheMissing: + return "cache-missing" + case Adopted: + return "adopted" + case Undownloadable: + return "undownloadable" + default: + return "unchanged" + } +} + +// NeedsFetch reports whether a class means bytes must come off the network. +func (c Class) NeedsFetch() bool { + switch c { + case New, Changed, DownloadNow, CacheMissing: + return true + } + return false +} + +// classify is pure. The probes are injected so it stays that way: cacheOK is the cheap +// on-disk check for the prior resolution, and adoptable reports a matching file found by +// scanning. +func classify(a model.Asset, prior lockfile.Entry, hasPrior bool, cacheOK, adoptable func() bool) Class { + resolved := hasPrior && prior.Tracked + + if !a.State.Downloadable() { + // A copy already mirrored stays usable after the store delists the asset; only + // one we do not have is a problem worth reporting. + if resolved && cacheOK() { + return Unchanged + } + return Undownloadable + } + if !resolved { + if adoptable() { + return Adopted + } + if hasPrior { + return DownloadNow + } + return New + } + if prior.ResolvedVersionID != a.Version.ID { + return Changed + } + if !cacheOK() { + if adoptable() { + return Adopted + } + return CacheMissing + } + return Unchanged +} + +// Store is the part of the Asset Store client a run needs. +type Store interface { + Enumerate(ctx context.Context) ([]model.Asset, error) + Lookup(ctx context.Context, id string) (model.Asset, bool, error) + Fetch(ctx context.Context, id string) (*store.Download, error) +} + +// Options configures a run. +type Options struct { + LibraryRoot string + Selected map[string]bool + OnlyGlob string + DryRun bool + FullVerify bool + Concurrency int + Now func() time.Time + Progress func(string) + + // Manifest is consulted for reporting only; a run never writes it. + Manifest manifest.Manifest +} + +// Result is one asset's outcome. +type Result struct { + Asset model.Asset + Class Class + Err error + Warning string +} + +// Report is what a run produced. +type Report struct { + Results []Result + Removed []lockfile.Entry + Unknown []manifest.Entry + Swept int + Freed int64 + Lockfile lockfile.Lockfile + + // Retryable counts failures a later run might fix. A permanently gone asset is + // reported but does not make the run exit non-zero, or one dead asset would fail + // every future run forever. + Retryable int + Permanent int +} + +// Failed reports whether the run should exit non-zero. +func (r Report) Failed() bool { return r.Retryable > 0 } + +// resolution is what a successful fetch or adoption produced. +type resolution struct { + cachePath string + sha string + size int64 + resolvedVersionID string + deliveredVersionID string + downloadedAt string + storeFilename string +} + +// Run executes a sync, or a status when DryRun. It returns the report even alongside an +// error, so a caller can show what did happen. +func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, opts Options) (Report, error) { + if opts.Now == nil { + opts.Now = time.Now + } + if opts.Progress == nil { + opts.Progress = func(string) {} + } + if opts.Concurrency < 1 { + opts.Concurrency = 1 + } + if opts.OnlyGlob != "" { + if _, err := filepath.Match(opts.OnlyGlob, ""); err != nil { + return Report{}, fmt.Errorf("bad --only pattern %q: %w", opts.OnlyGlob, err) + } + } + started := opts.Now() + + opts.Progress("enumerating owned assets…") + owned, err := s.Enumerate(ctx) + if err != nil { + return Report{}, err + } + if len(owned) == 0 && len(prior.Assets) > 0 { + return Report{}, ErrEmptyLibrary + } + + report := Report{Unknown: opts.Manifest.UnknownIDs(owned)} + + // Sweeping before classification matters: an abandoned partial left in the tree is + // otherwise a candidate the adopt scan could reach. + if !opts.DryRun { + n, freed, err := cache.SweepTemps(opts.LibraryRoot, started) + if err != nil { + return report, err + } + report.Swept, report.Freed = n, freed + if n > 0 { + opts.Progress(fmt.Sprintf("reclaimed %d abandoned download(s), %s", n, humanBytes(freed))) + } + } + + resolutions := map[string]resolution{} + var mu sync.Mutex + + // Classify everything selected, then fetch what needs fetching. + var pending []Result + for _, a := range owned { + if !selected(a, opts) { + continue + } + prevKey, prev, hasPrev := prior.FindByAssetID(a.ID) + _ = prevKey + derived := cache.RelPath(a.PublisherSlug(), a.Slug()) + + cacheOK := func() bool { + if prev.CachePath == "" { + return false + } + if opts.FullVerify { + return cache.VerifyDeep(opts.LibraryRoot, prev.CachePath, prev.SHA256) + } + return cache.Verify(opts.LibraryRoot, prev.CachePath, prev.SizeBytes, prev.DeliveredVersionID) + } + var found cache.Candidate + var foundOK bool + adoptable := func() bool { + found, foundOK = cache.Locate(opts.LibraryRoot, a.ID, derived) + if !foundOK { + return false + } + // The same floor a download must clear. Without it, a truncated package + // left in the library enters through the one door that skips the download + // path and is then hashed and recorded as truth. + if belowFloor(found.Size, a.AdvertisedSize) { + foundOK = false + return false + } + return found.Metadata.VersionID == a.Version.ID + } + + class := classify(a, prev, hasPrev, cacheOK, adoptable) + res := Result{Asset: a, Class: class} + + switch class { + case Adopted: + if opts.DryRun { + break + } + r, err := adopt(opts, a, found, derived) + if err != nil { + res.Err = err + report.Retryable++ + } else { + resolutions[a.ID] = r + } + case Unchanged: + // Keep the prior resolution, but move it if the slug changed under it. + if !opts.DryRun && hasPrev && prev.Tracked && prev.CachePath != derived { + if err := cache.Relocate(opts.LibraryRoot, prev.CachePath, derived); err != nil { + res.Warning = err.Error() + } else { + r := fromEntry(prev) + r.cachePath = derived + resolutions[a.ID] = r + } + } + } + if class.NeedsFetch() { + pending = append(pending, res) + continue + } + report.Results = append(report.Results, res) + } + + if opts.DryRun { + report.Results = append(report.Results, pending...) + report.Lockfile = build(owned, prior, resolutions, opts, &report) + return report, nil + } + + // Downloads run bounded, and a failure fails its asset rather than the run: one + // delisted or corrupt package must not stop a 75 GB mirror. + var ( + wg sync.WaitGroup + sem = make(chan struct{}, opts.Concurrency) + done = make([]Result, len(pending)) + ) + for i, res := range pending { + wg.Add(1) + go func(i int, res Result) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + if ctx.Err() != nil { + res.Err = ctx.Err() + done[i] = res + return + } + opts.Progress(fmt.Sprintf("fetching %s (%s)", res.Asset.Name, humanBytes(res.Asset.AdvertisedSize))) + r, warning, err := download(ctx, s, opts, res.Asset) + res.Warning, res.Err = warning, err + if err == nil { + mu.Lock() + resolutions[res.Asset.ID] = r + // Persisting per download is what keeps a run that dies at asset 90 of + // 100 from discarding the 89 it already fetched. + snapshot := build(owned, prior, resolutions, opts, nil) + mu.Unlock() + if err := lockfile.Save(lockPath, snapshot); err != nil { + res.Err = fmt.Errorf("persisting progress: %w", err) + } + } + done[i] = res + }(i, res) + } + wg.Wait() + + for _, res := range done { + if res.Err != nil { + if errors.Is(res.Err, store.ErrNotDownloadable) { + report.Permanent++ + } else { + report.Retryable++ + } + } + report.Results = append(report.Results, res) + } + + report.Lockfile = build(owned, prior, resolutions, opts, &report) + if err := lockfile.Save(lockPath, report.Lockfile); err != nil { + return report, err + } + return report, nil +} + +// adopt records a package already on disk, relocating it to where the layout puts it so +// the cache does not drift and quarry's facets stay right. +func adopt(opts Options, a model.Asset, found cache.Candidate, derived string) (resolution, error) { + if err := cache.Relocate(opts.LibraryRoot, found.RelPath, derived); err != nil { + return resolution{}, err + } + sha, size, err := cache.Hash(opts.LibraryRoot, derived) + if err != nil { + return resolution{}, err + } + return resolution{ + cachePath: derived, + sha: sha, + size: size, + // The bytes are verified to be this version, so the diff key is known even + // though nothing was fetched. Leaving it empty would make every adopted asset + // classify Changed on the next run and re-download. + resolvedVersionID: a.Version.ID, + deliveredVersionID: found.Metadata.VersionID, + // downloadedAt stays empty: the tool found the file, it did not fetch it. + }, nil +} + +// download fetches one asset and runs every semantic guard against the temp file before +// committing it. +func download(ctx context.Context, s Store, opts Options, a model.Asset) (resolution, string, error) { + dl, err := s.Fetch(ctx, a.ID) + if err != nil { + return resolution{}, "", err + } + defer dl.Body.Close() + + pending, err := cache.Store(opts.LibraryRoot, a.PublisherSlug(), a.Slug(), dl.Body) + if err != nil { + return resolution{}, "", err + } + + meta, metaErr := unitypackage.ReadFile(pending.TempPath()) + switch { + case metaErr != nil && !errors.Is(metaErr, unitypackage.ErrNoMetadata): + pending.Discard() + return resolution{}, "", fmt.Errorf("%s: %w", a.Name, metaErr) + case metaErr == nil && meta.ID != a.ID: + pending.Discard() + return resolution{}, "", fmt.Errorf("%s: the store served product %s, not %s", a.Name, meta.ID, a.ID) + } + + var warning string + if metaErr != nil { + warning = fmt.Sprintf("%s: package carries no store metadata, so later checks fall back to size alone", a.Name) + } + + if belowFloor(pending.Size, a.AdvertisedSize) { + // A short body is either a truncated transfer or a republish that moved the + // advertised size out from under us. One re-read of this product settles it. + if republished(ctx, s, a) { + pending.Discard() + return resolution{}, "", fmt.Errorf("%s: republished mid-download; the next run will fetch the new build", a.Name) + } + pending.Discard() + return resolution{}, "", fmt.Errorf("%s: received %d bytes against an advertised %d; body ended early", + a.Name, pending.Size, a.AdvertisedSize) + } + if a.AdvertisedSize > 0 && (pending.Size > a.AdvertisedSize || pending.Size < a.AdvertisedSize-64) { + warning = fmt.Sprintf("%s: received %d bytes, advertised %d", a.Name, pending.Size, a.AdvertisedSize) + } + + if err := pending.Commit(); err != nil { + return resolution{}, warning, err + } + return resolution{ + cachePath: pending.RelPath, + sha: pending.SHA256, + size: pending.Size, + resolvedVersionID: a.Version.ID, + deliveredVersionID: meta.VersionID, + downloadedAt: opts.Now().UTC().Format(time.RFC3339), + storeFilename: dl.Filename, + }, warning, nil +} + +// belowFloor is the hard short-body rule. The tolerance is absolute because the gap it +// forgives is a ceil-to-16 alignment artifact, and clamped so it cannot swallow a small +// package whole. +func belowFloor(received, advertised int64) bool { + if advertised <= 0 { + return false + } + allowance := int64(4096) + if eighth := advertised / 8; eighth < allowance { + allowance = eighth + } + return received < advertised-allowance +} + +// republished reports whether the store now advertises something different from what the +// enumeration saw. Keying on "delivered id differs from advertised id" instead would be +// wrong: for some products that difference is a steady state, and the floor would be +// switched off permanently for exactly those. +func republished(ctx context.Context, s Store, a model.Asset) bool { + fresh, ok, err := s.Lookup(ctx, a.ID) + if err != nil || !ok { + return false + } + return fresh.Version.ID != a.Version.ID || fresh.AdvertisedSize != a.AdvertisedSize +} + +// build produces the new lockfile: every owned asset gets an entry, advertised fields are +// refreshed, and any resolution this run did not touch is carried forward verbatim. +func build(owned []model.Asset, prior lockfile.Lockfile, resolutions map[string]resolution, + opts Options, report *Report) lockfile.Lockfile { + + out := lockfile.New() + kept := map[string]bool{} + + for _, a := range owned { + _, prev, hasPrev := prior.FindByAssetID(a.ID) + if hasPrev { + kept[a.ID] = true + } + e := lockfile.Entry{ + AssetID: a.ID, + Name: a.Name, + State: string(a.State), + Publisher: lockfile.Publisher{ID: a.Publisher.ID, Name: a.Publisher.Name}, + Version: lockfile.Version{ID: a.Version.ID, Name: a.Version.Name, PublishedDate: a.Version.PublishedDate}, + AdvertisedSize: a.AdvertisedSize, + } + if r, ok := resolutions[a.ID]; ok { + e.Tracked = true + e.ResolvedVersionID = r.resolvedVersionID + e.DeliveredVersionID = r.deliveredVersionID + e.SizeBytes = r.size + e.SHA256 = r.sha + e.CachePath = r.cachePath + e.DownloadedAt = r.downloadedAt + e.StoreFilename = r.storeFilename + } else if hasPrev { + e.Tracked = prev.Tracked + e.ResolvedVersionID = prev.ResolvedVersionID + e.DeliveredVersionID = prev.DeliveredVersionID + e.SizeBytes = prev.SizeBytes + e.SHA256 = prev.SHA256 + e.CachePath = prev.CachePath + e.DownloadedAt = prev.DownloadedAt + e.StoreFilename = prev.StoreFilename + } + + // An asset the run did not resolve keeps its prior key, so the key and the + // cachePath cannot drift apart between runs. + key := a.Slug() + if _, resolvedNow := resolutions[a.ID]; !resolvedNow && hasPrev { + if prevKey, _, ok := prior.FindByAssetID(a.ID); ok { + key = prevKey + } + } + out.Assets[key] = e + } + + if report != nil { + for _, e := range prior.Assets { + if !kept[e.AssetID] { + report.Removed = append(report.Removed, e) + } + } + } + return out +} + +func fromEntry(e lockfile.Entry) resolution { + return resolution{ + cachePath: e.CachePath, + sha: e.SHA256, + size: e.SizeBytes, + resolvedVersionID: e.ResolvedVersionID, + deliveredVersionID: e.DeliveredVersionID, + downloadedAt: e.DownloadedAt, + storeFilename: e.StoreFilename, + } +} + +func selected(a model.Asset, opts Options) bool { + if !opts.Selected[a.ID] { + return false + } + if opts.OnlyGlob == "" { + return true + } + ok, _ := filepath.Match(opts.OnlyGlob, a.Slug()) + return ok +} + +func humanBytes(n int64) string { + const unit = 1000 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for v := n / unit; v >= unit; v /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "kMGT"[exp]) +} diff --git a/internal/syncer/syncer_test.go b/internal/syncer/syncer_test.go new file mode 100644 index 0000000..22ed252 --- /dev/null +++ b/internal/syncer/syncer_test.go @@ -0,0 +1,635 @@ +package syncer + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/curbol/unity-sync/internal/cache" + "github.com/curbol/unity-sync/internal/lockfile" + "github.com/curbol/unity-sync/internal/manifest" + "github.com/curbol/unity-sync/internal/model" + "github.com/curbol/unity-sync/internal/store" +) + +// ---- fakes ----------------------------------------------------------------- + +type fakeStore struct { + owned []model.Asset + bodies map[string][]byte + fetchEr map[string]error + lookups map[string]model.Asset + + inFlight atomic.Int32 + maxSeen atomic.Int32 + hold time.Duration + mu sync.Mutex + fetched []string +} + +func (f *fakeStore) Enumerate(context.Context) ([]model.Asset, error) { return f.owned, nil } + +func (f *fakeStore) Lookup(_ context.Context, id string) (model.Asset, bool, error) { + a, ok := f.lookups[id] + return a, ok, nil +} + +func (f *fakeStore) Fetch(_ context.Context, id string) (*store.Download, error) { + n := f.inFlight.Add(1) + for { + max := f.maxSeen.Load() + if n <= max || f.maxSeen.CompareAndSwap(max, n) { + break + } + } + defer f.inFlight.Add(-1) + if f.hold > 0 { + time.Sleep(f.hold) + } + f.mu.Lock() + f.fetched = append(f.fetched, id) + f.mu.Unlock() + + if err := f.fetchEr[id]; err != nil { + return nil, err + } + body, ok := f.bodies[id] + if !ok { + return nil, store.ErrNotDownloadable + } + return &store.Download{Body: io.NopCloser(bytes.NewReader(body)), Filename: id + ".unitypackage"}, nil +} + +// pkg builds a package carrying a descriptor, padded to size. +func pkg(t *testing.T, productID, versionID string, size int) []byte { + t.Helper() + d := []byte(`{"id":"` + productID + `","version_id":"` + versionID + `"}`) + extra := []byte{'A', '$', 0, 0} + binary.LittleEndian.PutUint16(extra[2:4], uint16(len(d))) + extra = append(extra, d...) + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + zw.Header.Extra = extra + zw.Write(bytes.Repeat([]byte("x"), 32)) + zw.Close() + out := buf.Bytes() + for len(out) < size { + out = append(out, 0) + } + return out +} + +func asset(id, name string, versionID string, size int64) model.Asset { + return model.Asset{ + ID: id, Name: name, State: model.StatePublished, + Publisher: model.Publisher{ID: "p1", Name: "Pub One"}, + Version: model.Version{ID: versionID, Name: "1.0"}, + AdvertisedSize: size, + } +} + +func newRun(t *testing.T) (root, lockPath string) { + t.Helper() + root = t.TempDir() + return root, filepath.Join(t.TempDir(), "unity-sync.lock.json") +} + +func allSelected(assets ...model.Asset) map[string]bool { + m := map[string]bool{} + for _, a := range assets { + m[a.ID] = true + } + return m +} + +func opts(root string, sel map[string]bool) Options { + return Options{ + LibraryRoot: root, Selected: sel, Concurrency: 2, + Now: func() time.Time { return time.Unix(1700000000, 0).UTC() }, + } +} + +// ---- classify --------------------------------------------------------------- + +func TestClassifyCoversEveryClass(t *testing.T) { + yes := func() bool { return true } + no := func() bool { return false } + live := asset("1", "A", "v2", 1000) + tracked := lockfile.Entry{Tracked: true, ResolvedVersionID: "v2", CachePath: "p"} + + cases := []struct { + name string + a model.Asset + prior lockfile.Entry + hasPrior bool + cacheOK func() bool + adoptable func() bool + want Class + }{ + {"unchanged", live, tracked, true, yes, no, Unchanged}, + {"new", live, lockfile.Entry{}, false, no, no, New}, + {"changed", live, lockfile.Entry{Tracked: true, ResolvedVersionID: "v1", CachePath: "p"}, true, yes, no, Changed}, + {"download-now", live, lockfile.Entry{Tracked: false}, true, no, no, DownloadNow}, + {"cache-missing", live, tracked, true, no, no, CacheMissing}, + {"adopted with no record", live, lockfile.Entry{}, false, no, yes, Adopted}, + {"adopted when a record exists but nothing was mirrored", live, + lockfile.Entry{Tracked: false}, true, no, yes, Adopted}, + {"undownloadable", model.Asset{ID: "1", State: model.StateDisabled}, lockfile.Entry{}, false, no, no, Undownloadable}, + {"disabled but already mirrored stays usable", + model.Asset{ID: "1", State: model.StateDisabled, Version: model.Version{ID: "v2"}}, + tracked, true, yes, no, Unchanged}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := classify(tc.a, tc.prior, tc.hasPrior, tc.cacheOK, tc.adoptable); got != tc.want { + t.Errorf("classify = %v, want %v", got, tc.want) + } + }) + } +} + +// ---- lockfile coverage and carry-forward ------------------------------------- + +// The lockfile is the record of what is owned, not of what happens to be mirrored, and +// the adopt design depends on every owned asset having an entry. +func TestEveryOwnedAssetGetsAnEntryEvenWhenOnlyOneIsSelected(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Enabled", "v1", 500) + b := asset("2", "Not enabled", "v1", 500) + fs := &fakeStore{owned: []model.Asset{a, b}, bodies: map[string][]byte{"1": pkg(t, "1", "v1", 500)}} + + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, allSelected(a))) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(rep.Lockfile.Assets) != 2 { + t.Fatalf("lockfile has %d entries, want 2", len(rep.Lockfile.Assets)) + } + _, other, _ := rep.Lockfile.FindByAssetID("2") + if other.Tracked { + t.Error("the unselected asset was recorded as mirrored") + } + if other.Name != "Not enabled" { + t.Errorf("the unselected asset lost its advertised metadata: %+v", other) + } +} + +// The regression this guards is subtle: refreshing the advertised version beside a stale +// resolution would mark an unresolved asset as already current, forever. +func TestOnlyGlobPreservesOutOfScopeRecordsAndKeepsTheDiffKey(t *testing.T) { + root, lockPath := newRun(t) + inScope := asset("1", "In scope", "v2", 500) + outScope := asset("2", "Out of scope", "v2", 500) + + prior := lockfile.New() + prior.Assets["out-of-scope-2"] = lockfile.Entry{ + AssetID: "2", Name: "Out of scope", Tracked: true, + ResolvedVersionID: "v1", DeliveredVersionID: "v1", + SHA256: "old-sha", CachePath: "pub-one/out-of-scope-2/out-of-scope-2.unitypackage", + SizeBytes: 400, Version: lockfile.Version{ID: "v1"}, + } + + fs := &fakeStore{owned: []model.Asset{inScope, outScope}, bodies: map[string][]byte{"1": pkg(t, "1", "v2", 500)}} + o := opts(root, allSelected(inScope, outScope)) + o.OnlyGlob = "in-scope-1" + + rep, err := Run(context.Background(), fs, prior, lockPath, o) + if err != nil { + t.Fatalf("Run: %v", err) + } + _, out, ok := rep.Lockfile.FindByAssetID("2") + if !ok { + t.Fatal("the out-of-scope entry vanished") + } + if out.SHA256 != "old-sha" || out.CachePath == "" { + t.Errorf("out-of-scope resolution was not carried forward: %+v", out) + } + if out.Version.ID != "v2" { + t.Errorf("advertised version = %q, want the refreshed v2", out.Version.ID) + } + if out.ResolvedVersionID != "v1" { + t.Fatalf("resolvedVersionId = %q, want v1 carried verbatim; refreshing it would mark a "+ + "stale file as current forever", out.ResolvedVersionID) + } + // And it must still read as out of date next run. + if classify(outScope, out, true, func() bool { return true }, func() bool { return false }) != Changed { + t.Error("the carried-forward entry no longer classifies Changed") + } +} + +func TestRenamedAssetIsRecognisedByIdAndRekeyedOnce(t *testing.T) { + root, lockPath := newRun(t) + renamed := asset("1", "Brand New Name", "v1", 500) + + // Put a real file where the old slug says it is. + body := pkg(t, "1", "v1", 500) + p, err := cache.Store(root, "pub-one", "old-name-1", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + if err := p.Commit(); err != nil { + t.Fatal(err) + } + + prior := lockfile.New() + prior.Assets["old-name-1"] = lockfile.Entry{ + AssetID: "1", Name: "Old Name", Tracked: true, + ResolvedVersionID: "v1", DeliveredVersionID: "v1", + SizeBytes: p.Size, SHA256: p.SHA256, CachePath: p.RelPath, + Version: lockfile.Version{ID: "v1"}, + } + + fs := &fakeStore{owned: []model.Asset{renamed}} + rep, err := Run(context.Background(), fs, prior, lockPath, opts(root, allSelected(renamed))) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(fs.fetched) != 0 { + t.Errorf("a rename triggered a download of %v", fs.fetched) + } + if rep.Results[0].Class != Unchanged { + t.Errorf("class = %v, want Unchanged", rep.Results[0].Class) + } + if len(rep.Lockfile.Assets) != 1 { + t.Fatalf("lockfile has %d entries, want 1 — a rename must re-key, not duplicate", len(rep.Lockfile.Assets)) + } + if _, ok := rep.Lockfile.Assets["brand-new-name-1"]; !ok { + t.Errorf("entry was not re-keyed: %v", keys(rep.Lockfile)) + } + moved := filepath.Join(root, "pub-one", "brand-new-name-1", "brand-new-name-1.unitypackage") + if _, err := os.Stat(moved); err != nil { + t.Errorf("the cached directory did not move: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "pub-one", "old-name-1")); !os.IsNotExist(err) { + t.Error("the old directory survived, so quarry's pack facet would stay stale") + } +} + +func TestOwnershipDropIsReportedNotJustRemoved(t *testing.T) { + root, lockPath := newRun(t) + kept := asset("1", "Kept", "v1", 500) + prior := lockfile.New() + prior.Assets["kept-1"] = lockfile.Entry{AssetID: "1", Name: "Kept", Version: lockfile.Version{ID: "v1"}} + prior.Assets["gone-2"] = lockfile.Entry{ + AssetID: "2", Name: "Refunded", Tracked: true, + CachePath: "pub-one/gone-2/gone-2.unitypackage", SizeBytes: 900, + } + fs := &fakeStore{owned: []model.Asset{kept}, bodies: map[string][]byte{"1": pkg(t, "1", "v1", 500)}} + + rep, err := Run(context.Background(), fs, prior, lockPath, opts(root, allSelected(kept))) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(rep.Removed) != 1 || rep.Removed[0].AssetID != "2" { + t.Fatalf("Removed = %+v, want the no-longer-owned asset named", rep.Removed) + } + if _, _, ok := rep.Lockfile.FindByAssetID("2"); ok { + t.Error("the dropped asset is still in the lockfile") + } +} + +func TestEmptyEnumerationAgainstANonEmptyLockfileIsRefused(t *testing.T) { + root, lockPath := newRun(t) + prior := lockfile.New() + prior.Assets["a-1"] = lockfile.Entry{AssetID: "1"} + if err := lockfile.Save(lockPath, prior); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(lockPath) + + fs := &fakeStore{} + _, err := Run(context.Background(), fs, prior, lockPath, opts(root, nil)) + if !errors.Is(err, ErrEmptyLibrary) { + t.Fatalf("Run = %v, want ErrEmptyLibrary", err) + } + after, _ := os.ReadFile(lockPath) + if string(before) != string(after) { + t.Error("the refused run rewrote the lockfile anyway") + } +} + +func TestPreDownloadFailureWritesNoLockfileAtAll(t *testing.T) { + root, lockPath := newRun(t) + fs := &failingEnumerate{} + if _, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, nil)); err == nil { + t.Fatal("Run succeeded despite an enumeration failure") + } + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Error("a failure before any download still created a lockfile") + } +} + +type failingEnumerate struct{ fakeStore } + +func (f *failingEnumerate) Enumerate(context.Context) ([]model.Asset, error) { + return nil, errors.New("session expired") +} + +// ---- download guards ---------------------------------------------------------- + +func TestSemanticGuardsRejectAndDiscard(t *testing.T) { + good := pkg(t, "1", "v1", 2000) + + cases := []struct { + name string + body []byte + lookup *model.Asset // when set, the re-query reports this + wantOK bool + }{ + {name: "not gzip at all", body: []byte("sign in")}, + {name: "descriptor names another product", body: pkg(t, "999", "v1", 2000)}, + {name: "short body, re-query unchanged", body: pkg(t, "1", "v1", 100)}, + { + name: "short body, re-query shows a republish", + body: pkg(t, "1", "v1", 100), + lookup: &model.Asset{ID: "1", Version: model.Version{ID: "v2"}, AdvertisedSize: 4000}, + }, + {name: "20 bytes short is a warning only", body: pkg(t, "1", "v1", 1980), wantOK: true}, + {name: "exact", body: good, wantOK: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Asset", "v1", 2000) + fs := &fakeStore{owned: []model.Asset{a}, bodies: map[string][]byte{"1": tc.body}} + if tc.lookup != nil { + fs.lookups = map[string]model.Asset{"1": *tc.lookup} + } + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, allSelected(a))) + if err != nil { + t.Fatalf("Run: %v", err) + } + res := rep.Results[0] + if tc.wantOK { + if res.Err != nil { + t.Fatalf("Run rejected an acceptable body: %v", res.Err) + } + return + } + if res.Err == nil { + t.Fatal("Run accepted a body it should have refused") + } + // Nothing may survive at a real cache path. + final := filepath.Join(root, "pub-one", "asset-1", "asset-1.unitypackage") + if _, err := os.Stat(final); !os.IsNotExist(err) { + t.Error("a rejected body was committed to the cache") + } + if leftovers := tempsUnder(t, root); leftovers != 0 { + t.Errorf("%d temp files survived a rejected download", leftovers) + } + }) + } +} + +func tempsUnder(t *testing.T, root string) int { + t.Helper() + n := 0 + filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err == nil && !d.IsDir() && len(d.Name()) > 15 && d.Name()[:15] == ".unity-sync-dl-" { + n++ + } + return nil + }) + return n +} + +// ---- failure isolation and concurrency ---------------------------------------- + +// The template returns from Run on the first download error. Departing from that is a +// deliberate rule here, so it needs its own test. +func TestOneFailedAssetDoesNotStopTheRest(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Good one", "v1", 500) + b := asset("2", "Pulled", "v1", 500) + c := asset("3", "Also good", "v1", 500) + fs := &fakeStore{ + owned: []model.Asset{a, b, c}, + bodies: map[string][]byte{"1": pkg(t, "1", "v1", 500), "3": pkg(t, "3", "v1", 500)}, + fetchEr: map[string]error{"2": store.ErrNotDownloadable}, + } + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, allSelected(a, b, c))) + if err != nil { + t.Fatalf("Run: %v", err) + } + tracked := 0 + for _, e := range rep.Lockfile.Assets { + if e.Tracked { + tracked++ + } + } + if tracked != 2 { + t.Errorf("%d assets mirrored, want 2: one failure must not abort the others", tracked) + } + // A pulled asset is permanent, so it must not make every future run exit non-zero. + if rep.Permanent != 1 || rep.Retryable != 0 { + t.Errorf("Permanent=%d Retryable=%d, want 1 and 0", rep.Permanent, rep.Retryable) + } + if rep.Failed() { + t.Error("a permanently pulled asset made the run report failure") + } +} + +func TestConcurrencyCeilingIsHonoured(t *testing.T) { + root, lockPath := newRun(t) + var assets []model.Asset + bodies := map[string][]byte{} + for _, id := range []string{"1", "2", "3", "4", "5", "6"} { + a := asset(id, "Asset "+id, "v1", 500) + assets = append(assets, a) + bodies[id] = pkg(t, id, "v1", 500) + } + fs := &fakeStore{owned: assets, bodies: bodies, hold: 20 * time.Millisecond} + o := opts(root, allSelected(assets...)) + o.Concurrency = 2 + + if _, err := Run(context.Background(), fs, lockfile.New(), lockPath, o); err != nil { + t.Fatalf("Run: %v", err) + } + if got := fs.maxSeen.Load(); got > 2 { + t.Errorf("peak concurrent fetches = %d, want at most 2; the store is someone else's "+ + "infrastructure and this is the brief's one third-party requirement", got) + } + if got := fs.maxSeen.Load(); got < 2 { + t.Errorf("peak concurrent fetches = %d, want the pool actually used its budget", got) + } +} + +func TestProgressSurvivesAMidRunFailure(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "First", "v1", 500) + b := asset("2", "Breaks", "v1", 500) + fs := &fakeStore{ + owned: []model.Asset{a, b}, + bodies: map[string][]byte{"1": pkg(t, "1", "v1", 500)}, + fetchEr: map[string]error{"2": errors.New("connection reset")}, + } + o := opts(root, allSelected(a, b)) + o.Concurrency = 1 + + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, o) + if err != nil { + t.Fatalf("Run: %v", err) + } + if rep.Retryable != 1 { + t.Errorf("Retryable = %d, want 1", rep.Retryable) + } + saved, err := lockfile.Load(lockPath) + if err != nil { + t.Fatal(err) + } + _, first, _ := saved.FindByAssetID("1") + if !first.Tracked { + t.Error("the asset fetched before the failure was not persisted") + } +} + +// ---- dry run ------------------------------------------------------------------- + +func TestDryRunClassifiesAndTouchesNothing(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Asset", "v1", 500) + + // A stale temp and a package sitting off its derived path: a sync would sweep one + // and relocate the other. + leaf := filepath.Join(root, "pub-one", "elsewhere") + os.MkdirAll(leaf, 0o755) + os.WriteFile(filepath.Join(leaf, ".unity-sync-dl-stale"), []byte("junk"), 0o644) + os.WriteFile(filepath.Join(leaf, "elsewhere.unitypackage"), pkg(t, "1", "v1", 500), 0o644) + old := time.Unix(1600000000, 0) + os.Chtimes(filepath.Join(leaf, ".unity-sync-dl-stale"), old, old) + + before := treeSnapshot(t, root) + + fs := &fakeStore{owned: []model.Asset{a}, bodies: map[string][]byte{"1": pkg(t, "1", "v1", 500)}} + o := opts(root, allSelected(a)) + o.DryRun = true + + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, o) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(rep.Results) != 1 { + t.Fatalf("dry run produced %d results, want 1 — status must classify", len(rep.Results)) + } + if len(fs.fetched) != 0 { + t.Errorf("a dry run downloaded %v", fs.fetched) + } + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Error("a dry run wrote the lockfile") + } + if after := treeSnapshot(t, root); after != before { + t.Errorf("a dry run changed the library tree:\nbefore %v\nafter %v", before, after) + } +} + +func treeSnapshot(t *testing.T, root string) string { + t.Helper() + var out []byte + filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + rel, _ := filepath.Rel(root, p) + out = append(out, rel...) + if !d.IsDir() { + if fi, err := d.Info(); err == nil { + out = append(out, []byte(fmt.Sprint(fi.Size()))...) + } + } + out = append(out, '\n') + return nil + }) + return string(out) +} + +// ---- manifest reporting --------------------------------------------------------- + +func TestUnknownManifestIdsAreReported(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Owned", "v1", 500) + o := opts(root, allSelected(a)) + o.Manifest = manifest.Manifest{Assets: []manifest.Entry{ + {ID: "1", Name: "Owned", Enabled: true}, + {ID: "404", Name: "Refunded or mistyped", Enabled: true}, + }} + fs := &fakeStore{owned: []model.Asset{a}, bodies: map[string][]byte{"1": pkg(t, "1", "v1", 500)}} + + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, o) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(rep.Unknown) != 1 || rep.Unknown[0].ID != "404" { + t.Errorf("Unknown = %+v, want the id the account does not own; silence is the failure "+ + "mode this report exists to prevent", rep.Unknown) + } +} + +// ---- adoption --------------------------------------------------------------------- + +func TestAdoptionRecordsTheDiffKeyAndLeavesDownloadedAtEmpty(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Asset", "v1", 500) + body := pkg(t, "1", "v1", 500) + p, _ := cache.Store(root, a.PublisherSlug(), a.Slug(), bytes.NewReader(body)) + p.Commit() + + fs := &fakeStore{owned: []model.Asset{a}} + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, allSelected(a))) + if err != nil { + t.Fatalf("Run: %v", err) + } + if rep.Results[0].Class != Adopted { + t.Fatalf("class = %v, want Adopted", rep.Results[0].Class) + } + if len(fs.fetched) != 0 { + t.Errorf("adoption downloaded %v", fs.fetched) + } + _, e, _ := rep.Lockfile.FindByAssetID("1") + if e.ResolvedVersionID != "v1" { + t.Errorf("resolvedVersionId = %q; empty would make this asset re-download every run", e.ResolvedVersionID) + } + if e.DownloadedAt != "" { + t.Errorf("downloadedAt = %q, want empty: the file was found, not fetched", e.DownloadedAt) + } + if e.SHA256 == "" { + t.Error("an adopted entry has no digest, so --verify would prove nothing about it") + } +} + +func TestATruncatedFileIsNotAdopted(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Asset", "v1", 4000) + // Descriptor intact, body far short of what the store advertises. + p, _ := cache.Store(root, a.PublisherSlug(), a.Slug(), bytes.NewReader(pkg(t, "1", "v1", 200))) + p.Commit() + + fs := &fakeStore{owned: []model.Asset{a}, bodies: map[string][]byte{"1": pkg(t, "1", "v1", 4000)}} + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, allSelected(a))) + if err != nil { + t.Fatalf("Run: %v", err) + } + if rep.Results[0].Class == Adopted { + t.Fatal("a truncated package was adopted; the download floor exists to stop exactly this") + } + if len(fs.fetched) != 1 { + t.Errorf("fetched %v, want the asset re-downloaded", fs.fetched) + } +} + +func keys(lf lockfile.Lockfile) []string { + out := make([]string, 0, len(lf.Assets)) + for k := range lf.Assets { + out = append(out, k) + } + return out +} From 5c6ddb7b49f908ceade1db4f1c4af7ee09fac0a6 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 11:47:16 -0700 Subject: [PATCH 12/28] Serve the selection page with both ways of losing a selection closed 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- internal/web/web.go | 239 +++++++++++++++++++++++++++++++++++++++ internal/web/web_test.go | 148 ++++++++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 internal/web/web.go create mode 100644 internal/web/web_test.go diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..3699aae --- /dev/null +++ b/internal/web/web.go @@ -0,0 +1,239 @@ +// Package web serves the local page behind `unity-sync select`: every owned asset with +// its thumbnail and a checkbox, returning the chosen set so the caller can persist the +// manifest. It is the only command that writes the manifest, so the page is also where +// the guards against clobbering a curated file live. +package web + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "html/template" + "net" + "net/http" + "os/exec" + "runtime" + "sort" + "strings" + + "github.com/curbol/unity-sync/internal/model" +) + +// ErrWouldEmptySelection is returned when a save would clear every selection at once. +// A stale tab reopened after the library changed, or a mis-click on "none", should not +// silently wipe a curated allowlist. +var ErrWouldEmptySelection = errors.New("refusing a save that would deselect everything") + +// ErrStaleTab is returned when a POST does not carry this run's token, which means it +// came from a page some earlier run served. +var ErrStaleTab = errors.New("this page was served by an earlier run; reload and choose again") + +type row struct { + ID string + Name string + Publisher string + Size string + State string + Thumb string + Enabled bool +} + +type pageData struct { + Rows []row + Token string + Count int +} + +var page = template.Must(template.New("select").Parse(` +unity-sync select + +
+ unity-sync + + {{.Count}} selected + + + +
+
+ +
+{{range .Rows}} + +{{end}} +
+
+ +`)) + +// Selection is what the page returned. +type Selection map[string]bool + +// Handler renders the page and accepts one save. It is separated from Serve so the +// behaviour can be tested without a socket. +type Handler struct { + assets []model.Asset + enabled map[string]bool + token string + + done chan Selection + err chan error +} + +// NewHandler builds the page handler for one run. +func NewHandler(assets []model.Asset, enabled map[string]bool) *Handler { + buf := make([]byte, 16) + rand.Read(buf) + return &Handler{ + assets: assets, + enabled: enabled, + token: hex.EncodeToString(buf), + done: make(chan Selection, 1), + err: make(chan error, 1), + } +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + h.save(w, r) + return + } + sorted := append([]model.Asset(nil), h.assets...) + sort.Slice(sorted, func(i, j int) bool { return strings.ToLower(sorted[i].Name) < strings.ToLower(sorted[j].Name) }) + + data := pageData{Token: h.token} + for _, a := range sorted { + if h.enabled[a.ID] { + data.Count++ + } + data.Rows = append(data.Rows, row{ + ID: a.ID, + Name: a.Name, + Publisher: a.Publisher.Name, + Size: humanBytes(a.AdvertisedSize), + State: string(a.State), + Thumb: absoluteURL(a.ThumbnailURL), + Enabled: h.enabled[a.ID], + }) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + page.Execute(w, data) +} + +func (h *Handler) save(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + if r.PostFormValue("token") != h.token { + http.Error(w, ErrStaleTab.Error(), http.StatusConflict) + h.err <- ErrStaleTab + return + } + chosen := Selection{} + for _, id := range r.PostForm["asset"] { + chosen[id] = true + } + if len(chosen) == 0 && anyEnabled(h.enabled) { + http.Error(w, ErrWouldEmptySelection.Error(), http.StatusConflict) + h.err <- ErrWouldEmptySelection + return + } + fmt.Fprintf(w, "Saved %d selection(s). You can close this tab.", len(chosen)) + h.done <- chosen +} + +// Serve runs the page until it is saved once, the context ends, or a save is refused. +func Serve(ctx context.Context, addr string, assets []model.Asset, enabled map[string]bool) (Selection, error) { + h := NewHandler(assets, enabled) + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, err + } + srv := &http.Server{Handler: h} + go srv.Serve(ln) + defer srv.Close() + + url := "http://" + ln.Addr().String() + fmt.Println("select assets at", url) + openBrowser(url) + + select { + case sel := <-h.done: + return sel, nil + case err := <-h.err: + return nil, err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// absoluteURL fixes the store's protocol-relative image URLs, which would otherwise +// resolve to http:// on a page served from localhost. +func absoluteURL(u string) string { + if strings.HasPrefix(u, "//") { + return "https:" + u + } + return u +} + +func anyEnabled(m map[string]bool) bool { + for _, v := range m { + if v { + return true + } + } + return false +} + +func openBrowser(url string) { + var cmd string + switch runtime.GOOS { + case "darwin": + cmd = "open" + case "windows": + cmd = "explorer" + default: + cmd = "xdg-open" + } + exec.Command(cmd, url).Start() +} + +func humanBytes(n int64) string { + const unit = 1000 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for v := n / unit; v >= unit; v /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "kMGT"[exp]) +} diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..d2dcb10 --- /dev/null +++ b/internal/web/web_test.go @@ -0,0 +1,148 @@ +package web_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" + + "github.com/curbol/unity-sync/internal/model" + "github.com/curbol/unity-sync/internal/web" +) + +func assets() []model.Asset { + return []model.Asset{ + {ID: "115488", Name: "Quick Outline", State: model.StatePublished, + Publisher: model.Publisher{Name: "Chris Nolet"}, AdvertisedSize: 33824, + ThumbnailURL: "//assetstorev1-prd-cdn.unity3d.com/key-image/abc.png"}, + {ID: "193760", Name: "Fantasy Sounds Bundle", State: model.StateDisabled, + Publisher: model.Publisher{Name: "Cafofo"}, AdvertisedSize: 1000}, + } +} + +func render(t *testing.T, h http.Handler) string { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("GET = %d", rec.Code) + } + return rec.Body.String() +} + +var tokenRe = regexp.MustCompile(`name=token value="([0-9a-f]+)"`) + +func tokenFrom(t *testing.T, body string) string { + t.Helper() + m := tokenRe.FindStringSubmatch(body) + if m == nil { + t.Fatal("page carries no token") + } + return m[1] +} + +func TestPageRendersEveryAssetWithItsState(t *testing.T) { + h := web.NewHandler(assets(), map[string]bool{"115488": true}) + body := render(t, h) + + for _, want := range []string{"Quick Outline", "Chris Nolet", "Fantasy Sounds Bundle", "disabled"} { + if !strings.Contains(body, want) { + t.Errorf("page is missing %q", want) + } + } + if !strings.Contains(body, `value="115488" checked`) { + t.Error("the already-enabled asset is not checked") + } +} + +// The store returns protocol-relative image URLs; on a page served over http://localhost +// they would resolve to http:// and be blocked or broken. +func TestThumbnailsAreMadeAbsolute(t *testing.T) { + body := render(t, web.NewHandler(assets(), nil)) + if !strings.Contains(body, `src="https://assetstorev1-prd-cdn.unity3d.com/key-image/abc.png"`) { + t.Error("thumbnail URL was not normalised to https") + } + if strings.Contains(body, `src="//assetstore`) { + t.Error("page still carries a protocol-relative image URL") + } +} + +func TestSaveReturnsTheChosenSet(t *testing.T) { + h := web.NewHandler(assets(), map[string]bool{"115488": true}) + body := render(t, h) + + form := url.Values{"token": {tokenFrom(t, body)}, "asset": {"115488", "193760"}} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + done := make(chan struct{}) + go func() { h.ServeHTTP(rec, req); close(done) }() + <-done + + if rec.Code != http.StatusOK { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } +} + +// A page served by an earlier run still has a Save button. Honouring it would apply a +// selection made against a different library. +func TestStaleTabIsRefused(t *testing.T) { + h := web.NewHandler(assets(), map[string]bool{"115488": true}) + render(t, h) + + form := url.Values{"token": {"from-an-older-run"}, "asset": {"115488"}} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Errorf("POST from a stale tab = %d, want %d", rec.Code, http.StatusConflict) + } + if !strings.Contains(rec.Body.String(), "earlier run") { + t.Errorf("response %q does not explain the refusal", rec.Body) + } +} + +// select is the only command that writes the manifest, so clearing every selection at +// once has to be deliberate rather than a mis-click or a reloaded old tab. +func TestSaveThatWouldDeselectEverythingIsRefused(t *testing.T) { + h := web.NewHandler(assets(), map[string]bool{"115488": true}) + body := render(t, h) + + form := url.Values{"token": {tokenFrom(t, body)}} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Errorf("empty save = %d, want %d", rec.Code, http.StatusConflict) + } + if !strings.Contains(rec.Body.String(), "deselect everything") { + t.Errorf("response %q does not explain the refusal", rec.Body) + } +} + +// An empty save is legitimate when nothing was selected to begin with: that is a first +// run where the user decided not to pick anything yet. +func TestEmptySaveIsFineWhenNothingWasSelected(t *testing.T) { + h := web.NewHandler(assets(), map[string]bool{}) + body := render(t, h) + + form := url.Values{"token": {tokenFrom(t, body)}} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + done := make(chan struct{}) + go func() { h.ServeHTTP(rec, req); close(done) }() + <-done + + if rec.Code != http.StatusOK { + t.Errorf("empty save on a fresh manifest = %d, want 200", rec.Code) + } +} From bc465766fda6db337b1e2cea126480cd04ed72dd Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 11:48:59 -0700 Subject: [PATCH 13/28] Follow redirects when updating, unlike every other client here 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- internal/selfupdate/selfupdate.go | 258 +++++++++++++++++++++++++ internal/selfupdate/selfupdate_test.go | 166 ++++++++++++++++ 2 files changed, 424 insertions(+) create mode 100644 internal/selfupdate/selfupdate.go create mode 100644 internal/selfupdate/selfupdate_test.go diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go new file mode 100644 index 0000000..05b8b5e --- /dev/null +++ b/internal/selfupdate/selfupdate.go @@ -0,0 +1,258 @@ +// Package selfupdate replaces the running binary with a release build from GitHub. +// +// Its HTTP client follows redirects, unlike every client that talks to the Asset Store. +// That is deliberate: the repository is private, so the binary comes from the release +// *asset* API, which answers with a 302 to a signed CDN URL. Inheriting the store's +// redirect ban here would break updates entirely. +package selfupdate + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +const repo = "curbol/unity-sync" + +// Client is the GitHub API surface, injectable so tests need no network. +type Client struct { + http *http.Client + apiBase string + token string +} + +// New builds a client. A caller passing an empty base uses api.github.com. +func New(apiBase, token string) *Client { + if apiBase == "" { + apiBase = "https://api.github.com" + } + return &Client{ + // No CheckRedirect: the asset endpoint 302s to a signed CDN URL by design. + http: &http.Client{Timeout: 10 * time.Minute}, + apiBase: strings.TrimSuffix(apiBase, "/"), + token: token, + } +} + +// Token resolves a GitHub credential from the environment, falling back to the gh CLI. +// The repository is private, so an unauthenticated update cannot even list releases. +func Token() string { + for _, k := range []string{"GITHUB_TOKEN", "GH_TOKEN"} { + if v := os.Getenv(k); v != "" { + return v + } + } + out, err := exec.Command("gh", "auth", "token").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +type release struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + URL string `json:"url"` + } `json:"assets"` +} + +// PlatformAsset is the release archive name for the running platform. +func PlatformAsset(version string) (string, error) { + var os_, arch string + switch runtime.GOOS { + case "darwin": + os_ = "mac" + case "linux": + os_ = "linux" + case "windows": + os_ = "win" + default: + return "", fmt.Errorf("unsupported OS %s", runtime.GOOS) + } + switch runtime.GOARCH { + case "amd64": + arch = "intel" + case "arm64": + if os_ == "mac" { + arch = "apple" + } else { + arch = "arm64" + } + default: + return "", fmt.Errorf("unsupported architecture %s", runtime.GOARCH) + } + if os_ == "win" { + return fmt.Sprintf("unity-sync-%s-win.zip", version), nil + } + return fmt.Sprintf("unity-sync-%s-%s-%s.zip", version, os_, arch), nil +} + +func (c *Client) get(url, accept string) (*http.Response, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", accept) + req.Header.Set("User-Agent", "unity-sync") + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + return nil, fmt.Errorf("GET %s: status %d: %s", url, resp.StatusCode, strings.TrimSpace(string(body))) + } + return resp, nil +} + +// Resolve finds a release: the latest, or a specific version when one is named. +func (c *Client) Resolve(version string) (release, error) { + url := c.apiBase + "/repos/" + repo + "/releases/latest" + if version != "" { + url = c.apiBase + "/repos/" + repo + "/releases/tags/v" + strings.TrimPrefix(version, "v") + } + resp, err := c.get(url, "application/vnd.github+json") + if err != nil { + return release{}, err + } + defer resp.Body.Close() + var rel release + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return release{}, fmt.Errorf("decode release: %w", err) + } + if rel.TagName == "" { + return release{}, fmt.Errorf("release has no tag") + } + return rel, nil +} + +// DownloadBinary fetches the platform archive for a release and returns the binary +// inside it. +func (c *Client) DownloadBinary(rel release) ([]byte, error) { + want, err := PlatformAsset(strings.TrimPrefix(rel.TagName, "v")) + if err != nil { + return nil, err + } + var assetURL string + for _, a := range rel.Assets { + if a.Name == want { + assetURL = a.URL + break + } + } + if assetURL == "" { + return nil, fmt.Errorf("release %s has no asset %s", rel.TagName, want) + } + // The asset API answers with a 302 to a signed CDN URL; this client follows it. + resp, err := c.get(assetURL, "application/octet-stream") + if err != nil { + return nil, err + } + defer resp.Body.Close() + archive, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return binaryFromZip(archive) +} + +func binaryFromZip(archive []byte) ([]byte, error) { + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return nil, fmt.Errorf("release asset is not a zip: %w", err) + } + for _, f := range zr.File { + name := filepath.Base(f.Name) + if name != "unity-sync" && name != "unity-sync.exe" { + continue + } + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(rc) + } + return nil, fmt.Errorf("release asset contains no unity-sync binary") +} + +// Replace swaps the running executable for the given bytes, writing beside the target so +// the rename is atomic and cannot leave a half-written binary on PATH. +func Replace(targetPath string, binary []byte) error { + dir := filepath.Dir(targetPath) + tmp, err := os.CreateTemp(dir, ".unity-sync-update-*") + if err != nil { + return err + } + name := tmp.Name() + if _, err := tmp.Write(binary); err != nil { + tmp.Close() + os.Remove(name) + return err + } + if err := tmp.Chmod(0o755); err != nil { + tmp.Close() + os.Remove(name) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(name) + return err + } + if err := os.Rename(name, targetPath); err != nil { + os.Remove(name) + return err + } + return nil +} + +// Run performs an update of the running binary. +func Run(current, version string) error { + if current == "dev" { + return fmt.Errorf("this is a dev build; install a release first") + } + token := Token() + if token == "" { + return fmt.Errorf("no GitHub credential: set GITHUB_TOKEN or run `gh auth login` (the repo is private)") + } + c := New("", token) + rel, err := c.Resolve(version) + if err != nil { + return err + } + target := strings.TrimPrefix(rel.TagName, "v") + if target == current { + fmt.Printf("already on %s\n", current) + return nil + } + binary, err := c.DownloadBinary(rel) + if err != nil { + return err + } + self, err := os.Executable() + if err != nil { + return err + } + if self, err = filepath.EvalSymlinks(self); err != nil { + return err + } + if err := Replace(self, binary); err != nil { + return err + } + fmt.Printf("updated %s -> %s\n", current, target) + return nil +} diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go new file mode 100644 index 0000000..4b8c45d --- /dev/null +++ b/internal/selfupdate/selfupdate_test.go @@ -0,0 +1,166 @@ +package selfupdate_test + +import ( + "archive/zip" + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/curbol/unity-sync/internal/selfupdate" +) + +func zipWithBinary(t *testing.T, body string) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("unity-sync") + if err != nil { + t.Fatal(err) + } + w.Write([]byte(body)) + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// The private-repo asset API answers with a 302 to a signed CDN URL, so the test server +// really redirects. A client that inherited the Asset Store's redirect ban fails here. +func TestDownloadFollowsTheAssetRedirect(t *testing.T) { + archive := zipWithBinary(t, "#!/bin/true\n") + var cdn *httptest.Server + cdn = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.Write(archive) + })) + defer cdn.Close() + + var api *httptest.Server + api = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/releases/latest"): + name, err := selfupdate.PlatformAsset("9.9.9") + if err != nil { + t.Fatal(err) + } + json.NewEncoder(w).Encode(map[string]any{ + "tag_name": "v9.9.9", + "assets": []map[string]string{ + {"name": "unrelated.txt", "url": api.URL + "/assets/1"}, + {"name": name, "url": api.URL + "/assets/2"}, + }, + }) + case r.URL.Path == "/assets/2": + if got := r.Header.Get("Accept"); got != "application/octet-stream" { + t.Errorf("asset request Accept = %q", got) + } + http.Redirect(w, r, cdn.URL+"/signed", http.StatusFound) + default: + http.NotFound(w, r) + } + })) + defer api.Close() + + c := selfupdate.New(api.URL, "token") + rel, err := c.Resolve("") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + binary, err := c.DownloadBinary(rel) + if err != nil { + t.Fatalf("DownloadBinary: %v — a client with the store's redirect ban fails exactly here", err) + } + if string(binary) != "#!/bin/true\n" { + t.Errorf("binary = %q", binary) + } +} + +func TestResolveSendsTheTokenAndCanPinAVersion(t *testing.T) { + var seenAuth, seenPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenAuth = r.Header.Get("Authorization") + seenPath = r.URL.Path + json.NewEncoder(w).Encode(map[string]any{"tag_name": "v1.2.3"}) + })) + defer srv.Close() + + c := selfupdate.New(srv.URL, "secret-token") + if _, err := c.Resolve("1.2.3"); err != nil { + t.Fatalf("Resolve: %v", err) + } + if seenAuth != "Bearer secret-token" { + t.Errorf("Authorization = %q; the repo is private and an unauthenticated call cannot even list releases", seenAuth) + } + if !strings.HasSuffix(seenPath, "/releases/tags/v1.2.3") { + t.Errorf("path = %q, want the pinned tag", seenPath) + } +} + +func TestMissingPlatformAssetIsNamed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "tag_name": "v9.9.9", + "assets": []map[string]string{{"name": "unity-sync-9.9.9-somethingelse.zip", "url": "http://x"}}, + }) + })) + defer srv.Close() + + c := selfupdate.New(srv.URL, "t") + rel, err := c.Resolve("") + if err != nil { + t.Fatal(err) + } + _, err = c.DownloadBinary(rel) + if err == nil || !strings.Contains(err.Error(), "no asset") { + t.Errorf("DownloadBinary = %v, want a complaint naming the missing asset", err) + } +} + +func TestReplaceIsAtomicAndExecutable(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "unity-sync") + if err := os.WriteFile(target, []byte("old binary"), 0o755); err != nil { + t.Fatal(err) + } + if err := selfupdate.Replace(target, []byte("new binary")); err != nil { + t.Fatalf("Replace: %v", err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != "new binary" { + t.Errorf("content = %q", got) + } + fi, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm()&0o111 == 0 { + t.Errorf("mode = %v, want an executable bit", fi.Mode().Perm()) + } + // The swap must not leave scratch files on PATH. + entries, _ := os.ReadDir(dir) + if len(entries) != 1 { + names := []string{} + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("directory holds %v, want just the binary", names) + } +} + +func TestPlatformAssetNamesAreVersioned(t *testing.T) { + got, err := selfupdate.PlatformAsset("1.2.3") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(got, "unity-sync-1.2.3-") || !strings.HasSuffix(got, ".zip") { + t.Errorf("PlatformAsset = %q", got) + } +} From 4cb51180ca81849ad19b48f19f05664735faebde Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 11:50:58 -0700 Subject: [PATCH 14/28] Dispatch the subcommands, refusing a positional that would hide a flag 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- main.go | 333 +++++++++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 178 +++++++++++++++++++++++++++ 2 files changed, 511 insertions(+) create mode 100644 main.go create mode 100644 main_test.go diff --git a/main.go b/main.go new file mode 100644 index 0000000..153eeb1 --- /dev/null +++ b/main.go @@ -0,0 +1,333 @@ +// Command unity-sync mirrors the assets owned on the Unity Asset Store into a local +// library, downloading only what changed since the last run. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "sort" + "strings" + "syscall" + + "github.com/curbol/unity-sync/internal/config" + "github.com/curbol/unity-sync/internal/lockfile" + "github.com/curbol/unity-sync/internal/manifest" + "github.com/curbol/unity-sync/internal/selfupdate" + "github.com/curbol/unity-sync/internal/session" + "github.com/curbol/unity-sync/internal/store" + "github.com/curbol/unity-sync/internal/syncer" + "github.com/curbol/unity-sync/internal/web" +) + +// version is stamped at release time. +var version = "dev" + +const defaultSelectAddr = "127.0.0.1:8788" + +// stdout is where the tool's own output goes, so tests can capture it. Progress and +// diagnostics stay on stderr. +var stdout io.Writer = os.Stdout + +func main() { + code, err := run(os.Args[1:]) + if err != nil { + fmt.Fprintln(os.Stderr, "unity-sync:", err) + if code == 0 { + code = 1 + } + } + os.Exit(code) +} + +func run(args []string) (int, error) { + if len(args) == 0 { + usage() + return 1, errors.New("a subcommand is required") + } + cmd, rest := args[0], args[1:] + + fs := flag.NewFlagSet(cmd, flag.ContinueOnError) + fs.SetOutput(io.Discard) + cfgDir := fs.String("config", "", "user config dir (default $XDG_CONFIG_HOME/unity-sync)") + manifestFlag := fs.String("manifest", "", "project manifest path (default: nearest unity-sync.toml walking up)") + sessionFlag := fs.String("session", "", "session file: a pasted-curl or cookies.txt export") + library := fs.String("library", "", "library directory (overrides config / UNITY_SYNC_LIBRARY)") + only := fs.String("only", "", "limit to assets whose slug matches this glob") + concurrency := fs.Int("concurrency", 0, "max simultaneous downloads (overrides config)") + verify := fs.Bool("verify", false, "re-hash cached files instead of the cheap size+metadata check") + dryRun := fs.Bool("dry-run", false, "on sync, classify and report only") + addr := fs.String("addr", defaultSelectAddr, "on select, the address to serve the page at") + + switch cmd { + case "select", "status", "sync", "list", "update", "version", + "-h", "--help", "help", "-v", "--version": + default: + usage() + return 1, fmt.Errorf("unknown subcommand %q", cmd) + } + switch cmd { + case "-h", "--help", "help": + usage() + return 0, nil + case "version", "-v", "--version": + fmt.Fprintln(stdout, "unity-sync", version) + return 0, nil + } + if err := fs.Parse(rest); err != nil { + if errors.Is(err, flag.ErrHelp) { + usage() + return 0, nil + } + return 1, err + } + + // flag.Parse stops at the first non-flag argument, so an unchecked positional would + // silently swallow every flag after it: `sync foo --dry-run` would download. + if cmd == "update" { + if fs.NArg() > 1 { + return 1, fmt.Errorf("update takes at most one version, got %d arguments", fs.NArg()) + } + return 0, selfupdate.Run(version, fs.Arg(0)) + } + if fs.NArg() > 0 { + return 1, fmt.Errorf("%s takes no positional arguments (got %q); to limit assets use --only %s", + cmd, fs.Arg(0), fs.Arg(0)) + } + + configDir := config.ResolveDir(*cfgDir) + cfg, err := config.Load(configDir) + if err != nil { + return 1, err + } + if *library != "" { + cfg.LibraryPath = *library + } + if *concurrency > 0 { + cfg.Concurrency = *concurrency + } + if *sessionFlag != "" { + cfg.SessionSource = *sessionFlag + } + + manifestPath, err := resolveManifest(*manifestFlag, cmd) + if err != nil { + return 1, err + } + lockPath := manifest.LockPath(manifestPath) + + if cmd == "list" { + return 0, list(stdout, lockPath) + } + + cookie, err := resolveSession(cfg, configDir) + if err != nil { + return 1, err + } + client := store.New(cookie, version) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := client.Bootstrap(ctx); err != nil { + return 1, err + } + + if cmd == "select" { + return 0, selectAssets(ctx, client, manifestPath, *addr) + } + return syncOrStatus(ctx, client, cfg, manifestPath, lockPath, *only, *verify, cmd == "status" || *dryRun) +} + +// resolveManifest finds the project manifest. Every command needs one except select, +// which creates it in the working directory when no ancestor has one. +func resolveManifest(flagValue, cmd string) (string, error) { + if flagValue != "" { + return flagValue, nil + } + wd, err := os.Getwd() + if err != nil { + return "", err + } + if p, ok := manifest.Discover(wd); ok { + return p, nil + } + if cmd == "select" { + return filepath.Join(wd, manifest.FileName), nil + } + return "", fmt.Errorf("no %s found in this directory or its parents; run `unity-sync select` to create one", + manifest.FileName) +} + +func resolveSession(cfg config.Config, configDir string) (string, error) { + src := cfg.SessionSource + if src == "" { + if found, ok := session.Discover(configDir); ok { + src = found + } + } + if src == "" { + return "", fmt.Errorf("no session configured: save a pasted-curl file as %s, "+ + "set session_source in config.toml, or pass --session", + filepath.Join(configDir, "session.curl")) + } + return session.Resolve(src) +} + +func selectAssets(ctx context.Context, client *store.Client, manifestPath, addr string) error { + m, err := manifest.Load(manifestPath) + if err != nil { + return err + } + owned, err := client.Enumerate(ctx) + if err != nil { + return err + } + dropped, err := m.Reconcile(owned) + if err != nil { + return err + } + for _, e := range dropped { + fmt.Fprintf(os.Stderr, "no longer owned, dropping from the manifest: %s (%s)\n", e.Name, e.ID) + } + chosen, err := web.Serve(ctx, addr, owned, m.EnabledIDs()) + if err != nil { + return err + } + m.SetEnabled(chosen) + if err := manifest.Save(manifestPath, m); err != nil { + return err + } + fmt.Fprintf(stdout, "saved %d selected asset(s) to %s\n", len(chosen), manifestPath) + return nil +} + +func syncOrStatus(ctx context.Context, client *store.Client, cfg config.Config, + manifestPath, lockPath, only string, verify, dry bool) (int, error) { + + m, err := manifest.Load(manifestPath) + if err != nil { + return 1, err + } + prior, err := lockfile.Load(lockPath) + if err != nil { + return 1, err + } + selected := m.EnabledIDs() + if len(selected) == 0 { + fmt.Fprintln(os.Stderr, "nothing is enabled in", manifestPath, "— run `unity-sync select` to choose assets") + } + + rep, err := syncer.Run(ctx, client, prior, lockPath, syncer.Options{ + LibraryRoot: cfg.LibraryPath, + Selected: selected, + OnlyGlob: only, + DryRun: dry, + FullVerify: verify, + Concurrency: cfg.Concurrency, + Manifest: m, + Progress: func(s string) { fmt.Fprintln(os.Stderr, s) }, + }) + if err != nil { + return 1, err + } + printReport(stdout, rep, dry, cfg.LibraryPath) + if rep.Failed() { + return 1, nil + } + return 0, nil +} + +func printReport(w io.Writer, rep syncer.Report, dry bool, libraryPath string) { + counts := map[string]int{} + for _, r := range rep.Results { + counts[r.Class.String()]++ + } + verb := "sync" + if dry { + verb = "status (no changes made)" + } + fmt.Fprintf(w, "%s: %d asset(s) considered\n", verb, len(rep.Results)) + for _, class := range []string{"new", "changed", "download-now", "cache-missing", "adopted", "unchanged", "undownloadable"} { + if n := counts[class]; n > 0 { + fmt.Fprintf(w, " %-15s %d\n", class, n) + } + } + if rep.Swept > 0 { + fmt.Fprintf(w, " reclaimed %d abandoned download(s)\n", rep.Swept) + } + fmt.Fprintf(w, "library: %s\n", libraryPath) + + for _, r := range rep.Results { + if r.Warning != "" { + fmt.Fprintf(w, "warning: %s\n", r.Warning) + } + } + for _, r := range rep.Results { + if r.Err != nil { + fmt.Fprintf(w, "failed: %s: %v\n", r.Asset.Name, r.Err) + } + } + // A dropped asset leaves its bytes on disk; the summary names them so the user can + // decide, because this tool never deletes a package it once mirrored. + for _, e := range rep.Removed { + if e.CachePath != "" { + fmt.Fprintf(w, "no longer owned: %s — %s (%d bytes) left in place\n", e.Name, e.CachePath, e.SizeBytes) + } else { + fmt.Fprintf(w, "no longer owned: %s\n", e.Name) + } + } + for _, e := range rep.Unknown { + fmt.Fprintf(w, "manifest lists asset %s (%s), which this account does not own\n", e.ID, e.Name) + } +} + +func list(w io.Writer, lockPath string) error { + lf, err := lockfile.Load(lockPath) + if err != nil { + return err + } + if len(lf.Assets) == 0 { + fmt.Fprintln(w, "no lockfile yet at", lockPath) + return nil + } + keys := make([]string, 0, len(lf.Assets)) + for k := range lf.Assets { + keys = append(keys, k) + } + sort.Strings(keys) + + mirrored := 0 + for _, k := range keys { + e := lf.Assets[k] + state := "owned" + if e.Tracked { + state = "mirrored" + mirrored++ + } + fmt.Fprintf(w, "%-10s %-10s %-8s %s\n", state, e.Version.Name, e.AssetID, e.Name) + } + fmt.Fprintf(w, "\n%d owned, %d mirrored\n", len(keys), mirrored) + return nil +} + +func usage() { + fmt.Fprint(os.Stderr, strings.TrimLeft(` +unity-sync mirrors the assets you own on the Unity Asset Store. + + unity-sync select pick which assets to mirror (opens a local page) + unity-sync status what a sync would change; downloads nothing + unity-sync sync download the delta and update the lockfile + unity-sync list print the current lockfile + unity-sync update replace this binary with the latest release + unity-sync version print the installed version + +Flags: --config --manifest --session --library --only --concurrency --verify + --dry-run --addr +`, "\n")) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..4c10ff2 --- /dev/null +++ b/main_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/curbol/unity-sync/internal/lockfile" + "github.com/curbol/unity-sync/internal/manifest" +) + +func capture(t *testing.T) *bytes.Buffer { + t.Helper() + buf := &bytes.Buffer{} + old := stdout + stdout = buf + t.Cleanup(func() { stdout = old }) + return buf +} + +// isolate keeps a developer's real config, session and library out of the tests. +func isolate(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) + t.Setenv("XDG_DATA_HOME", filepath.Join(home, "data")) + for _, k := range []string{"UNITY_SYNC_CONFIG_DIR", "UNITY_SYNC_LIBRARY", "UNITY_SYNC_SESSION"} { + os.Unsetenv(k) + } + wd := t.TempDir() + prev, _ := os.Getwd() + if err := os.Chdir(wd); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(prev) }) + return wd +} + +func TestVersionAndHelpSucceed(t *testing.T) { + isolate(t) + out := capture(t) + code, err := run([]string{"version"}) + if code != 0 || err != nil { + t.Fatalf("version = %d, %v", code, err) + } + if !strings.Contains(out.String(), "unity-sync") { + t.Errorf("version printed %q", out) + } + if code, err := run([]string{"help"}); code != 0 || err != nil { + t.Errorf("help = %d, %v", code, err) + } +} + +func TestUnknownSubcommandFails(t *testing.T) { + isolate(t) + code, err := run([]string{"frobnicate"}) + if code == 0 || err == nil { + t.Fatalf("unknown subcommand = %d, %v; want a failure", code, err) + } + if !strings.Contains(err.Error(), "frobnicate") { + t.Errorf("error %q does not name the subcommand", err) + } +} + +// flag.Parse stops at the first positional, so an unchecked one swallows the flags after +// it: `sync foo --dry-run` would download. +func TestStrayPositionalIsRejectedAndSuggestsOnly(t *testing.T) { + isolate(t) + for _, cmd := range []string{"sync", "status", "list", "select"} { + code, err := run([]string{cmd, "some-asset", "--dry-run"}) + if code == 0 || err == nil { + t.Errorf("%s with a positional = %d, %v; want a failure", cmd, code, err) + continue + } + if !strings.Contains(err.Error(), "--only") { + t.Errorf("%s: error %q does not point at --only", cmd, err) + } + } +} + +// update is the one subcommand that takes a positional. +func TestUpdateAcceptsOneVersionAndRejectsTwo(t *testing.T) { + isolate(t) + code, err := run([]string{"update", "1.2.3", "4.5.6"}) + if code == 0 || err == nil || !strings.Contains(err.Error(), "at most one") { + t.Errorf("update with two versions = %d, %v", code, err) + } + // One argument gets past dispatch and fails later, on the dev-build guard. + _, err = run([]string{"update", "1.2.3"}) + if err == nil || !strings.Contains(err.Error(), "dev build") { + t.Errorf("update on a dev build = %v, want the dev-build guard", err) + } +} + +func TestCommandsThatNeedAManifestSayHowToMakeOne(t *testing.T) { + isolate(t) + for _, cmd := range []string{"status", "sync"} { + code, err := run([]string{cmd}) + if code == 0 || err == nil { + t.Fatalf("%s with no manifest = %d, %v; want a failure", cmd, code, err) + } + if !strings.Contains(err.Error(), "select") { + t.Errorf("%s: error %q does not tell the user how to create one", cmd, err) + } + } +} + +// list reads only the lockfile, so it must work with no session and no network. +func TestListNeedsNoSession(t *testing.T) { + wd := isolate(t) + out := capture(t) + + manifestPath := filepath.Join(wd, manifest.FileName) + if err := os.WriteFile(manifestPath, []byte("\n"), 0o644); err != nil { + t.Fatal(err) + } + lf := lockfile.New() + lf.Assets["quick-outline-115488"] = lockfile.Entry{ + AssetID: "115488", Name: "Quick Outline", Tracked: true, + Version: lockfile.Version{ID: "683375", Name: "1.1"}, + } + lf.Assets["owned-only-999"] = lockfile.Entry{ + AssetID: "999", Name: "Owned But Not Mirrored", + Version: lockfile.Version{ID: "1", Name: "1.0"}, + } + if err := lockfile.Save(manifest.LockPath(manifestPath), lf); err != nil { + t.Fatal(err) + } + + code, err := run([]string{"list"}) + if code != 0 || err != nil { + t.Fatalf("list = %d, %v", code, err) + } + body := out.String() + for _, want := range []string{"Quick Outline", "Owned But Not Mirrored", "2 owned, 1 mirrored"} { + if !strings.Contains(body, want) { + t.Errorf("list output is missing %q:\n%s", want, body) + } + } +} + +// Without a session every networked command must fail with advice, not a stack trace or +// an opaque HTTP error. +func TestMissingSessionIsExplained(t *testing.T) { + wd := isolate(t) + if err := os.WriteFile(filepath.Join(wd, manifest.FileName), []byte("\n"), 0o644); err != nil { + t.Fatal(err) + } + code, err := run([]string{"status"}) + if code == 0 || err == nil { + t.Fatalf("status with no session = %d, %v; want a failure", code, err) + } + if !strings.Contains(err.Error(), "session") { + t.Errorf("error %q does not mention the session", err) + } +} + +func TestSessionWithoutTheCredentialIsNamedBeforeAnyRequest(t *testing.T) { + wd := isolate(t) + if err := os.WriteFile(filepath.Join(wd, manifest.FileName), []byte("\n"), 0o644); err != nil { + t.Fatal(err) + } + sessionPath := filepath.Join(t.TempDir(), "session.curl") + body := "curl 'https://assetstore.unity.com/' -H 'Cookie: DS=abc; _csrf=zzz'" + if err := os.WriteFile(sessionPath, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + code, err := run([]string{"status", "--session", sessionPath}) + if code == 0 || err == nil { + t.Fatalf("status = %d, %v; want a failure", code, err) + } + if !strings.Contains(err.Error(), "LS") { + t.Errorf("error %q does not name the missing credential cookie", err) + } +} From 64e289c4004a632c61c319052846b721f337e702 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 11:51:56 -0700 Subject: [PATCH 15/28] Gate a release on exactly the checks a merge already passed 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- .github/workflows/ci.yml | 46 +++++++++++++++++++ .github/workflows/release.yml | 74 ++++++++++++++++++++++++++++++ install.sh | 86 +++++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100755 install.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5a4e453 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + # Called by release.yml as its gate, so the bar for tagging is exactly the bar for + # merging rather than a second copy that drifts. + workflow_call: + +permissions: + contents: read + +# A push to a PR branch would otherwise run twice, and superseded runs on the same ref +# are pointless once a newer commit exists. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - run: go build ./... + + - name: gofmt + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "unformatted files:" + echo "$unformatted" + exit 1 + fi + + - run: go vet ./... + + # The syncer fans out downloads under a semaphore and persists the lockfile from + # each goroutine, so the suite is worth running under the detector. No network and + # no session are needed: everything runs against httptest servers and the + # committed, scrubbed fixtures. + - run: go test -race ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..eb3c7c9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + check-branch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Verify tag is on main + run: | + git fetch origin main + git merge-base --is-ancestor ${{ github.sha }} origin/main || { + echo "Error: tag must point to a commit on main" + exit 1 + } + + test: + needs: check-branch + uses: ./.github/workflows/ci.yml + + release: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Get version from tag + id: version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Build release artifacts + env: + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + mkdir -p dist + + platforms=( + "darwin/amd64/mac-intel" + "darwin/arm64/mac-apple" + "linux/amd64/linux-intel" + "linux/arm64/linux-arm64" + "windows/amd64/win" + ) + + for p in "${platforms[@]}"; do + IFS='/' read -r goos goarch label <<< "$p" + bin="unity-sync" + if [ "$goos" = "windows" ]; then bin="unity-sync.exe"; fi + + CGO_ENABLED=0 GOOS=$goos GOARCH=$goarch go build \ + -ldflags "-X main.version=${VERSION}" \ + -o "dist/$bin" . + (cd dist && zip "unity-sync-${VERSION}-${label}.zip" "$bin" && rm "$bin") + done + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: dist/*.zip diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..9b6f60a --- /dev/null +++ b/install.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# unity-sync installer. Downloads the latest release binary for your platform into +# ~/.local/bin. The repo is private, so it authenticates with GITHUB_TOKEN, GH_TOKEN, +# or the gh CLI. +# +# Usage: +# gh api repos/curbol/unity-sync/contents/install.sh --jq .content | base64 -d | bash +set -euo pipefail + +REPO="curbol/unity-sync" +BINARY_NAME="unity-sync" +INSTALL_DIR="${HOME}/.local/bin" + +log() { printf 'INFO: %s\n' "$1"; } +err() { printf 'ERROR: %s\n' "$1" >&2; } + +auth_header() { + local token="${GITHUB_TOKEN:-${GH_TOKEN:-}}" + if [[ -z "$token" ]] && command -v gh >/dev/null 2>&1; then + token=$(gh auth token 2>/dev/null || true) + fi + [[ -n "$token" ]] && echo "Authorization: token $token" +} + +detect_platform() { + local os arch + case "$(uname -s)" in + Darwin*) os="mac" ;; + Linux*) os="linux" ;; + *) err "unsupported OS $(uname -s); on Windows use the release zip directly"; exit 1 ;; + esac + case "$(uname -m)" in + x86_64|amd64) arch="intel" ;; + arm64|aarch64) [[ "$os" == "mac" ]] && arch="apple" || arch="arm64" ;; + *) err "unsupported arch $(uname -m)"; exit 1 ;; + esac + PLATFORM="${os}-${arch}" + log "platform: $PLATFORM" +} + +latest_version() { + local hdr; hdr=$(auth_header) + local opts=(-fsSL); [[ -n "$hdr" ]] && opts+=(-H "$hdr") + VERSION=$(curl "${opts[@]}" "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + VERSION=${VERSION#v} + [[ -n "$VERSION" ]] || { err "could not resolve latest version (private repo needs gh auth or GITHUB_TOKEN)"; exit 1; } + log "latest version: $VERSION" +} + +install() { + local file="${BINARY_NAME}-${VERSION}-${PLATFORM}.zip" + local tmp; tmp=$(mktemp -d) + local hdr; hdr=$(auth_header) + local url + if [[ -n "$hdr" ]]; then + # Private repo: resolve the asset's API URL, then download with the token. + url=$(curl -fsSL -H "$hdr" "https://api.github.com/repos/${REPO}/releases/tags/v${VERSION}" \ + | grep -F -B3 "\"name\": \"${file}\"" | grep -F '"url"' | sed -E 's/.*"url": "([^"]+)".*/\1/') + [[ -n "$url" ]] || { err "asset ${file} not found in release v${VERSION}"; rm -rf "$tmp"; exit 1; } + curl -fsSL -H "$hdr" -H "Accept: application/octet-stream" -o "${tmp}/${file}" "$url" + else + curl -fsSL -o "${tmp}/${file}" "https://github.com/${REPO}/releases/download/v${VERSION}/${file}" + fi + + command -v unzip >/dev/null 2>&1 || { err "unzip is required"; rm -rf "$tmp"; exit 1; } + unzip -q "${tmp}/${file}" -d "$tmp" + mkdir -p "$INSTALL_DIR" + mv "${tmp}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" + chmod +x "${INSTALL_DIR}/${BINARY_NAME}" + rm -rf "$tmp" + log "installed to ${INSTALL_DIR}/${BINARY_NAME}" +} + +check_path() { + case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) log "note: $INSTALL_DIR is not on your PATH; add: export PATH=\"$INSTALL_DIR:\$PATH\"" ;; + esac +} + +detect_platform +latest_version +install +check_path +"${INSTALL_DIR}/${BINARY_NAME}" version || err "installed but 'unity-sync version' failed" From 6f3b9e3fac7f8c98d0c420377efbdd7cd242ad24 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 11:54:21 -0700 Subject: [PATCH 16/28] Document what the store actually does, and why each guard exists 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- CLAUDE.md | 82 +++++++++++++++ README.md | 163 +++++++++++++++++++++++++++++ config.example.toml | 25 +++++ docs/design.md | 224 ++++++++++++++++++++++++++++++++++++++++ unity-sync.example.toml | 17 +++ 5 files changed, 511 insertions(+) create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 config.example.toml create mode 100644 docs/design.md create mode 100644 unity-sync.example.toml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b0ffafe --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,82 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`unity-sync` is a Go CLI that mirrors the assets owned on the Unity Asset Store into a +local library, downloading only what changed since the last run. See `README.md` +(user-facing) and `docs/design.md` (the authoritative design doc: measured store +behaviour, identity rules, the failure model). Read `docs/design.md` before changing +enumeration, the lockfile, the cache layout, or the download guards. + +Browsing the mirrored library is a separate tool, +[quarry](https://github.com/curbol/quarry). This repo acquires files; quarry reads them. + +## Build & test + +```bash +go build -o unity-sync . # requires Go 1.26+, no cgo +go test ./... # full suite, fully offline +go test -race ./... # what CI runs +go test ./internal/syncer/ -run TestClassify -v +go vet ./... +gofmt -l . +``` + +No Makefile or task runner; use the `go` toolchain directly. The suite needs no network +and no session: everything runs against `httptest` servers and the committed, scrubbed +fixtures in `testdata/store/`. + +## Architecture + +`main.go` `run()` parses flags and dispatches `select`, `status`, `sync`, `list`, `update` +and `version`, returning an exit code alongside its error. Layered `internal/` packages, +each with a package doc comment stating its contract: + +- `model` — domain types and the identity rules. Carries `id` (the store product id) and + deliberately not `productId`, which is a different value no endpoint accepts. +- `config` — user settings by precedence: defaults → `config.toml` → env → flags. There is + no browser session default, because a browser session cannot work here. +- `session` — builds the Cookie header from a pasted curl file or a `cookies.txt`, and + asserts the `LS` cookie is present before any request. +- `retry` — backoff policy. `retry.Permanent` lets a caller stop on a body-based verdict + that the status code alone would have retried. +- `unitypackage` — reads the store descriptor from a package's gzip FEXTRA field. +- `store` — the Asset Store client and the response-level download guards. +- `cache` — the local mirror. Two-phase writes (`Store` → `Commit`/`Discard`), adopt by + scan, relocate on rename, temp sweep, root confinement. +- `lockfile` — `unity-sync.lock.json`, advertised fields kept apart from resolution fields. +- `manifest` — `unity-sync.toml`, the committed allowlist keyed by asset id. +- `syncer` — orchestration, the pure `classify`, and the semantic download guards. +- `web` — the `select` page. +- `selfupdate` — the `update` subcommand. +- `fixtures` + `cmd/scrubfixtures` — regenerate PII-free `testdata/` from raw captures. + +### Key invariants (don't break these) + +- **`LS` is the credential.** Not the NextAuth session token, which neither endpoint + consults. Its absence is reported before any request, because the store answers a + missing `LS` with an opaque 500. +- **No store client follows a redirect.** An unauthenticated download 302s to Unity's + OAuth page. `selfupdate` is the deliberate exception: it talks to GitHub, whose asset + API 302s to a signed CDN URL by design. +- **Downloads ask for `Accept-Encoding: identity`.** The endpoint honours gzip by + gzipping the already-gzipped package, and Go will not decode an encoding the caller + requested. +- **`resolvedVersionId` is the diff key**, not the advertised `version.id`. The advertised + value refreshes every run; pairing a refreshed id with an unresolved entry's file would + mark it current forever. +- **Nothing unverified reaches a real cache path.** `cache.Store` does not rename; + `Commit` does, after the syncer's guards pass. +- **A failed download fails its asset, not the run**, and a pulled asset does not make the + run exit non-zero. +- **Only `select` writes the manifest.** `status` and `sync` read it. +- **No account data in the repo.** Sessions and raw captures stay out; the + `internal/fixtures` guard test fails the build if any reaches `testdata/`. + +## Editing testdata + +Don't hand-edit `testdata/store/*.json`. They are generated by +`go run ./cmd/scrubfixtures` from git-excluded raw captures. Regenerate rather than patch, +and keep the guard test green. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b60bc3 --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# unity-sync + +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. It is the download manager the Asset +Store does not give you outside the Editor. Design: `docs/design.md`. + +## Install + +Grab the latest release into `~/.local/bin` (private repo, so it uses your `gh` login or +`GITHUB_TOKEN`): + +```bash +gh api repos/curbol/unity-sync/contents/install.sh --jq .content | base64 -d | bash +``` + +Then update in place: + +```bash +unity-sync update # latest release +unity-sync update 0.2.0 # a specific version +unity-sync version # what is installed +``` + +Releases are cut by pushing a `v*` tag; a workflow builds the cross-platform binaries and +publishes them. + +## Build from source + +```bash +go build -o unity-sync . +``` + +Requires Go 1.26+. No cgo. To stamp a version into a local build: +`go build -ldflags "-X main.version=0.2.0" -o unity-sync .` + +## One-time setup + +Two kinds of state, kept apart: + +- **User config** (session, machine defaults) lives *outside* any project, resolved as + `--config ` › `$UNITY_SYNC_CONFIG_DIR` › `$XDG_CONFIG_HOME/unity-sync` › + `~/.config/unity-sync`. + + ```bash + mkdir -p ~/.config/unity-sync + cp config.example.toml ~/.config/unity-sync/config.toml + ``` + +- **Project manifest** (`unity-sync.toml`: which assets this project draws from) lives + *in* the project that consumes the assets, committed to its repo. unity-sync finds it by + walking up from the working directory, or point at it with `--manifest`. Its lockfile + (`unity-sync.lock.json`) is written beside it. It carries no account identity. See + `unity-sync.example.toml`. + +Packages are cached at `$XDG_DATA_HOME/unity-sync` (`~/.local/share/unity-sync`) by +default; override with `library_path`, `UNITY_SYNC_LIBRARY`, or `--library`. + +## Session + +The store gates everything behind your signed-in session, and the cookie it actually +checks — `LS` — is a session cookie that lives only in your browser's memory. No browser +cookie database has it, so unity-sync cannot read your session automatically the way a +tool for some other store might. You paste one instead: + +In DevTools → Network, right-click any `assetstore.unity.com` request → Copy → Copy as +cURL, and save it: + +```bash +$EDITOR ~/.config/unity-sync/session.curl # paste, save +unity-sync status +``` + +A Netscape `cookies.txt` export works too, as long as your exporter keeps HttpOnly rows +(they are written with a `#HttpOnly_` prefix). Point at either file with `--session`, with +`session_source` in `config.toml`, or just save it as `session.curl` or `cookies.txt` in +the config dir, where unity-sync looks by default. + +If the file is missing the `LS` cookie, unity-sync says so before making any request, +because the store's own answer in that case is an HTTP 500 that reads like a server fault. + +A pasted session expires. When it does, re-copy it. + +## Commands + +```bash +unity-sync select # pick which assets to mirror (opens a local page) +unity-sync status # what a sync would change; downloads nothing, changes nothing +unity-sync sync # download the delta and update the lockfile +unity-sync list # print the current lockfile +``` + +Useful flags: `--manifest `, `--only `, `--library `, +`--concurrency `, `--verify`, `--config `, `--session `, +`--addr ` (the `select` page's address). + +## Selecting assets + +Selection is opt-in: an asset is mirrored only once you enable it. This matters more here +than it might sound — a typical Asset Store account owns hundreds of packages and tens of +gigabytes, and individual packages reach 23 GB. + +`unity-sync select` lists every owned asset with its thumbnail and a checkbox and writes +the `[[asset]]` entries into `unity-sync.toml`: + +```toml +[[asset]] + id = "115488" + name = "Quick Outline" + enabled = true +``` + +Entries key on the asset id, so a publisher renaming their asset cannot silently deselect +it. Newly-bought assets appear disabled on the next `select`, so buying something never +downloads it behind your back. Hand-editing the file is fine. + +`select` is the only command that writes the manifest. `status` and `sync` only read it. + +## What it does + +- Lists every owned asset with its current version inline, so a run costs two API calls + when nothing changed. +- Downloads only what is new, changed, or missing from the cache, into + `///.unitypackage`. +- Records everything in `unity-sync.lock.json` beside the manifest: what is owned, at what + version, what is mirrored, and its checksum. Commit it for a changelog. +- Reports assets your manifest lists that the account does not own, and assets the store + has delisted. + +## Verifying the cache + +Every run checks cached files cheaply: the file exists, its size is exactly what was +recorded, and the version stamped inside the package still matches. That catches a +truncated or replaced file without reading tens of gigabytes. + +`--verify` re-hashes instead, which is the only way to catch corruption in the middle of a +file. It is opt-in for the obvious reason. + +## Cache + +The library is local and expendable: current versions are re-downloadable, and `sync` +re-fetches anything missing or failing its check. Deleting the cache and re-syncing +rebuilds it. Durability of the assets you actually ship belongs in the consuming project, +not here. + +unity-sync never deletes a package it mirrored. When an asset leaves your account, its +entry drops out of the lockfile and the run tells you which file is now unreferenced, so +you can decide. + +## Browsing what you have + +Searching and previewing the mirrored library lives in a separate tool, +[quarry](https://github.com/curbol/quarry): it indexes an asset tree, reads inside +archives, and previews models. The cache layout here is three levels deep +(`publisher/asset/file`) precisely so quarry's vendor and pack filters work against it. +Point its `root` at your library path. + +## Notes + +- Test fixtures are generated: `cmd/scrubfixtures` regenerates the PII-free + `testdata/store/` from git-excluded raw captures, and a guard test fails the build if + account data reaches them. +- The tool is polite: bounded concurrent downloads (2 by default), backoff on rate limits, + and it identifies itself with a User-Agent. diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..7395996 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,25 @@ +# Example USER config. Copy to your config dir and edit (all keys optional): +# +# mkdir -p ~/.config/unity-sync +# cp config.example.toml ~/.config/unity-sync/config.toml +# +# Config dir resolution: --config > $UNITY_SYNC_CONFIG_DIR > +# $XDG_CONFIG_HOME/unity-sync > ~/.config/unity-sync. +# +# This file is user-scoped: session and machine defaults. Which assets a project mirrors +# is project-scoped and lives in unity-sync.toml instead. + +# Where your saved session lives: a pasted-curl file or a cookies.txt export. If unset, +# unity-sync looks for session.curl or cookies.txt in this config dir. +# +# There is no browser option. The cookie the store checks is a session cookie that never +# reaches a browser's cookie database, so reading one could not authenticate. +# session_source = "~/.config/unity-sync/session.curl" + +# Where packages are cached. Default: $XDG_DATA_HOME/unity-sync +# (~/.local/share/unity-sync). Or set UNITY_SYNC_LIBRARY / pass --library. +# library_path = "/path/to/your/unity-library" + +# Max simultaneous downloads. Packages here reach 23 GB and the store is someone else's +# infrastructure, so this is deliberately small. +concurrency = 2 diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..dfbd297 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,224 @@ +# unity-sync design + +`unity-sync` mirrors the assets owned on the Unity Asset Store into a local library, +detecting and downloading only what changed. This document is the authoritative record of +the store's behaviour, the identity rules, and the failure model. Read it before changing +enumeration, the lockfile, the cache layout, or the download guards. + +## Goals + +- One command pulls every selected asset to its current version with no clicking. +- Detect updates without downloading: the list API carries each asset's current version. +- A committed lockfile is the record of what is owned and at what version, whose monthly + diff reads like a changelog. +- The cache is local and expendable; the assets a project actually ships are made durable + in that project, not here. +- Resilient to what actually breaks: an expired session, a delisted asset, a truncated + transfer, a renamed asset. + +## Non-goals + +- Importing or unpacking `.unitypackage` into a Unity project. +- Converting or previewing assets. Browsing the mirror is [quarry](https://github.com/curbol/quarry). +- Scripting the Unity ID login. The session is handed in. + +## What the store exposes (measured) + +| Purpose | Call | +| --- | --- | +| Owned-asset list | `POST /api/graphql/batch`, operation `SearchMyAssets` | +| Single-product re-read | the same document with `ids: [""]` | +| Package bytes | `GET /api/downloads/{productId}` | + +The GraphQL body is a batch: a JSON array of operations, answered by a positional array. + +### Authentication + +`LS` is the entire credential. Measured cookie-by-cookie: `_csrf` + `LS` alone returns the +full owned list; a junk or absent `__Secure-next-auth.session-token` changes nothing; +removing `LS` turns any user-scoped query into an HTTP 500 with an empty `GraphqlError`. + +`LS` is a session cookie, so it never reaches a browser's cookie database. That is why the +only supported session sources are a pasted curl command and a `cookies.txt` export, and +why there is no browser-reading mode: it could not work. + +The `_csrf` cookie is a double-submit token required by the GraphQL endpoint only. Not +every storefront route issues it — `/` and `/publishers/{id}` answer 200 and set nothing, +while `/packages` answers 404 and sets it. The bootstrap route is pinned to `/packages`, +treats its own 404 as normal, and is exempt from the redirect rule below. + +`x-requested-with: XMLHttpRequest` decides the *shape* of a failure: with it, a failed call +answers with the JSON error that carries the diagnosis; without it, the same call answers +302 to an HTML error page. + +### Identity + +Three ids come back per product. `id` is the store product id: the one +`/api/downloads/{id}` takes and the one stamped inside the package. `productId` is a +different 12-digit value no endpoint here accepts, and `itemId` is likewise unused. The +domain model carries only `id`. + +Assets key on `slugify(name) + "-" + id`. That key is *not* the identity — a rename changes +it — so classification looks up prior entries by product id, and a rename re-keys the entry +and moves the cached directory rather than re-downloading. + +### Two version identities + +The store advertises `currentVersion.id`; the served package carries its own `version_id`. +These usually agree but sometimes do not, steadily: product 262163 advertises 1094273 and +serves 905463; 262495 advertises 1056339 and serves 839208. Twelve of fourteen measured +packages agree. + +So the lockfile records both. `version.id` is the advertised value, refreshed every run. +`resolvedVersionId` is the advertised id the cached file was fetched against, and it is the +diff key. Diffing on the delivered id instead would make those products re-download +forever. + +### The download endpoint + +`GET /api/downloads/{id}` returns the bytes directly: 200, `application/octet-stream`, with +`Content-Disposition` usually but not always present. There is no CDN redirect, no +`Content-Length`, no `ETag`, and `Range` is ignored — so resume is impossible and nothing +pretends otherwise. + +Two behaviours matter more than they look: + +- An unauthenticated request answers **302 to Unity's OAuth authorize URL**. A client that + follows redirects writes a sign-in page into the cache under a `.unitypackage` name. +- The endpoint honours `Accept-Encoding: gzip` by **gzipping the already-gzipped package**. + Go does not transparently decode an encoding the caller asked for, so a client that sets + the header itself caches a double-gzipped blob with no readable metadata. The tool sends + `Accept-Encoding: identity` and treats any `Content-Encoding` on the response as an error. + +`downloadSize` is approximate: it runs 0-16 bytes above the bytes delivered, an artifact of +rounding up to a 16-byte boundary. It bounds a transfer; it never checksums one. + +### Packages self-describe + +A `.unitypackage` is gzip whose **FEXTRA** field carries a JSON descriptor in a subfield +with id `A$` — not the comment field, which is empty on every real package. It holds the +product id, the version id, the Unity version and the publisher. Reading it costs a header +parse, so a cached file can be identified without decompressing or hashing it. + +The reader is driven by the subfield's own length. Every sampled package ends its +descriptor by byte 338, but XLEN is a uint16: a prefix-limited reader would silently report +"no metadata" for a package that has some, downgrading the hard wrong-asset check into a +tolerated warning. + +## Run flow + +``` +1. Resolve config user config dir, library path, session source +2. Load session Cookie header; assert LS is present +3. Bootstrap CSRF GET /packages (404, but sets _csrf) +4. Enumerate page 0..n at pageSize 100; compare raw rows to `total`; dedup +5. Apply the allowlist manifest entries with enabled = true, then --only +6. Sweep stale temps walk the tree; before classification, so a partial is never adopted +7. Classify Unchanged | New | Changed | DownloadNow | CacheMissing | Adopted | Undownloadable +8. Download the delta bounded; guard against the temp file; commit; persist per asset +9. Finish final lockfile write and summary +``` + +`status` is steps 1-7 with `DryRun`, which also gates every mutating step: a dry run sweeps +nothing, moves nothing, and writes nothing. + +## Where the guards live + +The download stream crosses two packages, so ownership is fixed rather than left to +whoever writes the signature first: + +- `store` owns the response-level guards, before any bytes are kept: no redirect followed, + no `Content-Encoding` accepted, content type must be an octet-stream. It returns an open + body plus the filename it parsed. +- `cache` owns the write, in two phases. `Store` streams to a temp file beside the + destination and hashes as it goes; `Commit` renames; `Discard` removes. Nothing + unverified ever occupies a real cache path, because an interrupt in that window would + strand a rejected body where the next run's adopt scan would take it for genuine. +- `syncer` owns the semantic guards, which need both the bytes and the enumeration + metadata: gzip magic, the descriptor's product id, the size floor and its re-query. + +Retry wraps store+cache together, so every attempt necessarily opens a fresh temp file and +a fresh hasher. Appending a retried response to a partial one would survive every other +check and then be hashed and recorded as its own truth. + +## The size floor + +Truncation is normally caught by the transport, but only for a *dropped* connection: a +stream the origin ends cleanly early yields a clean EOF, and with no `Content-Length` +nothing else notices. The descriptor lives in the first ~300 bytes, so it survives +truncation too. + +So a received count below `downloadSize - min(4096, downloadSize/8)` fails that asset. The +allowance is absolute because the gap it forgives is a fixed alignment artifact, and +clamped because 4 KB is a third of the smallest owned package. A body outside the tight +±64 window but above the floor is a warning. + +The one legitimate way to fall below the floor is a republish mid-download. The +discriminator is a single re-read of that product: if its advertised version or size moved, +it was a republish. It is deliberately *not* "the delivered id differs from the advertised +id", which is a steady state for some products and would switch the floor off permanently +for exactly them. + +## Failure model + +| Observation | Meaning | +| --- | --- | +| 400 `csrf token mismatch` | bootstrap failed | +| 500 + empty `GraphqlError` | session expired; not retried, because the status alone would say to retry | +| 3xx from any store endpoint | session expired; never followed | +| 404 on a download | the asset was pulled; permanent, so it does not fail the run | +| 429 / 408 / 5xx elsewhere | retried with backoff | +| rows collected != `total` | loud error, never a silent short walk | +| 200 with a non-empty `errors` array | loud error, never "you own nothing" | + +A failed download fails its asset, not the run: one delisted or corrupt package must not +stop a 75 GB mirror. The pool cancels early only for a run-fatal error. The exit status +separates actionable from permanent — a corrupt body exits non-zero, a pulled asset does +not. + +## Lockfile + +Committed beside the manifest as `unity-sync.lock.json`, keyed by asset slug, with +`assetId` inside each entry. Every owned asset gets an entry, whether or not it is +selected, because the file records what is *owned*. + +Each entry has two halves. The advertised half (`name`, `state`, `publisher`, `version`, +`advertisedSize`) refreshes every run. The resolution half (`tracked`, `resolvedVersionId`, +`deliveredVersionId`, `sizeBytes`, `sha256`, `cachePath`, `downloadedAt`, `storeFilename`) +is rewritten only when the run resolves that asset, and is otherwise carried forward +verbatim — along with the entry's key, so key and path cannot drift apart. + +`sizeBytes` is always the received count, never the advertised one. There is no run +timestamp: stamping one would dirty a committed file on every no-op run. + +## Cache layout + +``` +///.unitypackage +``` + +Three segments because quarry derives 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 would index every package with both facets empty. + +The filename is derived, not taken from `Content-Disposition`, which the store sends +inconsistently — trusting it would let one asset land under two names across runs and put a +machine-dependent path into a committed lockfile. + +Both slugs fall back when a name folds to nothing under ASCII folding: the publisher +segment becomes `publisher-` and the asset segment the bare product id. An empty +segment would collapse the layout and empty quarry's facets. + +## Testing + +The default suite is fully offline: `httptest` servers plus committed fixtures scrubbed +from real captures. The fixtures carry no account data, and a guard test fails the build if +any appears. Raw captures are never committed, and the scrubber lands before anything that +consumes fixtures, because git keeps what a later commit deletes. + +## Open questions + +- Whether Firefox's session store (`sessionstore-backups/recovery.jsonlz4`) holds `LS`. If + it does, a reader there would restore a zero-paste workflow. Unverified. +- Whether the Unity Editor recognises this cache layout if `library_path` points at its + `Asset Store-5.x` directory. Untested; the docs claim nothing. diff --git a/unity-sync.example.toml b/unity-sync.example.toml new file mode 100644 index 0000000..7dbdc96 --- /dev/null +++ b/unity-sync.example.toml @@ -0,0 +1,17 @@ +# Example PROJECT manifest. Commit this (as unity-sync.toml) in the repo that consumes the +# assets. unity-sync finds it by walking up from the working directory, or point at it +# with --manifest. Its lockfile is written beside it as unity-sync.lock.json. +# +# This file is project-scoped and carries no account identity: your session stays in the +# user config (~/.config/unity-sync/config.toml). +# +# `unity-sync select` manages these entries through a local page; hand-editing is fine. +# New assets land disabled, so buying one never downloads it behind your back. +# +# Entries key on `id`, not `name`: a publisher renaming their asset must not silently +# deselect it. + +# [[asset]] +# id = "115488" +# name = "Quick Outline" +# enabled = true From 54f35681ddaf938e8a2fd989ffaa83f0d61e34aa Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 12:09:11 -0700 Subject: [PATCH 17/28] Close the gaps the completeness audit found 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- README.md | 5 +- audit_test.go | 64 ++++++++ internal/cache/audit_test.go | 106 ++++++++++++ internal/cache/cache_test.go | 93 ----------- internal/fixtures/guard_test.go | 4 +- internal/fixtures/scrub.go | 12 ++ internal/selfupdate/audit_test.go | 64 ++++++++ internal/selfupdate/selfupdate_test.go | 51 ------ internal/session/audit_test.go | 56 +++++++ internal/session/session.go | 16 -- internal/session/session_test.go | 62 ------- internal/store/audit_test.go | 194 ++++++++++++++++++++++ internal/store/store.go | 24 ++- internal/store/store_test.go | 179 -------------------- internal/syncer/audit_test.go | 219 +++++++++++++++++++++++++ internal/syncer/syncer.go | 78 +++++++-- internal/syncer/syncer_test.go | 181 -------------------- main.go | 16 +- main_test.go | 51 ------ testdata/store/my_assets_p0.json | 100 ----------- testdata/store/my_assets_p1.json | 76 --------- 21 files changed, 813 insertions(+), 838 deletions(-) create mode 100644 audit_test.go create mode 100644 internal/cache/audit_test.go create mode 100644 internal/selfupdate/audit_test.go create mode 100644 internal/session/audit_test.go create mode 100644 internal/store/audit_test.go create mode 100644 internal/syncer/audit_test.go diff --git a/README.md b/README.md index 0b60bc3..348ee14 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,9 @@ downloads it behind your back. Hand-editing the file is fine. ## What it does -- Lists every owned asset with its current version inline, so a run costs two API calls - when nothing changed. +- Lists every owned asset with its current version inline, so a run that changes nothing + costs only the enumeration: one bootstrap plus a page request per 100 owned assets, and + no package bytes at all. - Downloads only what is new, changed, or missing from the cache, into `///.unitypackage`. - Records everything in `unity-sync.lock.json` beside the manifest: what is owned, at what diff --git a/audit_test.go b/audit_test.go new file mode 100644 index 0000000..94e344b --- /dev/null +++ b/audit_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/curbol/unity-sync/internal/manifest" +) + +// Failure models the CLI must keep pinned: ways a command could do something other than +// what the user typed. + +// flag.Parse stops at the first positional, so an unchecked one swallows the flags after +// it: `sync foo --dry-run` would download. +func TestStrayPositionalIsRejectedAndSuggestsOnly(t *testing.T) { + isolate(t) + for _, cmd := range []string{"sync", "status", "list", "select"} { + code, err := run([]string{cmd, "some-asset", "--dry-run"}) + if code == 0 || err == nil { + t.Errorf("%s with a positional = %d, %v; want a failure", cmd, code, err) + continue + } + if !strings.Contains(err.Error(), "--only") { + t.Errorf("%s: error %q does not point at --only", cmd, err) + } + } +} + +func TestSessionWithoutTheCredentialIsNamedBeforeAnyRequest(t *testing.T) { + wd := isolate(t) + if err := os.WriteFile(filepath.Join(wd, manifest.FileName), []byte("\n"), 0o644); err != nil { + t.Fatal(err) + } + sessionPath := filepath.Join(t.TempDir(), "session.curl") + body := "curl 'https://assetstore.unity.com/' -H 'Cookie: DS=abc; _csrf=zzz'" + if err := os.WriteFile(sessionPath, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + code, err := run([]string{"status", "--session", sessionPath}) + if code == 0 || err == nil { + t.Fatalf("status = %d, %v; want a failure", code, err) + } + if !strings.Contains(err.Error(), "LS") { + t.Errorf("error %q does not name the missing credential cookie", err) + } +} + +// Without a session every networked command must fail with advice, not a stack trace or +// an opaque HTTP error. +func TestMissingSessionIsExplained(t *testing.T) { + wd := isolate(t) + if err := os.WriteFile(filepath.Join(wd, manifest.FileName), []byte("\n"), 0o644); err != nil { + t.Fatal(err) + } + code, err := run([]string{"status"}) + if code == 0 || err == nil { + t.Fatalf("status with no session = %d, %v; want a failure", code, err) + } + if !strings.Contains(err.Error(), "session") { + t.Errorf("error %q does not mention the session", err) + } +} diff --git a/internal/cache/audit_test.go b/internal/cache/audit_test.go new file mode 100644 index 0000000..bf38d3d --- /dev/null +++ b/internal/cache/audit_test.go @@ -0,0 +1,106 @@ +package cache_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" + + "github.com/curbol/unity-sync/internal/cache" +) + +// Failure models this package must keep pinned. Every case is a way the cache could record +// the wrong bytes as verified. + +// The window between writing bytes and accepting them is where a rejected body would +// otherwise sit at a real cache path. +func TestStoreLeavesNothingAtTheRealPathUntilCommit(t *testing.T) { + root := t.TempDir() + body := pkg(t, "115488", "683375", 500) + p, err := cache.Store(root, "chris-nolet", "quick-outline-115488", bytes.NewReader(body)) + if err != nil { + t.Fatalf("Store: %v", err) + } + final := filepath.Join(root, filepath.FromSlash(p.RelPath)) + if _, err := os.Stat(final); !os.IsNotExist(err) { + t.Fatal("Store put bytes at the real path before they were checked") + } + if p.Size != int64(len(body)) { + t.Errorf("Size = %d, want %d", p.Size, len(body)) + } + if err := p.Commit(); err != nil { + t.Fatalf("Commit: %v", err) + } + if _, err := os.Stat(final); err != nil { + t.Fatalf("Commit did not place the file: %v", err) + } +} + +// The caller records the digest of whatever lands at the destination, so an overwrite +// here would certify the wrong bytes — with nothing else in the design watching. +func TestRelocateRefusesAnOccupiedDestination(t *testing.T) { + root := t.TempDir() + from := cache.RelPath("pub", "stray-111") + to := cache.RelPath("pub", "asset-111") + storeCommitted(t, root, "pub", "stray-111", pkg(t, "111", "9", 400)) + storeCommitted(t, root, "pub", "asset-111", pkg(t, "111", "9", 900)) + + if err := cache.Relocate(root, from, to); err == nil { + t.Fatal("Relocate silently overwrote an occupied destination") + } + fi, err := os.Stat(filepath.Join(root, filepath.FromSlash(to))) + if err != nil || fi.Size() != 900 { + t.Errorf("the destination file was disturbed: size %v, err %v", fi.Size(), err) + } + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(from))); err != nil { + t.Error("the source file was lost to a refused move") + } +} + +func TestSweepWalksTheTreeAndSparesInFlightTemps(t *testing.T) { + root := t.TempDir() + leaf := filepath.Join(root, "pub", "asset-1") + if err := os.MkdirAll(leaf, 0o755); err != nil { + t.Fatal(err) + } + stale := filepath.Join(leaf, ".unity-sync-dl-old") + fresh := filepath.Join(leaf, ".unity-sync-dl-live") + os.WriteFile(stale, bytes.Repeat([]byte("x"), 100), 0o644) + os.WriteFile(fresh, bytes.Repeat([]byte("x"), 50), 0o644) + old := time.Now().Add(-2 * time.Hour) + os.Chtimes(stale, old, old) + + cutoff := time.Now().Add(-time.Hour) + n, bytesFreed, err := cache.SweepTemps(root, cutoff) + if err != nil { + t.Fatalf("SweepTemps: %v", err) + } + // A root-only scan would report zero here while leaving a multi-gigabyte orphan. + if n != 1 || bytesFreed != 100 { + t.Errorf("swept %d files / %d bytes, want 1 / 100", n, bytesFreed) + } + if _, err := os.Stat(fresh); err != nil { + t.Error("the sweep deleted a temp newer than the cutoff, i.e. another run's transfer") + } +} + +func TestUnsafePathsAreRefused(t *testing.T) { + root := t.TempDir() + for _, seg := range []string{"", ".", "..", "a/b", `a\b`, ".hidden", "with\x00null"} { + if _, err := cache.Store(root, seg, "asset", bytes.NewReader([]byte("x"))); err == nil { + t.Errorf("Store accepted publisher slug %q", seg) + } + if _, err := cache.Store(root, "pub", seg, bytes.NewReader([]byte("x"))); err == nil { + t.Errorf("Store accepted asset slug %q", seg) + } + } + for _, rel := range []string{"", "/etc/passwd", "../escape.unitypackage", "a/../../escape"} { + if cache.Verify(root, rel, 1, "") { + t.Errorf("Verify accepted path %q", rel) + } + if _, _, err := cache.Hash(root, rel); err == nil { + t.Errorf("Hash accepted path %q", rel) + } + } +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 588b2b1..f31ed81 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -8,7 +8,6 @@ import ( "path/filepath" "strings" "testing" - "time" "github.com/curbol/unity-sync/internal/cache" ) @@ -56,30 +55,6 @@ func TestLayoutIsThreeSegmentsSoQuarryGetsBothFacets(t *testing.T) { } } -// The window between writing bytes and accepting them is where a rejected body would -// otherwise sit at a real cache path. -func TestStoreLeavesNothingAtTheRealPathUntilCommit(t *testing.T) { - root := t.TempDir() - body := pkg(t, "115488", "683375", 500) - p, err := cache.Store(root, "chris-nolet", "quick-outline-115488", bytes.NewReader(body)) - if err != nil { - t.Fatalf("Store: %v", err) - } - final := filepath.Join(root, filepath.FromSlash(p.RelPath)) - if _, err := os.Stat(final); !os.IsNotExist(err) { - t.Fatal("Store put bytes at the real path before they were checked") - } - if p.Size != int64(len(body)) { - t.Errorf("Size = %d, want %d", p.Size, len(body)) - } - if err := p.Commit(); err != nil { - t.Fatalf("Commit: %v", err) - } - if _, err := os.Stat(final); err != nil { - t.Fatalf("Commit did not place the file: %v", err) - } -} - func TestDiscardRemovesTheTempAndLeavesNoFile(t *testing.T) { root := t.TempDir() p, err := cache.Store(root, "pub", "asset-1", bytes.NewReader(pkg(t, "1", "2", 300))) @@ -222,71 +197,3 @@ func TestRelocateIsANoOpWhenAlreadyInPlace(t *testing.T) { t.Errorf("the no-op relocation lost the file: %v", err) } } - -// The caller records the digest of whatever lands at the destination, so an overwrite -// here would certify the wrong bytes — with nothing else in the design watching. -func TestRelocateRefusesAnOccupiedDestination(t *testing.T) { - root := t.TempDir() - from := cache.RelPath("pub", "stray-111") - to := cache.RelPath("pub", "asset-111") - storeCommitted(t, root, "pub", "stray-111", pkg(t, "111", "9", 400)) - storeCommitted(t, root, "pub", "asset-111", pkg(t, "111", "9", 900)) - - if err := cache.Relocate(root, from, to); err == nil { - t.Fatal("Relocate silently overwrote an occupied destination") - } - fi, err := os.Stat(filepath.Join(root, filepath.FromSlash(to))) - if err != nil || fi.Size() != 900 { - t.Errorf("the destination file was disturbed: size %v, err %v", fi.Size(), err) - } - if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(from))); err != nil { - t.Error("the source file was lost to a refused move") - } -} - -func TestSweepWalksTheTreeAndSparesInFlightTemps(t *testing.T) { - root := t.TempDir() - leaf := filepath.Join(root, "pub", "asset-1") - if err := os.MkdirAll(leaf, 0o755); err != nil { - t.Fatal(err) - } - stale := filepath.Join(leaf, ".unity-sync-dl-old") - fresh := filepath.Join(leaf, ".unity-sync-dl-live") - os.WriteFile(stale, bytes.Repeat([]byte("x"), 100), 0o644) - os.WriteFile(fresh, bytes.Repeat([]byte("x"), 50), 0o644) - old := time.Now().Add(-2 * time.Hour) - os.Chtimes(stale, old, old) - - cutoff := time.Now().Add(-time.Hour) - n, bytesFreed, err := cache.SweepTemps(root, cutoff) - if err != nil { - t.Fatalf("SweepTemps: %v", err) - } - // A root-only scan would report zero here while leaving a multi-gigabyte orphan. - if n != 1 || bytesFreed != 100 { - t.Errorf("swept %d files / %d bytes, want 1 / 100", n, bytesFreed) - } - if _, err := os.Stat(fresh); err != nil { - t.Error("the sweep deleted a temp newer than the cutoff, i.e. another run's transfer") - } -} - -func TestUnsafePathsAreRefused(t *testing.T) { - root := t.TempDir() - for _, seg := range []string{"", ".", "..", "a/b", `a\b`, ".hidden", "with\x00null"} { - if _, err := cache.Store(root, seg, "asset", bytes.NewReader([]byte("x"))); err == nil { - t.Errorf("Store accepted publisher slug %q", seg) - } - if _, err := cache.Store(root, "pub", seg, bytes.NewReader([]byte("x"))); err == nil { - t.Errorf("Store accepted asset slug %q", seg) - } - } - for _, rel := range []string{"", "/etc/passwd", "../escape.unitypackage", "a/../../escape"} { - if cache.Verify(root, rel, 1, "") { - t.Errorf("Verify accepted path %q", rel) - } - if _, _, err := cache.Hash(root, rel); err == nil { - t.Errorf("Hash accepted path %q", rel) - } - } -} diff --git a/internal/fixtures/guard_test.go b/internal/fixtures/guard_test.go index 7e0cd4f..35ac008 100644 --- a/internal/fixtures/guard_test.go +++ b/internal/fixtures/guard_test.go @@ -41,7 +41,7 @@ func TestCommittedFixturesCarryNoAccountData(t *testing.T) { if err != nil { return err } - if d.IsDir() || !strings.HasSuffix(path, ".json") { + if d.IsDir() { return nil } seen++ @@ -66,6 +66,6 @@ func TestCommittedFixturesCarryNoAccountData(t *testing.T) { t.Fatalf("walk testdata: %v", err) } if seen == 0 { - t.Fatal("no JSON fixtures found; the guard would pass vacuously") + t.Fatal("no fixtures found; the guard would pass vacuously") } } diff --git a/internal/fixtures/scrub.go b/internal/fixtures/scrub.go index e1f9906..0c956a8 100644 --- a/internal/fixtures/scrub.go +++ b/internal/fixtures/scrub.go @@ -16,6 +16,11 @@ import ( // identifies the grant, not the asset, and no code path in this tool uses it. const entitlementField = "id" +// unusedImageFields are image sizes the captures carry but the pinned query does not +// request. Dropping them keeps a fixture shaped exactly like a real response to the +// query the client actually sends. +var unusedImageFields = []string{"icon", "big", "small", "facebook"} + // Scrub rewrites one captured `searchMyAssets` batch response into fixture form, // preserving key order-independent structure and stable indentation so the committed // file diffs cleanly. It fails rather than guessing when the payload is not the batch @@ -39,6 +44,13 @@ func Scrub(raw []byte) ([]byte, error) { return nil, fmt.Errorf("result row is %T, want object", row) } delete(m, entitlementField) + if product, ok := m["product"].(map[string]any); ok { + if img, ok := product["mainImage"].(map[string]any); ok { + for _, f := range unusedImageFields { + delete(img, f) + } + } + } } } var out bytes.Buffer diff --git a/internal/selfupdate/audit_test.go b/internal/selfupdate/audit_test.go new file mode 100644 index 0000000..547ee04 --- /dev/null +++ b/internal/selfupdate/audit_test.go @@ -0,0 +1,64 @@ +package selfupdate_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/curbol/unity-sync/internal/selfupdate" +) + +// Failure models this package must keep pinned. + +// The private-repo asset API answers with a 302 to a signed CDN URL, so the test server +// really redirects. A client that inherited the Asset Store's redirect ban fails here. +func TestDownloadFollowsTheAssetRedirect(t *testing.T) { + archive := zipWithBinary(t, "#!/bin/true\n") + var cdn *httptest.Server + cdn = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.Write(archive) + })) + defer cdn.Close() + + var api *httptest.Server + api = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/releases/latest"): + name, err := selfupdate.PlatformAsset("9.9.9") + if err != nil { + t.Fatal(err) + } + json.NewEncoder(w).Encode(map[string]any{ + "tag_name": "v9.9.9", + "assets": []map[string]string{ + {"name": "unrelated.txt", "url": api.URL + "/assets/1"}, + {"name": name, "url": api.URL + "/assets/2"}, + }, + }) + case r.URL.Path == "/assets/2": + if got := r.Header.Get("Accept"); got != "application/octet-stream" { + t.Errorf("asset request Accept = %q", got) + } + http.Redirect(w, r, cdn.URL+"/signed", http.StatusFound) + default: + http.NotFound(w, r) + } + })) + defer api.Close() + + c := selfupdate.New(api.URL, "token") + rel, err := c.Resolve("") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + binary, err := c.DownloadBinary(rel) + if err != nil { + t.Fatalf("DownloadBinary: %v — a client with the store's redirect ban fails exactly here", err) + } + if string(binary) != "#!/bin/true\n" { + t.Errorf("binary = %q", binary) + } +} diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go index 4b8c45d..bbfbf18 100644 --- a/internal/selfupdate/selfupdate_test.go +++ b/internal/selfupdate/selfupdate_test.go @@ -29,57 +29,6 @@ func zipWithBinary(t *testing.T, body string) []byte { return buf.Bytes() } -// The private-repo asset API answers with a 302 to a signed CDN URL, so the test server -// really redirects. A client that inherited the Asset Store's redirect ban fails here. -func TestDownloadFollowsTheAssetRedirect(t *testing.T) { - archive := zipWithBinary(t, "#!/bin/true\n") - var cdn *httptest.Server - cdn = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/octet-stream") - w.Write(archive) - })) - defer cdn.Close() - - var api *httptest.Server - api = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case strings.HasSuffix(r.URL.Path, "/releases/latest"): - name, err := selfupdate.PlatformAsset("9.9.9") - if err != nil { - t.Fatal(err) - } - json.NewEncoder(w).Encode(map[string]any{ - "tag_name": "v9.9.9", - "assets": []map[string]string{ - {"name": "unrelated.txt", "url": api.URL + "/assets/1"}, - {"name": name, "url": api.URL + "/assets/2"}, - }, - }) - case r.URL.Path == "/assets/2": - if got := r.Header.Get("Accept"); got != "application/octet-stream" { - t.Errorf("asset request Accept = %q", got) - } - http.Redirect(w, r, cdn.URL+"/signed", http.StatusFound) - default: - http.NotFound(w, r) - } - })) - defer api.Close() - - c := selfupdate.New(api.URL, "token") - rel, err := c.Resolve("") - if err != nil { - t.Fatalf("Resolve: %v", err) - } - binary, err := c.DownloadBinary(rel) - if err != nil { - t.Fatalf("DownloadBinary: %v — a client with the store's redirect ban fails exactly here", err) - } - if string(binary) != "#!/bin/true\n" { - t.Errorf("binary = %q", binary) - } -} - func TestResolveSendsTheTokenAndCanPinAVersion(t *testing.T) { var seenAuth, seenPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/session/audit_test.go b/internal/session/audit_test.go new file mode 100644 index 0000000..84a55c5 --- /dev/null +++ b/internal/session/audit_test.go @@ -0,0 +1,56 @@ +package session_test + +import ( + "errors" + "strings" + "testing" + + "github.com/curbol/unity-sync/internal/session" +) + +// Failure models this package must keep pinned. Each case exists because getting it wrong +// fails silently or misdiagnoses the user. + +// The credential is HttpOnly. A parser that treats every '#' line as a comment drops +// exactly the cookie that authenticates and then reports "no cookies found". +func TestCookiesTxtKeepsHttpOnlyRecords(t *testing.T) { + got, err := session.Resolve(write(t, "cookies.txt", cookiesTxt)) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if !strings.Contains(got, "LS=the-credential") { + t.Errorf("header %q dropped the #HttpOnly_ record", got) + } +} + +// The storefront sets some cookies host-only and others on Domain=.unity.com, and which +// scope LS uses was never established, so both must be accepted. +func TestCookiesTxtAcceptsTheWholeUnityFamilyAndNothingElse(t *testing.T) { + body := "#HttpOnly_assetstore.unity.com\tFALSE\t/\tTRUE\t0\tLS\thost-only\n" + + ".unity.com\tTRUE\t/\tFALSE\t0\twide\tyes\n" + + "evil.com\tFALSE\t/\tFALSE\t0\tleaked\tno\n" + got, err := session.Resolve(write(t, "cookies.txt", body)) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + for _, want := range []string{"LS=host-only", "wide=yes"} { + if !strings.Contains(got, want) { + t.Errorf("header %q is missing %q", got, want) + } + } + if strings.Contains(got, "leaked") { + t.Errorf("header %q carries a cookie from an unrelated site", got) + } +} + +func TestMissingCredentialIsNamedBeforeAnyRequest(t *testing.T) { + body := "assetstore.unity.com\tFALSE\t/\tTRUE\t0\tDS\tabc\n" + _, err := session.Resolve(write(t, "cookies.txt", body)) + var missing *session.ErrNoCredential + if !errors.As(err, &missing) { + t.Fatalf("Resolve = %v, want ErrNoCredential", err) + } + if !strings.Contains(err.Error(), "LS") { + t.Errorf("diagnostic %q does not name the missing cookie", err) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index d44f9eb..2cfc052 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -174,19 +174,3 @@ func join(pairs map[string]string) string { } return b.String() } - -// WithCSRF returns header with its _csrf cookie replaced by token, adding it when -// absent. A pasted session usually carries a stale _csrf, and sending two would leave -// the store matching the header against whichever it picked. -func WithCSRF(header, token string) string { - pairs := map[string]string{} - for _, part := range strings.Split(header, ";") { - name, value, ok := strings.Cut(strings.TrimSpace(part), "=") - if !ok || name == "" { - continue - } - pairs[name] = value - } - pairs["_csrf"] = token - return join(pairs) -} diff --git a/internal/session/session_test.go b/internal/session/session_test.go index aff2766..fa92f67 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -1,7 +1,6 @@ package session_test import ( - "errors" "os" "path/filepath" "strings" @@ -47,50 +46,6 @@ func TestResolveReadsAPastedCurlCommand(t *testing.T) { } } -// The credential is HttpOnly. A parser that treats every '#' line as a comment drops -// exactly the cookie that authenticates and then reports "no cookies found". -func TestCookiesTxtKeepsHttpOnlyRecords(t *testing.T) { - got, err := session.Resolve(write(t, "cookies.txt", cookiesTxt)) - if err != nil { - t.Fatalf("Resolve: %v", err) - } - if !strings.Contains(got, "LS=the-credential") { - t.Errorf("header %q dropped the #HttpOnly_ record", got) - } -} - -// The storefront sets some cookies host-only and others on Domain=.unity.com, and which -// scope LS uses was never established, so both must be accepted. -func TestCookiesTxtAcceptsTheWholeUnityFamilyAndNothingElse(t *testing.T) { - body := "#HttpOnly_assetstore.unity.com\tFALSE\t/\tTRUE\t0\tLS\thost-only\n" + - ".unity.com\tTRUE\t/\tFALSE\t0\twide\tyes\n" + - "evil.com\tFALSE\t/\tFALSE\t0\tleaked\tno\n" - got, err := session.Resolve(write(t, "cookies.txt", body)) - if err != nil { - t.Fatalf("Resolve: %v", err) - } - for _, want := range []string{"LS=host-only", "wide=yes"} { - if !strings.Contains(got, want) { - t.Errorf("header %q is missing %q", got, want) - } - } - if strings.Contains(got, "leaked") { - t.Errorf("header %q carries a cookie from an unrelated site", got) - } -} - -func TestMissingCredentialIsNamedBeforeAnyRequest(t *testing.T) { - body := "assetstore.unity.com\tFALSE\t/\tTRUE\t0\tDS\tabc\n" - _, err := session.Resolve(write(t, "cookies.txt", body)) - var missing *session.ErrNoCredential - if !errors.As(err, &missing) { - t.Fatalf("Resolve = %v, want ErrNoCredential", err) - } - if !strings.Contains(err.Error(), "LS") { - t.Errorf("diagnostic %q does not name the missing cookie", err) - } -} - // An export whose header comment mentions curl must still parse as a cookies.txt. func TestCurlDetectionUsesStructureNotTheWord(t *testing.T) { body := "# Generated for use with curl\n" + @@ -117,23 +72,6 @@ func TestHeaderIsDeterministic(t *testing.T) { } } -func TestWithCSRFReplacesRatherThanAppends(t *testing.T) { - got := session.WithCSRF("LS=cred; _csrf=stale; DS=abc", "fresh") - if strings.Count(got, "_csrf=") != 1 { - t.Errorf("header %q has %d _csrf cookies, want exactly 1", got, strings.Count(got, "_csrf=")) - } - if !strings.Contains(got, "_csrf=fresh") { - t.Errorf("header %q kept the stale token", got) - } - if !strings.Contains(got, "LS=cred") { - t.Errorf("header %q lost the credential", got) - } - added := session.WithCSRF("LS=cred", "fresh") - if !strings.Contains(added, "_csrf=fresh") { - t.Errorf("header %q did not gain a token when none was present", added) - } -} - func TestDiscoverPrefersSessionCurl(t *testing.T) { dir := t.TempDir() if _, ok := session.Discover(dir); ok { diff --git a/internal/store/audit_test.go b/internal/store/audit_test.go new file mode 100644 index 0000000..3d3d665 --- /dev/null +++ b/internal/store/audit_test.go @@ -0,0 +1,194 @@ +package store_test + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/curbol/unity-sync/internal/store" +) + +// Failure models this package must keep pinned: what the client sends, and how it reads a +// response that only looks successful. + +func TestErrorMapping(t *testing.T) { + cases := []struct { + name string + handler http.HandlerFunc + want error + }{ + {"csrf mismatch", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, "csrf token mismatch") + }, store.ErrCSRF}, + {"expired session as an empty GraphqlError", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + io.WriteString(w, `[{"data":null,"errors":[{"errorCode":"GraphqlError","message":""}]}]`) + }, store.ErrExpiredSession}, + {"expired session as a redirect", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "/errors/unexpected") + w.WriteHeader(http.StatusFound) + }, store.ErrExpiredSession}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, _ := serve(t, csrfRouter(tc.handler)) + c.Bootstrap(context.Background()) + _, err := c.Enumerate(context.Background()) + if !errors.Is(err, tc.want) { + t.Errorf("Enumerate = %v, want %v", err, tc.want) + } + }) + } +} + +// A populated errors array with a 200 must never read as "you own nothing", which on a +// first run would look like a legitimate empty library. +func TestPopulatedErrorsArrayIsNotAnEmptyLibrary(t *testing.T) { + c, _ := serve(t, csrfRouter(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `[{"data":null,"errors":[{"errorCode":"Throttled","message":"slow down"}]}]`) + })) + c.Bootstrap(context.Background()) + assets, err := c.Enumerate(context.Background()) + if err == nil { + t.Fatalf("Enumerate = %d assets, nil error; want an error", len(assets)) + } + if !strings.Contains(err.Error(), "Throttled") { + t.Errorf("error %v does not report what the store said", err) + } +} + +func TestNonJSONSuccessIsAnError(t *testing.T) { + c, _ := serve(t, csrfRouter(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "sign in") + })) + c.Bootstrap(context.Background()) + if _, err := c.Enumerate(context.Background()); err == nil { + t.Error("Enumerate parsed an HTML body as a result set") + } +} + +// Every one of these headers is invisible when missing: the request still succeeds +// against a permissive server, so only an assertion catches an omission. +func TestClientSendsTheHeadersTheStoreNeeds(t *testing.T) { + var got http.Header + var body string + c, _ := serve(t, csrfRouter(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Clone() + raw, _ := io.ReadAll(r.Body) + body = string(raw) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `[{"data":{"searchMyAssets":{"total":0,"results":[]}}}]`) + })) + if err := c.Bootstrap(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := c.Enumerate(context.Background()); err != nil { + t.Fatal(err) + } + + for header, want := range map[string]string{ + "X-Requested-With": "XMLHttpRequest", + "Accept-Encoding": "identity", + "User-Agent": "unity-sync/test", + "X-Csrf-Token": "issued-token", + } { + if got.Get(header) != want { + t.Errorf("%s = %q, want %q", header, got.Get(header), want) + } + } + if cookie := got.Get("Cookie"); !strings.Contains(cookie, "_csrf=issued-token") { + t.Errorf("Cookie %q does not carry the token the header claims", cookie) + } + if strings.Count(got.Get("Cookie"), "_csrf=") != 1 { + t.Errorf("Cookie %q has more than one _csrf", got.Get("Cookie")) + } + // Losing this field from the document would break every classification silently. + if !strings.Contains(body, `currentVersion { id name publishedDate }`) { + t.Error("the pinned query no longer requests currentVersion.id") + } +} + +func TestFetchGuardsTheResponseBeforeAnyBytesAreKept(t *testing.T) { + pkg := "\x1f\x8b\x08\x00rest-of-a-package" + cases := []struct { + name string + handler http.HandlerFunc + wantErr error + wantSub string + }{ + {"re-encoded body", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Encoding", "gzip") + io.WriteString(w, pkg) + }, nil, "re-encoded"}, + {"wrong content type", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + io.WriteString(w, "") + }, nil, "Content-Type"}, + {"redirect to sign-in", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "https://api.unity.com/v1/oauth2/authorize") + w.WriteHeader(http.StatusFound) + }, store.ErrExpiredSession, ""}, + {"pulled asset", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, store.ErrNotDownloadable, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, _ := serve(t, tc.handler) + _, err := c.Fetch(context.Background(), "115488") + if err == nil { + t.Fatal("Fetch accepted a response it should have refused") + } + if tc.wantErr != nil && !errors.Is(err, tc.wantErr) { + t.Errorf("Fetch = %v, want %v", err, tc.wantErr) + } + if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) { + t.Errorf("Fetch = %v, want it to mention %q", err, tc.wantSub) + } + }) + } +} + +// The whole point of a response-header timeout is that a slow *body* is legitimate — a +// 23 GB package takes a while — while a server that never answers is not. Against +// kilobyte fixtures the two policies are indistinguishable, so this pins them apart. +func TestSlowBodyIsAllowedButSlowHeadersAreNot(t *testing.T) { + slowBody, _ := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + for range 4 { + time.Sleep(30 * time.Millisecond) + io.WriteString(w, "chunk") + w.(http.Flusher).Flush() + } + }, store.WithResponseHeaderTimeout(50*time.Millisecond)) + dl, err := slowBody.Fetch(context.Background(), "1") + if err != nil { + t.Fatalf("Fetch with a slow body: %v — a whole-request timeout would kill real downloads", err) + } + body, err := io.ReadAll(dl.Body) + dl.Body.Close() + if err != nil { + t.Fatalf("reading a slow body: %v", err) + } + if len(body) != 20 { + t.Errorf("read %d bytes, want 20", len(body)) + } + + slowHeaders, _ := serve(t, func(w http.ResponseWriter, r *http.Request) { + time.Sleep(300 * time.Millisecond) + w.Header().Set("Content-Type", "application/octet-stream") + }, store.WithResponseHeaderTimeout(50*time.Millisecond)) + if _, err := slowHeaders.Fetch(context.Background(), "1"); err == nil { + t.Error("Fetch waited indefinitely for headers") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 112b291..60d7812 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -269,13 +269,28 @@ type graphQLError struct { func (c *Client) search(ctx context.Context, vars map[string]any) (searchResult, error) { var out searchResult + var csrfRetried bool err := retry.Do(ctx, c.retries, func(int) error { res, err := c.searchOnce(ctx, vars) - if err != nil { - return err + if err == nil { + out = res + return nil } - out = res - return nil + // A stale token is worth exactly one more go: re-bootstrap and retry, since the + // token can expire between the bootstrap and the call that uses it. A second + // mismatch is a real problem and is reported. + if errors.Is(err, ErrCSRF) && !csrfRetried { + csrfRetried = true + if boot := c.Bootstrap(ctx); boot != nil { + return retry.Permanent(err) + } + res, err = c.searchOnce(ctx, vars) + if err == nil { + out = res + return nil + } + } + return err }) return out, err } @@ -375,6 +390,7 @@ func (c *Client) Fetch(ctx context.Context, id string) (*Download, error) { req.Header.Set("User-Agent", c.agent) req.Header.Set("Accept", "*/*") req.Header.Set("Referer", c.base+"/") + req.Header.Set("X-Requested-With", "XMLHttpRequest") req.Header.Set("Cookie", c.cookie) // Asking for identity is not hygiene: the endpoint honours Accept-Encoding: gzip by // gzipping the already-gzipped package, and Go does not transparently decode an diff --git a/internal/store/store_test.go b/internal/store/store_test.go index b782e98..e4fd7eb 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -2,7 +2,6 @@ package store_test import ( "context" - "errors" "io" "net/http" "net/http/httptest" @@ -155,148 +154,6 @@ func TestStrictParsingRejectsAMissingVersionId(t *testing.T) { } } -func TestErrorMapping(t *testing.T) { - cases := []struct { - name string - handler http.HandlerFunc - want error - }{ - {"csrf mismatch", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - io.WriteString(w, "csrf token mismatch") - }, store.ErrCSRF}, - {"expired session as an empty GraphqlError", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - io.WriteString(w, `[{"data":null,"errors":[{"errorCode":"GraphqlError","message":""}]}]`) - }, store.ErrExpiredSession}, - {"expired session as a redirect", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Location", "/errors/unexpected") - w.WriteHeader(http.StatusFound) - }, store.ErrExpiredSession}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - c, _ := serve(t, csrfRouter(tc.handler)) - c.Bootstrap(context.Background()) - _, err := c.Enumerate(context.Background()) - if !errors.Is(err, tc.want) { - t.Errorf("Enumerate = %v, want %v", err, tc.want) - } - }) - } -} - -// A populated errors array with a 200 must never read as "you own nothing", which on a -// first run would look like a legitimate empty library. -func TestPopulatedErrorsArrayIsNotAnEmptyLibrary(t *testing.T) { - c, _ := serve(t, csrfRouter(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - io.WriteString(w, `[{"data":null,"errors":[{"errorCode":"Throttled","message":"slow down"}]}]`) - })) - c.Bootstrap(context.Background()) - assets, err := c.Enumerate(context.Background()) - if err == nil { - t.Fatalf("Enumerate = %d assets, nil error; want an error", len(assets)) - } - if !strings.Contains(err.Error(), "Throttled") { - t.Errorf("error %v does not report what the store said", err) - } -} - -func TestNonJSONSuccessIsAnError(t *testing.T) { - c, _ := serve(t, csrfRouter(func(w http.ResponseWriter, r *http.Request) { - io.WriteString(w, "sign in") - })) - c.Bootstrap(context.Background()) - if _, err := c.Enumerate(context.Background()); err == nil { - t.Error("Enumerate parsed an HTML body as a result set") - } -} - -// Every one of these headers is invisible when missing: the request still succeeds -// against a permissive server, so only an assertion catches an omission. -func TestClientSendsTheHeadersTheStoreNeeds(t *testing.T) { - var got http.Header - var body string - c, _ := serve(t, csrfRouter(func(w http.ResponseWriter, r *http.Request) { - got = r.Header.Clone() - raw, _ := io.ReadAll(r.Body) - body = string(raw) - w.Header().Set("Content-Type", "application/json") - io.WriteString(w, `[{"data":{"searchMyAssets":{"total":0,"results":[]}}}]`) - })) - if err := c.Bootstrap(context.Background()); err != nil { - t.Fatal(err) - } - if _, err := c.Enumerate(context.Background()); err != nil { - t.Fatal(err) - } - - for header, want := range map[string]string{ - "X-Requested-With": "XMLHttpRequest", - "Accept-Encoding": "identity", - "User-Agent": "unity-sync/test", - "X-Csrf-Token": "issued-token", - } { - if got.Get(header) != want { - t.Errorf("%s = %q, want %q", header, got.Get(header), want) - } - } - if cookie := got.Get("Cookie"); !strings.Contains(cookie, "_csrf=issued-token") { - t.Errorf("Cookie %q does not carry the token the header claims", cookie) - } - if strings.Count(got.Get("Cookie"), "_csrf=") != 1 { - t.Errorf("Cookie %q has more than one _csrf", got.Get("Cookie")) - } - // Losing this field from the document would break every classification silently. - if !strings.Contains(body, `currentVersion { id name publishedDate }`) { - t.Error("the pinned query no longer requests currentVersion.id") - } -} - -func TestFetchGuardsTheResponseBeforeAnyBytesAreKept(t *testing.T) { - pkg := "\x1f\x8b\x08\x00rest-of-a-package" - cases := []struct { - name string - handler http.HandlerFunc - wantErr error - wantSub string - }{ - {"re-encoded body", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Content-Encoding", "gzip") - io.WriteString(w, pkg) - }, nil, "re-encoded"}, - {"wrong content type", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - io.WriteString(w, "") - }, nil, "Content-Type"}, - {"redirect to sign-in", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Location", "https://api.unity.com/v1/oauth2/authorize") - w.WriteHeader(http.StatusFound) - }, store.ErrExpiredSession, ""}, - {"pulled asset", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - }, store.ErrNotDownloadable, ""}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - c, _ := serve(t, tc.handler) - _, err := c.Fetch(context.Background(), "115488") - if err == nil { - t.Fatal("Fetch accepted a response it should have refused") - } - if tc.wantErr != nil && !errors.Is(err, tc.wantErr) { - t.Errorf("Fetch = %v, want %v", err, tc.wantErr) - } - if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) { - t.Errorf("Fetch = %v, want it to mention %q", err, tc.wantSub) - } - }) - } -} - func TestFetchReturnsTheBodyAndTheStoresFilename(t *testing.T) { c, _ := serve(t, func(w http.ResponseWriter, r *http.Request) { if enc := r.Header.Get("Accept-Encoding"); enc != "identity" { @@ -319,39 +176,3 @@ func TestFetchReturnsTheBodyAndTheStoresFilename(t *testing.T) { t.Errorf("body = %q, want the package bytes untouched", got) } } - -// The whole point of a response-header timeout is that a slow *body* is legitimate — a -// 23 GB package takes a while — while a server that never answers is not. Against -// kilobyte fixtures the two policies are indistinguishable, so this pins them apart. -func TestSlowBodyIsAllowedButSlowHeadersAreNot(t *testing.T) { - slowBody, _ := serve(t, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/octet-stream") - w.WriteHeader(http.StatusOK) - w.(http.Flusher).Flush() - for range 4 { - time.Sleep(30 * time.Millisecond) - io.WriteString(w, "chunk") - w.(http.Flusher).Flush() - } - }, store.WithResponseHeaderTimeout(50*time.Millisecond)) - dl, err := slowBody.Fetch(context.Background(), "1") - if err != nil { - t.Fatalf("Fetch with a slow body: %v — a whole-request timeout would kill real downloads", err) - } - body, err := io.ReadAll(dl.Body) - dl.Body.Close() - if err != nil { - t.Fatalf("reading a slow body: %v", err) - } - if len(body) != 20 { - t.Errorf("read %d bytes, want 20", len(body)) - } - - slowHeaders, _ := serve(t, func(w http.ResponseWriter, r *http.Request) { - time.Sleep(300 * time.Millisecond) - w.Header().Set("Content-Type", "application/octet-stream") - }, store.WithResponseHeaderTimeout(50*time.Millisecond)) - if _, err := slowHeaders.Fetch(context.Background(), "1"); err == nil { - t.Error("Fetch waited indefinitely for headers") - } -} diff --git a/internal/syncer/audit_test.go b/internal/syncer/audit_test.go new file mode 100644 index 0000000..05acad9 --- /dev/null +++ b/internal/syncer/audit_test.go @@ -0,0 +1,219 @@ +package syncer + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/curbol/unity-sync/internal/lockfile" + "github.com/curbol/unity-sync/internal/model" + "github.com/curbol/unity-sync/internal/store" +) + +// Failure models this package must keep pinned: the guards between a bad response and a +// committed lockfile entry, and the blast radius of one bad asset. + +func TestSemanticGuardsRejectAndDiscard(t *testing.T) { + good := pkg(t, "1", "v1", 2000) + + cases := []struct { + name string + body []byte + lookup *model.Asset // when set, the re-query reports this + wantOK bool + wantWarn string + wantStore bool + }{ + {name: "not gzip at all", body: []byte("sign in")}, + {name: "descriptor names another product", body: pkg(t, "999", "v1", 2000)}, + {name: "short body, re-query unchanged", body: pkg(t, "1", "v1", 100)}, + { + // A republish is the one legitimate way to fall below the floor. It warns, + // stores nothing, and leaves the new build for the next run. + name: "short body, re-query shows a republish", + body: pkg(t, "1", "v1", 100), + lookup: &model.Asset{ID: "1", Version: model.Version{ID: "v2"}, AdvertisedSize: 4000}, + wantOK: true, + wantWarn: "republished", + wantStore: false, + }, + {name: "20 bytes short is a warning only", body: pkg(t, "1", "v1", 1980), wantOK: true, wantStore: true}, + {name: "exact", body: good, wantOK: true, wantStore: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Asset", "v1", 2000) + fs := &fakeStore{owned: []model.Asset{a}, bodies: map[string][]byte{"1": tc.body}} + if tc.lookup != nil { + fs.lookups = map[string]model.Asset{"1": *tc.lookup} + } + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, allSelected(a))) + if err != nil { + t.Fatalf("Run: %v", err) + } + res := rep.Results[0] + final := filepath.Join(root, "pub-one", "asset-1", "asset-1.unitypackage") + if tc.wantOK { + if res.Err != nil { + t.Fatalf("Run rejected an acceptable body: %v", res.Err) + } + if tc.wantWarn != "" && !strings.Contains(res.Warning, tc.wantWarn) { + t.Errorf("warning = %q, want it to mention %q", res.Warning, tc.wantWarn) + } + _, statErr := os.Stat(final) + if tc.wantStore && statErr != nil { + t.Errorf("expected the package to be committed: %v", statErr) + } + if !tc.wantStore && statErr == nil { + t.Error("a republish stored bytes; it should leave the new build for the next run") + } + if leftovers := tempsUnder(t, root); leftovers != 0 { + t.Errorf("%d temp files survived", leftovers) + } + return + } + if res.Err == nil { + t.Fatal("Run accepted a body it should have refused") + } + // Nothing may survive at a real cache path. + if _, err := os.Stat(final); !os.IsNotExist(err) { + t.Error("a rejected body was committed to the cache") + } + if leftovers := tempsUnder(t, root); leftovers != 0 { + t.Errorf("%d temp files survived a rejected download", leftovers) + } + }) + } +} + +// The template returns from Run on the first download error. Departing from that is a +// deliberate rule here, so it needs its own test. +func TestOneFailedAssetDoesNotStopTheRest(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Good one", "v1", 500) + b := asset("2", "Pulled", "v1", 500) + c := asset("3", "Also good", "v1", 500) + fs := &fakeStore{ + owned: []model.Asset{a, b, c}, + bodies: map[string][]byte{"1": pkg(t, "1", "v1", 500), "3": pkg(t, "3", "v1", 500)}, + fetchEr: map[string]error{"2": store.ErrNotDownloadable}, + } + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, allSelected(a, b, c))) + if err != nil { + t.Fatalf("Run: %v", err) + } + tracked := 0 + for _, e := range rep.Lockfile.Assets { + if e.Tracked { + tracked++ + } + } + if tracked != 2 { + t.Errorf("%d assets mirrored, want 2: one failure must not abort the others", tracked) + } + // A pulled asset is permanent, so it must not make every future run exit non-zero. + if rep.Permanent != 1 || rep.Retryable != 0 { + t.Errorf("Permanent=%d Retryable=%d, want 1 and 0", rep.Permanent, rep.Retryable) + } + if rep.Failed() { + t.Error("a permanently pulled asset made the run report failure") + } +} + +func TestConcurrencyCeilingIsHonoured(t *testing.T) { + root, lockPath := newRun(t) + var assets []model.Asset + bodies := map[string][]byte{} + for _, id := range []string{"1", "2", "3", "4", "5", "6"} { + a := asset(id, "Asset "+id, "v1", 500) + assets = append(assets, a) + bodies[id] = pkg(t, id, "v1", 500) + } + fs := &fakeStore{owned: assets, bodies: bodies, hold: 20 * time.Millisecond} + o := opts(root, allSelected(assets...)) + o.Concurrency = 2 + + if _, err := Run(context.Background(), fs, lockfile.New(), lockPath, o); err != nil { + t.Fatalf("Run: %v", err) + } + if got := fs.maxSeen.Load(); got > 2 { + t.Errorf("peak concurrent fetches = %d, want at most 2; the store is someone else's "+ + "infrastructure and this is the brief's one third-party requirement", got) + } + if got := fs.maxSeen.Load(); got < 2 { + t.Errorf("peak concurrent fetches = %d, want the pool actually used its budget", got) + } +} + +func TestDryRunClassifiesAndTouchesNothing(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Asset", "v1", 500) + + // A stale temp and a package sitting off its derived path: a sync would sweep one + // and relocate the other. + leaf := filepath.Join(root, "pub-one", "elsewhere") + os.MkdirAll(leaf, 0o755) + os.WriteFile(filepath.Join(leaf, ".unity-sync-dl-stale"), []byte("junk"), 0o644) + os.WriteFile(filepath.Join(leaf, "elsewhere.unitypackage"), pkg(t, "1", "v1", 500), 0o644) + old := time.Unix(1600000000, 0) + os.Chtimes(filepath.Join(leaf, ".unity-sync-dl-stale"), old, old) + + before := treeSnapshot(t, root) + + fs := &fakeStore{owned: []model.Asset{a}, bodies: map[string][]byte{"1": pkg(t, "1", "v1", 500)}} + o := opts(root, allSelected(a)) + o.DryRun = true + + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, o) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(rep.Results) != 1 { + t.Fatalf("dry run produced %d results, want 1 — status must classify", len(rep.Results)) + } + if len(fs.fetched) != 0 { + t.Errorf("a dry run downloaded %v", fs.fetched) + } + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Error("a dry run wrote the lockfile") + } + if after := treeSnapshot(t, root); after != before { + t.Errorf("a dry run changed the library tree:\nbefore %v\nafter %v", before, after) + } +} + +func TestEmptyEnumerationAgainstANonEmptyLockfileIsRefused(t *testing.T) { + root, lockPath := newRun(t) + prior := lockfile.New() + prior.Assets["a-1"] = lockfile.Entry{AssetID: "1"} + if err := lockfile.Save(lockPath, prior); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(lockPath) + + fs := &fakeStore{} + _, err := Run(context.Background(), fs, prior, lockPath, opts(root, nil)) + if !errors.Is(err, ErrEmptyLibrary) { + t.Fatalf("Run = %v, want ErrEmptyLibrary", err) + } + after, _ := os.ReadFile(lockPath) + if string(before) != string(after) { + t.Error("the refused run rewrote the lockfile anyway") + } +} + +func TestPreDownloadFailureWritesNoLockfileAtAll(t *testing.T) { + root, lockPath := newRun(t) + fs := &failingEnumerate{} + if _, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, nil)); err == nil { + t.Fatal("Run succeeded despite an enumeration failure") + } + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Error("a failure before any download still created a lockfile") + } +} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index 35a6a34..dfc1c6c 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -15,6 +15,7 @@ import ( "github.com/curbol/unity-sync/internal/lockfile" "github.com/curbol/unity-sync/internal/manifest" "github.com/curbol/unity-sync/internal/model" + "github.com/curbol/unity-sync/internal/retry" "github.com/curbol/unity-sync/internal/store" "github.com/curbol/unity-sync/internal/unitypackage" ) @@ -119,6 +120,11 @@ type Options struct { Now func() time.Time Progress func(string) + // Retry governs download attempts. Downloads get their own budget rather than the + // API's: re-transferring a multi-gigabyte body is not the same kind of cheap as + // re-issuing a 2 KB query. + Retry retry.Policy + // Manifest is consulted for reporting only; a run never writes it. Manifest manifest.Manifest } @@ -173,6 +179,9 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, if opts.Concurrency < 1 { opts.Concurrency = 1 } + if opts.Retry.Attempts < 1 { + opts.Retry = retry.Policy{Attempts: 2, Base: 2 * time.Second} + } if opts.OnlyGlob != "" { if _, err := filepath.Match(opts.OnlyGlob, ""); err != nil { return Report{}, fmt.Errorf("bad --only pattern %q: %w", opts.OnlyGlob, err) @@ -213,8 +222,7 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, if !selected(a, opts) { continue } - prevKey, prev, hasPrev := prior.FindByAssetID(a.ID) - _ = prevKey + _, prev, hasPrev := prior.FindByAssetID(a.ID) derived := cache.RelPath(a.PublisherSlug(), a.Slug()) cacheOK := func() bool { @@ -285,6 +293,11 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, // Downloads run bounded, and a failure fails its asset rather than the run: one // delisted or corrupt package must not stop a 75 GB mirror. + // The pool stops early only for a run-fatal error: an expired session makes every + // remaining download pointless, while one corrupt or delisted asset does not. + poolCtx, cancelPool := context.WithCancel(ctx) + defer cancelPool() + var ( wg sync.WaitGroup sem = make(chan struct{}, opts.Concurrency) @@ -296,14 +309,33 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, defer wg.Done() sem <- struct{}{} defer func() { <-sem }() - if ctx.Err() != nil { - res.Err = ctx.Err() + if poolCtx.Err() != nil { + res.Err = poolCtx.Err() done[i] = res return } opts.Progress(fmt.Sprintf("fetching %s (%s)", res.Asset.Name, humanBytes(res.Asset.AdvertisedSize))) - r, warning, err := download(ctx, s, opts, res.Asset) + var ( + r resolution + warning string + resolved bool + ) + // Retry wraps the fetch and the write together, so every attempt necessarily + // opens a fresh temp file and a fresh hasher. Appending a retried response to + // a partial one would survive every guard here and then be hashed and + // recorded as its own truth. + err := retry.Do(poolCtx, opts.Retry, func(int) error { + var attemptErr error + r, warning, resolved, attemptErr = download(ctx, s, opts, res.Asset) + return attemptErr + }) res.Warning, res.Err = warning, err + if err == nil && !resolved { + // A republish mid-download: nothing was stored, and the next run picks up + // the new build. Not a failure. + done[i] = res + return + } if err == nil { mu.Lock() resolutions[res.Asset.ID] = r @@ -315,6 +347,9 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, res.Err = fmt.Errorf("persisting progress: %w", err) } } + if errors.Is(res.Err, store.ErrExpiredSession) { + cancelPool() + } done[i] = res }(i, res) } @@ -362,32 +397,41 @@ func adopt(opts Options, a model.Asset, found cache.Candidate, derived string) ( } // download fetches one asset and runs every semantic guard against the temp file before -// committing it. -func download(ctx context.Context, s Store, opts Options, a model.Asset) (resolution, string, error) { +// committing it. The bool reports whether anything was stored: a republish discovered +// mid-transfer is a warning, not a failure, and resolves nothing this run. +func download(ctx context.Context, s Store, opts Options, a model.Asset) (resolution, string, bool, error) { dl, err := s.Fetch(ctx, a.ID) if err != nil { - return resolution{}, "", err + return resolution{}, "", false, err } defer dl.Body.Close() pending, err := cache.Store(opts.LibraryRoot, a.PublisherSlug(), a.Slug(), dl.Body) if err != nil { - return resolution{}, "", err + return resolution{}, "", false, err } meta, metaErr := unitypackage.ReadFile(pending.TempPath()) switch { case metaErr != nil && !errors.Is(metaErr, unitypackage.ErrNoMetadata): pending.Discard() - return resolution{}, "", fmt.Errorf("%s: %w", a.Name, metaErr) + return resolution{}, "", false, retry.Permanent(fmt.Errorf("%s: %w", a.Name, metaErr)) case metaErr == nil && meta.ID != a.ID: pending.Discard() - return resolution{}, "", fmt.Errorf("%s: the store served product %s, not %s", a.Name, meta.ID, a.ID) + return resolution{}, "", false, retry.Permanent( + fmt.Errorf("%s: the store served product %s, not %s", a.Name, meta.ID, a.ID)) } var warning string - if metaErr != nil { + switch { + case metaErr != nil: warning = fmt.Sprintf("%s: package carries no store metadata, so later checks fall back to size alone", a.Name) + case meta.VersionID != a.Version.ID: + // Steady state for a few products: the store advertises one build and serves + // another. Both ids are recorded; the run says so once rather than silently + // papering over the difference. + warning = fmt.Sprintf("%s: store advertises version %s but served %s; both recorded", + a.Name, a.Version.ID, meta.VersionID) } if belowFloor(pending.Size, a.AdvertisedSize) { @@ -395,10 +439,12 @@ func download(ctx context.Context, s Store, opts Options, a model.Asset) (resolu // advertised size out from under us. One re-read of this product settles it. if republished(ctx, s, a) { pending.Discard() - return resolution{}, "", fmt.Errorf("%s: republished mid-download; the next run will fetch the new build", a.Name) + return resolution{}, fmt.Sprintf( + "%s: republished mid-download; nothing stored, the next run will fetch the new build", + a.Name), false, nil } pending.Discard() - return resolution{}, "", fmt.Errorf("%s: received %d bytes against an advertised %d; body ended early", + return resolution{}, "", false, fmt.Errorf("%s: received %d bytes against an advertised %d; body ended early", a.Name, pending.Size, a.AdvertisedSize) } if a.AdvertisedSize > 0 && (pending.Size > a.AdvertisedSize || pending.Size < a.AdvertisedSize-64) { @@ -406,7 +452,7 @@ func download(ctx context.Context, s Store, opts Options, a model.Asset) (resolu } if err := pending.Commit(); err != nil { - return resolution{}, warning, err + return resolution{}, warning, false, err } return resolution{ cachePath: pending.RelPath, @@ -416,7 +462,7 @@ func download(ctx context.Context, s Store, opts Options, a model.Asset) (resolu deliveredVersionID: meta.VersionID, downloadedAt: opts.Now().UTC().Format(time.RFC3339), storeFilename: dl.Filename, - }, warning, nil + }, warning, true, nil } // belowFloor is the hard short-body rule. The tolerance is absolute because the gap it diff --git a/internal/syncer/syncer_test.go b/internal/syncer/syncer_test.go index 22ed252..d1162bc 100644 --- a/internal/syncer/syncer_test.go +++ b/internal/syncer/syncer_test.go @@ -298,37 +298,6 @@ func TestOwnershipDropIsReportedNotJustRemoved(t *testing.T) { } } -func TestEmptyEnumerationAgainstANonEmptyLockfileIsRefused(t *testing.T) { - root, lockPath := newRun(t) - prior := lockfile.New() - prior.Assets["a-1"] = lockfile.Entry{AssetID: "1"} - if err := lockfile.Save(lockPath, prior); err != nil { - t.Fatal(err) - } - before, _ := os.ReadFile(lockPath) - - fs := &fakeStore{} - _, err := Run(context.Background(), fs, prior, lockPath, opts(root, nil)) - if !errors.Is(err, ErrEmptyLibrary) { - t.Fatalf("Run = %v, want ErrEmptyLibrary", err) - } - after, _ := os.ReadFile(lockPath) - if string(before) != string(after) { - t.Error("the refused run rewrote the lockfile anyway") - } -} - -func TestPreDownloadFailureWritesNoLockfileAtAll(t *testing.T) { - root, lockPath := newRun(t) - fs := &failingEnumerate{} - if _, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, nil)); err == nil { - t.Fatal("Run succeeded despite an enumeration failure") - } - if _, err := os.Stat(lockPath); !os.IsNotExist(err) { - t.Error("a failure before any download still created a lockfile") - } -} - type failingEnumerate struct{ fakeStore } func (f *failingEnumerate) Enumerate(context.Context) ([]model.Asset, error) { @@ -337,60 +306,6 @@ func (f *failingEnumerate) Enumerate(context.Context) ([]model.Asset, error) { // ---- download guards ---------------------------------------------------------- -func TestSemanticGuardsRejectAndDiscard(t *testing.T) { - good := pkg(t, "1", "v1", 2000) - - cases := []struct { - name string - body []byte - lookup *model.Asset // when set, the re-query reports this - wantOK bool - }{ - {name: "not gzip at all", body: []byte("sign in")}, - {name: "descriptor names another product", body: pkg(t, "999", "v1", 2000)}, - {name: "short body, re-query unchanged", body: pkg(t, "1", "v1", 100)}, - { - name: "short body, re-query shows a republish", - body: pkg(t, "1", "v1", 100), - lookup: &model.Asset{ID: "1", Version: model.Version{ID: "v2"}, AdvertisedSize: 4000}, - }, - {name: "20 bytes short is a warning only", body: pkg(t, "1", "v1", 1980), wantOK: true}, - {name: "exact", body: good, wantOK: true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - root, lockPath := newRun(t) - a := asset("1", "Asset", "v1", 2000) - fs := &fakeStore{owned: []model.Asset{a}, bodies: map[string][]byte{"1": tc.body}} - if tc.lookup != nil { - fs.lookups = map[string]model.Asset{"1": *tc.lookup} - } - rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, allSelected(a))) - if err != nil { - t.Fatalf("Run: %v", err) - } - res := rep.Results[0] - if tc.wantOK { - if res.Err != nil { - t.Fatalf("Run rejected an acceptable body: %v", res.Err) - } - return - } - if res.Err == nil { - t.Fatal("Run accepted a body it should have refused") - } - // Nothing may survive at a real cache path. - final := filepath.Join(root, "pub-one", "asset-1", "asset-1.unitypackage") - if _, err := os.Stat(final); !os.IsNotExist(err) { - t.Error("a rejected body was committed to the cache") - } - if leftovers := tempsUnder(t, root); leftovers != 0 { - t.Errorf("%d temp files survived a rejected download", leftovers) - } - }) - } -} - func tempsUnder(t *testing.T, root string) int { t.Helper() n := 0 @@ -405,65 +320,6 @@ func tempsUnder(t *testing.T, root string) int { // ---- failure isolation and concurrency ---------------------------------------- -// The template returns from Run on the first download error. Departing from that is a -// deliberate rule here, so it needs its own test. -func TestOneFailedAssetDoesNotStopTheRest(t *testing.T) { - root, lockPath := newRun(t) - a := asset("1", "Good one", "v1", 500) - b := asset("2", "Pulled", "v1", 500) - c := asset("3", "Also good", "v1", 500) - fs := &fakeStore{ - owned: []model.Asset{a, b, c}, - bodies: map[string][]byte{"1": pkg(t, "1", "v1", 500), "3": pkg(t, "3", "v1", 500)}, - fetchEr: map[string]error{"2": store.ErrNotDownloadable}, - } - rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, allSelected(a, b, c))) - if err != nil { - t.Fatalf("Run: %v", err) - } - tracked := 0 - for _, e := range rep.Lockfile.Assets { - if e.Tracked { - tracked++ - } - } - if tracked != 2 { - t.Errorf("%d assets mirrored, want 2: one failure must not abort the others", tracked) - } - // A pulled asset is permanent, so it must not make every future run exit non-zero. - if rep.Permanent != 1 || rep.Retryable != 0 { - t.Errorf("Permanent=%d Retryable=%d, want 1 and 0", rep.Permanent, rep.Retryable) - } - if rep.Failed() { - t.Error("a permanently pulled asset made the run report failure") - } -} - -func TestConcurrencyCeilingIsHonoured(t *testing.T) { - root, lockPath := newRun(t) - var assets []model.Asset - bodies := map[string][]byte{} - for _, id := range []string{"1", "2", "3", "4", "5", "6"} { - a := asset(id, "Asset "+id, "v1", 500) - assets = append(assets, a) - bodies[id] = pkg(t, id, "v1", 500) - } - fs := &fakeStore{owned: assets, bodies: bodies, hold: 20 * time.Millisecond} - o := opts(root, allSelected(assets...)) - o.Concurrency = 2 - - if _, err := Run(context.Background(), fs, lockfile.New(), lockPath, o); err != nil { - t.Fatalf("Run: %v", err) - } - if got := fs.maxSeen.Load(); got > 2 { - t.Errorf("peak concurrent fetches = %d, want at most 2; the store is someone else's "+ - "infrastructure and this is the brief's one third-party requirement", got) - } - if got := fs.maxSeen.Load(); got < 2 { - t.Errorf("peak concurrent fetches = %d, want the pool actually used its budget", got) - } -} - func TestProgressSurvivesAMidRunFailure(t *testing.T) { root, lockPath := newRun(t) a := asset("1", "First", "v1", 500) @@ -495,43 +351,6 @@ func TestProgressSurvivesAMidRunFailure(t *testing.T) { // ---- dry run ------------------------------------------------------------------- -func TestDryRunClassifiesAndTouchesNothing(t *testing.T) { - root, lockPath := newRun(t) - a := asset("1", "Asset", "v1", 500) - - // A stale temp and a package sitting off its derived path: a sync would sweep one - // and relocate the other. - leaf := filepath.Join(root, "pub-one", "elsewhere") - os.MkdirAll(leaf, 0o755) - os.WriteFile(filepath.Join(leaf, ".unity-sync-dl-stale"), []byte("junk"), 0o644) - os.WriteFile(filepath.Join(leaf, "elsewhere.unitypackage"), pkg(t, "1", "v1", 500), 0o644) - old := time.Unix(1600000000, 0) - os.Chtimes(filepath.Join(leaf, ".unity-sync-dl-stale"), old, old) - - before := treeSnapshot(t, root) - - fs := &fakeStore{owned: []model.Asset{a}, bodies: map[string][]byte{"1": pkg(t, "1", "v1", 500)}} - o := opts(root, allSelected(a)) - o.DryRun = true - - rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, o) - if err != nil { - t.Fatalf("Run: %v", err) - } - if len(rep.Results) != 1 { - t.Fatalf("dry run produced %d results, want 1 — status must classify", len(rep.Results)) - } - if len(fs.fetched) != 0 { - t.Errorf("a dry run downloaded %v", fs.fetched) - } - if _, err := os.Stat(lockPath); !os.IsNotExist(err) { - t.Error("a dry run wrote the lockfile") - } - if after := treeSnapshot(t, root); after != before { - t.Errorf("a dry run changed the library tree:\nbefore %v\nafter %v", before, after) - } -} - func treeSnapshot(t *testing.T, root string) string { t.Helper() var out []byte diff --git a/main.go b/main.go index 153eeb1..e01753e 100644 --- a/main.go +++ b/main.go @@ -72,11 +72,17 @@ func run(args []string) (int, error) { return 1, fmt.Errorf("unknown subcommand %q", cmd) } switch cmd { - case "-h", "--help", "help": - usage() - return 0, nil - case "version", "-v", "--version": - fmt.Fprintln(stdout, "unity-sync", version) + case "-h", "--help", "help", "version", "-v", "--version": + // These return before flag parsing, so their positionals are checked here or not + // at all: `unity-sync version foo` should not quietly succeed. + if len(rest) > 0 { + return 1, fmt.Errorf("%s takes no arguments (got %q)", cmd, rest[0]) + } + if cmd == "version" || cmd == "-v" || cmd == "--version" { + fmt.Fprintln(stdout, "unity-sync", version) + } else { + usage() + } return 0, nil } if err := fs.Parse(rest); err != nil { diff --git a/main_test.go b/main_test.go index 4c10ff2..8c96cc2 100644 --- a/main_test.go +++ b/main_test.go @@ -65,22 +65,6 @@ func TestUnknownSubcommandFails(t *testing.T) { } } -// flag.Parse stops at the first positional, so an unchecked one swallows the flags after -// it: `sync foo --dry-run` would download. -func TestStrayPositionalIsRejectedAndSuggestsOnly(t *testing.T) { - isolate(t) - for _, cmd := range []string{"sync", "status", "list", "select"} { - code, err := run([]string{cmd, "some-asset", "--dry-run"}) - if code == 0 || err == nil { - t.Errorf("%s with a positional = %d, %v; want a failure", cmd, code, err) - continue - } - if !strings.Contains(err.Error(), "--only") { - t.Errorf("%s: error %q does not point at --only", cmd, err) - } - } -} - // update is the one subcommand that takes a positional. func TestUpdateAcceptsOneVersionAndRejectsTwo(t *testing.T) { isolate(t) @@ -141,38 +125,3 @@ func TestListNeedsNoSession(t *testing.T) { } } } - -// Without a session every networked command must fail with advice, not a stack trace or -// an opaque HTTP error. -func TestMissingSessionIsExplained(t *testing.T) { - wd := isolate(t) - if err := os.WriteFile(filepath.Join(wd, manifest.FileName), []byte("\n"), 0o644); err != nil { - t.Fatal(err) - } - code, err := run([]string{"status"}) - if code == 0 || err == nil { - t.Fatalf("status with no session = %d, %v; want a failure", code, err) - } - if !strings.Contains(err.Error(), "session") { - t.Errorf("error %q does not mention the session", err) - } -} - -func TestSessionWithoutTheCredentialIsNamedBeforeAnyRequest(t *testing.T) { - wd := isolate(t) - if err := os.WriteFile(filepath.Join(wd, manifest.FileName), []byte("\n"), 0o644); err != nil { - t.Fatal(err) - } - sessionPath := filepath.Join(t.TempDir(), "session.curl") - body := "curl 'https://assetstore.unity.com/' -H 'Cookie: DS=abc; _csrf=zzz'" - if err := os.WriteFile(sessionPath, []byte(body), 0o600); err != nil { - t.Fatal(err) - } - code, err := run([]string{"status", "--session", sessionPath}) - if code == 0 || err == nil { - t.Fatalf("status = %d, %v; want a failure", code, err) - } - if !strings.Contains(err.Error(), "LS") { - t.Errorf("error %q does not name the missing credential cookie", err) - } -} diff --git a/testdata/store/my_assets_p0.json b/testdata/store/my_assets_p0.json index 2d96a72..1e651c0 100644 --- a/testdata/store/my_assets_p0.json +++ b/testdata/store/my_assets_p0.json @@ -13,7 +13,6 @@ "downloadSize": "410768352", "id": "234255", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/3e30e9a1-1890-41c0-932b-b67e120602c1.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/881019ea-8af0-4d97-910f-08f50e4e22c2.png" }, "name": "POLYGON - Meadow Forest - Nature Biomes - 3D Environment Art by Synty", @@ -34,7 +33,6 @@ "downloadSize": "381108720", "id": "271742", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/82ed0f53-62ab-4ca3-9d17-49d31f220985.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/c32b83ff-43d3-40fe-b0e2-f47f4b886bf0.png" }, "name": "COZY: Stylized Weather 3", @@ -55,7 +53,6 @@ "downloadSize": "124330544", "id": "137126", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a7d5bd17-2075-464c-a4bf-6e40c6f201b3.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/e895e0c0-5864-4419-b003-7dd31d90f8dc.png" }, "name": "POLYGON - Prototype Pack - Art by Synty", @@ -76,7 +73,6 @@ "downloadSize": "62329520", "id": "258357", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/cb313299-a07b-4bca-abad-35e1e79b4c47.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/540cf39e-7c51-4116-9cd2-e0a200ba2aa6.png" }, "name": "STYLIZED Fantasy Forge \u0026 Armory - Low Poly 3D Art", @@ -97,7 +93,6 @@ "downloadSize": "45844352", "id": "249203", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/e96f0b99-6c0f-4448-bf99-9b28d30abe69.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/d89ebf71-4ec1-45ef-a2fd-d23e607432d8.png" }, "name": "STYLIZED Fantasy Armory - Low Poly 3D Art", @@ -118,7 +113,6 @@ "downloadSize": "118370384", "id": "168372", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/651124a0-ca89-4026-ab29-c437a9b792e6.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/842d003c-538f-4cb5-8457-4e260a04c181.png" }, "name": "POLYGON - Particle FX Pack - Art by Synty", @@ -139,7 +133,6 @@ "downloadSize": "1384007920", "id": "211348", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/cced39f3-f5d4-42c0-9a2f-1f8f872aa1e9.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/f85b17c7-b4e2-4864-b6b9-7e7d6a91ae02.png" }, "name": "Wyrms", @@ -160,7 +153,6 @@ "downloadSize": "170034608", "id": "120152", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/b76e074a-97ee-4c38-a932-7dc00fb644b2.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/9fa2e075-eb09-4b6b-a28f-d0faa6253007.png" }, "name": "POLYGON - Nature Pack - Art by Synty", @@ -181,7 +173,6 @@ "downloadSize": "49821984", "id": "260824", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/54616356-7153-4243-8b38-814e82bbed49.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a337b2bd-7fd5-4382-b5dc-9a6759305fd1.png" }, "name": "STYLIZED Fantasy Market - Low Poly 3D Art", @@ -202,7 +193,6 @@ "downloadSize": "148676576", "id": "92579", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/3737e35a-dc8f-48bb-b171-378daa3596c2.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/497ec2f3-4f11-4809-a8c8-ad0690e62cfd.png" }, "name": "POLYGON - Pirate Pack - Art by Synty", @@ -223,7 +213,6 @@ "downloadSize": "114664160", "id": "156819", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/444064ed-5d44-4898-8c21-9ca95259e6ec.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6e243fac-4f94-471a-aab5-a129fe57f0f6.png" }, "name": "POLYGON - Starter Pack - Art by Synty", @@ -244,7 +233,6 @@ "downloadSize": "164470832", "id": "143468", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/1bebcb09-0716-4781-8335-bb164f5c099e.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/629f4436-4f44-4921-aca5-814597e682ad.png" }, "name": "POLYGON - Modular Fantasy Hero Characters Pack - Art by Synty", @@ -265,7 +253,6 @@ "downloadSize": "261514240", "id": "234254", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/7cc1c328-b91b-49cb-92e8-de3affd3c026.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/1ad28ece-190c-4ef3-989a-f8d784f61c5a.png" }, "name": "POLYGON - Swamp Marshland - Nature Biomes - 3D Environment Art by Synty", @@ -286,7 +273,6 @@ "downloadSize": "127717968", "id": "202117", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/6a6fea8e-786b-42b9-92cb-5c29a39cfb4b.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/e44eb0c6-cc18-40f4-a984-b451aa3b2847.png" }, "name": "POLYGON - Icons Pack - Art by Synty", @@ -307,7 +293,6 @@ "downloadSize": "280778352", "id": "234253", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/8459f88a-77aa-4f84-962a-acebd41db2f1.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/17df8081-ecbe-4ae9-b6d7-a9d625ab67e2.png" }, "name": "POLYGON - Tropical Jungle - Nature Biomes - 3D Environment Art by Synty", @@ -328,7 +313,6 @@ "downloadSize": "128628848", "id": "89551", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/dc8ef8cf-bc0d-45a8-b8cc-79b60627172a.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/fa945e09-1bed-4103-9d8a-5bdd652e34cc.png" }, "name": "POLYGON - Samurai Pack - Art by Synty", @@ -349,7 +333,6 @@ "downloadSize": "138812272", "id": "83694", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d1b94c94-8250-46b1-987d-96727c5a12d7.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/29ce21ee-922b-4328-b2f7-3f78ed670230.png" }, "name": "POLYGON - Knights Pack - Art by Synty", @@ -370,7 +353,6 @@ "downloadSize": "15232192", "id": "104017", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/f3e7ecac-5ac1-4c38-9828-7beb72a46ad2.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/380870e7-418c-439e-9631-ff31faf61238.png" }, "name": "Polyverse Skies | Low Poly Skybox Shaders", @@ -391,7 +373,6 @@ "downloadSize": "161756256", "id": "143368", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/1dbc3e78-fcd1-493c-9c69-34afa00c4df2.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/c516c43b-9b15-4e82-bf16-e70d22fdf7cb.png" }, "name": "Flat Kit: Toon Shading and Water", @@ -412,7 +393,6 @@ "downloadSize": "114824416", "id": "97186", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/5f8f8032-20c0-4cdc-871a-d12172a76af2.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/0b7ee5a6-1ed2-4cd5-b368-f446affa6775.png" }, "name": "POLYGON - Fantasy Characters Pack - Art by Synty", @@ -433,7 +413,6 @@ "downloadSize": "121372240", "id": "122084", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/8e1f306e-70c4-4da9-a820-8dfe861a0196.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6e1805c7-e18a-4ccf-9107-46f6d03e709e.png" }, "name": "POLYGON MINI - Fantasy Character Pack - Art by Synty", @@ -454,7 +433,6 @@ "downloadSize": "119597840", "id": "118399", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/159204cf-17a4-49bc-bf86-725ed2a99bab.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/07bdc399-3221-4fb3-b017-06a67fe031c3.png" }, "name": "POLYGON - Fantasy Rivals Pack - Art by Synty", @@ -475,7 +453,6 @@ "downloadSize": "119818704", "id": "96800", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/4b8bac7a-031d-47a5-8080-ae7ffa6d0c97.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/857b27ab-7c0a-4f05-b1bc-7b9a35fa054a.png" }, "name": "POLYGON MINI - Fantasy Pack - Art by Synty", @@ -496,7 +473,6 @@ "downloadSize": "132651904", "id": "85664", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/9013b7aa-ebc9-4326-8279-38a47314d1be.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/053ac792-3f6d-4ff4-ac05-d34d08ba7aa4.png" }, "name": "POLYGON - Vikings Pack - Art by Synty", @@ -517,7 +493,6 @@ "downloadSize": "232818896", "id": "224020", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/7184c193-4874-433b-a0c1-fc786d830f20.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/ac5c83c0-716c-4b74-b9d7-f52fad39984b.png" }, "name": "POLYGON - Ancient Empire Pack - Art by Synty", @@ -538,7 +513,6 @@ "downloadSize": "94958240", "id": "133704", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/81e52d4c-b832-4439-86ef-08e9b987185f.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6dd90315-d360-4f76-8069-4571aa3a9b61.png" }, "name": "RPG VFX Bundle", @@ -559,7 +533,6 @@ "downloadSize": "278496384", "id": "164532", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/71434092-b423-4adb-a3f2-bc5e26644de7.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/7e90c4ff-c0ef-482d-9400-4b2573657da9.png" }, "name": "POLYGON - Fantasy Kingdom Pack - Art by Synty", @@ -580,7 +553,6 @@ "downloadSize": "47344", "id": "282348", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/f9746116-f3c2-4311-8a2e-2dd8f0d19ca5.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/683d7f6c-b78e-4d92-80dd-2f6731547265.png" }, "name": "RPG Animations Pack(Bundle)", @@ -601,7 +573,6 @@ "downloadSize": "10914544", "id": "182328", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/805178bb-184b-4aac-9087-4e51a57a5d05.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/44005ad2-d1ab-4c3c-ba1d-57d6c25a4fde.png" }, "name": "Grabbit - Editor Physics Transforms", @@ -622,7 +593,6 @@ "downloadSize": "163896224", "id": "102677", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/482776c2-114c-4f3f-940b-85780fdd83c0.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/1564c479-107d-4789-83fb-8ea4689cc39e.png" }, "name": "POLYGON - Dungeons Pack - Art by Synty", @@ -643,7 +613,6 @@ "downloadSize": "297530528", "id": "309179", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/0a10a705-3c8b-45eb-8359-21f045174355.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/4b877d35-7e95-4009-9a0c-6cf7b3c8e8e4.png" }, "name": "RPG_Animations - Two Hand Base", @@ -664,7 +633,6 @@ "downloadSize": "114301584", "id": "143026", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/7b4fa47b-0b0c-4408-8b7b-5717b5ec3dd2.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/e21f218a-49b9-4a02-9d36-6227610d3a65.png" }, "name": "POLYGON - Fantasy Dungeon Map - Art by Synty", @@ -685,7 +653,6 @@ "downloadSize": "186585328", "id": "189093", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a49e8c6b-8490-4f04-9a00-d55f00e4ed1c.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/0d96a9df-dcf7-4ac4-8062-a85cb18483f7.png" }, "name": "POLYGON - Dungeon Realms Pack - Art by Synty", @@ -706,7 +673,6 @@ "downloadSize": "266475040", "id": "309180", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/4b731cb5-1165-4a76-ba9c-bcfcc8e3e34c.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/785bee30-ea83-4981-8b66-26c9c0b22c54.png" }, "name": "RPG_Animations - Two Hand Up", @@ -727,7 +693,6 @@ "downloadSize": "316587600", "id": "183370", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/04a3b4f5-c0b9-4820-a9bf-390c7a003209.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/0f44cacc-0406-4a7b-bb8a-63859b223e54.png" }, "name": "Feel", @@ -748,7 +713,6 @@ "downloadSize": "6290368", "id": "89041", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/c1a2ba24-5397-48af-8dac-ebae07291b9e.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6497b8ed-fee7-4580-9507-54690d70fec4.png" }, "name": "Odin Inspector and Serializer", @@ -769,7 +733,6 @@ "downloadSize": "256992", "id": "768", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/013887ed-eaa6-4fdf-bcb6-151c76a1f6d0.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3f104c5c-f7eb-470b-8201-168e80f305bf.png" }, "name": "Easy Save - The Complete Save Game \u0026 Data Serializer System", @@ -790,7 +753,6 @@ "downloadSize": "219234432", "id": "154574", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d2d0a691-a898-4198-aa22-61234815f215.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/cfa4737f-10de-4074-a1b3-8b73e9041528.png" }, "name": "Ultimate Clean GUI Pack", @@ -811,7 +773,6 @@ "downloadSize": "124846384", "id": "80585", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/51bf7a4b-5dd6-4b14-8755-3fd956310c6f.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/952cc307-b8a3-4822-9f37-217dec345be2.png" }, "name": "POLYGON - Adventure Pack - Art by Synty", @@ -832,7 +793,6 @@ "downloadSize": "603360", "id": "32416", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/fd052e88-d81f-46d3-ac8f-b2c2214f9376.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/cf534981-836c-4f21-a66a-f9e7e504bbf7.png" }, "name": "DOTween Pro", @@ -853,7 +813,6 @@ "downloadSize": "23164235200", "id": "89126", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a1a68cd3-ba5c-4998-b926-326c2c19b118.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/fa39240f-65fb-43aa-bd99-35023d0c7112.png" }, "name": "Total Music Collection", @@ -874,7 +833,6 @@ "downloadSize": "243120", "id": "27676", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/b8d0dbfb-d982-4b07-bc8e-d4797fc3085c.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/778803bc-a2db-49ff-92e2-609bbf31ac9f.png" }, "name": "DOTween (HOTween v2)", @@ -895,7 +853,6 @@ "downloadSize": "79283824", "id": "309183", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/14b60e25-7604-4533-b279-e590a6c46720.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/ba395a76-e85e-4d0c-b0da-07ca9d3d9f38.png" }, "name": "RPG_Animations - Action \u0026 Dead Pose", @@ -916,7 +873,6 @@ "downloadSize": "2836958224", "id": "132195", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/6bd2bbc7-f525-4c28-bc69-1756a20fa046.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/f64a8b5f-8e7f-4728-8006-706a0515e050.png" }, "name": "Meadow Environment - Dynamic Nature", @@ -937,7 +893,6 @@ "downloadSize": "127113344", "id": "15567", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/e0372c47-2510-4302-b5ce-5fcb420afcf0.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/4137b417-21a8-4971-bb31-4430c32cca00.png" }, "name": "BIRDS PACK", @@ -958,7 +913,6 @@ "downloadSize": "135255472", "id": "203178", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/72aa860f-dcb9-4fb5-8af0-713a760b7369.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/247103b7-9b85-48d3-a99d-ad5fc9203d9b.png" }, "name": "Quibli: Anime Shaders and Tools", @@ -979,7 +933,6 @@ "downloadSize": "179761360", "id": "217205", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/70f68dea-a4e5-4609-8098-214a040ffbcb.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/ea71498b-df62-4322-ba53-9981a0b4b72b.png" }, "name": "Ivy Studio - Procedural vine generation", @@ -1000,7 +953,6 @@ "downloadSize": "1792112", "id": "159992", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/cf9d1019-318c-4704-b3df-1c183479119a.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/54fa1ee6-a54d-4a77-832b-588f0d5f9479.png" }, "name": "Jupiter - Procedural Sky Shader \u0026 Day Night Cycle", @@ -1021,7 +973,6 @@ "downloadSize": "34980432", "id": "225934", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/afd9584f-a6c4-4e49-8785-8bc0fa1bdd97.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/95be70a7-172e-4405-ba62-d873e18ab64d.png" }, "name": "Radiant Global Illumination", @@ -1042,7 +993,6 @@ "downloadSize": "23453600", "id": "158988", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/291df5c4-0f15-475b-9197-5e3cdc3bd505.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/87d9a20e-2f07-4887-9f2f-619b6c1134b3.png" }, "name": "Sprite Shaders Ultimate", @@ -1063,7 +1013,6 @@ "downloadSize": "68256368", "id": "309181", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/65e4df5a-ae9a-4191-8872-458db4247b69.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/9b294624-2a69-4f1c-9579-46ddac78f6de.png" }, "name": "RPG_Animations - NPC Actions", @@ -1084,7 +1033,6 @@ "downloadSize": "256785136", "id": "309182", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/6143000b-38d0-475d-aec8-e3df355f590c.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/adbc82f3-cb1d-4c3a-86cb-8cf7d16df4b5.png" }, "name": "RPG_Animations - Torch,Lantern,Enemy Attack,Hit,Magic", @@ -1105,7 +1053,6 @@ "downloadSize": "14146000", "id": "230509", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/b5cb8df4-bcfe-4535-826a-d5a723dd4516.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/99964151-b71a-450b-9a26-60569b0bcac0.png" }, "name": "Flexalon Pro: 3D \u0026 UI Layouts", @@ -1126,7 +1073,6 @@ "downloadSize": "3503953968", "id": "229460", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/f12c9582-f123-41e4-ad68-c94cfcaab11c.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/8f75c6cd-a955-487c-9466-922b4ea7b737.png" }, "name": "6200 Fantasy RPG Icons Pack", @@ -1147,7 +1093,6 @@ "downloadSize": "5485024", "id": "148408", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/eb16579b-099f-41e0-9f27-13e4c20398c3.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/8834c4b6-04b5-4223-868f-d74058652051.png" }, "name": "IK Helper Tool", @@ -1168,7 +1113,6 @@ "downloadSize": "10904544", "id": "194727", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a98758b0-b204-4925-8c4b-eecc6faf847f.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/d8e0529c-9387-485f-9ae8-cc36be902091.png" }, "name": "Pixelate - Pixel Art Converter", @@ -1189,7 +1133,6 @@ "downloadSize": "199251504", "id": "323439", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/55d9173a-d0fa-42f0-8f91-35b73d862149.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/cd07a668-193e-4f3f-8b67-09d36cde71a5.png" }, "name": "Base Move Animation", @@ -1210,7 +1153,6 @@ "downloadSize": "55913936", "id": "50282", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/ce67dc83-3d7b-4a3b-9b29-b237f8c4743a.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/c311904a-5d51-4ee7-aa53-89ee2b9e7022.png" }, "name": "Turn Based Strategy Framework", @@ -1231,7 +1173,6 @@ "downloadSize": "210158944", "id": "162341", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/833ca243-3b92-482e-9231-8978dd9ee8b2.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/e25b18b8-9afe-45b5-b0b3-48da849d20a4.png" }, "name": "Human Mega Animations Pack", @@ -1252,7 +1193,6 @@ "downloadSize": "53836656", "id": "157744", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/332cae64-6b71-4eaf-9a90-b7a0b385eb12.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/00a7471d-99ae-4203-8316-4aefd00e3a84.png" }, "name": "Human Basic Motions", @@ -1273,7 +1213,6 @@ "downloadSize": "31302416", "id": "135594", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/1b6fb1eb-c281-440e-a1e3-53293e2cff84.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/4559f61c-3507-4eec-ab7e-8f3ce317c698.png" }, "name": "Boing Kit: Dynamic Bouncy Bones, Grass, and More", @@ -1294,7 +1233,6 @@ "downloadSize": "327960496", "id": "309177", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/091ba98d-0d84-4992-8186-49c1c565c970.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/cb4ebbe5-0725-4126-94ba-961a4eeaeb87.png" }, "name": "RPG_Animations - One Hand Base", @@ -1315,7 +1253,6 @@ "downloadSize": "21483104", "id": "108753", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/b461e6a2-9779-4fc8-8314-55d64af010d3.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/fbefb7dc-1b57-449e-9532-39e94c8a51e4.png" }, "name": "SC Post Effects Pack (for Unity 2020-2023)", @@ -1336,7 +1273,6 @@ "downloadSize": "302579376", "id": "309178", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/bfe59621-6914-4c15-bb55-31d5067bbe9a.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3f5aeb12-ea15-4191-8ecc-a540e22bdb0d.png" }, "name": "RPG_Animations - One Hand Up", @@ -1357,7 +1293,6 @@ "downloadSize": "258868928", "id": "54733", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/45d0f2a1-9610-4aba-aa2e-940f873c3205.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/f27170ce-59eb-419a-8ccc-78d7cbf232bb.png" }, "name": "Low Poly Ultimate Pack", @@ -1378,7 +1313,6 @@ "downloadSize": "38446560", "id": "157187", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/0752c9a4-faa0-465a-9a2d-bee79c8b7dec.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/e51e38cb-3dc6-4355-8a3b-94db4fae5c76.png" }, "name": "Easy Performant Outline 2D | 3D (URP / HDRP)", @@ -1399,7 +1333,6 @@ "downloadSize": "245836528", "id": "309184", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/4806cab3-03a0-4106-bbfb-5bc56742b828.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/2f9cbca5-c5c2-4d57-896a-76436eb458b3.png" }, "name": "RPG_Animations - Bow", @@ -1420,7 +1353,6 @@ "downloadSize": "130309120", "id": "93089", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/99b85c07-c6eb-405c-8876-d9a98af6d298.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/65c27d72-90da-4717-adef-bb8fab369cd2.png" }, "name": "Low Poly Animated Animals", @@ -1441,7 +1373,6 @@ "downloadSize": "69129536", "id": "156748", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d67756ba-e43a-48a1-9e3f-7b1547bdc345.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/b4777594-7859-4ffc-ac18-19cbffad6930.png" }, "name": "Low Poly Animated People", @@ -1462,7 +1393,6 @@ "downloadSize": "38718320", "id": "177023", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/19bb215c-c479-408f-9ab2-717746cfaa08.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/12904059-22ab-4ec2-a022-14795e18fc7f.png" }, "name": "Map Graph", @@ -1483,7 +1413,6 @@ "downloadSize": "1986960", "id": "205336", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/708ca4ee-ee2f-43c9-b81d-767bbd9f09ea.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/4afcc942-a01a-44b6-9d3f-c01dc145ee03.png" }, "name": "SensorToolkit 2", @@ -1504,7 +1433,6 @@ "downloadSize": "96192", "id": "112837", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/5f3af86b-8b92-4dbd-892a-e77b9325d32e.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/63934f4d-23d7-43ca-9941-fd29beaa0d15.png" }, "name": "Asset Usage Detector", @@ -1525,7 +1453,6 @@ "downloadSize": "31130656", "id": "137259", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/413159b3-8398-4928-aa8d-217554b9e257.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a90f91f4-e27c-449f-be64-aa8067273389.png" }, "name": "Quirky Series - Animals Mega Pack Vol 1", @@ -1546,7 +1473,6 @@ "downloadSize": "29964416", "id": "183280", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/db99f677-ae99-47d8-a255-53e28158f16b.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/dd9b99c6-4b76-48cb-b1a4-aa4bbff662f3.png" }, "name": "Quirky Series - Animals Mega Pack Vol 2", @@ -1567,7 +1493,6 @@ "downloadSize": "1216250880", "id": "152412", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/e8054c99-33ea-455e-92aa-93fb19971da9.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a9f8c83e-0c18-4a35-8b23-25ec6d27db7c.png" }, "name": "Demonic UI 8k + Icons", @@ -1588,7 +1513,6 @@ "downloadSize": "154211504", "id": "63772", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/910f37f4-cc6d-45e4-8e58-c631efd36d98.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/dbc04e94-67cb-42e7-87c6-819146a48967.png" }, "name": "RPG Character Mecanim Animation Pack", @@ -1609,7 +1533,6 @@ "downloadSize": "610242160", "id": "193841", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/44987cf1-eeb6-488f-817e-7aa59baa7717.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3c71011f-adfe-4190-8f27-e4daa7bc3f74.png" }, "name": "Classic Fantasy RPG - UI Kit", @@ -1630,7 +1553,6 @@ "downloadSize": "25414870", "id": "183075", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a5b40915-53a9-4e6d-b1e1-6bffb06114f8.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/b2af1f9b-9e5d-41cd-8120-91af030eeb72.png" }, "name": "Unity Learn | Foundations of Real-Time Audio | URP", @@ -1651,7 +1573,6 @@ "downloadSize": "2807614464", "id": "180688", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/4709fe85-fc7a-4402-9787-11a21acc77d3.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/c2c3f43d-c445-42d6-957a-d572e631f64c.png" }, "name": "Medieval Kingdom UI", @@ -1672,7 +1593,6 @@ "downloadSize": "29543760", "id": "111398", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/cbf4e129-14d8-450e-827e-c1dcd15cf327.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/ab225426-93f1-4771-bc6e-61c07443e66f.png" }, "name": "Character Creator 2D", @@ -1693,7 +1613,6 @@ "downloadSize": "57083264", "id": "135882", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/59d3772c-9318-458e-9845-d2080709488d.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/7969defd-9f66-4bfe-971d-94ead7d16503.png" }, "name": "Gothic UI", @@ -1714,7 +1633,6 @@ "downloadSize": "50304", "id": "319643", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/0447c923-a682-4705-b83f-332e25e4cd22.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/432f1fb1-c37d-4c20-b81e-4c3686b931ca.png" }, "name": "Easy FBX Anim Modifier", @@ -1735,7 +1653,6 @@ "downloadSize": "404090272", "id": "123499", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/47761ceb-3c7d-4bd2-ab16-ea3daa73ddf4.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/788cfe1b-dc4c-434f-8ae0-dda3674802c6.png" }, "name": "Citadel RPG GUI", @@ -1756,7 +1673,6 @@ "downloadSize": "19373744", "id": "134149", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/77d71f38-f78a-4815-8ca2-276e4966c828.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/5e77cd21-c32b-437c-95a2-ff69748fd193.png" }, "name": "Highlight Plus - All in One Outline \u0026 Selection Effects", @@ -1777,7 +1693,6 @@ "downloadSize": "7021840", "id": "159068", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a7c50e2b-52c5-4959-9c78-930b00d0013b.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/2deaf83f-722d-4284-bf41-841a42321cfd.png" }, "name": "GUI Parts", @@ -1798,7 +1713,6 @@ "downloadSize": "383190928", "id": "112002", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d673d1ea-3fc8-4204-beaa-5ca32d11cd8a.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3bf08591-4a60-4838-acb0-f8225b9ec6aa.png" }, "name": "Sci-Fi Turret Constructor", @@ -1819,7 +1733,6 @@ "downloadSize": "6056048", "id": "47302", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/02ed0fba-ca47-4472-9ad1-878715518267.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/89ceeeb0-f8f2-468f-8a5a-f59cc5ffb1d6.png" }, "name": "Crossbow Warrior Mecanim Animation Pack", @@ -1840,7 +1753,6 @@ "downloadSize": "1040271056", "id": "215197", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/62c34864-5a5f-4299-8a43-2f6a80203c0a.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3e3599d8-d9de-4152-92e5-46bdbfaa4706.png" }, "name": "Toon Fantasy Nature", @@ -1861,7 +1773,6 @@ "downloadSize": "52608", "id": "190683", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/852e8288-c5a5-413e-b2ea-4caac32b7c80.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/0443bc51-6608-4849-8156-e65928e46f82.png" }, "name": "Warrior Pack Super Bundle", @@ -1882,7 +1793,6 @@ "downloadSize": "6669808", "id": "35101", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/ef7bc7ea-3c9b-4dd3-964f-33256fe17666.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/aa08f459-41e1-4ade-8e21-c09531e17655.png" }, "name": "Karate Warrior Mecanim Animation Pack", @@ -1903,7 +1813,6 @@ "downloadSize": "6311296", "id": "42286", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/b0f6d61f-41be-4d0a-8fd3-f44828b6007f.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/e9b295a8-585f-4c4c-a045-2fa73d436c79.png" }, "name": "2-Handed Warrior Mecanim Animation Pack", @@ -1924,7 +1833,6 @@ "downloadSize": "5948256", "id": "35577", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/341ec3ad-268e-4e75-8289-1a5c9713c144.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3fc9921f-3766-4cb4-92c8-c3d371826851.png" }, "name": "Brute Warrior Mecanim Animation Pack", @@ -1945,7 +1853,6 @@ "downloadSize": "8427008", "id": "35814", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/05e1b5ff-b072-43f7-a5e2-299163d1b4e7.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3b435b1d-6ddc-4691-b011-d4ea8450c163.png" }, "name": "Sorceress Warrior Mecanim Animation Pack", @@ -1966,7 +1873,6 @@ "downloadSize": "6106288", "id": "43153", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/5f11d518-c1a3-4645-9e3b-8e7e4df896db.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/56dc3ae6-85b5-4aae-a4f0-c44578682b85.png" }, "name": "Swordsman Warrior Mecanim Animation Pack", @@ -1987,7 +1893,6 @@ "downloadSize": "10606272", "id": "35307", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/ba174c19-85b7-440b-bc91-d9620121e1a7.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/027c15f6-ce99-4eb5-85c2-46162b04d6e8.png" }, "name": "Ninja Warrior Mecanim Animation Pack", @@ -2008,7 +1913,6 @@ "downloadSize": "6177360", "id": "41714", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/3fce4ace-84a7-45de-941c-cdd39469e3bc.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/b942e066-73b9-4774-a1ad-ae92e1714e58.png" }, "name": "Archer Warrior Mecanim Animation Pack", @@ -2029,7 +1933,6 @@ "downloadSize": "7166592", "id": "38814", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/8e26d2a0-467b-47aa-989b-91c2a47fe6f5.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/7d486bbe-371d-42c5-87fc-5586af73925f.png" }, "name": "Knight Warrior Mecanim Animation Pack", @@ -2050,7 +1953,6 @@ "downloadSize": "6568624", "id": "46860", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/ce46063b-07bc-4cb7-bc01-d760db493c8a.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/512458a2-9782-40dc-a2e6-5b7895b88975.png" }, "name": "Hammer Warrior Mecanim Animation Pack", @@ -2071,7 +1973,6 @@ "downloadSize": "6424448", "id": "39519", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/cb70bba6-8d86-47c2-8c32-ec4ff3b0c842.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/acd458c4-960d-456e-a250-626bb4e0cec1.png" }, "name": "Mage Warrior Mecanim Animation Pack", @@ -2092,7 +1993,6 @@ "downloadSize": "6182048", "id": "46399", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a3b83321-02d8-49c6-8a73-ad5c79e44112.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/d2ac582e-b6d1-4997-bda3-ca1368e4516a.png" }, "name": "Spearman Warrior Mecanim Animation Pack", diff --git a/testdata/store/my_assets_p1.json b/testdata/store/my_assets_p1.json index 0d7ca8d..48c0817 100644 --- a/testdata/store/my_assets_p1.json +++ b/testdata/store/my_assets_p1.json @@ -13,7 +13,6 @@ "downloadSize": "211058768", "id": "64248", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/270c8150-e1e0-4ab7-984a-8dde4763cd01.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/e4bf5216-4c2f-4720-940a-0f21979d149e.png" }, "name": "Little Dragons: Tiger", @@ -34,7 +33,6 @@ "downloadSize": "63142032", "id": "266535", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/ce4f0d8d-47ef-4aa4-b61b-8ba77ba3da20.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/d680075a-394b-4ec6-b757-377a24ced8b8.png" }, "name": "Body Proportions", @@ -55,7 +53,6 @@ "downloadSize": "2903760", "id": "148410", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/e54628cb-4973-4fe2-92b6-589cce49a99e.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/276b62dd-b4dd-4b81-a261-967e651e90fd.png" }, "name": "RPG Poly Pack - Lite", @@ -76,7 +73,6 @@ "downloadSize": "12640", "id": "262163", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/c4e520f4-e052-4b68-8855-2f314174f2f9.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6e2d5d2a-4487-4080-a729-0938710fbe10.png" }, "name": "UI Toolkit Bundle 1", @@ -97,7 +93,6 @@ "downloadSize": "159429792", "id": "179083", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/e3edfe3c-9c93-4562-ac88-a5a33056cb8e.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/31f22bf7-395a-4cbd-a20d-f52776445348.png" }, "name": "Monsters Ultimate Pack 02 Cute Series", @@ -118,7 +113,6 @@ "downloadSize": "87409280", "id": "61157", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/06ba4f3d-0574-4eee-8fa7-f38e4bf02069.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/2e3bc354-323c-459d-baca-ac98b9e4e530.png" }, "name": "Underwater FX", @@ -139,7 +133,6 @@ "downloadSize": "121657712", "id": "193760", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d0c86a49-3132-4a72-a0dd-d6a2a44edffe.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/1a0a6a99-02ac-488d-a385-5c41b67cfe50.png" }, "name": "Fantasy Sounds Bundle", @@ -160,7 +153,6 @@ "downloadSize": "625728", "id": "127775", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/673aaec2-ac97-4cba-8841-d415dc1319f5.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/27d23e8e-6446-4e05-8277-2a8d7b64d32f.png" }, "name": "Low Poly Fantasy Warrior", @@ -181,7 +173,6 @@ "downloadSize": "399312", "id": "96925", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/7843f91c-727a-4f67-a1b0-0456d1e2d766.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/809f22b5-3b8a-466c-ace1-13d9ae3e545f.png" }, "name": "Smooth Sync", @@ -202,7 +193,6 @@ "downloadSize": "548052304", "id": "163280", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/2df4583b-7396-42a0-8047-549d94df4f12.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/9d0f079a-ff91-4e84-ad85-d7e947c0f9d5.png" }, "name": "5000 Fantasy Icons", @@ -223,7 +213,6 @@ "downloadSize": "5410304", "id": "205222", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/9b22ca9b-da0b-498e-b784-43f8363f6942.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/9bd43fdc-bee1-4832-9f07-4e461ae6b1f2.png" }, "name": "Pixel Art Full GUI / UI Kit + 151 icons!", @@ -244,7 +233,6 @@ "downloadSize": "3040904275", "id": "213197", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/5356fafe-5b56-44a5-b8fb-ab11014b372e.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/0f944911-416f-474a-9073-fc872015a707.png" }, "name": "Unity Terrain - URP Demo Scene", @@ -265,7 +253,6 @@ "downloadSize": "48544", "id": "262495", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a4fc501c-f8cb-4967-a9a5-b517afe79528.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/b747be4c-fca1-40b5-8f90-ad34df0f8846.png" }, "name": "Utility USS", @@ -286,7 +273,6 @@ "downloadSize": "24521904", "id": "160144", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/44487fdd-cbd0-40e8-b869-a11f4ce324db.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/54ff8831-e6df-4f10-8508-1c07ebe3ef84.png" }, "name": "Magica Cloth", @@ -307,7 +293,6 @@ "downloadSize": "50476272", "id": "152053", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/182545f4-9e85-420a-ad3c-0190a4f459fe.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/e30cc1e2-eedb-4d96-b369-139bbf132115.png" }, "name": "RPG \u0026 MMO UI X", @@ -328,7 +313,6 @@ "downloadSize": "1883744", "id": "177877", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/cea71fd4-26e7-4e92-a9e5-038bee3af03d.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/ecb49962-d7c2-4063-990f-7e5d3e32fec0.png" }, "name": "ProPixelizer - Realtime 3D Pixel Art", @@ -349,7 +333,6 @@ "downloadSize": "272226032", "id": "231178", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/67a3a721-b7bb-4123-b4f0-e8516dbdb9c1.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/c7c08c5e-f345-46cf-b7b3-9eb01cdf12d1.png" }, "name": "Dragon Crashers - UI Toolkit Sample project", @@ -370,7 +353,6 @@ "downloadSize": "238291072", "id": "186580", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/aa10dbb4-894b-4469-afd4-1456ac486d05.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6b3bdab9-8714-4a93-9680-2b5737649c18.png" }, "name": "Easy AR : Make Awesome AR Apps Without Coding", @@ -391,7 +373,6 @@ "downloadSize": "335387776", "id": "146014", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/532f7d28-88c4-4d85-b037-8c5c6ec40165.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/820684d8-011d-4ec4-967a-76253d48f6d2.png" }, "name": "AllSky Free - 10 Sky / Skybox Set", @@ -412,7 +393,6 @@ "downloadSize": "91462928", "id": "160253", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/eb0c121c-b5ce-4843-b204-7373f2382cca.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/4a9c5254-9c3e-4161-b496-409ffefdcd2c.png" }, "name": "Classic RPG GUI", @@ -433,7 +413,6 @@ "downloadSize": "44284368", "id": "251843", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/0b5b7c99-f041-48b8-b2ff-18d47d0c041e.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/648debc1-9e39-4ed4-8f95-d704bc6e46d2.png" }, "name": "URP+ 2022 - Improved Universal Render Pipeline", @@ -454,7 +433,6 @@ "downloadSize": "21649024", "id": "221125", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/b1f51465-ed93-4728-b6e6-0c2fe30a5289.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/fcda904d-5100-470c-99a1-629ce2105f0b.png" }, "name": "Skill \u0026 Attack Indicators", @@ -475,7 +453,6 @@ "downloadSize": "928667344", "id": "234071", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/53371ad8-3d29-4c3a-a01c-de11224fa869.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/5ffa31e9-ad6c-4732-ba5b-ba71a4fb27ac.png" }, "name": "Fantasy RPG Music Collection - Fallen Kingdom", @@ -496,7 +473,6 @@ "downloadSize": "1118524096", "id": "89624", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/08a4b47e-e5f0-438b-923e-9b6b06aad55d.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3d1c2570-4f74-47f5-9e34-cc80bb0889fd.png" }, "name": "SciFi Space Base", @@ -517,7 +493,6 @@ "downloadSize": "2184784", "id": "81692", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/a33bce33-9728-4012-84bc-12784718a0a0.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/80d8b213-bfc7-4fad-8559-60bb18cb95c9.png" }, "name": "Simple Forest Animals - Cartoon Assets", @@ -538,7 +513,6 @@ "downloadSize": "191259600", "id": "136564", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/2fbdd5b6-e6ff-4430-a4ee-6adaa8b587d3.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/50b90022-5d84-4028-a2be-1f4784fd0baa.png" }, "name": "500 Skill Icons", @@ -559,7 +533,6 @@ "downloadSize": "95393792", "id": "221389", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/32980f7f-a5c9-43cd-a223-e4c916c63d7c.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/d2ebdca7-60b8-4529-a2b3-5abb933f3436.png" }, "name": "Brute Force - Snow \u0026 Ice Shader", @@ -580,7 +553,6 @@ "downloadSize": "32205840", "id": "207966", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/b4f8d461-740c-4dca-808b-5729446367cc.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/273a2497-efe9-43c4-ae58-fd028269798b.png" }, "name": "Shiny Items VFX for URP", @@ -601,7 +573,6 @@ "downloadSize": "2299743520", "id": "115747", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/37870577-4415-47aa-8f47-1be72b29d1c1.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/2297589a-1306-4ed0-a48b-ef19207cef5c.png" }, "name": "Unity Learn | 3D Game Kit", @@ -622,7 +593,6 @@ "downloadSize": "607672928", "id": "213593", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/db3b0011-df23-4f28-80b8-192ce31c5aac.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/dec49d89-714f-4aa2-9c1a-b10b0f874e0e.png" }, "name": "Fantasy Map Creator", @@ -643,7 +613,6 @@ "downloadSize": "1338836112", "id": "142554", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/c195214d-c4da-48e7-8771-4da1134227f9.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/44dcbfa1-2a21-452f-a85e-9a1e5feab231.png" }, "name": "Forest animals", @@ -664,7 +633,6 @@ "downloadSize": "356912", "id": "235062", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/82ac7317-d958-4d19-a542-91b8e58affbd.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/592efcd1-bca5-4efd-a6f3-77b506a1948d.png" }, "name": "Exporter for Unreal to Unity 2023", @@ -685,7 +653,6 @@ "downloadSize": "461868736", "id": "191447", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/b23887d6-c1d5-4dd8-a129-30c6e825fe1d.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/dd972b1b-c6d2-4518-abab-83610fc05aba.png" }, "name": "5592 Fantasy RPG Icons", @@ -706,7 +673,6 @@ "downloadSize": "82775552", "id": "198411", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d3ba04fc-c42b-4cbb-9f03-bc194a48e3d1.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3ff9f3e5-5f90-4bd3-a9f5-8de61174630e.png" }, "name": "Party Monster Rumble PBR", @@ -727,7 +693,6 @@ "downloadSize": "326481040", "id": "64768", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/75f058a2-a078-4450-8903-599eafb9e391.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/4125a11c-e529-4fe1-980a-75a5cb842fdb.png" }, "name": "Skill And Ability Icons", @@ -748,7 +713,6 @@ "downloadSize": "8264192", "id": "161366", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/2239075a-2d86-4777-a0ae-5551e3a3fc8d.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/19ae8c50-d16c-4b3c-b276-88f4fdc591f1.png" }, "name": "Seamless Texture Generator", @@ -769,7 +733,6 @@ "downloadSize": "28805888", "id": "154032", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/04ef01fc-5081-4a81-ab3f-6d5f4b764be6.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/1411cc37-cc9b-420d-95ab-9f899d84b867.png" }, "name": "Powerslide Kart Physics", @@ -790,7 +753,6 @@ "downloadSize": "23661984", "id": "44361", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/fc02181b-e961-44bf-8ddb-be4b0591d282.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/cfe8a505-1adf-460d-bef4-27cb2d41c9ea.png" }, "name": "ProTips - Tooltip System", @@ -811,7 +773,6 @@ "downloadSize": "6291278720", "id": "151756", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/6c7c13dd-3a89-471f-a8cc-04fa8ccb478a.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/b3772630-55e9-4a53-ab7c-73e3d7ddf618.png" }, "name": "Ultimate Sound FX Bundle", @@ -832,7 +793,6 @@ "downloadSize": "130365232", "id": "176744", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d2580ec5-7e6d-4f98-bd7e-543211fb076f.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a0ee1a91-3926-4b05-b955-eed7630ad2b0.png" }, "name": "Monster Sounds Pack", @@ -853,7 +813,6 @@ "downloadSize": "972558697", "id": "208063", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/218a67d8-17cf-4d0f-8994-68eb272eb9f5.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/53239341-b683-41d4-83d8-3b270a763d31.png" }, "name": "Interactive Guide: Cross-Platform UI", @@ -874,7 +833,6 @@ "downloadSize": "33824", "id": "115488", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/c0cfbe0b-2771-4b8d-a793-ac4c08f0b9ad.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3c58effc-acb7-48f1-9da3-e760bba1afdc.png" }, "name": "Quick Outline", @@ -895,7 +853,6 @@ "downloadSize": "288215056", "id": "103633", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/f272c42c-d5d0-42d4-b048-31b7c061c298.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/0ff283e5-baff-4a39-9854-3e46bec419eb.png" }, "name": "Skybox Series Free", @@ -916,7 +873,6 @@ "downloadSize": "1799677570", "id": "29140", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/23942802-9210-4612-b449-3691f0b65a39.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/b48a0552-cab7-479d-b73c-801ce3a16bf5.png" }, "name": "Viking Village URP", @@ -937,7 +893,6 @@ "downloadSize": "2956018592", "id": "65780", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/2ccb161c-750f-4fa2-a56d-a7a0811b255b.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/735b81ed-59ce-4ae4-a213-c148773e2527.png" }, "name": "Eternal Temple", @@ -958,7 +913,6 @@ "downloadSize": "348447648", "id": "154257", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/65e6220e-d6ec-4494-89f8-66d7c25600db.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/1e9faf26-3081-477f-82ba-6f8a96189774.png" }, "name": "Sci-Fi Sound Pack", @@ -979,7 +933,6 @@ "downloadSize": "336057360", "id": "73563", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/5f7f6ae1-2f72-43b9-a415-f3ccfcca9325.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/2c18f034-f017-4320-b66e-9637559cb62b.png" }, "name": "Lighting Optimisation Tutorial", @@ -1000,7 +953,6 @@ "downloadSize": "101036256", "id": "201671", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/fa02dc3f-fb12-46a4-8bfd-32d6328ecc7f.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/b2245728-ec37-47ef-b125-6813a4d1833f.png" }, "name": "Human Vocal Sounds (PRO)", @@ -1021,7 +973,6 @@ "downloadSize": "6956576", "id": "162025", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/f7e40afa-de9d-48b2-960f-c8d10e9f55e2.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/f98cab8f-151b-48ad-9a31-894593cda6a5.png" }, "name": "Stylized Water For URP", @@ -1042,7 +993,6 @@ "downloadSize": "4681200", "id": "151985", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/2fa34eba-b815-4821-810d-f9130b7c0fa8.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a8940c88-062c-4d9e-be01-0f5c3563ef45.png" }, "name": "Weapon Sounds", @@ -1063,7 +1013,6 @@ "downloadSize": "3630992", "id": "153777", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/dd917882-b9bd-4ac0-b80f-a4b24b43ce1d.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/1d9da85a-c559-4bb9-b67e-fc6b4f6a194c.png" }, "name": "Shield Sounds", @@ -1084,7 +1033,6 @@ "downloadSize": "209465539", "id": "174461", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/3dcea019-4a60-42bb-8c71-2aead0833950.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a6c909f0-6db2-42a4-9044-70cee8623d0d.png" }, "name": "Tiling Textures - 3D Microgame Add-Ons", @@ -1105,7 +1053,6 @@ "downloadSize": "133409047", "id": "25422", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/38197ea1-3efb-4722-acbd-b24cc6c56283.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/ee3d744e-6d87-4dd3-8815-9e2aa85f6777.png" }, "name": "Shader Calibration Scene", @@ -1126,7 +1073,6 @@ "downloadSize": "77948704", "id": "165660", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d68eac6c-faa9-4582-8a90-8d59a4fcb60b.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/d4f24e01-7f9d-4935-9b03-5197e02abf35.png" }, "name": "Footsteps Sound Pack", @@ -1147,7 +1093,6 @@ "downloadSize": "75408912", "id": "180881", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/5197e910-6cbb-49e0-a5da-eb1b4657d276.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/085cc0a8-1324-4dc8-9d37-86627ed65709.png" }, "name": "300+ RPG Flat Icons Pack", @@ -1168,7 +1113,6 @@ "downloadSize": "21301264", "id": "224138", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/759d24ac-7bf3-4811-87fd-eb13a2186f8a.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/3d826d36-dcd4-45a5-a0dd-ddbbd36377e9.png" }, "name": "RPG Flat Skills 05", @@ -1189,7 +1133,6 @@ "downloadSize": "117497872", "id": "82713", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/89ee1692-f2cc-4a03-8caf-23c73d4a86b5.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6e9c83dd-cfd3-4457-a128-bfbe3b11b994.png" }, "name": "Flat Skills Icons", @@ -1210,7 +1153,6 @@ "downloadSize": "2987776", "id": "80667", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/6cf09e1e-fbef-4b56-8b63-33210a9766e2.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/5865c88c-1b3a-48b2-8b3b-c392a8dcd32f.png" }, "name": "Soft Mask", @@ -1231,7 +1173,6 @@ "downloadSize": "20175488", "id": "180105", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/e0d2d0cb-c0ec-4dc0-9fa0-15bed5263b58.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6407ee40-ca4f-40c9-b371-a8c1587481fe.png" }, "name": "Stylized RPG Cursors", @@ -1252,7 +1193,6 @@ "downloadSize": "893115456", "id": "194725", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/802f1f16-b4e2-4401-9d95-7e6d880fd206.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/f4574f0a-bb29-41a2-80c3-f0d917ad33f4.png" }, "name": "Fantasy Music Pack Vol 1", @@ -1273,7 +1213,6 @@ "downloadSize": "160195248", "id": "196149", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/7872fbe5-94a2-4466-bf2b-10cdcc1b9fe2.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/461be42e-63f2-464d-b357-3d337f5fa672.png" }, "name": "Fire Sounds Pack", @@ -1294,7 +1233,6 @@ "downloadSize": "14557680", "id": "150771", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/0f154fcf-dcfd-47c8-bc7e-684efdaf4088.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/921b8cf0-7be6-43a6-a6e3-2d8cde538a8e.png" }, "name": "Classic UI icons", @@ -1315,7 +1253,6 @@ "downloadSize": "655103888", "id": "155819", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/4e967f9b-73f5-495d-b874-c65a4dec3037.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a16f1332-ed91-40ff-8e21-c13c46237481.png" }, "name": "Flat Icons Megapack", @@ -1336,7 +1273,6 @@ "downloadSize": "9317856", "id": "129638", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/2e11b202-08d4-426b-8678-19aa1b1fd904.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/6665b031-8c41-4aab-a4b3-346c1703b3fa.png" }, "name": "Fast Mobile Post Processing: Color Correction(LUT), Blur, Bloom ( URP , VR , AR , LWRP )", @@ -1357,7 +1293,6 @@ "downloadSize": "1584368", "id": "120049", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/3024dd49-6a4a-4c33-9979-5f50d11f1f8a.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/5a668076-acd7-4b52-9972-609650c5979b.png" }, "name": "Fantasy RPG Cursors (silver)", @@ -1378,7 +1313,6 @@ "downloadSize": "1049008", "id": "173127", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/fdf530d7-e7d1-42dd-8c89-969509439f85.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a12bace8-8c8f-48f6-90fd-aa20444ab0aa.png" }, "name": "Low Poly Hex Tiles Vol.2 (Dungeons)", @@ -1399,7 +1333,6 @@ "downloadSize": "266485824", "id": "163516", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/cac64d74-42ad-4a72-b27b-9af8bb1c8593.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/35b850b9-15e2-41b6-8194-a30d0107a680.png" }, "name": "Water Sounds Pack", @@ -1420,7 +1353,6 @@ "downloadSize": "25998768", "id": "141915", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/4b3d8b8a-c296-46da-aa44-6a231630930a.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/ce59d9fb-a75f-487f-b303-8fb2cb9e19d4.png" }, "name": "Dragon Mobile UI", @@ -1441,7 +1373,6 @@ "downloadSize": "27039984", "id": "164168", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/90a00afb-1237-4a8c-8b99-053389948090.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/08cdfd01-8682-40ea-9e86-a2c2b21e34ad.png" }, "name": "Attribute Icons", @@ -1462,7 +1393,6 @@ "downloadSize": "43239168", "id": "171260", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/20ceaf71-3e41-467c-bcb4-365bfdc4bd2e.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/a9ad2632-eeea-4c14-902e-96161819dd06.png" }, "name": "Rock Sounds Pack", @@ -1483,7 +1413,6 @@ "downloadSize": "674272", "id": "136082", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/c1619ece-8466-49d2-a451-0f7342e5aaca.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/c8981f98-b1b1-4d5f-9995-a3e09f40184b.png" }, "name": "Bézier Path Creator", @@ -1504,7 +1433,6 @@ "downloadSize": "33239824", "id": "15257", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/0f3027c8-bf0c-452c-a8f0-0608e6cb815c.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/23bda8fe-db8c-464a-9253-f48ae97e04f0.png" }, "name": "Gothic RPG Buttons", @@ -1525,7 +1453,6 @@ "downloadSize": "2563680", "id": "120952", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/d67d3093-5b3f-4ff0-bbb8-fadd278f7635.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/76a44737-ab2d-4e29-bd79-d51bdac4cb3d.png" }, "name": "Low Poly Hex Tiles Vol.1", @@ -1546,7 +1473,6 @@ "downloadSize": "1038320", "id": "156221", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/82a5f8f4-a05d-4b30-b689-74c1e7c39be7.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/2a64dd49-4ded-4340-9b91-46f1f57489d8.png" }, "name": "TBS Stylized Hex and Platforms", @@ -1567,7 +1493,6 @@ "downloadSize": "281968", "id": "69448", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/5c0f853d-f0bf-4da3-a356-84e5a9e2d3a5.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/ab0e423e-ab31-40b7-aa35-9f3d35823d18.png" }, "name": "Transition Blocks", @@ -1588,7 +1513,6 @@ "downloadSize": "99312", "id": "43321", "mainImage": { - "icon": "//assetstorev1-prd-cdn.unity3d.com/key-image/199bd058-ca14-41c3-8e29-f8f39991f6a0.png", "icon75": "//assetstorev1-prd-cdn.unity3d.com/key-image/71c45fa0-df9f-4919-9957-e508dd77f146.png" }, "name": "RTS camera", From 9ec8e66c8dd806c6f0215192094b80ac18723b65 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 12:18:38 -0700 Subject: [PATCH 18/28] Cover the branches the second audit found only design behind 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- README.md | 4 +- internal/store/audit_test.go | 19 +++ internal/store/store.go | 9 +- internal/store/testdata/search_query.graphql | 16 +++ internal/syncer/audit_test.go | 77 ++++++++++ main.go | 12 +- main_test.go | 139 +++++++++++++++++++ testdata/store/search_query.graphql | 0 8 files changed, 268 insertions(+), 8 deletions(-) create mode 100644 internal/store/testdata/search_query.graphql create mode 100644 testdata/store/search_query.graphql diff --git a/README.md b/README.md index 348ee14..e6d4f91 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,8 @@ unity-sync list # print the current lockfile ``` Useful flags: `--manifest `, `--only `, `--library `, -`--concurrency `, `--verify`, `--config `, `--session `, -`--addr ` (the `select` page's address). +`--concurrency `, `--verify`, `--dry-run` (makes `sync` behave like `status`), +`--config `, `--session `, `--addr ` (the `select` page's address). ## Selecting assets diff --git a/internal/store/audit_test.go b/internal/store/audit_test.go index 3d3d665..594a8fe 100644 --- a/internal/store/audit_test.go +++ b/internal/store/audit_test.go @@ -5,6 +5,8 @@ import ( "errors" "io" "net/http" + "os" + "path/filepath" "strings" "testing" "time" @@ -192,3 +194,20 @@ func TestSlowBodyIsAllowedButSlowHeadersAreNot(t *testing.T) { t.Error("Fetch waited indefinitely for headers") } } + +// The pinned document is the tool's contract with the store, and every field in it is +// load-bearing: losing currentVersion.id would break every classification silently, and +// adding a field back would start requesting account data the tool has no use for. A +// substring check would miss both, so the whole document is golden. +func TestQueryDocumentMatchesItsGoldenCopy(t *testing.T) { + want, err := os.ReadFile(filepath.Join("testdata", "search_query.graphql")) + if err != nil { + t.Fatalf("read golden: %v", err) + } + if store.SearchDocument != string(want) { + t.Errorf("the pinned query document changed.\n--- got ---\n%s\n--- want ---\n%s\n"+ + "If this change is intended, update testdata/search_query.graphql and say why in the "+ + "commit: the field set is a privacy boundary as well as a correctness one.", + store.SearchDocument, want) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 60d7812..6d17b16 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -49,11 +49,12 @@ var ( ErrNotDownloadable = errors.New("asset is not downloadable") ) -// searchDocument is pinned. Its field set is the tool's contract with the store, and it +// SearchDocument is pinned. Its field set is the tool's contract with the store, and it // deliberately omits the per-row entitlement id and every other account-identifying // field the API would return if asked. currentVersion.id is mandatory: it is the diff -// key, and losing it silently would break every classification. -const searchDocument = `query SearchMyAssets($page: Int, $pageSize: Int, $ids: [String!]) { +// key, and losing it silently would break every classification. It is exported so a +// golden test can compare the whole document, not a substring of it. +const SearchDocument = `query SearchMyAssets($page: Int, $pageSize: Int, $ids: [String!]) { searchMyAssets(page: $page, pageSize: $pageSize, ids: $ids) { total results { @@ -297,7 +298,7 @@ func (c *Client) search(ctx context.Context, vars map[string]any) (searchResult, func (c *Client) searchOnce(ctx context.Context, vars map[string]any) (searchResult, error) { body, err := json.Marshal([]map[string]any{{ - "query": searchDocument, + "query": SearchDocument, "variables": vars, "operationName": "SearchMyAssets", }}) diff --git a/internal/store/testdata/search_query.graphql b/internal/store/testdata/search_query.graphql new file mode 100644 index 0000000..6081a0d --- /dev/null +++ b/internal/store/testdata/search_query.graphql @@ -0,0 +1,16 @@ +query SearchMyAssets($page: Int, $pageSize: Int, $ids: [String!]) { + searchMyAssets(page: $page, pageSize: $pageSize, ids: $ids) { + total + results { + product { + id + name + state + downloadSize + currentVersion { id name publishedDate } + publisher { id name } + mainImage { icon75 } + } + } + } +} \ No newline at end of file diff --git a/internal/syncer/audit_test.go b/internal/syncer/audit_test.go index 05acad9..fb6a409 100644 --- a/internal/syncer/audit_test.go +++ b/internal/syncer/audit_test.go @@ -1,6 +1,7 @@ package syncer import ( + "bytes" "context" "errors" "os" @@ -9,6 +10,7 @@ import ( "testing" "time" + "github.com/curbol/unity-sync/internal/cache" "github.com/curbol/unity-sync/internal/lockfile" "github.com/curbol/unity-sync/internal/model" "github.com/curbol/unity-sync/internal/store" @@ -217,3 +219,78 @@ func TestPreDownloadFailureWritesNoLockfileAtAll(t *testing.T) { t.Error("a failure before any download still created a lockfile") } } + +// The gate the whole adopt design rests on. A file that really is this product, but a +// different build than the store now advertises, must not be adopted: a wrong adopt is +// silent and permanent, where a redundant download is loud and self-correcting. +func TestAdoptRefusesACandidateFromAnotherVersion(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Asset", "v2", 500) + + // On disk: the right product, stamped with the previous build. + p, err := cache.Store(root, a.PublisherSlug(), a.Slug(), bytes.NewReader(pkg(t, "1", "v1", 500))) + if err != nil { + t.Fatal(err) + } + if err := p.Commit(); err != nil { + t.Fatal(err) + } + + fs := &fakeStore{owned: []model.Asset{a}, bodies: map[string][]byte{"1": pkg(t, "1", "v2", 500)}} + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, allSelected(a))) + if err != nil { + t.Fatalf("Run: %v", err) + } + if rep.Results[0].Class == Adopted { + t.Fatal("adopted a package stamped with a different version than the store advertises") + } + if len(fs.fetched) != 1 { + t.Errorf("fetched %v, want the asset re-downloaded instead of adopted", fs.fetched) + } + _, e, _ := rep.Lockfile.FindByAssetID("1") + if e.DeliveredVersionID != "v2" { + t.Errorf("deliveredVersionId = %q, want the freshly downloaded build", e.DeliveredVersionID) + } +} + +// Adoption exists for a file that is not where the current layout would put it, so the +// relocation half needs its own coverage: live QA cannot reach it, because its subject +// already sits at the derived path where relocation is a deliberate no-op. +func TestAdoptRelocatesACandidateFoundOffTheDerivedPath(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Renamed Asset", "v1", 500) + + // The package sits under a stale slug, as it would after an upstream rename. + stale, err := cache.Store(root, a.PublisherSlug(), "old-slug-1", bytes.NewReader(pkg(t, "1", "v1", 500))) + if err != nil { + t.Fatal(err) + } + if err := stale.Commit(); err != nil { + t.Fatal(err) + } + + fs := &fakeStore{owned: []model.Asset{a}} + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, allSelected(a))) + if err != nil { + t.Fatalf("Run: %v", err) + } + if rep.Results[0].Class != Adopted { + t.Fatalf("class = %v, want Adopted", rep.Results[0].Class) + } + if len(fs.fetched) != 0 { + t.Errorf("adoption downloaded %v", fs.fetched) + } + + derived := cache.RelPath(a.PublisherSlug(), a.Slug()) + _, e, _ := rep.Lockfile.FindByAssetID("1") + if e.CachePath != derived { + t.Errorf("cachePath = %q, want the derived path %q — a legacy path would keep quarry's "+ + "facets on the old name and strand the file at the next version bump", e.CachePath, derived) + } + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(derived))); err != nil { + t.Errorf("the adopted file was not moved: %v", err) + } + if _, err := os.Stat(filepath.Join(root, a.PublisherSlug(), "old-slug-1")); !os.IsNotExist(err) { + t.Error("the emptied source directory survived the adopt") + } +} diff --git a/main.go b/main.go index e01753e..9a55632 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "github.com/curbol/unity-sync/internal/config" "github.com/curbol/unity-sync/internal/lockfile" "github.com/curbol/unity-sync/internal/manifest" + "github.com/curbol/unity-sync/internal/model" "github.com/curbol/unity-sync/internal/selfupdate" "github.com/curbol/unity-sync/internal/session" "github.com/curbol/unity-sync/internal/store" @@ -185,7 +186,14 @@ func resolveSession(cfg config.Config, configDir string) (string, error) { return session.Resolve(src) } -func selectAssets(ctx context.Context, client *store.Client, manifestPath, addr string) error { +// enumerator is the slice of the store client that `select` needs. Narrowing it here lets +// the command be driven by a fake in tests, which is the only way its happy path gets +// covered at all: everything above it needs a live session. +type enumerator interface { + Enumerate(ctx context.Context) ([]model.Asset, error) +} + +func selectAssets(ctx context.Context, client enumerator, manifestPath, addr string) error { m, err := manifest.Load(manifestPath) if err != nil { return err @@ -213,7 +221,7 @@ func selectAssets(ctx context.Context, client *store.Client, manifestPath, addr return nil } -func syncOrStatus(ctx context.Context, client *store.Client, cfg config.Config, +func syncOrStatus(ctx context.Context, client syncer.Store, cfg config.Config, manifestPath, lockPath, only string, verify, dry bool) (int, error) { m, err := manifest.Load(manifestPath) diff --git a/main_test.go b/main_test.go index 8c96cc2..9d33a9c 100644 --- a/main_test.go +++ b/main_test.go @@ -2,13 +2,20 @@ package main import ( "bytes" + "compress/gzip" + "context" + "encoding/binary" + "io" "os" "path/filepath" "strings" "testing" + "github.com/curbol/unity-sync/internal/config" "github.com/curbol/unity-sync/internal/lockfile" "github.com/curbol/unity-sync/internal/manifest" + "github.com/curbol/unity-sync/internal/model" + "github.com/curbol/unity-sync/internal/store" ) func capture(t *testing.T) *bytes.Buffer { @@ -125,3 +132,135 @@ func TestListNeedsNoSession(t *testing.T) { } } } + +// fakeStore drives the commands that otherwise need a live session, so their happy paths +// are covered rather than only their error paths. +type fakeStore struct { + owned []model.Asset + bodies map[string][]byte +} + +func (f *fakeStore) Enumerate(context.Context) ([]model.Asset, error) { return f.owned, nil } + +func (f *fakeStore) Lookup(_ context.Context, id string) (model.Asset, bool, error) { + for _, a := range f.owned { + if a.ID == id { + return a, true, nil + } + } + return model.Asset{}, false, nil +} + +func (f *fakeStore) Fetch(_ context.Context, id string) (*store.Download, error) { + body, ok := f.bodies[id] + if !ok { + return nil, store.ErrNotDownloadable + } + return &store.Download{Body: io.NopCloser(bytes.NewReader(body)), Filename: id + ".unitypackage"}, nil +} + +func testPackage(t *testing.T, productID, versionID string, size int) []byte { + t.Helper() + d := []byte(`{"id":"` + productID + `","version_id":"` + versionID + `"}`) + extra := []byte{'A', '$', 0, 0} + binary.LittleEndian.PutUint16(extra[2:4], uint16(len(d))) + extra = append(extra, d...) + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + zw.Header.Extra = extra + zw.Write(bytes.Repeat([]byte("x"), 32)) + zw.Close() + out := buf.Bytes() + for len(out) < size { + out = append(out, 0) + } + return out +} + +func ownedAsset(id, name, versionID string, size int64) model.Asset { + return model.Asset{ + ID: id, Name: name, State: model.StatePublished, + Publisher: model.Publisher{ID: "p1", Name: "Pub One"}, + Version: model.Version{ID: versionID, Name: "1.0"}, + AdvertisedSize: size, + } +} + +func TestStatusThenSyncThroughTheCommandLayer(t *testing.T) { + wd := isolate(t) + out := capture(t) + + a := ownedAsset("115488", "Quick Outline", "683375", 500) + manifestPath := filepath.Join(wd, manifest.FileName) + if err := manifest.Save(manifestPath, manifest.Manifest{ + Assets: []manifest.Entry{{ID: a.ID, Name: a.Name, Enabled: true}}, + }); err != nil { + t.Fatal(err) + } + fake := &fakeStore{owned: []model.Asset{a}, bodies: map[string][]byte{a.ID: testPackage(t, a.ID, "683375", 500)}} + cfg := config.Config{LibraryPath: filepath.Join(wd, "library"), Concurrency: 1} + lockPath := manifest.LockPath(manifestPath) + + // status classifies without writing anything. + code, err := syncOrStatus(context.Background(), fake, cfg, manifestPath, lockPath, "", false, true) + if code != 0 || err != nil { + t.Fatalf("status = %d, %v", code, err) + } + if !strings.Contains(out.String(), "new") { + t.Errorf("status did not classify the asset as new:\n%s", out) + } + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Error("status wrote a lockfile") + } + + // sync downloads it and records it. + out.Reset() + code, err = syncOrStatus(context.Background(), fake, cfg, manifestPath, lockPath, "", false, false) + if code != 0 || err != nil { + t.Fatalf("sync = %d, %v", code, err) + } + lf, err := lockfile.Load(lockPath) + if err != nil { + t.Fatal(err) + } + _, e, ok := lf.FindByAssetID(a.ID) + if !ok || !e.Tracked { + t.Fatalf("sync did not record the asset: %+v", e) + } + pkgPath := filepath.Join(cfg.LibraryPath, "pub-one", "quick-outline-115488", "quick-outline-115488.unitypackage") + if _, err := os.Stat(pkgPath); err != nil { + t.Errorf("package not at its derived path: %v", err) + } + + // and a second status is a no-op. + out.Reset() + code, err = syncOrStatus(context.Background(), fake, cfg, manifestPath, lockPath, "", false, true) + if code != 0 || err != nil { + t.Fatalf("second status = %d, %v", code, err) + } + if !strings.Contains(out.String(), "unchanged") { + t.Errorf("second status did not report unchanged:\n%s", out) + } +} + +// select is the only command that writes the manifest, so its refusal to rewrite a +// curated file against an empty enumeration is checked at the command layer. +func TestSelectRefusesToEmptyACuratedManifest(t *testing.T) { + wd := isolate(t) + manifestPath := filepath.Join(wd, manifest.FileName) + if err := manifest.Save(manifestPath, manifest.Manifest{ + Assets: []manifest.Entry{{ID: "115488", Name: "Quick Outline", Enabled: true}}, + }); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(manifestPath) + + err := selectAssets(context.Background(), &fakeStore{}, manifestPath, "127.0.0.1:0") + if err == nil { + t.Fatal("select rewrote the manifest against an empty enumeration") + } + after, _ := os.ReadFile(manifestPath) + if string(before) != string(after) { + t.Error("the refused select changed the manifest anyway") + } +} diff --git a/testdata/store/search_query.graphql b/testdata/store/search_query.graphql new file mode 100644 index 0000000..e69de29 From aeb344d56034e11ead4e6ba851b0485934d2e877 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 18:20:29 -0700 Subject: [PATCH 19/28] Stop a damaged cached file from being adopted back in as truth 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- internal/cache/cache.go | 34 ++++- internal/store/audit_test.go | 30 +++++ internal/syncer/audit_test.go | 188 ++++++++++++++++++++++++++++ internal/syncer/syncer.go | 39 +++++- main.go | 2 +- testdata/store/search_query.graphql | 0 6 files changed, 287 insertions(+), 6 deletions(-) delete mode 100644 testdata/store/search_query.graphql diff --git a/internal/cache/cache.go b/internal/cache/cache.go index d914c6a..2858922 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -221,8 +221,16 @@ type Candidate struct { // size floor while still carrying an intact descriptor. // // When several files claim the same product, the one already at preferRel wins, so an -// adopt that is really a no-op does not turn into a relocation conflict. -func Locate(root, productID, preferRel string) (Candidate, bool) { +// adopt that is really a no-op does not turn into a relocation conflict. Paths in +// excludeRel are skipped entirely: a file that just failed verification is not a candidate +// for adoption, however intact its descriptor still looks. +func Locate(root, productID, preferRel string, excludeRel ...string) (Candidate, bool) { + skip := map[string]bool{} + for _, e := range excludeRel { + if e != "" { + skip[e] = true + } + } var found []Candidate filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { @@ -244,7 +252,9 @@ func Locate(root, productID, preferRel string) (Candidate, bool) { if err != nil { return nil } - found = append(found, Candidate{RelPath: filepath.ToSlash(rel), Size: fi.Size(), Metadata: m}) + if slug := filepath.ToSlash(rel); !skip[slug] { + found = append(found, Candidate{RelPath: slug, Size: fi.Size(), Metadata: m}) + } return nil }) if len(found) == 0 { @@ -338,3 +348,21 @@ func SweepTemps(root string, olderThan time.Time) (int, int64, error) { } return count, bytes, err } + +// RemoveStale deletes a package this tool mirrored for an asset whose derived path has +// since changed, and prunes the directories the removal empties. It is only ever called +// with a path the lockfile itself recorded, never with a file the tool did not write. +func RemoveStale(root, rel string) error { + full, err := resolve(root, rel) + if err != nil { + return err + } + if err := os.Remove(full); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + pruneEmptyParents(root, filepath.Dir(full)) + return nil +} diff --git a/internal/store/audit_test.go b/internal/store/audit_test.go index 594a8fe..0406fff 100644 --- a/internal/store/audit_test.go +++ b/internal/store/audit_test.go @@ -211,3 +211,33 @@ func TestQueryDocumentMatchesItsGoldenCopy(t *testing.T) { store.SearchDocument, want) } } + +// The identity encoding matters most on the download endpoint — that is where asking for +// gzip makes the store gzip an already-gzipped package — so the download's headers get +// their own assertion rather than riding on the GraphQL one. +func TestFetchSendsTheHeadersTheDownloadEndpointNeeds(t *testing.T) { + var got http.Header + c, _ := serve(t, func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Clone() + w.Header().Set("Content-Type", "application/octet-stream") + io.WriteString(w, "\x1f\x8b\x08\x04payload") + }) + dl, err := c.Fetch(context.Background(), "115488") + if err != nil { + t.Fatalf("Fetch: %v", err) + } + dl.Body.Close() + + for header, want := range map[string]string{ + "Accept-Encoding": "identity", + "X-Requested-With": "XMLHttpRequest", + "User-Agent": "unity-sync/test", + } { + if got.Get(header) != want { + t.Errorf("%s = %q, want %q", header, got.Get(header), want) + } + } + if !strings.Contains(got.Get("Cookie"), "LS=cred") { + t.Errorf("Cookie %q does not carry the credential", got.Get("Cookie")) + } +} diff --git a/internal/syncer/audit_test.go b/internal/syncer/audit_test.go index fb6a409..0e531e4 100644 --- a/internal/syncer/audit_test.go +++ b/internal/syncer/audit_test.go @@ -2,6 +2,7 @@ package syncer import ( "bytes" + "compress/gzip" "context" "errors" "os" @@ -294,3 +295,190 @@ func TestAdoptRelocatesACandidateFoundOffTheDerivedPath(t *testing.T) { t.Error("the emptied source directory survived the adopt") } } + +// The one door into the cache that skips every download guard. A truncation or a mid-file +// flip leaves the descriptor intact and can clear the size floor, so an adopt scan that +// considered the file which just failed verification would re-hash the damaged bytes and +// record them as truth. +func TestAFileThatFailedVerificationIsNotAdoptedBackIn(t *testing.T) { + for _, tc := range []struct { + name string + fullVerify bool + damage func(t *testing.T, path string, size int64) + }{ + { + name: "truncated", + damage: func(t *testing.T, path string, size int64) { + if err := os.Truncate(path, size-100); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "mid-file flip under --verify", + fullVerify: true, + damage: func(t *testing.T, path string, _ int64) { + f, err := os.OpenFile(path, os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + defer f.Close() + f.WriteAt([]byte{0xFF}, 3000) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Asset", "v1", 40000) + good := pkg(t, "1", "v1", 40000) + + p, err := cache.Store(root, a.PublisherSlug(), a.Slug(), bytes.NewReader(good)) + if err != nil { + t.Fatal(err) + } + if err := p.Commit(); err != nil { + t.Fatal(err) + } + prior := lockfile.New() + prior.Assets[a.Slug()] = lockfile.Entry{ + AssetID: "1", Name: a.Name, Tracked: true, + ResolvedVersionID: "v1", DeliveredVersionID: "v1", + SizeBytes: p.Size, SHA256: p.SHA256, CachePath: p.RelPath, + Version: lockfile.Version{ID: "v1"}, + } + tc.damage(t, filepath.Join(root, filepath.FromSlash(p.RelPath)), p.Size) + + fs := &fakeStore{owned: []model.Asset{a}, bodies: map[string][]byte{"1": good}} + o := opts(root, allSelected(a)) + o.FullVerify = tc.fullVerify + + rep, err := Run(context.Background(), fs, prior, lockPath, o) + if err != nil { + t.Fatalf("Run: %v", err) + } + if rep.Results[0].Class == Adopted { + t.Fatal("a damaged file was adopted back in and its digest recorded as truth") + } + if len(fs.fetched) != 1 { + t.Errorf("fetched %v, want the damaged asset re-downloaded", fs.fetched) + } + _, e, _ := rep.Lockfile.FindByAssetID("1") + if e.SizeBytes != int64(len(good)) { + t.Errorf("sizeBytes = %d, want the freshly downloaded %d", e.SizeBytes, len(good)) + } + }) + } +} + +// A rename that also bumps the version downloads to the new derived path, so the prior +// directory would otherwise be left holding a superseded copy of the same asset. +func TestARenameWithAVersionBumpDoesNotStrandTheOldDirectory(t *testing.T) { + root, lockPath := newRun(t) + renamed := asset("1", "Brand New Name", "v2", 500) + + old, err := cache.Store(root, renamed.PublisherSlug(), "old-name-1", bytes.NewReader(pkg(t, "1", "v1", 500))) + if err != nil { + t.Fatal(err) + } + if err := old.Commit(); err != nil { + t.Fatal(err) + } + prior := lockfile.New() + prior.Assets["old-name-1"] = lockfile.Entry{ + AssetID: "1", Name: "Old Name", Tracked: true, + ResolvedVersionID: "v1", DeliveredVersionID: "v1", + SizeBytes: old.Size, SHA256: old.SHA256, CachePath: old.RelPath, + Version: lockfile.Version{ID: "v1"}, + } + + fs := &fakeStore{owned: []model.Asset{renamed}, bodies: map[string][]byte{"1": pkg(t, "1", "v2", 500)}} + rep, err := Run(context.Background(), fs, prior, lockPath, opts(root, allSelected(renamed))) + if err != nil { + t.Fatalf("Run: %v", err) + } + if rep.Results[0].Class != Changed { + t.Fatalf("class = %v, want Changed", rep.Results[0].Class) + } + if _, err := os.Stat(filepath.Join(root, renamed.PublisherSlug(), "old-name-1")); !os.IsNotExist(err) { + t.Error("the superseded copy's directory was left behind after the rename") + } + derived := cache.RelPath(renamed.PublisherSlug(), renamed.Slug()) + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(derived))); err != nil { + t.Errorf("the new build is not at its derived path: %v", err) + } +} + +// Both of these tell the user something the design says they should hear, and neither +// changes any other observable, so only an assertion catches their absence. +func TestDownloadWarnings(t *testing.T) { + t.Run("advertised and delivered versions disagree", func(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Asset", "v-advertised", 500) + fs := &fakeStore{owned: []model.Asset{a}, + bodies: map[string][]byte{"1": pkg(t, "1", "v-delivered", 500)}} + + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, allSelected(a))) + if err != nil { + t.Fatalf("Run: %v", err) + } + if rep.Results[0].Err != nil { + t.Fatalf("a version mismatch must not fail the asset: %v", rep.Results[0].Err) + } + if !strings.Contains(rep.Results[0].Warning, "v-delivered") { + t.Errorf("warning = %q, want it to name the build actually served", rep.Results[0].Warning) + } + _, e, _ := rep.Lockfile.FindByAssetID("1") + if e.ResolvedVersionID != "v-advertised" || e.DeliveredVersionID != "v-delivered" { + t.Errorf("both ids should be recorded, got resolved=%q delivered=%q", + e.ResolvedVersionID, e.DeliveredVersionID) + } + }) + + t.Run("package carries no descriptor", func(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Asset", "v1", 500) + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + zw.Write(bytes.Repeat([]byte("x"), 400)) + zw.Close() + body := buf.Bytes() + for len(body) < 500 { + body = append(body, 0) + } + fs := &fakeStore{owned: []model.Asset{a}, bodies: map[string][]byte{"1": body}} + + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, allSelected(a))) + if err != nil { + t.Fatalf("Run: %v", err) + } + if rep.Results[0].Err != nil { + t.Fatalf("a descriptor-less package must still be stored: %v", rep.Results[0].Err) + } + if !strings.Contains(rep.Results[0].Warning, "no store metadata") { + t.Errorf("warning = %q, want it to say later checks fall back to size", rep.Results[0].Warning) + } + _, e, _ := rep.Lockfile.FindByAssetID("1") + if e.DeliveredVersionID != "" { + t.Errorf("deliveredVersionId = %q, want empty", e.DeliveredVersionID) + } + }) + + t.Run("outside the advisory window but above the floor", func(t *testing.T) { + root, lockPath := newRun(t) + // 200 bytes short of 4000: past the +-64 window, inside the floor's 500-byte + // allowance (4000/8). + a := asset("1", "Asset", "v1", 4000) + fs := &fakeStore{owned: []model.Asset{a}, bodies: map[string][]byte{"1": pkg(t, "1", "v1", 3800)}} + + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, allSelected(a))) + if err != nil { + t.Fatalf("Run: %v", err) + } + if rep.Results[0].Err != nil { + t.Fatalf("a body inside the floor must not fail: %v", rep.Results[0].Err) + } + if !strings.Contains(rep.Results[0].Warning, "3800") { + t.Errorf("warning = %q, want it to report the received count", rep.Results[0].Warning) + } + }) +} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index dfc1c6c..a193e08 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "path/filepath" + "strings" "sync" "time" @@ -139,6 +140,10 @@ type Result struct { // Report is what a run produced. type Report struct { + // Owned is every asset the account holds, not only the selected ones, so a run with an + // empty allowlist can still say what there is to choose from. + Owned int + Results []Result Removed []lockfile.Entry Unknown []manifest.Entry @@ -198,7 +203,7 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, return Report{}, ErrEmptyLibrary } - report := Report{Unknown: opts.Manifest.UnknownIDs(owned)} + report := Report{Owned: len(owned), Unknown: opts.Manifest.UnknownIDs(owned)} // Sweeping before classification matters: an abandoned partial left in the tree is // otherwise a candidate the adopt scan could reach. @@ -214,6 +219,9 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, } resolutions := map[string]resolution{} + // priorPaths remembers where each asset's bytes used to live, so a download that lands + // somewhere else can clean up after itself. + priorPaths := map[string]string{} var mu sync.Mutex // Classify everything selected, then fetch what needs fetching. @@ -236,8 +244,14 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, } var found cache.Candidate var foundOK bool + // excludeRel is set when a recorded file exists but failed verification. Adoption + // must not reach for that same file: a truncation or a mid-file flip leaves the + // descriptor intact and can clear the size floor, so the scan would re-adopt the + // damaged bytes and record them as truth — the outcome every other guard exists to + // prevent, arriving through the one door that skips them. + var excludeRel string adoptable := func() bool { - found, foundOK = cache.Locate(opts.LibraryRoot, a.ID, derived) + found, foundOK = cache.Locate(opts.LibraryRoot, a.ID, derived, excludeRel) if !foundOK { return false } @@ -251,6 +265,12 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, return found.Metadata.VersionID == a.Version.ID } + if hasPrev && prev.Tracked { + priorPaths[a.ID] = prev.CachePath + if prev.CachePath != "" && !cacheOK() { + excludeRel = prev.CachePath + } + } class := classify(a, prev, hasPrev, cacheOK, adoptable) res := Result{Asset: a, Class: class} @@ -327,6 +347,12 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, err := retry.Do(poolCtx, opts.Retry, func(int) error { var attemptErr error r, warning, resolved, attemptErr = download(ctx, s, opts, res.Asset) + // Neither of these improves on a second attempt: the asset is gone, or + // the session is. + if errors.Is(attemptErr, store.ErrExpiredSession) || + errors.Is(attemptErr, store.ErrNotDownloadable) { + return retry.Permanent(attemptErr) + } return attemptErr }) res.Warning, res.Err = warning, err @@ -337,6 +363,15 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, return } if err == nil { + // A rename that also bumped the version downloads to the new derived + // path, so the prior directory would otherwise be left holding a + // superseded copy of the same asset. + if old := priorPaths[res.Asset.ID]; old != "" && old != r.cachePath { + if rmErr := cache.RemoveStale(opts.LibraryRoot, old); rmErr != nil { + res.Warning = strings.TrimSpace(res.Warning + " " + + fmt.Sprintf("(could not remove the superseded copy at %s: %v)", old, rmErr)) + } + } mu.Lock() resolutions[res.Asset.ID] = r // Persisting per download is what keeps a run that dies at asset 90 of diff --git a/main.go b/main.go index 9a55632..1757049 100644 --- a/main.go +++ b/main.go @@ -266,7 +266,7 @@ func printReport(w io.Writer, rep syncer.Report, dry bool, libraryPath string) { if dry { verb = "status (no changes made)" } - fmt.Fprintf(w, "%s: %d asset(s) considered\n", verb, len(rep.Results)) + fmt.Fprintf(w, "%s: %d owned, %d selected\n", verb, rep.Owned, len(rep.Results)) for _, class := range []string{"new", "changed", "download-now", "cache-missing", "adopted", "unchanged", "undownloadable"} { if n := counts[class]; n > 0 { fmt.Fprintf(w, " %-15s %d\n", class, n) diff --git a/testdata/store/search_query.graphql b/testdata/store/search_query.graphql deleted file mode 100644 index e69de29..0000000 From 2623c3ead71f385090688a75ad1ea77a60db94aa Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 18:29:01 -0700 Subject: [PATCH 20/28] Report progress while a multi-gigabyte body is actually streaming 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- README.md | 8 ++++--- docs/design.md | 25 +++++++++++++++++++++ internal/syncer/audit_test.go | 36 ++++++++++++++++++++++++++++++ internal/syncer/syncer.go | 41 +++++++++++++++++++++++++++++++++-- 4 files changed, 105 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e6d4f91..1bdf7fa 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,11 @@ re-fetches anything missing or failing its check. Deleting the cache and re-sync rebuilds it. Durability of the assets you actually ship belongs in the consuming project, not here. -unity-sync never deletes a package it mirrored. When an asset leaves your account, its -entry drops out of the lockfile and the run tells you which file is now unreferenced, so -you can decide. +When an asset leaves your account, unity-sync does not delete anything: its entry drops +out of the lockfile and the run tells you which file is now unreferenced, so you can +decide. The one thing a run does remove is an asset's own superseded copy — if a package +you already mirror is renamed and updated in the same run, the new build lands at the new +path and the old one is cleaned up rather than left as a duplicate. ## Browsing what you have diff --git a/docs/design.md b/docs/design.md index dfbd297..cc33a14 100644 --- a/docs/design.md +++ b/docs/design.md @@ -159,6 +159,31 @@ it was a republish. It is deliberately *not* "the delivered id differs from the id", which is a steady state for some products and would switch the floor off permanently for exactly them. +## Adoption + +A package can be on disk with no lockfile resolution behind it: the lockfile was deleted, +the asset was mirrored on another machine, or a rename moved the path out from under the +record. Rather than re-fetching gigabytes, a run scans the library for a file whose own +descriptor claims that product and adopts it — recording its size, digest and delivered +version id, and moving it to the path the current layout dictates before recording it, +so quarry's facets and the lockfile agree. + +Three gates keep adoption from laundering a bad file into the cache. The descriptor's +product id must match. Its version id must match what the store currently advertises, so a +stale build cannot be recorded as current. And the file must clear the same size floor a +download must clear, because that is the one route into the cache that skips the download +guards entirely. + +A file that just failed verification is excluded from the scan. A truncation or a mid-file +flip leaves the descriptor intact and a small truncation clears the floor, so without that +exclusion the damaged bytes would be re-hashed and their digest recorded as the asset's +truth — the precise outcome every other guard exists to prevent. + +Nothing deletes a package the tool mirrored, with one exception: when a download lands at +a different derived path than the entry's previous one, the superseded copy of that same +asset is removed. That is not the de-owned case, which is reported and left in place; it +is the asset's own prior build, and the cache holds only current versions. + ## Failure model | Observation | Meaning | diff --git a/internal/syncer/audit_test.go b/internal/syncer/audit_test.go index 0e531e4..cff4ec9 100644 --- a/internal/syncer/audit_test.go +++ b/internal/syncer/audit_test.go @@ -14,6 +14,7 @@ import ( "github.com/curbol/unity-sync/internal/cache" "github.com/curbol/unity-sync/internal/lockfile" "github.com/curbol/unity-sync/internal/model" + "github.com/curbol/unity-sync/internal/retry" "github.com/curbol/unity-sync/internal/store" ) @@ -482,3 +483,38 @@ func TestDownloadWarnings(t *testing.T) { } }) } + +// Retrying either of these wastes a backoff on something a second attempt cannot fix, and +// the waste is invisible: the run still ends the same way, just later. +func TestPermanentDownloadFailuresAreNotRetried(t *testing.T) { + for name, sentinel := range map[string]error{ + "pulled asset": store.ErrNotDownloadable, + "expired session": store.ErrExpiredSession, + } { + t.Run(name, func(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Asset", "v1", 500) + fs := &fakeStore{owned: []model.Asset{a}, fetchEr: map[string]error{"1": sentinel}} + + o := opts(root, allSelected(a)) + o.Retry = retryPolicyWithAttempts(3) + + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, o) + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(fs.fetched) != 1 { + t.Errorf("fetched %d times, want exactly 1: %v cannot be fixed by trying again", + len(fs.fetched), sentinel) + } + if rep.Results[0].Err == nil { + t.Error("the failure was swallowed") + } + }) + } +} + +// retryPolicyWithAttempts gives a test a real attempt budget without real sleeping. +func retryPolicyWithAttempts(n int) retry.Policy { + return retry.Policy{Attempts: n, Base: time.Millisecond, Sleep: func(time.Duration) {}} +} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index a193e08..330f745 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "io" "path/filepath" "strings" "sync" @@ -346,7 +347,7 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, // recorded as its own truth. err := retry.Do(poolCtx, opts.Retry, func(int) error { var attemptErr error - r, warning, resolved, attemptErr = download(ctx, s, opts, res.Asset) + r, warning, resolved, attemptErr = download(poolCtx, s, opts, res.Asset) // Neither of these improves on a second attempt: the asset is gone, or // the session is. if errors.Is(attemptErr, store.ErrExpiredSession) || @@ -441,7 +442,16 @@ func download(ctx context.Context, s Store, opts Options, a model.Asset) (resolu } defer dl.Body.Close() - pending, err := cache.Store(opts.LibraryRoot, a.PublisherSlug(), a.Slug(), dl.Body) + // A 23 GB package would otherwise report nothing between "fetching" and "done", so + // progress is counted off the body as it streams rather than announced up front. + body := &progressReader{ + r: dl.Body, + total: a.AdvertisedSize, + report: func(read, total int64) { + opts.Progress(fmt.Sprintf(" %s: %s", a.Name, progressLine(read, total))) + }, + } + pending, err := cache.Store(opts.LibraryRoot, a.PublisherSlug(), a.Slug(), body) if err != nil { return resolution{}, "", false, err } @@ -623,3 +633,30 @@ func humanBytes(n int64) string { } return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "kMGT"[exp]) } + +// progressReader reports how far a transfer has got, at most once a second, so a +// multi-gigabyte download is visibly alive without flooding the terminal. +type progressReader struct { + r io.Reader + total int64 + read int64 + last time.Time + report func(read, total int64) +} + +func (p *progressReader) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + p.read += int64(n) + if now := time.Now(); now.Sub(p.last) >= time.Second { + p.last = now + p.report(p.read, p.total) + } + return n, err +} + +func progressLine(read, total int64) string { + if total <= 0 { + return humanBytes(read) + } + return fmt.Sprintf("%s of %s (%d%%)", humanBytes(read), humanBytes(total), read*100/total) +} From 53beaa844ef6af7392f1166f7229fd597bc1abe8 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 18:37:25 -0700 Subject: [PATCH 21/28] Keep the session verdict from swallowing ordinary server errors 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- internal/store/audit_test.go | 26 ++++++++++++++++++++++++++ internal/store/store.go | 10 ++++++++-- main.go | 3 ++- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/internal/store/audit_test.go b/internal/store/audit_test.go index 0406fff..c929b89 100644 --- a/internal/store/audit_test.go +++ b/internal/store/audit_test.go @@ -241,3 +241,29 @@ func TestFetchSendsTheHeadersTheDownloadEndpointNeeds(t *testing.T) { t.Errorf("Cookie %q does not carry the credential", got.Get("Cookie")) } } + +// Only the empty-message shape is the session verdict. A 5xx that says what went wrong is +// an ordinary server error, and short-circuiting it would turn a transient outage into an +// immediate failure. +func TestAServerErrorThatExplainsItselfStillRetries(t *testing.T) { + var calls int + c, _ := serve(t, csrfRouter(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + if calls == 1 { + w.WriteHeader(http.StatusInternalServerError) + io.WriteString(w, `[{"data":null,"errors":[{"errorCode":"Backend","message":"upstream timeout"}]}]`) + return + } + io.WriteString(w, `[{"data":{"searchMyAssets":{"total":0,"results":[]}}}]`) + })) + if err := c.Bootstrap(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := c.Enumerate(context.Background()); err != nil { + t.Fatalf("Enumerate = %v, want the second attempt to succeed", err) + } + if calls < 2 { + t.Errorf("made %d calls, want the explained 5xx to be retried", calls) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 6d17b16..ce04d30 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -363,8 +363,14 @@ func (c *Client) searchOnce(ctx context.Context, vars map[string]any) (searchRes if resp.StatusCode == http.StatusInternalServerError && op.Errors[0].Message == "" { return searchResult{}, retry.Permanent(ErrExpiredSession) } - return searchResult{}, retry.Permanent(fmt.Errorf("store returned %s: %q", - op.Errors[0].ErrorCode, op.Errors[0].Message)) + err := fmt.Errorf("store returned %s: %q", op.Errors[0].ErrorCode, op.Errors[0].Message) + // Only the empty-message shape is the session verdict. A server error that + // bothered to say what went wrong is still a server error, and the ordinary + // backoff applies. + if retry.Retryable(resp.StatusCode) { + return searchResult{}, err + } + return searchResult{}, retry.Permanent(err) } if op.Data.Search == nil { return searchResult{}, retry.Permanent(fmt.Errorf("response carries neither data nor errors")) diff --git a/main.go b/main.go index 1757049..a851385 100644 --- a/main.go +++ b/main.go @@ -288,7 +288,8 @@ func printReport(w io.Writer, rep syncer.Report, dry bool, libraryPath string) { } } // A dropped asset leaves its bytes on disk; the summary names them so the user can - // decide, because this tool never deletes a package it once mirrored. + // decide. Losing ownership of an asset never deletes its package; the only copy a run + // removes is an asset's own superseded build after a rename moved its path. for _, e := range rep.Removed { if e.CachePath != "" { fmt.Fprintf(w, "no longer owned: %s — %s (%d bytes) left in place\n", e.Name, e.CachePath, e.SizeBytes) From 1112115e8d1c117421635d4a3797b09e6c56ab59 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 18:49:10 -0700 Subject: [PATCH 22/28] Test the recovery paths round 6 found untested 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- README.md | 5 +++-- internal/model/model.go | 8 +++++--- internal/model/model_test.go | 15 +++++++++++++++ internal/store/audit_test.go | 32 ++++++++++++++++++++++++++++++++ internal/syncer/audit_test.go | 32 ++++++++++++++++++++++++++++++++ internal/syncer/syncer.go | 28 ++++++++++++++++++++++------ 6 files changed, 109 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 1bdf7fa..a7d2877 100644 --- a/README.md +++ b/README.md @@ -130,8 +130,9 @@ downloads it behind your back. Hand-editing the file is fine. ## Verifying the cache Every run checks cached files cheaply: the file exists, its size is exactly what was -recorded, and the version stamped inside the package still matches. That catches a -truncated or replaced file without reading tens of gigabytes. +recorded, and — for packages that carry a version stamp — that stamp still matches. That +catches a truncated or replaced file without reading tens of gigabytes. A package with no +stamp is checked on size alone, so it does not re-download on every run. `--verify` re-hashes instead, which is the only way to catch corruption in the middle of a file. It is opt-in for the obvious reason. diff --git a/internal/model/model.go b/internal/model/model.go index 9616b51..9aebe15 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -90,9 +90,11 @@ func (a Asset) PublisherSlug() string { var nonSlug = regexp.MustCompile(`[^a-z0-9]+`) -// slugify folds to lowercase ASCII and collapses everything else to single hyphens. -// Non-ASCII letters are dropped rather than transliterated, which is why callers need -// an empty-result fallback. +// slugify lowercases ASCII and turns everything else into a separator, so runs of +// non-ASCII collapse to a single hyphen rather than being transliterated: "Bézier Path +// Creator" becomes "b-zier-path-creator", which is also what the store itself does. A name +// with no ASCII at all therefore slugifies to the empty string, which is why callers need +// a fallback. func slugify(s string) string { var b strings.Builder for _, r := range s { diff --git a/internal/model/model_test.go b/internal/model/model_test.go index aa84480..3a98bdd 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -77,3 +77,18 @@ func TestOnlyDisabledIsUndownloadable(t *testing.T) { } } } + +// A partially non-ASCII name is the interesting case: the run of non-ASCII becomes one +// separator rather than disappearing, which is also what the store's own slug does. +func TestPartiallyNonASCIINamesCollapseRatherThanTransliterate(t *testing.T) { + a := model.Asset{ID: "136082", Name: "Bézier Path Creator"} + if got, want := a.Slug(), "b-zier-path-creator-136082"; got != want { + t.Errorf("Slug() = %q, want %q", got, want) + } + // Whatever the folding does, the result must stay a single safe path element. + for _, r := range a.Slug() { + if r == '/' || r == '\\' || r < 0x20 { + t.Fatalf("slug %q contains a path-unsafe rune", a.Slug()) + } + } +} diff --git a/internal/store/audit_test.go b/internal/store/audit_test.go index c929b89..0ac0c5d 100644 --- a/internal/store/audit_test.go +++ b/internal/store/audit_test.go @@ -267,3 +267,35 @@ func TestAServerErrorThatExplainsItselfStillRetries(t *testing.T) { t.Errorf("made %d calls, want the explained 5xx to be retried", calls) } } + +// The token can expire between the bootstrap and the call that uses it, so one mismatch is +// worth a re-bootstrap and a second try. A server that always mismatches still reports +// ErrCSRF, which is what the other test pins; this one pins the recovery. +func TestATransientCSRFMismatchRecoversOnce(t *testing.T) { + var issued, posts int + c, _ := serve(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/packages" { + issued++ + http.SetCookie(w, &http.Cookie{Name: "_csrf", Value: "token", Path: "/"}) + w.WriteHeader(http.StatusNotFound) + return + } + posts++ + if posts == 1 { + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, "csrf token mismatch") + return + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `[{"data":{"searchMyAssets":{"total":0,"results":[]}}}]`) + }) + if err := c.Bootstrap(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := c.Enumerate(context.Background()); err != nil { + t.Fatalf("Enumerate = %v, want the retry after a re-bootstrap to succeed", err) + } + if issued < 2 { + t.Errorf("bootstrap ran %d times, want a re-bootstrap after the mismatch", issued) + } +} diff --git a/internal/syncer/audit_test.go b/internal/syncer/audit_test.go index cff4ec9..4796796 100644 --- a/internal/syncer/audit_test.go +++ b/internal/syncer/audit_test.go @@ -518,3 +518,35 @@ func TestPermanentDownloadFailuresAreNotRetried(t *testing.T) { func retryPolicyWithAttempts(n int) retry.Policy { return retry.Policy{Attempts: n, Base: time.Millisecond, Sleep: func(time.Duration) {}} } + +// The probes this wraps re-hash whole packages under --verify, so asking twice does not +// merely repeat work, it doubles the cost of verifying the whole library. +func TestMemoizeRunsTheProbeOnce(t *testing.T) { + calls := 0 + probe := memoize(func() bool { + calls++ + return true + }) + for range 5 { + if !probe() { + t.Fatal("memoized probe changed its answer") + } + } + if calls != 1 { + t.Errorf("probe ran %d times, want 1", calls) + } + + calls = 0 + falsey := memoize(func() bool { + calls++ + return false + }) + for range 3 { + if falsey() { + t.Fatal("memoized probe changed its answer") + } + } + if calls != 1 { + t.Errorf("a false result was recomputed %d times, want 1", calls) + } +} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index 330f745..876cda1 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -234,15 +234,19 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, _, prev, hasPrev := prior.FindByAssetID(a.ID) derived := cache.RelPath(a.PublisherSlug(), a.Slug()) - cacheOK := func() bool { - if prev.CachePath == "" { + // Memoized: classify calls this, and so does the excludeRel decision below. Under + // --verify each call is a full re-hash, so letting it run twice would double the + // cost of verifying a 75 GB library. + cacheOK := memoize(func() bool { + switch { + case prev.CachePath == "": return false - } - if opts.FullVerify { + case opts.FullVerify: return cache.VerifyDeep(opts.LibraryRoot, prev.CachePath, prev.SHA256) + default: + return cache.Verify(opts.LibraryRoot, prev.CachePath, prev.SizeBytes, prev.DeliveredVersionID) } - return cache.Verify(opts.LibraryRoot, prev.CachePath, prev.SizeBytes, prev.DeliveredVersionID) - } + }) var found cache.Candidate var foundOK bool // excludeRel is set when a recorded file exists but failed verification. Adoption @@ -660,3 +664,15 @@ func progressLine(read, total int64) string { } return fmt.Sprintf("%s of %s (%d%%)", humanBytes(read), humanBytes(total), read*100/total) } + +// memoize runs a probe at most once. The probes it wraps read or hash whole packages, so +// a caller asking twice is not merely redundant, it doubles the work. +func memoize(probe func() bool) func() bool { + var done, result bool + return func() bool { + if !done { + done, result = true, probe() + } + return result + } +} From 10f03539e58525eafd502ffd725f51cc984a9676 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 18:58:39 -0700 Subject: [PATCH 23/28] Name the assets the store will not serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- audit_test.go | 25 +++++++++++++++++++++++++ main.go | 8 ++++++++ 2 files changed, 33 insertions(+) diff --git a/audit_test.go b/audit_test.go index 94e344b..08ddb68 100644 --- a/audit_test.go +++ b/audit_test.go @@ -1,12 +1,15 @@ package main import ( + "bytes" "os" "path/filepath" "strings" "testing" "github.com/curbol/unity-sync/internal/manifest" + "github.com/curbol/unity-sync/internal/model" + "github.com/curbol/unity-sync/internal/syncer" ) // Failure models the CLI must keep pinned: ways a command could do something other than @@ -62,3 +65,25 @@ func TestMissingSessionIsExplained(t *testing.T) { t.Errorf("error %q does not mention the session", err) } } + +// The class tally counts a delisted asset but cannot say which one, and which one is the +// only part the user can act on. +func TestTheSummaryNamesEachDelistedAsset(t *testing.T) { + buf := &bytes.Buffer{} + printReport(buf, syncer.Report{ + Owned: 2, + Results: []syncer.Result{ + {Asset: model.Asset{ID: "1", Name: "Still Fine", State: model.StatePublished}, Class: syncer.Unchanged}, + {Asset: model.Asset{ID: "193760", Name: "Fantasy Sounds Bundle", State: model.StateDisabled}, Class: syncer.Undownloadable}, + }, + }, false, "/lib") + out := buf.String() + for _, want := range []string{"Fantasy Sounds Bundle", "193760", "disabled"} { + if !strings.Contains(out, want) { + t.Errorf("the summary does not name %q:\n%s", want, out) + } + } + if strings.Contains(out, "Still Fine") { + t.Errorf("the summary named an asset that is not delisted:\n%s", out) + } +} diff --git a/main.go b/main.go index a851385..09dec3a 100644 --- a/main.go +++ b/main.go @@ -287,6 +287,14 @@ func printReport(w io.Writer, rep syncer.Report, dry bool, libraryPath string) { fmt.Fprintf(w, "failed: %s: %v\n", r.Asset.Name, r.Err) } } + // A tally of one among hundreds of owned assets does not tell the user which package + // the store stopped serving, and that is the only thing they can act on. + for _, r := range rep.Results { + if r.Class == syncer.Undownloadable { + fmt.Fprintf(w, "delisted, cannot be downloaded: %s (%s), state %s\n", + r.Asset.Name, r.Asset.ID, r.Asset.State) + } + } // A dropped asset leaves its bytes on disk; the summary names them so the user can // decide. Losing ownership of an asset never deletes its package; the only copy a run // removes is an asset's own superseded build after a rename moved its path. From 4eb4b8bf891dd08e4bdf8d7b9e7e7191ba1abab7 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 19:30:41 -0700 Subject: [PATCH 24/28] Fix what the review of the diff found 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- README.md | 10 ++-- install.sh | 3 ++ internal/config/config.go | 5 +- internal/lockfile/lockfile.go | 3 -- internal/manifest/manifest.go | 4 +- internal/selfupdate/selfupdate.go | 2 +- internal/store/audit_test.go | 79 +++++++++++++++++++++++++++++ internal/store/store.go | 54 ++++++++++++++------ internal/syncer/audit_test.go | 84 +++++++++++++++++++++++++++++++ internal/syncer/syncer.go | 34 ++++++++++--- internal/web/web.go | 17 ++++--- internal/web/web_test.go | 37 ++++++++++++++ main.go | 4 +- 13 files changed, 289 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index a7d2877..e907385 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,8 @@ downloads it behind your back. Hand-editing the file is fine. ## What it does - Lists every owned asset with its current version inline, so a run that changes nothing - costs only the enumeration: one bootstrap plus a page request per 100 owned assets, and - no package bytes at all. + costs only the enumeration: one bootstrap, a page request per 100 owned assets plus the + empty page that ends the walk, and no package bytes at all. - Downloads only what is new, changed, or missing from the cache, into `///.unitypackage`. - Records everything in `unity-sync.lock.json` beside the manifest: what is owned, at what @@ -146,9 +146,9 @@ not here. When an asset leaves your account, unity-sync does not delete anything: its entry drops out of the lockfile and the run tells you which file is now unreferenced, so you can -decide. The one thing a run does remove is an asset's own superseded copy — if a package -you already mirror is renamed and updated in the same run, the new build lands at the new -path and the old one is cleaned up rather than left as a duplicate. +decide. A run removes a file only when it is replacing that same asset's own copy: the +superseded build after a rename moved its path, and a copy that failed its check when a +good copy of the same package is adopted over it. ## Browsing what you have diff --git a/install.sh b/install.sh index 9b6f60a..d5d77f2 100755 --- a/install.sh +++ b/install.sh @@ -20,6 +20,9 @@ auth_header() { token=$(gh auth token 2>/dev/null || true) fi [[ -n "$token" ]] && echo "Authorization: token $token" + # Under `set -e` a bare failing test would abort the caller's assignment, so no + # credential has to look like success here; the caller decides what to do with "". + return 0 } detect_platform() { diff --git a/internal/config/config.go b/internal/config/config.go index 0e781b3..92aeff2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,10 +15,7 @@ import ( // Config is the resolved user-scoped configuration. type Config struct { - // SessionSource is a path to a pasted-curl file or a cookies.txt. There is - // deliberately no browser default: the Asset Store's credential is a session - // cookie no browser cookie database holds, so defaulting to one would make every - // out-of-box run fail with a confusing diagnostic. + // SessionSource is a path to a pasted-curl file or a cookies.txt. SessionSource string // LibraryPath is where packages are mirrored. A user may point this at Unity's own diff --git a/internal/lockfile/lockfile.go b/internal/lockfile/lockfile.go index e45eccd..792e120 100644 --- a/internal/lockfile/lockfile.go +++ b/internal/lockfile/lockfile.go @@ -74,9 +74,6 @@ type Entry struct { } // Lockfile is the whole document. -// -// There is deliberately no run timestamp. Stamping one would dirty a committed file on -// every no-op run, which is exactly the churn that buries the changelog. type Lockfile struct { Assets map[string]Entry `json:"assets"` } diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index dc489a9..4aeda4a 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -3,9 +3,7 @@ // and lives with the consuming project, not with the tool, and it carries no account // identity. // -// Only `select` ever writes it. Reconcile and Save exist for that one command: a `sync` -// that rewrote the manifest would re-encode a hand-edited committed file on every run, -// and a wrong-org enumeration would delete the user's curated selections. +// Only `select` ever writes it. package manifest import ( diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index 05b8b5e..52236ca 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -36,7 +36,7 @@ func New(apiBase, token string) *Client { apiBase = "https://api.github.com" } return &Client{ - // No CheckRedirect: the asset endpoint 302s to a signed CDN URL by design. + // This client follows redirects: the asset endpoint 302s to a signed CDN URL. http: &http.Client{Timeout: 10 * time.Minute}, apiBase: strings.TrimSuffix(apiBase, "/"), token: token, diff --git a/internal/store/audit_test.go b/internal/store/audit_test.go index 0ac0c5d..885bc54 100644 --- a/internal/store/audit_test.go +++ b/internal/store/audit_test.go @@ -8,9 +8,11 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" + "github.com/curbol/unity-sync/internal/retry" "github.com/curbol/unity-sync/internal/store" ) @@ -299,3 +301,80 @@ func TestATransientCSRFMismatchRecoversOnce(t *testing.T) { t.Errorf("bootstrap ran %d times, want a re-bootstrap after the mismatch", issued) } } + +// The syncer shares one client across its download pool, and a CSRF mismatch re-bootstraps +// from inside a request, so the credential pair is written while other goroutines read it. +// Without synchronisation this reports a data race under -race; a torn Cookie value on the +// wire would present as an unreproducible mid-sync "session expired". +func TestTheClientIsSafeToShareAcrossTheDownloadPool(t *testing.T) { + c, _ := serve(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/packages": + http.SetCookie(w, &http.Cookie{Name: "_csrf", Value: "token", Path: "/"}) + w.WriteHeader(http.StatusNotFound) + case "/api/graphql/batch": + // Always a mismatch, so every call re-bootstraps mid-flight. + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, "csrf token mismatch") + default: + w.Header().Set("Content-Type", "application/octet-stream") + io.WriteString(w, "\x1f\x8b\x08\x00payload") + } + }) + if err := c.Bootstrap(context.Background()); err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + for i := range 8 { + wg.Add(1) + go func() { + defer wg.Done() + if i%2 == 0 { + c.Lookup(context.Background(), "1") + return + } + if dl, err := c.Fetch(context.Background(), "1"); err == nil { + io.Copy(io.Discard, dl.Body) + dl.Body.Close() + } + }() + } + wg.Wait() +} + +// A 403 says the same thing on the second attempt, and the download policy's backoff is +// measured in seconds per asset, so the status has to reach the caller as permanent. Run +// through retry.Do, which is the only public way to observe that. +func TestANonRetryableDownloadStatusIsNotRetried(t *testing.T) { + var calls int + c, _ := serve(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(http.StatusForbidden) + }) + policy := retry.Policy{Attempts: 3, Base: time.Millisecond, Sleep: func(time.Duration) {}} + err := retry.Do(context.Background(), policy, func(int) error { + _, err := c.Fetch(context.Background(), "1") + return err + }) + if err == nil { + t.Fatal("Fetch on a 403 = nil, want an error") + } + if calls != 1 { + t.Errorf("the store was called %d times for a 403, want 1", calls) + } + + // A 503 is the opposite case, and proves the test can tell the difference. + calls = 0 + busy, _ := serve(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(http.StatusServiceUnavailable) + }) + retry.Do(context.Background(), policy, func(int) error { + _, err := busy.Fetch(context.Background(), "1") + return err + }) + if calls != 3 { + t.Errorf("a 503 was attempted %d times, want all 3", calls) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index ce04d30..d1d2f11 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -15,6 +15,7 @@ import ( "net/http" "strconv" "strings" + "sync" "time" "github.com/curbol/unity-sync/internal/model" @@ -49,11 +50,9 @@ var ( ErrNotDownloadable = errors.New("asset is not downloadable") ) -// SearchDocument is pinned. Its field set is the tool's contract with the store, and it -// deliberately omits the per-row entitlement id and every other account-identifying -// field the API would return if asked. currentVersion.id is mandatory: it is the diff -// key, and losing it silently would break every classification. It is exported so a -// golden test can compare the whole document, not a substring of it. +// SearchDocument is pinned. Its field set is the tool's contract with the store: it asks +// for no account-identifying field, and currentVersion.id is mandatory, being the key +// every classification diffs on. const SearchDocument = `query SearchMyAssets($page: Int, $pageSize: Int, $ids: [String!]) { searchMyAssets(page: $page, pageSize: $pageSize, ids: $ids) { total @@ -75,10 +74,29 @@ const SearchDocument = `query SearchMyAssets($page: Int, $pageSize: Int, $ids: [ type Client struct { http *http.Client base string - cookie string - csrf string agent string retries retry.Policy + + // A CSRF mismatch re-bootstraps mid-flight, and the syncer's download pool shares one + // client, so these two are written while other goroutines are reading them. + mu sync.RWMutex + cookie string + csrf string +} + +// credentials reads the pair that every request carries. +func (c *Client) credentials() (cookie, csrf string) { + c.mu.RLock() + defer c.mu.RUnlock() + return c.cookie, c.csrf +} + +// adoptCSRF folds a freshly issued token into both the header and the cookie jar. +func (c *Client) adoptCSRF(token string) { + c.mu.Lock() + defer c.mu.Unlock() + c.csrf = token + c.cookie = withCSRF(c.cookie, token) } // Option adjusts a Client for tests. @@ -136,7 +154,8 @@ func (c *Client) Bootstrap(ctx context.Context) error { } req.Header.Set("User-Agent", c.agent) req.Header.Set("Accept", "text/html,*/*") - req.Header.Set("Cookie", c.cookie) + cookie, _ := c.credentials() + req.Header.Set("Cookie", cookie) resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("csrf bootstrap: %w", err) @@ -145,8 +164,7 @@ func (c *Client) Bootstrap(ctx context.Context) error { for _, ck := range resp.Cookies() { if ck.Name == "_csrf" && ck.Value != "" { - c.csrf = ck.Value - c.cookie = withCSRF(c.cookie, ck.Value) + c.adoptCSRF(ck.Value) return nil } } @@ -320,8 +338,9 @@ func (c *Client) searchOnce(ctx context.Context, vars map[string]any) (searchRes req.Header.Set("X-Source", "storefront") req.Header.Set("Operations", "SearchMyAssets") req.Header.Set("Accept-Encoding", "identity") - req.Header.Set("X-Csrf-Token", c.csrf) - req.Header.Set("Cookie", c.cookie) + cookie, csrf := c.credentials() + req.Header.Set("X-Csrf-Token", csrf) + req.Header.Set("Cookie", cookie) resp, err := c.http.Do(req) if err != nil { @@ -398,7 +417,8 @@ func (c *Client) Fetch(ctx context.Context, id string) (*Download, error) { req.Header.Set("Accept", "*/*") req.Header.Set("Referer", c.base+"/") req.Header.Set("X-Requested-With", "XMLHttpRequest") - req.Header.Set("Cookie", c.cookie) + cookie, _ := c.credentials() + req.Header.Set("Cookie", cookie) // Asking for identity is not hygiene: the endpoint honours Accept-Encoding: gzip by // gzipping the already-gzipped package, and Go does not transparently decode an // encoding the caller requested, so the cache would receive a double-gzipped blob @@ -419,7 +439,13 @@ func (c *Client) Fetch(ctx context.Context, id string) (*Download, error) { } if resp.StatusCode != http.StatusOK { drain(resp) - return nil, fmt.Errorf("download %s: status %d", id, resp.StatusCode) + err := fmt.Errorf("download %s: status %d", id, resp.StatusCode) + // A 403 or a 400 will say the same thing on the second attempt, and the download + // policy's backoff is measured in seconds per asset. + if !retry.Retryable(resp.StatusCode) { + return nil, retry.Permanent(err) + } + return nil, err } if enc := resp.Header.Get("Content-Encoding"); enc != "" { drain(resp) diff --git a/internal/syncer/audit_test.go b/internal/syncer/audit_test.go index 4796796..3d7851c 100644 --- a/internal/syncer/audit_test.go +++ b/internal/syncer/audit_test.go @@ -464,6 +464,28 @@ func TestDownloadWarnings(t *testing.T) { } }) + t.Run("both a version mismatch and a size outside the window", func(t *testing.T) { + root, lockPath := newRun(t) + // 200 short of 4000: past the +-64 window, inside the floor's 500-byte allowance. + a := asset("1", "Asset", "v-advertised", 4000) + fs := &fakeStore{owned: []model.Asset{a}, + bodies: map[string][]byte{"1": pkg(t, "1", "v-delivered", 3800)}} + + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, allSelected(a))) + if err != nil { + t.Fatalf("Run: %v", err) + } + got := rep.Results[0].Warning + // The size notice must not silence the version one: the lockfile now holds two + // different ids and this line is the only thing that explains why. + if !strings.Contains(got, "v-delivered") { + t.Errorf("warning = %q, want the version mismatch kept alongside the size notice", got) + } + if !strings.Contains(got, "3800") { + t.Errorf("warning = %q, want the size notice too", got) + } + }) + t.Run("outside the advisory window but above the floor", func(t *testing.T) { root, lockPath := newRun(t) // 200 bytes short of 4000: past the +-64 window, inside the floor's 500-byte @@ -550,3 +572,65 @@ func TestMemoizeRunsTheProbeOnce(t *testing.T) { t.Errorf("a false result was recomputed %d times, want 1", calls) } } + +// The damaged file sits at the derived path, so a good copy found elsewhere has nowhere to +// land unless the damaged one goes first. Getting this wrong is not a bad classification, +// it is a permanent one: nothing resolves, the prior entry carries forward, and every later +// run refuses in exactly the same way. +func TestAGoodCopyReplacesTheDamagedFileHoldingItsPath(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Asset", "v1", 4000) + good := pkg(t, "1", "v1", 4000) + + derived, err := cache.Store(root, a.PublisherSlug(), a.Slug(), bytes.NewReader(good)) + if err != nil { + t.Fatal(err) + } + if err := derived.Commit(); err != nil { + t.Fatal(err) + } + stray, err := cache.Store(root, "somewhere", "else-1", bytes.NewReader(good)) + if err != nil { + t.Fatal(err) + } + if err := stray.Commit(); err != nil { + t.Fatal(err) + } + + // Truncating leaves the descriptor intact, so the scan still sees a candidate; only the + // recorded size no longer matches, which is what fails verification. + full := filepath.Join(root, filepath.FromSlash(derived.RelPath)) + if err := os.Truncate(full, derived.Size-100); err != nil { + t.Fatal(err) + } + + prior := lockfile.New() + prior.Assets[a.Slug()] = lockfile.Entry{ + AssetID: "1", Name: a.Name, Tracked: true, + ResolvedVersionID: "v1", DeliveredVersionID: "v1", + SizeBytes: derived.Size, SHA256: derived.SHA256, CachePath: derived.RelPath, + Version: lockfile.Version{ID: "v1"}, + } + + fs := &fakeStore{owned: []model.Asset{a}} + rep, err := Run(context.Background(), fs, prior, lockPath, opts(root, allSelected(a))) + if err != nil { + t.Fatalf("Run: %v", err) + } + if rep.Results[0].Err != nil { + t.Fatalf("adoption failed: %v", rep.Results[0].Err) + } + if rep.Results[0].Class != Adopted { + t.Fatalf("class = %v, want Adopted", rep.Results[0].Class) + } + if len(fs.fetched) != 0 { + t.Errorf("downloaded %v; a good copy was already on disk", fs.fetched) + } + if got := cache.VerifyDeep(root, derived.RelPath, stray.SHA256); !got { + t.Error("the derived path does not hold the good copy's bytes") + } + e, ok := rep.Lockfile.Assets[a.Slug()] + if !ok || e.CachePath != derived.RelPath || e.SHA256 != stray.SHA256 { + t.Errorf("lockfile records %+v, want the adopted copy at the derived path", e) + } +} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index 876cda1..5a65a3d 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -284,7 +284,7 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, if opts.DryRun { break } - r, err := adopt(opts, a, found, derived) + r, err := adopt(opts, a, found, derived, excludeRel) if err != nil { res.Err = err report.Retryable++ @@ -380,10 +380,13 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, mu.Lock() resolutions[res.Asset.ID] = r // Persisting per download is what keeps a run that dies at asset 90 of - // 100 from discarding the 89 it already fetched. - snapshot := build(owned, prior, resolutions, opts, nil) + // 100 from discarding the 89 it already fetched. The write stays inside + // the lock: released first, two goroutines can reach the rename in the + // order opposite to how they built their snapshots, and the older one + // wins — losing exactly the record this write exists to keep. + err := lockfile.Save(lockPath, build(owned, prior, resolutions, opts, nil)) mu.Unlock() - if err := lockfile.Save(lockPath, snapshot); err != nil { + if err != nil { res.Err = fmt.Errorf("persisting progress: %w", err) } } @@ -415,7 +418,17 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, // adopt records a package already on disk, relocating it to where the layout puts it so // the cache does not drift and quarry's facets stay right. -func adopt(opts Options, a model.Asset, found cache.Candidate, derived string) (resolution, error) { +func adopt(opts Options, a model.Asset, found cache.Candidate, derived, damagedRel string) (resolution, error) { + // Relocate refuses an occupied destination, which is what stops it certifying bytes + // nothing checked. The one occupant that must not stop it is this asset's own recorded + // copy after it failed verification: a download would rename straight over that file, + // so a verified copy of the same package may replace it too. Without this the good copy + // can never move in, nothing is resolved, and every later run repeats the refusal. + if damagedRel != "" && damagedRel == derived { + if err := cache.RemoveStale(opts.LibraryRoot, damagedRel); err != nil { + return resolution{}, err + } + } if err := cache.Relocate(opts.LibraryRoot, found.RelPath, derived); err != nil { return resolution{}, err } @@ -432,7 +445,6 @@ func adopt(opts Options, a model.Asset, found cache.Candidate, derived string) ( // classify Changed on the next run and re-download. resolvedVersionID: a.Version.ID, deliveredVersionID: found.Metadata.VersionID, - // downloadedAt stays empty: the tool found the file, it did not fetch it. }, nil } @@ -497,7 +509,15 @@ func download(ctx context.Context, s Store, opts Options, a model.Asset) (resolu a.Name, pending.Size, a.AdvertisedSize) } if a.AdvertisedSize > 0 && (pending.Size > a.AdvertisedSize || pending.Size < a.AdvertisedSize-64) { - warning = fmt.Sprintf("%s: received %d bytes, advertised %d", a.Name, pending.Size, a.AdvertisedSize) + // Appended, not assigned: a package can both be served at a different version than + // advertised and land outside the window, and the version notice is the one the + // lockfile's two ids need explaining. + size := fmt.Sprintf("%s: received %d bytes, advertised %d", a.Name, pending.Size, a.AdvertisedSize) + if warning == "" { + warning = size + } else { + warning += "; " + size + } } if err := pending.Commit(); err != nil { diff --git a/internal/web/web.go b/internal/web/web.go index 3699aae..7f89476 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -97,15 +97,23 @@ type Selection map[string]bool // Handler renders the page and accepts one save. It is separated from Serve so the // behaviour can be tested without a socket. +// +// A refused save is answered and nothing more: the page stays up so the user can correct +// the mistake the refusal describes. Tearing the server down instead would make the 409's +// own advice impossible to follow, and would let any page the user has open end their +// selection with a single cross-origin POST. type Handler struct { assets []model.Asset enabled map[string]bool token string done chan Selection - err chan error } +// Selection delivers the one accepted save, so a caller without a socket can wait on the +// same channel Serve does. +func (h *Handler) Selection() <-chan Selection { return h.done } + // NewHandler builds the page handler for one run. func NewHandler(assets []model.Asset, enabled map[string]bool) *Handler { buf := make([]byte, 16) @@ -115,7 +123,6 @@ func NewHandler(assets []model.Asset, enabled map[string]bool) *Handler { enabled: enabled, token: hex.EncodeToString(buf), done: make(chan Selection, 1), - err: make(chan error, 1), } } @@ -153,7 +160,6 @@ func (h *Handler) save(w http.ResponseWriter, r *http.Request) { } if r.PostFormValue("token") != h.token { http.Error(w, ErrStaleTab.Error(), http.StatusConflict) - h.err <- ErrStaleTab return } chosen := Selection{} @@ -162,14 +168,13 @@ func (h *Handler) save(w http.ResponseWriter, r *http.Request) { } if len(chosen) == 0 && anyEnabled(h.enabled) { http.Error(w, ErrWouldEmptySelection.Error(), http.StatusConflict) - h.err <- ErrWouldEmptySelection return } fmt.Fprintf(w, "Saved %d selection(s). You can close this tab.", len(chosen)) h.done <- chosen } -// Serve runs the page until it is saved once, the context ends, or a save is refused. +// Serve runs the page until it is saved once or the context ends. func Serve(ctx context.Context, addr string, assets []model.Asset, enabled map[string]bool) (Selection, error) { h := NewHandler(assets, enabled) ln, err := net.Listen("tcp", addr) @@ -187,8 +192,6 @@ func Serve(ctx context.Context, addr string, assets []model.Asset, enabled map[s select { case sel := <-h.done: return sel, nil - case err := <-h.err: - return nil, err case <-ctx.Done(): return nil, ctx.Err() } diff --git a/internal/web/web_test.go b/internal/web/web_test.go index d2dcb10..686ba3d 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -7,6 +7,7 @@ import ( "regexp" "strings" "testing" + "time" "github.com/curbol/unity-sync/internal/model" "github.com/curbol/unity-sync/internal/web" @@ -146,3 +147,39 @@ func TestEmptySaveIsFineWhenNothingWasSelected(t *testing.T) { t.Errorf("empty save on a fresh manifest = %d, want 200", rec.Code) } } + +// The 409 body tells the user to reload and choose again, so the page has to still be +// there when they do. The same property is what stops any page the user has open from +// ending a selection with one cross-origin POST. +func TestARefusedSaveLeavesThePageServing(t *testing.T) { + h := web.NewHandler(assets(), map[string]bool{"115488": true}) + body := render(t, h) + + stale := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader( + url.Values{"token": {"from-an-older-run"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + h.ServeHTTP(stale, req) + if stale.Code != http.StatusConflict { + t.Fatalf("stale POST = %d, want %d", stale.Code, http.StatusConflict) + } + + // The page is still up, with the same token, and a correct save still lands. + if got := render(t, h); tokenFrom(t, got) != tokenFrom(t, body) { + t.Error("the refusal changed the page token") + } + saved := httptest.NewRecorder() + ok := httptest.NewRequest(http.MethodPost, "/", strings.NewReader( + url.Values{"token": {tokenFrom(t, body)}, "asset": {"115488"}}.Encode())) + ok.Header.Set("Content-Type", "application/x-www-form-urlencoded") + go h.ServeHTTP(saved, ok) + + select { + case sel := <-h.Selection(): + if !sel["115488"] { + t.Errorf("selection = %v, want asset 115488", sel) + } + case <-time.After(2 * time.Second): + t.Fatal("the save after a refusal never arrived") + } +} diff --git a/main.go b/main.go index 09dec3a..794d17f 100644 --- a/main.go +++ b/main.go @@ -186,9 +186,7 @@ func resolveSession(cfg config.Config, configDir string) (string, error) { return session.Resolve(src) } -// enumerator is the slice of the store client that `select` needs. Narrowing it here lets -// the command be driven by a fake in tests, which is the only way its happy path gets -// covered at all: everything above it needs a live session. +// enumerator is the slice of the store client that `select` needs. type enumerator interface { Enumerate(ctx context.Context) ([]model.Asset, error) } From dda6558aca43835759ec71c42453037b7e1417c4 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 19:50:08 -0700 Subject: [PATCH 25/28] Correct what the previous fix left behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- README.md | 4 +- docs/design.md | 11 ++-- internal/cache/audit_test.go | 12 +++++ internal/cache/cache.go | 10 ++-- internal/syncer/audit_test.go | 94 +++++++++++++++++++++++++++++++++++ internal/syncer/syncer.go | 22 ++++++-- internal/web/web.go | 4 +- main.go | 4 +- 8 files changed, 141 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index e907385..6f199f7 100644 --- a/README.md +++ b/README.md @@ -147,8 +147,8 @@ not here. When an asset leaves your account, unity-sync does not delete anything: its entry drops out of the lockfile and the run tells you which file is now unreferenced, so you can decide. A run removes a file only when it is replacing that same asset's own copy: the -superseded build after a rename moved its path, and a copy that failed its check when a -good copy of the same package is adopted over it. +superseded build after its path changed, whether the new copy was downloaded or adopted, +and a copy that failed its check when a good copy of the same package is adopted over it. ## Browsing what you have diff --git a/docs/design.md b/docs/design.md index cc33a14..e345c37 100644 --- a/docs/design.md +++ b/docs/design.md @@ -179,10 +179,13 @@ flip leaves the descriptor intact and a small truncation clears the floor, so wi exclusion the damaged bytes would be re-hashed and their digest recorded as the asset's truth — the precise outcome every other guard exists to prevent. -Nothing deletes a package the tool mirrored, with one exception: when a download lands at -a different derived path than the entry's previous one, the superseded copy of that same -asset is removed. That is not the de-owned case, which is reported and left in place; it -is the asset's own prior build, and the cache holds only current versions. +A run deletes a package only when it is replacing that same asset's own copy, which +happens three ways: a download lands at a different derived path than the entry's previous +one, an adoption does the same, or an adoption replaces a recorded copy that failed its +check and is sitting on the destination. None of these is the de-owned case, which is +reported and left in place. The cache holds only current versions, so a superseded copy of +the same asset is not something to keep, but a copy the tool did not write is never +touched: every path removed here came out of the lockfile. ## Failure model diff --git a/internal/cache/audit_test.go b/internal/cache/audit_test.go index bf38d3d..0d2a469 100644 --- a/internal/cache/audit_test.go +++ b/internal/cache/audit_test.go @@ -104,3 +104,15 @@ func TestUnsafePathsAreRefused(t *testing.T) { } } } + +// A library that does not exist yet is the first-run case, not an error: the sweep runs +// before anything has been written. +func TestSweepingAMissingRootIsNotAnError(t *testing.T) { + n, bytes, err := cache.SweepTemps(filepath.Join(t.TempDir(), "never-created"), time.Now()) + if err != nil { + t.Errorf("SweepTemps on a missing root = %v, want nil", err) + } + if n != 0 || bytes != 0 { + t.Errorf("swept %d files / %d bytes from a missing root", n, bytes) + } +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 2858922..90a0f22 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -343,15 +343,13 @@ func SweepTemps(root string, olderThan time.Time) (int, int64, error) { } return nil }) - if os.IsNotExist(err) { - return 0, 0, nil - } return count, bytes, err } -// RemoveStale deletes a package this tool mirrored for an asset whose derived path has -// since changed, and prunes the directories the removal empties. It is only ever called -// with a path the lockfile itself recorded, never with a file the tool did not write. +// RemoveStale deletes a package this tool mirrored and is now replacing with another copy +// of the same asset, and prunes the directories the removal empties. It is only ever +// called with a path the lockfile itself recorded, never with a file the tool did not +// write. func RemoveStale(root, rel string) error { full, err := resolve(root, rel) if err != nil { diff --git a/internal/syncer/audit_test.go b/internal/syncer/audit_test.go index 3d7851c..bef9b87 100644 --- a/internal/syncer/audit_test.go +++ b/internal/syncer/audit_test.go @@ -7,6 +7,7 @@ import ( "errors" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -634,3 +635,96 @@ func TestAGoodCopyReplacesTheDamagedFileHoldingItsPath(t *testing.T) { t.Errorf("lockfile records %+v, want the adopted copy at the derived path", e) } } + +// The candidate can already be at the derived path while the lockfile still points at the +// old one — a run killed between the commit and the incremental save leaves exactly that. +// Adoption then relocates nothing, so without an explicit removal the recorded copy is +// orphaned: unreferenced by the new lockfile and named by nothing in the summary. +func TestAdoptionRemovesTheEntrysOwnSupersededCopy(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "New Name", "v1", 4000) + body := pkg(t, "1", "v1", 4000) + + atDerived, err := cache.Store(root, a.PublisherSlug(), a.Slug(), bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + if err := atDerived.Commit(); err != nil { + t.Fatal(err) + } + stale, err := cache.Store(root, a.PublisherSlug(), "old-name-1", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + if err := stale.Commit(); err != nil { + t.Fatal(err) + } + // Truncated, so the recorded copy fails verification and adoption is what runs. + if err := os.Truncate(filepath.Join(root, filepath.FromSlash(stale.RelPath)), stale.Size-100); err != nil { + t.Fatal(err) + } + + prior := lockfile.New() + prior.Assets["old-name-1"] = lockfile.Entry{ + AssetID: "1", Name: "Old Name", Tracked: true, + ResolvedVersionID: "v1", DeliveredVersionID: "v1", + SizeBytes: stale.Size, SHA256: stale.SHA256, CachePath: stale.RelPath, + Version: lockfile.Version{ID: "v1"}, + } + + fs := &fakeStore{owned: []model.Asset{a}} + rep, err := Run(context.Background(), fs, prior, lockPath, opts(root, allSelected(a))) + if err != nil { + t.Fatalf("Run: %v", err) + } + if rep.Results[0].Class != Adopted || rep.Results[0].Err != nil { + t.Fatalf("class = %v, err = %v; want a clean Adopted", rep.Results[0].Class, rep.Results[0].Err) + } + if len(fs.fetched) != 0 { + t.Errorf("downloaded %v; the current build was already at the derived path", fs.fetched) + } + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(stale.RelPath))); !os.IsNotExist(err) { + t.Errorf("the superseded copy at %s survived adoption", stale.RelPath) + } + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(atDerived.RelPath))); err != nil { + t.Errorf("the adopted copy is gone: %v", err) + } +} + +// Two runs that find the same droppped assets must print them the same way; ranging a map +// does not. +func TestDroppedAssetsAreReportedInAStableOrder(t *testing.T) { + root, lockPath := newRun(t) + kept := asset("1", "Kept", "v1", 500) + + prior := lockfile.New() + for _, e := range []struct{ id, name string }{ + {"7", "Zulu"}, {"8", "Alpha"}, {"9", "Mike"}, {"10", "Bravo"}, + } { + prior.Assets[e.name] = lockfile.Entry{AssetID: e.id, Name: e.name, Tracked: true} + } + prior.Assets[kept.Slug()] = lockfile.Entry{AssetID: "1", Name: "Kept", Tracked: false} + + var first []string + for run := range 6 { + fs := &fakeStore{owned: []model.Asset{kept}} + rep, err := Run(context.Background(), fs, prior, lockPath, opts(root, allSelected(kept))) + if err != nil { + t.Fatal(err) + } + var names []string + for _, e := range rep.Removed { + names = append(names, e.Name) + } + if run == 0 { + first = names + if want := []string{"Alpha", "Bravo", "Mike", "Zulu"}; !slices.Equal(names, want) { + t.Fatalf("order = %v, want %v", names, want) + } + continue + } + if !slices.Equal(names, first) { + t.Fatalf("run %d reported %v, run 0 reported %v", run, names, first) + } + } +} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index 5a65a3d..f02ce60 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "path/filepath" + "sort" "strings" "sync" "time" @@ -289,6 +290,16 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, res.Err = err report.Retryable++ } else { + // Same rule the download branch applies: the entry's own prior copy is + // superseded by what just landed at the derived path, and nothing else + // will ever mention it again — the summary names only assets that left + // the account, so an orphan here is one the tool made and never reports. + if old := prev.CachePath; old != "" && old != r.cachePath { + if rmErr := cache.RemoveStale(opts.LibraryRoot, old); rmErr != nil { + res.Warning = fmt.Sprintf( + "could not remove the superseded copy at %s: %v", old, rmErr) + } + } resolutions[a.ID] = r } case Unchanged: @@ -509,9 +520,9 @@ func download(ctx context.Context, s Store, opts Options, a model.Asset) (resolu a.Name, pending.Size, a.AdvertisedSize) } if a.AdvertisedSize > 0 && (pending.Size > a.AdvertisedSize || pending.Size < a.AdvertisedSize-64) { - // Appended, not assigned: a package can both be served at a different version than - // advertised and land outside the window, and the version notice is the one the - // lockfile's two ids need explaining. + // A package can both be served at a different version than advertised and land + // outside the window, and the version notice is the one the lockfile's two ids + // need explaining. size := fmt.Sprintf("%s: received %d bytes, advertised %d", a.Name, pending.Size, a.AdvertisedSize) if warning == "" { warning = size @@ -618,6 +629,11 @@ func build(owned []model.Asset, prior lockfile.Lockfile, resolutions map[string] report.Removed = append(report.Removed, e) } } + // Map order otherwise, which would reorder the summary between two runs that + // found the same thing. + sort.Slice(report.Removed, func(i, j int) bool { + return report.Removed[i].Name < report.Removed[j].Name + }) } return out } diff --git a/internal/web/web.go b/internal/web/web.go index 7f89476..762ce07 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -99,9 +99,7 @@ type Selection map[string]bool // behaviour can be tested without a socket. // // A refused save is answered and nothing more: the page stays up so the user can correct -// the mistake the refusal describes. Tearing the server down instead would make the 409's -// own advice impossible to follow, and would let any page the user has open end their -// selection with a single cross-origin POST. +// the mistake the refusal describes. type Handler struct { assets []model.Asset enabled map[string]bool diff --git a/main.go b/main.go index 794d17f..3cb769b 100644 --- a/main.go +++ b/main.go @@ -294,8 +294,8 @@ func printReport(w io.Writer, rep syncer.Report, dry bool, libraryPath string) { } } // A dropped asset leaves its bytes on disk; the summary names them so the user can - // decide. Losing ownership of an asset never deletes its package; the only copy a run - // removes is an asset's own superseded build after a rename moved its path. + // decide. Losing ownership of an asset never deletes its package: a run only ever + // removes a copy it is replacing with a newer one of that same asset. for _, e := range rep.Removed { if e.CachePath != "" { fmt.Fprintf(w, "no longer owned: %s — %s (%d bytes) left in place\n", e.Name, e.CachePath, e.SizeBytes) From ab7df0f818b1ca2ee70ba1aa1b3d857aefbf28fe Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 20:07:01 -0700 Subject: [PATCH 26/28] Stop paying for a check the classification never consults 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- internal/cache/audit_test.go | 19 +++++++++++++++++++ internal/cache/cache.go | 13 +++++++++---- internal/syncer/syncer.go | 27 ++++++++++++++++++--------- internal/web/web.go | 10 +++++----- main.go | 6 ++++++ 5 files changed, 57 insertions(+), 18 deletions(-) diff --git a/internal/cache/audit_test.go b/internal/cache/audit_test.go index 0d2a469..f2a9a4f 100644 --- a/internal/cache/audit_test.go +++ b/internal/cache/audit_test.go @@ -116,3 +116,22 @@ func TestSweepingAMissingRootIsNotAnError(t *testing.T) { t.Errorf("swept %d files / %d bytes from a missing root", n, bytes) } } + +// The exclude list comes from the lockfile, which is hand-editable and travels between +// machines. Comparing it as a raw string would let "./pub/a/a.unitypackage" fail to skip +// the file "pub/a/a.unitypackage" names, re-offering a file that just failed verification +// as something to adopt. +func TestLocateSkipsAnExcludedFileWrittenNonCanonically(t *testing.T) { + root := t.TempDir() + rel := cache.RelPath("pub", "asset-1") + storeCommitted(t, root, "pub", "asset-1", pkg(t, "111", "9", 400)) + + if _, ok := cache.Locate(root, "111", "", rel); ok { + t.Fatal("the canonical exclude did not skip the file") + } + for _, spelling := range []string{"./" + rel, "pub/./asset-1/asset-1.unitypackage"} { + if _, ok := cache.Locate(root, "111", "", spelling); ok { + t.Errorf("exclude %q did not skip the same file", spelling) + } + } +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 90a0f22..d8091a3 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -225,10 +225,14 @@ type Candidate struct { // excludeRel are skipped entirely: a file that just failed verification is not a candidate // for adoption, however intact its descriptor still looks. func Locate(root, productID, preferRel string, excludeRel ...string) (Candidate, bool) { + // Resolved, not compared as strings: excludeRel comes from the lockfile, which is + // hand-editable and travels between machines, so "./pub/a/a.unitypackage" has to skip + // the same file "pub/a/a.unitypackage" names. Missing the match would re-offer a file + // that just failed verification as a candidate to adopt. skip := map[string]bool{} for _, e := range excludeRel { - if e != "" { - skip[e] = true + if full, err := resolve(root, e); err == nil { + skip[full] = true } } var found []Candidate @@ -252,9 +256,10 @@ func Locate(root, productID, preferRel string, excludeRel ...string) (Candidate, if err != nil { return nil } - if slug := filepath.ToSlash(rel); !skip[slug] { - found = append(found, Candidate{RelPath: slug, Size: fi.Size(), Metadata: m}) + if skip[filepath.Clean(p)] { + return nil } + found = append(found, Candidate{RelPath: filepath.ToSlash(rel), Size: fi.Size(), Metadata: m}) return nil }) if len(found) == 0 { diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index f02ce60..1767e0a 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -121,7 +121,10 @@ type Options struct { FullVerify bool Concurrency int Now func() time.Time - Progress func(string) + + // Progress is called from every download goroutine as well as the main pass, so an + // implementation that does more than one write needs its own synchronisation. + Progress func(string) // Retry governs download attempts. Downloads get their own budget rather than the // API's: re-transferring a multi-gigabyte body is not the same kind of cheap as @@ -257,6 +260,12 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, // prevent, arriving through the one door that skips them. var excludeRel string adoptable := func() bool { + // Decided here rather than before classify: classify short-circuits to + // Changed without ever asking, and under --verify the probe is a full + // re-hash of a file that is about to be replaced by the download. + if hasPrev && prev.Tracked && prev.CachePath != "" && !cacheOK() { + excludeRel = prev.CachePath + } found, foundOK = cache.Locate(opts.LibraryRoot, a.ID, derived, excludeRel) if !foundOK { return false @@ -273,9 +282,6 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, if hasPrev && prev.Tracked { priorPaths[a.ID] = prev.CachePath - if prev.CachePath != "" && !cacheOK() { - excludeRel = prev.CachePath - } } class := classify(a, prev, hasPrev, cacheOK, adoptable) res := Result{Asset: a, Class: class} @@ -323,7 +329,7 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, if opts.DryRun { report.Results = append(report.Results, pending...) - report.Lockfile = build(owned, prior, resolutions, opts, &report) + report.Lockfile = build(owned, prior, resolutions, &report) return report, nil } @@ -395,7 +401,7 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, // the lock: released first, two goroutines can reach the rename in the // order opposite to how they built their snapshots, and the older one // wins — losing exactly the record this write exists to keep. - err := lockfile.Save(lockPath, build(owned, prior, resolutions, opts, nil)) + err := lockfile.Save(lockPath, build(owned, prior, resolutions, nil)) mu.Unlock() if err != nil { res.Err = fmt.Errorf("persisting progress: %w", err) @@ -420,7 +426,7 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, report.Results = append(report.Results, res) } - report.Lockfile = build(owned, prior, resolutions, opts, &report) + report.Lockfile = build(owned, prior, resolutions, &report) if err := lockfile.Save(lockPath, report.Lockfile); err != nil { return report, err } @@ -574,7 +580,7 @@ func republished(ctx context.Context, s Store, a model.Asset) bool { // build produces the new lockfile: every owned asset gets an entry, advertised fields are // refreshed, and any resolution this run did not touch is carried forward verbatim. func build(owned []model.Asset, prior lockfile.Lockfile, resolutions map[string]resolution, - opts Options, report *Report) lockfile.Lockfile { + report *Report) lockfile.Lockfile { out := lockfile.New() kept := map[string]bool{} @@ -632,7 +638,10 @@ func build(owned []model.Asset, prior lockfile.Lockfile, resolutions map[string] // Map order otherwise, which would reorder the summary between two runs that // found the same thing. sort.Slice(report.Removed, func(i, j int) bool { - return report.Removed[i].Name < report.Removed[j].Name + if report.Removed[i].Name != report.Removed[j].Name { + return report.Removed[i].Name < report.Removed[j].Name + } + return report.Removed[i].AssetID < report.Removed[j].AssetID }) } return out diff --git a/internal/web/web.go b/internal/web/web.go index 762ce07..6f5d1a0 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -21,13 +21,13 @@ import ( "github.com/curbol/unity-sync/internal/model" ) -// ErrWouldEmptySelection is returned when a save would clear every selection at once. -// A stale tab reopened after the library changed, or a mis-click on "none", should not -// silently wipe a curated allowlist. +// ErrWouldEmptySelection is the 409 a save gets when it would clear every selection at +// once. A stale tab reopened after the library changed, or a mis-click on "none", should +// not silently wipe a curated allowlist. var ErrWouldEmptySelection = errors.New("refusing a save that would deselect everything") -// ErrStaleTab is returned when a POST does not carry this run's token, which means it -// came from a page some earlier run served. +// ErrStaleTab is the 409 a POST gets when it does not carry this run's token, which +// means it came from a page some earlier run served. var ErrStaleTab = errors.New("this page was served by an earlier run; reload and choose again") type row struct { diff --git a/main.go b/main.go index 3cb769b..201135d 100644 --- a/main.go +++ b/main.go @@ -246,6 +246,12 @@ func syncOrStatus(ctx context.Context, client syncer.Store, cfg config.Config, Progress: func(s string) { fmt.Fprintln(os.Stderr, s) }, }) if err != nil { + // Run hands back the report alongside an error, and a late failure — the final + // lockfile write, say — comes after a full download pass whose outcome the user + // still needs. An error raised before any of that has nothing to show. + if len(rep.Results) > 0 { + printReport(stdout, rep, dry, cfg.LibraryPath) + } return 1, err } printReport(stdout, rep, dry, cfg.LibraryPath) From 828b5d5899c450f050095c493f9e4e38225e7561 Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 20:32:40 -0700 Subject: [PATCH 27/28] Fix the quiet failures a fourth reading turned up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- internal/cache/audit_test.go | 62 ++++++++++++++++++++++++++---- internal/cache/cache.go | 27 ++++++++++--- internal/lockfile/lockfile.go | 10 +++++ internal/lockfile/lockfile_test.go | 31 +++++++++++++++ internal/manifest/manifest.go | 10 +++++ internal/manifest/manifest_test.go | 29 ++++++++++++++ internal/syncer/audit_test.go | 41 ++++++++++++++++++++ internal/syncer/syncer.go | 8 +--- internal/web/web.go | 23 ++++++----- 9 files changed, 210 insertions(+), 31 deletions(-) diff --git a/internal/cache/audit_test.go b/internal/cache/audit_test.go index f2a9a4f..27f8b43 100644 --- a/internal/cache/audit_test.go +++ b/internal/cache/audit_test.go @@ -72,10 +72,7 @@ func TestSweepWalksTheTreeAndSparesInFlightTemps(t *testing.T) { os.Chtimes(stale, old, old) cutoff := time.Now().Add(-time.Hour) - n, bytesFreed, err := cache.SweepTemps(root, cutoff) - if err != nil { - t.Fatalf("SweepTemps: %v", err) - } + n, bytesFreed := cache.SweepTemps(root, cutoff) // A root-only scan would report zero here while leaving a multi-gigabyte orphan. if n != 1 || bytesFreed != 100 { t.Errorf("swept %d files / %d bytes, want 1 / 100", n, bytesFreed) @@ -108,10 +105,7 @@ func TestUnsafePathsAreRefused(t *testing.T) { // A library that does not exist yet is the first-run case, not an error: the sweep runs // before anything has been written. func TestSweepingAMissingRootIsNotAnError(t *testing.T) { - n, bytes, err := cache.SweepTemps(filepath.Join(t.TempDir(), "never-created"), time.Now()) - if err != nil { - t.Errorf("SweepTemps on a missing root = %v, want nil", err) - } + n, bytes := cache.SweepTemps(filepath.Join(t.TempDir(), "never-created"), time.Now()) if n != 0 || bytes != 0 { t.Errorf("swept %d files / %d bytes from a missing root", n, bytes) } @@ -135,3 +129,55 @@ func TestLocateSkipsAnExcludedFileWrittenNonCanonically(t *testing.T) { } } } + +// The library root comes from a flag or a config file, so it arrives however the user +// typed it. Comparing it raw against paths that have been cleaned makes pruning a no-op +// for the ordinary "./lib" spelling, and nothing else notices. +func TestPruningSurvivesHoweverTheRootWasSpelled(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + spellings := map[string]func(string) string{ + "absolute": func(base string) string { return base }, + "trailing slash": func(base string) string { return base + string(filepath.Separator) }, + "dot relative": func(base string) string { + rel, err := filepath.Rel(wd, base) + if err != nil { + t.Skip("temp dir is not reachable relatively from the working directory") + } + return "." + string(filepath.Separator) + rel + }, + } + for name, spell := range spellings { + t.Run(name, func(t *testing.T) { + base := t.TempDir() + root := spell(base) + storeCommitted(t, root, "pub", "old-slug-1", pkg(t, "1", "9", 400)) + if err := cache.Relocate(root, cache.RelPath("pub", "old-slug-1"), + cache.RelPath("pub", "new-slug-1")); err != nil { + t.Fatalf("Relocate: %v", err) + } + if _, err := os.Stat(filepath.Join(base, "pub", "old-slug-1")); !os.IsNotExist(err) { + t.Errorf("root spelled %q left the emptied directory behind", root) + } + }) + } +} + +// An exclusion names a file that must not be adopted. If it cannot be resolved, the scan +// cannot tell which file that is, and offering a candidate anyway would let the excluded +// one back in through the door that skips the download guards. +func TestAnUnresolvableExclusionRefusesEveryCandidate(t *testing.T) { + root := t.TempDir() + storeCommitted(t, root, "pub", "asset-1", pkg(t, "111", "9", 400)) + + if _, ok := cache.Locate(root, "111", ""); !ok { + t.Fatal("the candidate is not findable at all") + } + for _, bad := range []string{"/etc/passwd", "../outside/x.unitypackage"} { + if _, ok := cache.Locate(root, "111", "", bad); ok { + t.Errorf("exclusion %q was dropped and a candidate offered anyway", bad) + } + } +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go index d8091a3..0f1e55d 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -231,9 +231,18 @@ func Locate(root, productID, preferRel string, excludeRel ...string) (Candidate, // that just failed verification as a candidate to adopt. skip := map[string]bool{} for _, e := range excludeRel { - if full, err := resolve(root, e); err == nil { - skip[full] = true + if e == "" { + continue } + full, err := resolve(root, e) + if err != nil { + // The caller is naming a file that must not be adopted and this cannot tell + // which one it is. Refusing every candidate falls back to a re-download, + // where the full set of guards applies; guessing would let the excluded file + // back in through the one door that skips them. + return Candidate{}, false + } + skip[full] = true } var found []Candidate filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { @@ -309,6 +318,10 @@ func Relocate(root, fromRel, toRel string) error { // pruneEmptyParents removes directories the move emptied, walking up but never past the // library root. func pruneEmptyParents(root, dir string) { + // Cleaned, because every path this compares against came through resolve, which + // joins and cleans. A root of "./lib" or "lib/" would otherwise match nothing and + // silently prune nothing. + root = filepath.Clean(root) for { if dir == root || !strings.HasPrefix(dir, root+string(filepath.Separator)) { return @@ -328,10 +341,14 @@ func pruneEmptyParents(root, dir string) { // and how many bytes. It walks rather than scanning the root, because temps live beside // their destinations, and it spares anything newer than the cutoff so a concurrent run's // in-flight transfer survives. -func SweepTemps(root string, olderThan time.Time) (int, int64, error) { +// +// Nothing here fails: a subtree that cannot be read, or a root that does not exist yet on +// a first run, is skipped. One unreadable directory must not stop a 75 GB mirror over a +// housekeeping pass. +func SweepTemps(root string, olderThan time.Time) (int, int64) { var count int var bytes int64 - err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { if err != nil { return nil } @@ -348,7 +365,7 @@ func SweepTemps(root string, olderThan time.Time) (int, int64, error) { } return nil }) - return count, bytes, err + return count, bytes } // RemoveStale deletes a package this tool mirrored and is now replacing with another copy diff --git a/internal/lockfile/lockfile.go b/internal/lockfile/lockfile.go index 792e120..32e86cc 100644 --- a/internal/lockfile/lockfile.go +++ b/internal/lockfile/lockfile.go @@ -122,6 +122,16 @@ func Save(path string, lf Lockfile) error { os.Remove(name) return err } + // CreateTemp makes the file 0600 and the rename carries that over, which would quietly + // strip group and other from a file this design wants committed and hand-edited. + mode := os.FileMode(0o644) + if fi, err := os.Stat(path); err == nil { + mode = fi.Mode().Perm() + } + if err := os.Chmod(name, mode); err != nil { + os.Remove(name) + return err + } return os.Rename(name, path) } diff --git a/internal/lockfile/lockfile_test.go b/internal/lockfile/lockfile_test.go index 722078d..20e6dd9 100644 --- a/internal/lockfile/lockfile_test.go +++ b/internal/lockfile/lockfile_test.go @@ -165,3 +165,34 @@ func TestCorruptLockfileIsAnError(t *testing.T) { t.Error("Load accepted a corrupt lockfile") } } + +// The lockfile is meant to be committed and read by other people and tools. Writing +// through a temp file and renaming would otherwise leave it owner-only. +func TestSaveDoesNotMakeTheLockfileOwnerOnly(t *testing.T) { + path := filepath.Join(t.TempDir(), "unity-sync.lock.json") + lf := lockfile.New() + lf.Assets["a-1"] = lockfile.Entry{AssetID: "1", Name: "A"} + + if err := lockfile.Save(path, lf); err != nil { + t.Fatal(err) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := fi.Mode().Perm(); got != 0o644 { + t.Errorf("a fresh lockfile is mode %04o, want 0644", got) + } + + // An existing file keeps whatever mode the user gave it. + if err := os.Chmod(path, 0o664); err != nil { + t.Fatal(err) + } + if err := lockfile.Save(path, lf); err != nil { + t.Fatal(err) + } + fi, _ = os.Stat(path) + if got := fi.Mode().Perm(); got != 0o664 { + t.Errorf("rewriting reset the mode to %04o, want the 0664 it had", got) + } +} diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index 4aeda4a..714f374 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -104,6 +104,16 @@ func Save(path string, m Manifest) error { os.Remove(name) return err } + // CreateTemp makes the file 0600 and the rename carries that over, which would quietly + // strip group and other from a file this design wants committed and hand-edited. + mode := os.FileMode(0o644) + if fi, err := os.Stat(path); err == nil { + mode = fi.Mode().Perm() + } + if err := os.Chmod(name, mode); err != nil { + os.Remove(name) + return err + } return os.Rename(name, path) } diff --git a/internal/manifest/manifest_test.go b/internal/manifest/manifest_test.go index db7a7ef..1d5fbad 100644 --- a/internal/manifest/manifest_test.go +++ b/internal/manifest/manifest_test.go @@ -168,3 +168,32 @@ func TestSaveDoesNotReorderTheCallersSlice(t *testing.T) { t.Error("Save sorted the caller's slice in place") } } + +// The manifest is committed and hand-edited, so a save must not quietly strip group and +// other from it. +func TestSaveDoesNotMakeTheManifestOwnerOnly(t *testing.T) { + path := filepath.Join(t.TempDir(), manifest.FileName) + m := manifest.Manifest{Assets: []manifest.Entry{{ID: "1", Name: "A", Enabled: true}}} + + if err := manifest.Save(path, m); err != nil { + t.Fatal(err) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := fi.Mode().Perm(); got != 0o644 { + t.Errorf("a fresh manifest is mode %04o, want 0644", got) + } + + if err := os.Chmod(path, 0o664); err != nil { + t.Fatal(err) + } + if err := manifest.Save(path, m); err != nil { + t.Fatal(err) + } + fi, _ = os.Stat(path) + if got := fi.Mode().Perm(); got != 0o664 { + t.Errorf("rewriting reset the mode to %04o, want the 0664 it had", got) + } +} diff --git a/internal/syncer/audit_test.go b/internal/syncer/audit_test.go index bef9b87..f44991d 100644 --- a/internal/syncer/audit_test.go +++ b/internal/syncer/audit_test.go @@ -728,3 +728,44 @@ func TestDroppedAssetsAreReportedInAStableOrder(t *testing.T) { } } } + +// The dry-run test proves the sweep is gated; nothing proved it happens. Deleting the +// SweepTemps call from Run left the whole suite green. +func TestARealRunSweepsAbandonedTemps(t *testing.T) { + root, lockPath := newRun(t) + a := asset("1", "Asset", "v1", 500) + + leaf := filepath.Join(root, "pub-one", "asset-1") + if err := os.MkdirAll(leaf, 0o755); err != nil { + t.Fatal(err) + } + stale := filepath.Join(leaf, ".unity-sync-dl-stale") + if err := os.WriteFile(stale, bytes.Repeat([]byte("x"), 100), 0o644); err != nil { + t.Fatal(err) + } + // Older than the run start, which opts() pins to 2023-11-14. + old := time.Unix(1600000000, 0) + if err := os.Chtimes(stale, old, old); err != nil { + t.Fatal(err) + } + // One the sweep must spare: a concurrent run's transfer, still in flight. + live := filepath.Join(leaf, ".unity-sync-dl-live") + if err := os.WriteFile(live, []byte("in flight"), 0o644); err != nil { + t.Fatal(err) + } + + fs := &fakeStore{owned: []model.Asset{a}, bodies: map[string][]byte{"1": pkg(t, "1", "v1", 500)}} + rep, err := Run(context.Background(), fs, lockfile.New(), lockPath, opts(root, allSelected(a))) + if err != nil { + t.Fatalf("Run: %v", err) + } + if rep.Swept != 1 { + t.Errorf("Swept = %d, want 1", rep.Swept) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Error("the abandoned temp survived the run") + } + if _, err := os.Stat(live); err != nil { + t.Errorf("the run swept a temp newer than its own start: %v", err) + } +} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index 1767e0a..880cfb3 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -153,7 +153,6 @@ type Report struct { Removed []lockfile.Entry Unknown []manifest.Entry Swept int - Freed int64 Lockfile lockfile.Lockfile // Retryable counts failures a later run might fix. A permanently gone asset is @@ -213,11 +212,8 @@ func Run(ctx context.Context, s Store, prior lockfile.Lockfile, lockPath string, // Sweeping before classification matters: an abandoned partial left in the tree is // otherwise a candidate the adopt scan could reach. if !opts.DryRun { - n, freed, err := cache.SweepTemps(opts.LibraryRoot, started) - if err != nil { - return report, err - } - report.Swept, report.Freed = n, freed + n, freed := cache.SweepTemps(opts.LibraryRoot, started) + report.Swept = n if n > 0 { opts.Progress(fmt.Sprintf("reclaimed %d abandoned download(s), %s", n, humanBytes(freed))) } diff --git a/internal/web/web.go b/internal/web/web.go index 6f5d1a0..043b461 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -8,11 +8,11 @@ import ( "context" "crypto/rand" "encoding/hex" - "errors" "fmt" "html/template" "net" "net/http" + "os" "os/exec" "runtime" "sort" @@ -21,14 +21,13 @@ import ( "github.com/curbol/unity-sync/internal/model" ) -// ErrWouldEmptySelection is the 409 a save gets when it would clear every selection at -// once. A stale tab reopened after the library changed, or a mis-click on "none", should -// not silently wipe a curated allowlist. -var ErrWouldEmptySelection = errors.New("refusing a save that would deselect everything") - -// ErrStaleTab is the 409 a POST gets when it does not carry this run's token, which -// means it came from a page some earlier run served. -var ErrStaleTab = errors.New("this page was served by an earlier run; reload and choose again") +// The two refusals, as the page states them. A save that would clear every selection at +// once, and a POST from a page some earlier run served: a stale tab reopened after the +// library changed, or a mis-click on "none", should not silently wipe a curated allowlist. +const ( + msgWouldEmptySelection = "refusing a save that would deselect everything" + msgStaleTab = "this page was served by an earlier run; reload and choose again" +) type row struct { ID string @@ -157,7 +156,7 @@ func (h *Handler) save(w http.ResponseWriter, r *http.Request) { return } if r.PostFormValue("token") != h.token { - http.Error(w, ErrStaleTab.Error(), http.StatusConflict) + http.Error(w, msgStaleTab, http.StatusConflict) return } chosen := Selection{} @@ -165,7 +164,7 @@ func (h *Handler) save(w http.ResponseWriter, r *http.Request) { chosen[id] = true } if len(chosen) == 0 && anyEnabled(h.enabled) { - http.Error(w, ErrWouldEmptySelection.Error(), http.StatusConflict) + http.Error(w, msgWouldEmptySelection, http.StatusConflict) return } fmt.Fprintf(w, "Saved %d selection(s). You can close this tab.", len(chosen)) @@ -184,7 +183,7 @@ func Serve(ctx context.Context, addr string, assets []model.Asset, enabled map[s defer srv.Close() url := "http://" + ln.Addr().String() - fmt.Println("select assets at", url) + fmt.Fprintln(os.Stderr, "select assets at", url) openBrowser(url) select { From cfa355d58f2ce7ffba47d6964984da56b7c3ab4f Mon Sep 17 00:00:00 2001 From: curbol Date: Sat, 22 Aug 2026 20:56:54 -0700 Subject: [PATCH 28/28] Let the installer report the failure everyone will hit first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_012LiuqQ1Kt5Ak2YgAxuzUZV --- .github/workflows/ci.yml | 3 +-- install.sh | 56 ++++++++++++++++++++++++++-------------- 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a4e453..2db62db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,8 +11,7 @@ on: permissions: contents: read -# A push to a PR branch would otherwise run twice, and superseded runs on the same ref -# are pointless once a newer commit exists. +# Superseded runs on the same ref are pointless once a newer commit exists. concurrency: group: ci-${{ github.ref }} cancel-in-progress: true diff --git a/install.sh b/install.sh index d5d77f2..d899311 100755 --- a/install.sh +++ b/install.sh @@ -14,15 +14,30 @@ INSTALL_DIR="${HOME}/.local/bin" log() { printf 'INFO: %s\n' "$1"; } err() { printf 'ERROR: %s\n' "$1" >&2; } -auth_header() { +TMPDIR_SELF="" +cleanup() { [[ -z "$TMPDIR_SELF" ]] || rm -rf "$TMPDIR_SELF"; } +trap cleanup EXIT + +auth_token() { local token="${GITHUB_TOKEN:-${GH_TOKEN:-}}" if [[ -z "$token" ]] && command -v gh >/dev/null 2>&1; then token=$(gh auth token 2>/dev/null || true) fi - [[ -n "$token" ]] && echo "Authorization: token $token" - # Under `set -e` a bare failing test would abort the caller's assignment, so no - # credential has to look like success here; the caller decides what to do with "". - return 0 + printf '%s' "$token" +} + +# fetch GETs a URL, passing the credential through a curl config on stdin rather than as an +# argument, which would put the token in `ps` output for every local user. Callers append +# `|| true` where they want to report the failure themselves: every one of them assigns from +# a pipeline, and `set -e` plus `pipefail` would otherwise abort before the diagnostic runs. +fetch() { + local url="$1"; shift + local token; token=$(auth_token) + if [[ -n "$token" ]]; then + printf 'header = "Authorization: token %s"\n' "$token" | curl -fsSL -K - "$@" "$url" + else + curl -fsSL "$@" "$url" + fi } detect_platform() { @@ -42,10 +57,8 @@ detect_platform() { } latest_version() { - local hdr; hdr=$(auth_header) - local opts=(-fsSL); [[ -n "$hdr" ]] && opts+=(-H "$hdr") - VERSION=$(curl "${opts[@]}" "https://api.github.com/repos/${REPO}/releases/latest" \ - | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + VERSION=$(fetch "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' || true) VERSION=${VERSION#v} [[ -n "$VERSION" ]] || { err "could not resolve latest version (private repo needs gh auth or GITHUB_TOKEN)"; exit 1; } log "latest version: $VERSION" @@ -53,25 +66,27 @@ latest_version() { install() { local file="${BINARY_NAME}-${VERSION}-${PLATFORM}.zip" - local tmp; tmp=$(mktemp -d) - local hdr; hdr=$(auth_header) + TMPDIR_SELF=$(mktemp -d) + local tmp="$TMPDIR_SELF" local url - if [[ -n "$hdr" ]]; then + + if [[ -n "$(auth_token)" ]]; then # Private repo: resolve the asset's API URL, then download with the token. - url=$(curl -fsSL -H "$hdr" "https://api.github.com/repos/${REPO}/releases/tags/v${VERSION}" \ - | grep -F -B3 "\"name\": \"${file}\"" | grep -F '"url"' | sed -E 's/.*"url": "([^"]+)".*/\1/') - [[ -n "$url" ]] || { err "asset ${file} not found in release v${VERSION}"; rm -rf "$tmp"; exit 1; } - curl -fsSL -H "$hdr" -H "Accept: application/octet-stream" -o "${tmp}/${file}" "$url" + url=$(fetch "https://api.github.com/repos/${REPO}/releases/tags/v${VERSION}" \ + | grep -F -B3 "\"name\": \"${file}\"" | grep -F '"url"' | sed -E 's/.*"url": "([^"]+)".*/\1/' || true) + [[ -n "$url" ]] || { err "asset ${file} not found in release v${VERSION}"; exit 1; } + fetch "$url" -H "Accept: application/octet-stream" -o "${tmp}/${file}" \ + || { err "could not download ${file}"; exit 1; } else - curl -fsSL -o "${tmp}/${file}" "https://github.com/${REPO}/releases/download/v${VERSION}/${file}" + curl -fsSL -o "${tmp}/${file}" "https://github.com/${REPO}/releases/download/v${VERSION}/${file}" \ + || { err "could not download ${file}"; exit 1; } fi - command -v unzip >/dev/null 2>&1 || { err "unzip is required"; rm -rf "$tmp"; exit 1; } + command -v unzip >/dev/null 2>&1 || { err "unzip is required"; exit 1; } unzip -q "${tmp}/${file}" -d "$tmp" mkdir -p "$INSTALL_DIR" mv "${tmp}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" chmod +x "${INSTALL_DIR}/${BINARY_NAME}" - rm -rf "$tmp" log "installed to ${INSTALL_DIR}/${BINARY_NAME}" } @@ -86,4 +101,5 @@ detect_platform latest_version install check_path -"${INSTALL_DIR}/${BINARY_NAME}" version || err "installed but 'unity-sync version' failed" +"${INSTALL_DIR}/${BINARY_NAME}" version \ + || { err "installed but 'unity-sync version' failed"; exit 1; }