@shortlink-org/portolan 0.2.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +174 -6
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1213 -0
  5. package/catalog/roundtrip_test.go +210 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +14 -1
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +21 -0
  11. package/go.sum +34 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +28 -15
  35. package/plugin/describe.go +156 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +404 -50
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-argocd/options.schema.json +44 -0
  42. package/plugins/extract-celery/extract.py +0 -2
  43. package/plugins/extract-celery/extract_test.py +1 -1
  44. package/plugins/extract-celery/main.py +1 -0
  45. package/plugins/extract-csharp-ddd/README.md +213 -0
  46. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  47. package/plugins/extract-django/README.md +52 -19
  48. package/plugins/extract-django/choices.py +47 -0
  49. package/plugins/extract-django/domain.py +30 -18
  50. package/plugins/extract-django/extract.py +21 -7
  51. package/plugins/extract-django/extract_test.py +68 -2
  52. package/plugins/extract-django/lifecycle.py +4 -28
  53. package/plugins/extract-django/main.py +1 -0
  54. package/plugins/extract-django/operations.py +1 -1
  55. package/plugins/extract-django/routing_test.py +109 -1
  56. package/plugins/extract-django/rules.py +154 -0
  57. package/plugins/extract-django/rules_test.py +158 -0
  58. package/plugins/extract-django/store.py +1 -1
  59. package/plugins/extract-django/transport.py +101 -55
  60. package/plugins/extract-django/verbs.py +241 -0
  61. package/plugins/extract-go/README.md +47 -0
  62. package/plugins/extract-go-sqs/options.schema.json +21 -0
  63. package/plugins/extract-http-clients/describe.go +20 -0
  64. package/plugins/extract-http-clients/describe_test.go +11 -0
  65. package/plugins/extract-http-clients/extract.go +740 -0
  66. package/plugins/extract-http-clients/extract_test.go +1561 -0
  67. package/plugins/extract-http-clients/main.go +41 -0
  68. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  69. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  70. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  71. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  72. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  73. package/plugins/extract-k8s/options.schema.json +32 -0
  74. package/plugins/extract-laravel/Cargo.lock +962 -0
  75. package/plugins/extract-laravel/Cargo.toml +20 -0
  76. package/plugins/extract-laravel/README.md +200 -0
  77. package/plugins/extract-laravel/options.schema.json +68 -0
  78. package/plugins/extract-laravel/rustfmt.toml +1 -0
  79. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  80. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  81. package/plugins/extract-php-ddd/README.md +141 -0
  82. package/plugins/extract-php-ddd/options.schema.json +50 -0
  83. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  84. package/plugins/extract-python-kafka/extract.py +0 -2
  85. package/plugins/extract-python-kafka/extract_test.py +1 -1
  86. package/plugins/extract-python-kafka/main.py +1 -0
  87. package/plugins/extract-terraform/options.schema.json +26 -0
  88. package/plugins/extract-ts/extract.test.ts +2 -2
  89. package/plugins/extract-ts/extract.ts +4 -5
  90. package/plugins/extract-ts/graphql.test.ts +1 -1
  91. package/plugins/extract-ts/main.ts +1 -0
  92. package/plugins/openapi/ids.go +261 -0
  93. package/plugins/openapi/ids_test.go +98 -0
  94. package/plugins/phpscan/Cargo.lock +953 -0
  95. package/plugins/phpscan/Cargo.toml +19 -0
  96. package/plugins/phpscan/rustfmt.toml +1 -0
  97. package/plugins/portolan-go.wasm +0 -0
  98. package/plugins/pyplugin/catalog.py +24 -1
  99. package/plugins/pyplugin/protocol.py +1 -5
  100. package/plugins/verify-otel/options.schema.json +12 -0
  101. package/portolan.json +132 -4
  102. package/rules/builtin.json +218 -0
  103. package/schema/portolan.schema.json +905 -4
  104. package/scripts/README.md +21 -13
  105. package/scripts/catalog-sources.mjs +6 -0
  106. package/scripts/delivery-presets.mjs +57 -14
  107. package/scripts/diff.mjs +5 -1
  108. package/scripts/django-aggregates.test.mjs +58 -0
  109. package/scripts/gen-likec4.mjs +150 -17
  110. package/scripts/gen-likec4.test.mjs +96 -0
  111. package/scripts/gen.mjs +148 -118
  112. package/scripts/gitops-example.test.mjs +108 -0
  113. package/scripts/go-discovery.test.mjs +30 -0
  114. package/scripts/history.mjs +186 -3
  115. package/scripts/history.test.mjs +1 -1
  116. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  117. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  118. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  119. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  120. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  121. package/scripts/host-plugins/fetch-git.mjs +78 -21
  122. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  123. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  124. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  125. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  126. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  127. package/scripts/json-format.mjs +192 -0
  128. package/scripts/json-format.test.mjs +97 -0
  129. package/scripts/local-api.mjs +287 -12
  130. package/scripts/local-api.test.mjs +150 -5
  131. package/scripts/local-discovery.mjs +94 -9
  132. package/scripts/manifest.mjs +14 -3
  133. package/scripts/manifest.test.mjs +24 -0
  134. package/scripts/output-diff.mjs +94 -0
  135. package/scripts/output-diff.test.mjs +36 -0
  136. package/scripts/package-smoke.mjs +62 -4
  137. package/scripts/plugin-host.mjs +45 -3
  138. package/scripts/plugin-host.test.mjs +9 -0
  139. package/scripts/plugin-wasm-worker.mjs +4 -1
  140. package/scripts/plugins-fresh.mjs +97 -0
  141. package/scripts/plugins-fresh.test.mjs +64 -0
  142. package/scripts/provenance.mjs +72 -0
  143. package/scripts/provenance.test.mjs +149 -0
  144. package/scripts/run-builtin.mjs +62 -7
  145. package/scripts/schema.mjs +167 -19
  146. package/scripts/trace-trials.mjs +176 -0
  147. package/scripts/trace-trials.test.mjs +142 -0
  148. package/scripts/warning-policy.mjs +167 -0
  149. package/scripts/warning-policy.test.mjs +93 -0
  150. package/src/app/Breadcrumbs.test.ts +3 -0
  151. package/src/app/Breadcrumbs.tsx +3 -0
  152. package/src/app/CatalogApp.tsx +2 -0
  153. package/src/app/Sidebar.tsx +3 -3
  154. package/src/app/SidebarFooter.tsx +20 -4
  155. package/src/catalog-docs.test.ts +64 -0
  156. package/src/catalog-docs.ts +35 -0
  157. package/src/catalog-error.test.ts +15 -0
  158. package/src/catalog-index.ts +25 -0
  159. package/src/catalog-model.ts +293 -5
  160. package/src/catalog-validation.ts +113 -2
  161. package/src/catalog.test.ts +40 -1
  162. package/src/chat/Starter.tsx +5 -11
  163. package/src/chat/tools.test.ts +27 -0
  164. package/src/chat/tools.ts +5 -9
  165. package/src/components/CatalogStamp.tsx +10 -8
  166. package/src/components/ChannelRows.test.tsx +76 -1
  167. package/src/components/ChannelRows.tsx +223 -60
  168. package/src/components/DeploymentRows.tsx +131 -0
  169. package/src/components/DocsLinks.test.tsx +27 -0
  170. package/src/components/DocsLinks.tsx +56 -0
  171. package/src/components/FieldTree.tsx +5 -2
  172. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  173. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  174. package/src/components/Integrations.tsx +1 -1
  175. package/src/components/MachineDocs.tsx +6 -5
  176. package/src/components/MethodRows.tsx +9 -2
  177. package/src/components/PluginIcon.tsx +77 -0
  178. package/src/components/ProblemRow.tsx +173 -153
  179. package/src/components/RelationEvidence.test.tsx +14 -0
  180. package/src/components/RelationEvidence.tsx +53 -0
  181. package/src/components/RuleMarks.tsx +22 -0
  182. package/src/components/ShapeRows.tsx +24 -20
  183. package/src/data.ts +46 -7
  184. package/src/enrich.test.ts +459 -4
  185. package/src/enrich.ts +308 -7
  186. package/src/er/ErCanvas.tsx +218 -12
  187. package/src/er/GroupNode.tsx +57 -0
  188. package/src/er/StoreHeader.tsx +1 -0
  189. package/src/er/layout.test.ts +85 -2
  190. package/src/er/layout.ts +140 -5
  191. package/src/er/spec.test.ts +17 -0
  192. package/src/er/spec.ts +23 -10
  193. package/src/flow/Recordings.test.tsx +52 -0
  194. package/src/flow/Recordings.tsx +236 -0
  195. package/src/flow/StepDetail.tsx +59 -0
  196. package/src/flow/TraceTrial.tsx +419 -0
  197. package/src/flow/evidence.test.ts +16 -0
  198. package/src/flow/evidence.ts +34 -0
  199. package/src/flow/examples.test.ts +33 -0
  200. package/src/flow/examples.ts +37 -0
  201. package/src/flow/outline.test.ts +28 -0
  202. package/src/flow/outline.ts +7 -2
  203. package/src/flow/trace-trial-resume.test.ts +42 -0
  204. package/src/flow/trace-trial-resume.ts +74 -0
  205. package/src/graph/elk.ts +78 -0
  206. package/src/index.css +68 -0
  207. package/src/landing/DraggableReveal.tsx +3 -2
  208. package/src/landing/EvidencePipeline.tsx +105 -0
  209. package/src/landing/LandingPage.tsx +17 -70
  210. package/src/landing/ProductTour.tsx +6 -6
  211. package/src/lib/all-problems.ts +27 -17
  212. package/src/lib/catalog-diff.ts +1 -1
  213. package/src/lib/centrality.test.ts +251 -0
  214. package/src/lib/centrality.ts +232 -0
  215. package/src/lib/confluence.test.ts +37 -0
  216. package/src/lib/confluence.ts +41 -0
  217. package/src/lib/context-color.ts +1 -1
  218. package/src/lib/deployment-drift.ts +22 -0
  219. package/src/lib/derive.ts +43 -69
  220. package/src/lib/django-aggregates.d.mts +9 -0
  221. package/src/lib/django-aggregates.mjs +36 -0
  222. package/src/lib/django-aggregates.test.ts +29 -0
  223. package/src/lib/django-aggregates.ts +5 -0
  224. package/src/lib/environments.test.ts +83 -0
  225. package/src/lib/environments.ts +57 -0
  226. package/src/lib/integration-url.test.ts +30 -0
  227. package/src/lib/integration-url.ts +63 -0
  228. package/src/lib/kafka-ui.ts +3 -45
  229. package/src/lib/local-api.ts +116 -4
  230. package/src/lib/notion.ts +13 -0
  231. package/src/lib/plugin-index.json +2765 -0
  232. package/src/lib/plugins.test.ts +68 -0
  233. package/src/lib/plugins.ts +264 -0
  234. package/src/lib/problem-flows.test.ts +61 -0
  235. package/src/lib/problem-flows.ts +78 -0
  236. package/src/lib/problem-rules-cel.d.mts +43 -0
  237. package/src/lib/problem-rules-cel.mjs +407 -0
  238. package/src/lib/problem-rules.test.ts +287 -0
  239. package/src/lib/problem-rules.ts +271 -0
  240. package/src/lib/problem-subjects.ts +737 -0
  241. package/src/lib/rule-entries.ts +39 -0
  242. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  243. package/src/lib/rules-deploy.test.ts +133 -0
  244. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  245. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  246. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  247. package/src/lib/rules.test.ts +51 -0
  248. package/src/lib/rules.ts +86 -0
  249. package/src/lib/setup-info.test.ts +17 -0
  250. package/src/lib/setup-info.ts +58 -0
  251. package/src/lib/shape.test.ts +32 -0
  252. package/src/lib/shape.ts +30 -6
  253. package/src/lib/tech.ts +16 -0
  254. package/src/lib/trace-project.test.ts +34 -0
  255. package/src/lib/trace-project.ts +50 -0
  256. package/src/lib/use-problems.ts +23 -0
  257. package/src/lib/warnings.test.ts +63 -0
  258. package/src/lib/warnings.ts +260 -0
  259. package/src/likec4/ids.test.ts +6 -2
  260. package/src/likec4/ids.ts +43 -0
  261. package/src/main.tsx +23 -0
  262. package/src/map/ContextMapGraph.tsx +76 -32
  263. package/src/merge-deployments.test.ts +127 -0
  264. package/src/merge.test.ts +82 -0
  265. package/src/merge.ts +189 -18
  266. package/src/pages/AggregatePage.tsx +65 -14
  267. package/src/pages/ContextMap.tsx +45 -3
  268. package/src/pages/ContextPage.tsx +8 -5
  269. package/src/pages/EventPage.tsx +15 -5
  270. package/src/pages/FlowDetail.tsx +23 -2
  271. package/src/pages/GraphPage.tsx +40 -3
  272. package/src/pages/Overview.tsx +152 -12
  273. package/src/pages/PluginIndex.tsx +190 -0
  274. package/src/pages/Problems.tsx +396 -128
  275. package/src/pages/ServicePage.tsx +62 -5
  276. package/src/pages/Settings.tsx +217 -43
  277. package/src/pages/settings/AboutSettings.tsx +8 -1
  278. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  279. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  280. package/src/pages/settings/RecordingSettings.tsx +138 -0
  281. package/src/pages/settings/RulesSettings.tsx +825 -0
  282. package/src/routes.test.ts +9 -0
  283. package/src/routes.ts +22 -1
  284. package/src/selection/DetailPanel.tsx +15 -0
  285. package/src/virtual-provenance.d.ts +11 -0
  286. package/vite.config.ts +5 -0
  287. package/scripts/vendor-lock.mjs +0 -58
  288. package/scripts/vendor-lock.test.mjs +0 -69
  289. package/src/lib/data-problems.ts +0 -314
  290. package/src/lib/proto-problems.ts +0 -237
  291. package/src/lib/wire-problems.ts +0 -342
@@ -0,0 +1,274 @@
1
+ // The fetcher, against a recorded Argo CD. NEVER against a live one: the
2
+ // whole point of this plugin's design is that the build does not depend on
3
+ // a control plane being up, or on anyone holding a token.
4
+ import { createHash } from "node:crypto";
5
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { createServer } from "node:http";
7
+ import { tmpdir } from "node:os";
8
+ import { dirname, join } from "node:path";
9
+ import { afterEach, describe, expect, it } from "vitest";
10
+
11
+ import { runPlugin } from "../plugin-host.mjs";
12
+ import { FRAGMENT_NAME, LIST_PATH, LOCK_NAME, OFFLINE_ENV, TOKEN_ENV, baseUrl, deploymentOf, run, toolOf } from "./fetch-argocd.mjs";
13
+
14
+ const REVISION = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678";
15
+
16
+ /** Two applications the way the API lists them, with everything a page must not keep. */
17
+ const CART = {
18
+ metadata: { name: "shop-cart", namespace: "argocd", labels: { env: "prod", team: "shop", "app.kubernetes.io/part-of": "shop", "app.kubernetes.io/name": "cart" } },
19
+ spec: {
20
+ project: "shop",
21
+ source: { repoURL: "https://github.com/shortlink-org/portolan.git", path: "./examples/shop/cart/deploy/k8s/", targetRevision: "main", kustomize: { namePrefix: "prod-" } },
22
+ destination: { server: "https://kubernetes.default.svc", namespace: "shop" },
23
+ },
24
+ status: {
25
+ sync: { status: "Synced", revision: REVISION },
26
+ health: { status: "Healthy" },
27
+ summary: { images: ["ghcr.io/shortlink-org/cart:1.4.2", "ghcr.io/shortlink-org/cart:1.4.2", "redis:7"] },
28
+ sourceType: "Kustomize",
29
+ operationState: { finishedAt: "2026-09-11T10:00:00Z" },
30
+ },
31
+ };
32
+ const PRICING = {
33
+ metadata: { name: "shop-pricing", namespace: "argocd" },
34
+ spec: {
35
+ project: "shop",
36
+ sources: [
37
+ { repoURL: "https://charts.example.com", chart: "service", targetRevision: "2.1.0", helm: { valueFiles: ["$values/pricing.yaml"], parameters: [{ name: "db.password", value: "hunter2" }] } },
38
+ { repoURL: "git@github.com:shortlink-org/portolan.git", path: "examples/shop/pricing/deploy", targetRevision: "HEAD", ref: "values" },
39
+ ],
40
+ destination: { name: "eu-west-1", namespace: "shop" },
41
+ },
42
+ status: { sync: { revisions: ["2.1.0", REVISION] }, summary: { images: ["ghcr.io/shortlink-org/pricing:0.9.0"] } },
43
+ };
44
+
45
+ const cleanups = [];
46
+ afterEach(async () => {
47
+ for (const cleanup of cleanups.splice(0)) await cleanup();
48
+ });
49
+
50
+ /** Serves the one call the plugin makes, from recorded shapes. */
51
+ async function argocd(handle = null) {
52
+ const seen = [];
53
+ const urls = [];
54
+ const server = createServer((request, response) => {
55
+ seen.push(request.headers.authorization ?? "");
56
+ urls.push(request.url);
57
+ if (handle) return handle(request, response);
58
+ response.setHeader("Content-Type", "application/json");
59
+ if (request.url.startsWith(LIST_PATH)) {
60
+ response.end(JSON.stringify({ metadata: { resourceVersion: "12345" }, items: [PRICING, CART] }));
61
+ } else {
62
+ response.statusCode = 404;
63
+ response.end(JSON.stringify({ error: "not found", code: 5, message: "no such route" }));
64
+ }
65
+ });
66
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
67
+ cleanups.push(() => new Promise((resolve) => server.close(resolve)));
68
+ return { url: `http://127.0.0.1:${server.address().port}`, seen, urls };
69
+ }
70
+
71
+ function cache() {
72
+ const dir = mkdtempSync(join(tmpdir(), "portolan-fetch-argocd-"));
73
+ cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
74
+ return dir;
75
+ }
76
+
77
+ // A test must never inherit a real credential or a real offline setting.
78
+ const isolated = { [TOKEN_ENV]: "", [OFFLINE_ENV]: "", CI: "" };
79
+ const options = (server, cacheDir, extra = {}) => ({ server, cache: cacheDir, ...extra });
80
+ const fetch = (opts, env = {}) => run({ options: opts }, { env: { ...isolated, ...env } });
81
+ const names = (response) => response.files.map((file) => file.name).sort();
82
+ const contentsOf = (response, name) => response.files.find((file) => file.name === name)?.contents ?? "";
83
+ const write = (dir, response) => {
84
+ for (const file of response.files) {
85
+ mkdirSync(dirname(join(dir, file.name)), { recursive: true });
86
+ writeFileSync(join(dir, file.name), file.contents);
87
+ }
88
+ };
89
+
90
+ describe("fetch-argocd", () => {
91
+ it("writes the snapshot and a lock beside it, sorted, keeping what a deploy changes and nothing else", async () => {
92
+ const server = await argocd();
93
+ const response = await fetch(options(server.url, cache(), { projects: ["shop"] }));
94
+ expect(names(response)).toEqual([FRAGMENT_NAME, LOCK_NAME]);
95
+ expect(server.urls).toEqual([`${LIST_PATH}?projects=shop`]);
96
+
97
+ const catalog = JSON.parse(contentsOf(response, FRAGMENT_NAME));
98
+ expect(catalog).toMatchObject({ contexts: [], defs: {}, flows: [], adrs: [] });
99
+ expect(catalog.deployments.map((d) => d.id)).toEqual(["argocd/shop-cart", "argocd/shop-pricing"]);
100
+ expect(catalog.deployments[0]).toEqual({
101
+ id: "argocd/shop-cart",
102
+ name: "shop-cart",
103
+ project: "shop",
104
+ environment: "prod",
105
+ cluster: "in-cluster",
106
+ namespace: "shop",
107
+ repo: "github.com/shortlink-org/portolan",
108
+ path: "examples/shop/cart/deploy/k8s",
109
+ targetRevision: "main",
110
+ revision: REVISION,
111
+ tool: "kustomize",
112
+ url: `${server.url}/applications/argocd/shop-cart`,
113
+ basis: "api",
114
+ service: "shop.cart",
115
+ images: ["ghcr.io/shortlink-org/cart:1.4.2", "redis:7"],
116
+ });
117
+ // No labels, no service: the catalog places it by repository and path.
118
+ expect(catalog.deployments[1].service).toBeUndefined();
119
+ // A multi-source application follows the source that carries the
120
+ // manifests, and its cluster names the environment when no label does.
121
+ expect(catalog.deployments[1]).toMatchObject({
122
+ environment: "eu-west-1",
123
+ cluster: "eu-west-1",
124
+ repo: "github.com/shortlink-org/portolan",
125
+ path: "examples/shop/pricing/deploy",
126
+ targetRevision: "HEAD",
127
+ revision: REVISION,
128
+ tool: "helm",
129
+ });
130
+ expect(catalog.deployments[1].chart).toBeUndefined();
131
+ // Nothing that moves without a commit, and no value, reaches the tree.
132
+ const text = contentsOf(response, FRAGMENT_NAME);
133
+ for (const leaked of ["Synced", "Healthy", "finishedAt", "hunter2", "namePrefix", "resourceVersion"]) expect(text).not.toContain(leaked);
134
+
135
+ const lock = JSON.parse(contentsOf(response, LOCK_NAME));
136
+ expect(lock).toEqual({
137
+ server: server.url,
138
+ applications: ["argocd/shop-cart", "argocd/shop-pricing"],
139
+ sha256: createHash("sha256").update(text).digest("hex"),
140
+ });
141
+ expect(response.warnings).toEqual([]);
142
+ });
143
+
144
+ it("asks with the projects, the selector and the token, and lets none of them reach the output", async () => {
145
+ const server = await argocd();
146
+ const anonymous = await fetch(options(server.url, cache()));
147
+ const authorised = await fetch(options(server.url, cache(), { projects: ["shop", "platform"], selector: "team=shop" }), { [TOKEN_ENV]: "a-real-looking-secret" });
148
+ expect(server.urls).toEqual([LIST_PATH, `${LIST_PATH}?projects=shop&projects=platform&selector=team%3Dshop`]);
149
+ expect(server.seen).toEqual(["", "Bearer a-real-looking-secret"]);
150
+ for (const file of anonymous.files) {
151
+ expect(contentsOf(authorised, file.name)).toBe(file.contents);
152
+ expect(contentsOf(authorised, file.name)).not.toContain("a-real-looking-secret");
153
+ }
154
+ });
155
+
156
+ it("replays the committed snapshot offline, byte for byte, and in CI", async () => {
157
+ const server = await argocd();
158
+ const cacheDir = cache();
159
+ const online = await fetch(options(server.url, cacheDir));
160
+ write(cacheDir, online);
161
+
162
+ const replayed = await fetch(options(server.url, cacheDir), { [OFFLINE_ENV]: "1" });
163
+ expect(names(replayed)).toEqual(names(online));
164
+ for (const file of online.files) expect(contentsOf(replayed, file.name)).toBe(file.contents);
165
+ expect(replayed.warnings).toHaveLength(1);
166
+ expect(replayed.warnings[0].message).toContain("not fetched (offline)");
167
+
168
+ const inCi = await fetch(options(server.url, cacheDir), { CI: "true" });
169
+ expect(names(inCi)).toEqual(names(online));
170
+ expect(server.urls).toHaveLength(1);
171
+ });
172
+
173
+ it("names a snapshot edited by hand", async () => {
174
+ const server = await argocd();
175
+ const cacheDir = cache();
176
+ write(cacheDir, await fetch(options(server.url, cacheDir)));
177
+ writeFileSync(join(cacheDir, FRAGMENT_NAME), '{ "deployments": [] }\n');
178
+ await expect(fetch(options(server.url, cacheDir), { [OFFLINE_ENV]: "1" })).rejects.toThrow(/edited by hand/);
179
+ });
180
+
181
+ it("fails rather than emitting nothing when there is no snapshot to fall back to", async () => {
182
+ await expect(fetch(options("http://127.0.0.1:1", cache()), { [OFFLINE_ENV]: "1" })).rejects.toThrow(/no argocd\.lock\.json/);
183
+
184
+ const broken = await argocd((request, response) => {
185
+ response.statusCode = 500;
186
+ response.end(JSON.stringify({ error: "the control plane is having a day", code: 13 }));
187
+ });
188
+ await expect(fetch(options(broken.url, cache()))).rejects.toThrow(/the control plane is having a day.*no usable snapshot/);
189
+
190
+ const empty = await argocd((request, response) => {
191
+ response.end(JSON.stringify({ items: null }));
192
+ });
193
+ await expect(fetch(options(empty.url, cache()))).rejects.toThrow(/lists no applications/);
194
+ });
195
+
196
+ it("falls back to the committed snapshot when the server fails", async () => {
197
+ const server = await argocd();
198
+ const cacheDir = cache();
199
+ const online = await fetch(options(server.url, cacheDir));
200
+ write(cacheDir, online);
201
+
202
+ const broken = await argocd((request, response) => {
203
+ response.statusCode = 502;
204
+ response.end();
205
+ });
206
+ const fallback = await fetch(options(broken.url, cacheDir));
207
+ expect(names(fallback)).toEqual(names(online));
208
+ expect(fallback.warnings[0].message).toMatch(/not fetched \(.*http 502\)/);
209
+ });
210
+
211
+ it("refuses a missing server and a missing cache", async () => {
212
+ await expect(fetch(options("", cache()))).rejects.toThrow(/server/);
213
+ await expect(fetch(options("argocd.example.com", ""))).rejects.toThrow(/cache/);
214
+ });
215
+
216
+ it("runs through the host like any plugin", async () => {
217
+ const server = await argocd();
218
+ const cacheDir = cache();
219
+ write(cacheDir, await fetch(options(server.url, cacheDir)));
220
+ const saved = { CI: process.env.CI, [OFFLINE_ENV]: process.env[OFFLINE_ENV] };
221
+ process.env.CI = "";
222
+ process.env[OFFLINE_ENV] = "1";
223
+ try {
224
+ const result = await runPlugin({ name: "argocd", host: "fetch-argocd" }, { portolanVersion: "0.1.0", options: options(server.url, cacheDir) });
225
+ expect(result.files).toHaveLength(2);
226
+ expect(result.warnings).toEqual([`${server.url}: not fetched (offline); the snapshot committed in this repository is used unchanged`]);
227
+ const described = await runPlugin({ name: "argocd", host: "fetch-argocd" }, { portolanVersion: "0.1.0", kind: "describe" });
228
+ expect(described.describe).toMatchObject({ name: "fetch-argocd", phases: ["extract"] });
229
+ } finally {
230
+ for (const [key, value] of Object.entries(saved)) {
231
+ if (value === undefined) delete process.env[key];
232
+ else process.env[key] = value;
233
+ }
234
+ }
235
+ });
236
+ });
237
+
238
+ describe("one application", () => {
239
+ it("spells the server as a URL, https unless told otherwise", () => {
240
+ expect(baseUrl("argocd.example.com")).toBe("https://argocd.example.com");
241
+ expect(baseUrl("https://argocd.example.com/")).toBe("https://argocd.example.com");
242
+ expect(baseUrl("http://127.0.0.1:8080")).toBe("http://127.0.0.1:8080");
243
+ });
244
+
245
+ it("reads the environment off the label the manifest names, else off the cluster", () => {
246
+ const base = "https://argocd.example.com";
247
+ expect(deploymentOf(CART, base, "env").environment).toBe("prod");
248
+ expect(deploymentOf(CART, base, "tier").environment).toBe("in-cluster");
249
+ expect(deploymentOf({ ...CART, spec: { ...CART.spec, destination: { server: "https://1.2.3.4", namespace: "x" } } }, base, "tier").environment).toBe("https://1.2.3.4");
250
+ });
251
+
252
+ it("infers the tool from the source until the status has said", () => {
253
+ expect(toolOf("Kustomize", {})).toBe("kustomize");
254
+ expect(toolOf("", { chart: "x" })).toBe("helm");
255
+ expect(toolOf("", { helm: {} })).toBe("helm");
256
+ expect(toolOf("", { kustomize: {} })).toBe("kustomize");
257
+ expect(toolOf("", { plugin: {} })).toBe("plugin");
258
+ expect(toolOf("", { directory: {} })).toBe("directory");
259
+ expect(toolOf("", {})).toBe("");
260
+ });
261
+
262
+ it("places by the labels the manifest names, and by nothing when only one of the two is there", () => {
263
+ const base = "https://argocd.example.com";
264
+ const labelled = (labels) => ({ ...CART, metadata: { ...CART.metadata, labels } });
265
+ expect(deploymentOf(labelled({ team: "shop", component: "cart" }), base, "env", { context: "team", service: "component" }).service).toBe("shop.cart");
266
+ expect(deploymentOf(labelled({ "app.kubernetes.io/name": "cart" }), base).service).toBeUndefined();
267
+ expect(deploymentOf(labelled({}), base).service).toBeUndefined();
268
+ });
269
+
270
+ it("refuses an application without a name, and defaults the namespace and project", () => {
271
+ expect(() => deploymentOf({ metadata: {} }, "https://a")).toThrow(/no name/);
272
+ expect(deploymentOf({ metadata: { name: "x" } }, "https://a")).toMatchObject({ id: "argocd/x", project: "default", tool: "", url: "https://a/applications/argocd/x" });
273
+ });
274
+ });
@@ -46,6 +46,7 @@ export function describe() {
46
46
  return {
47
47
  name: "fetch-bsr",
48
48
  summary: "Fetches pinned modules from a Buf Schema Registry into the tree, with a lock beside each, so the proto extractor can read a published contract.",
49
+ category: "sources",
49
50
  phases: ["extract"],
50
51
  options: optionsSchema,
51
52
  };
@@ -34,6 +34,7 @@ export function describe() {
34
34
  return {
35
35
  name: "fetch-csr",
36
36
  summary: "Fetches pinned subject versions from a Confluent Schema Registry into the tree, with a lock beside each, so a schema published to a registry can be read without talking to it.",
37
+ category: "sources",
37
38
  phases: ["extract"],
38
39
  options: optionsSchema,
39
40
  };
@@ -44,10 +44,23 @@ export const LOCK_NAME = "git.lock.json";
44
44
  export const PIN_NAME = "git.repo.json";
45
45
  export const OFFLINE_ENV = "PORTOLAN_OFFLINE";
46
46
 
47
+ const SNIFF_BYTES = 8 * 1024;
48
+ const BINARY_SUFFIXES = [
49
+ ".tar.gz", ".tar.bz2", ".tar.xz",
50
+ ".7z", ".a", ".apk", ".avif", ".bin", ".bmp", ".bz2", ".class",
51
+ ".deb", ".dll", ".dmg", ".doc", ".docx", ".eot", ".exe", ".gif",
52
+ ".gz", ".ico", ".iso", ".jar", ".jpeg", ".jpg", ".lib", ".mmdb",
53
+ ".mov", ".mp3", ".mp4", ".o", ".otf", ".pdf", ".png", ".pyc",
54
+ ".rar", ".so", ".tar", ".tgz", ".tif", ".tiff", ".ttf", ".war",
55
+ ".wasm", ".webm", ".webp", ".woff", ".woff2", ".xls", ".xlsx",
56
+ ".xz", ".zip",
57
+ ];
58
+
47
59
  export function describe() {
48
60
  return {
49
61
  name: "fetch-git",
50
62
  summary: "Fetches pinned directories of another git repository into the tree, with a lock, so extractors can read a service that lives elsewhere.",
63
+ category: "sources",
51
64
  phases: ["extract"],
52
65
  options: optionsSchema,
53
66
  };
@@ -105,7 +118,7 @@ export function run(request, { env = process.env } = {}) {
105
118
  }
106
119
  continue;
107
120
  }
108
- emitFetched(out, dir, want, fetched.commit, fetched.files, generatedAt);
121
+ emitFetched(out, dir, want, fetched.commit, fetched.files, fetched.skipped, generatedAt);
109
122
  }
110
123
 
111
124
  return out.response();
@@ -172,9 +185,11 @@ export function pin(repo, commit, generatedAt = "") {
172
185
  /** The lock, written the way every generated file here is written. */
173
186
  export function encodeLock(entry) {
174
187
  const files = [...entry.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
188
+ const skipped = [...(entry.skipped ?? [])].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
175
189
  const record = { repo: entry.repo, commit: entry.commit };
176
190
  if (entry.paths?.length) record.paths = [...entry.paths].sort();
177
191
  record.files = files.map((file) => ({ path: file.path, sha256: file.sha256, size: file.size }));
192
+ if (skipped.length) record.skipped = skipped.map((file) => ({ path: file.path, size: file.size, reason: file.reason }));
178
193
  return `${JSON.stringify({ repos: [record] }, null, 2)}\n`;
179
194
  }
180
195
 
@@ -191,13 +206,15 @@ function live(url, want, out, env) {
191
206
  commit = resolve(url, want.ref, env);
192
207
  out.warn(want.repo, `is not pinned; "${want.ref || "HEAD"}" resolved to ${commit}. Pin it in portolan.json or every run is a lottery.`);
193
208
  }
194
- const files = download(url, commit, want.paths ?? [], env);
195
- if (files.size === 0) throw new Error(`${want.repo} at ${commit} holds no files under ${(want.paths ?? []).join(", ")}`);
196
- return { commit, files };
209
+ const fetched = download(url, commit, want.paths ?? [], env);
210
+ if (fetched.files.size === 0 && fetched.skipped.length === 0) {
211
+ throw new Error(`${want.repo} at ${commit} holds no files under ${(want.paths ?? []).join(", ")}`);
212
+ }
213
+ return { commit, ...fetched };
197
214
  }
198
215
 
199
- function emitFetched(out, dir, want, commit, files, generatedAt) {
200
- const entry = { repo: want.repo, commit, paths: want.paths ?? [], files: [] };
216
+ function emitFetched(out, dir, want, commit, files, skipped, generatedAt) {
217
+ const entry = { repo: want.repo, commit, paths: want.paths ?? [], files: [], skipped };
201
218
  for (const path of [...files.keys()].sort()) {
202
219
  const contents = files.get(path);
203
220
  out.file(posix.join(dir, path), contents);
@@ -205,6 +222,7 @@ function emitFetched(out, dir, want, commit, files, generatedAt) {
205
222
  }
206
223
  out.file(posix.join(dir, LOCK_NAME), encodeLock(entry));
207
224
  out.file(posix.join(dir, PIN_NAME), pin(want.repo, commit, generatedAt));
225
+ warnSkipped(out, want.repo, skipped);
208
226
  }
209
227
 
210
228
  /**
@@ -225,6 +243,13 @@ function emitCached(out, dir, at, want, why, generatedAt = "") {
225
243
  // what is actually on disk rather than what was asked for.
226
244
  out.file(posix.join(dir, PIN_NAME), pin(want.repo, held.lock.commit, generatedAt));
227
245
  out.warn(want.repo, `not fetched (${why}); the copy committed in this repository is used unchanged`);
246
+ warnSkipped(out, want.repo, held.lock.skipped);
247
+ }
248
+
249
+ function warnSkipped(out, repo, skipped = []) {
250
+ if (skipped.length === 0) return;
251
+ const bytes = skipped.reduce((total, file) => total + file.size, 0);
252
+ out.warn(repo, `skipped ${skipped.length} binary ${skipped.length === 1 ? "file" : "files"} (${bytes} bytes); metadata is recorded in ${LOCK_NAME}`);
228
253
  }
229
254
 
230
255
  /**
@@ -252,7 +277,14 @@ function replay(dir) {
252
277
  if (repos.length !== 1) throw new Error(`${lockPath} names ${repos.length} repositories; expected exactly one`);
253
278
  const entry = repos[0];
254
279
  const files = new Map();
280
+ const kept = [];
281
+ const skipped = [...(Array.isArray(entry.skipped) ? entry.skipped : [])];
255
282
  for (const want of entry.files ?? []) {
283
+ const known = knownBinaryReason(want.path);
284
+ if (known) {
285
+ skipped.push({ path: want.path, size: want.size, reason: known });
286
+ continue;
287
+ }
256
288
  let contents;
257
289
  try {
258
290
  contents = readFileSync(join(dir, ...String(want.path).split("/")));
@@ -261,9 +293,15 @@ function replay(dir) {
261
293
  throw cause;
262
294
  }
263
295
  if (digestOf(contents) !== want.sha256) throw new Error(`${want.path} does not match its digest; the vendored copy was edited by hand`);
296
+ const sniffed = sniffedBinaryReason(contents);
297
+ if (sniffed) {
298
+ skipped.push({ path: want.path, size: contents.length, reason: sniffed });
299
+ continue;
300
+ }
264
301
  files.set(want.path, contents);
302
+ kept.push(want);
265
303
  }
266
- return { lock: { repo: entry.repo, commit: entry.commit, paths: entry.paths ?? [], files: entry.files ?? [] }, files };
304
+ return { lock: { repo: entry.repo, commit: entry.commit, paths: entry.paths ?? [], files: kept, skipped }, files };
267
305
  }
268
306
 
269
307
  // --- git --------------------------------------------------------------------
@@ -305,7 +343,7 @@ function resolve(url, ref, env) {
305
343
  * its branches where it does not, which is the case for a plain file://
306
344
  * remote; either way what is read is verified to be that commit.
307
345
  *
308
- * @returns {Map<string, Buffer>} path contents
346
+ * @returns {{files: Map<string, Buffer>, skipped: {path: string, size: number, reason: string}[]}}
309
347
  */
310
348
  function download(url, commit, paths, env) {
311
349
  const tmp = mkdtempSync(join(tmpdir(), "portolan-fetch-git-"));
@@ -326,19 +364,24 @@ function download(url, commit, paths, env) {
326
364
  throw new Error(`${commit} is not a commit ${url} has, or not one reachable from a branch`);
327
365
  }
328
366
 
329
- const listing = git(tmp, ["ls-tree", "-r", "-z", "--format=%(objectname) %(objecttype) %(path)", commit, "--", ...paths], env);
367
+ const listing = git(tmp, ["ls-tree", "-r", "-z", "--format=%(objectname) %(objecttype) %(objectsize) %(path)", commit, "--", ...paths], env);
330
368
  const blobs = [];
369
+ const skipped = [];
331
370
  for (const line of listing.split("\0")) {
332
371
  if (!line) continue;
333
- const [sha, type, ...rest] = line.split(" ");
372
+ const [sha, type, rawSize, ...rest] = line.split(" ");
334
373
  if (type !== "blob") continue;
335
- blobs.push({ sha, path: posix.normalize(rest.join(" ")) });
374
+ const path = posix.normalize(rest.join(" "));
375
+ const size = Number(rawSize);
376
+ const reason = knownBinaryReason(path);
377
+ if (reason) skipped.push({ path, size, reason });
378
+ else blobs.push({ sha, path, size });
336
379
  }
337
380
  const files = new Map();
338
- if (blobs.length === 0) return files;
381
+ if (blobs.length === 0) return { files, skipped };
339
382
 
340
- // One process for every blob: `cat-file --batch` answers each sha with a
341
- // header line and the bytes.
383
+ // One `cat-file --batch` process answers every remaining sha with a header
384
+ // line and the bytes. Known binaries never enter this batch.
342
385
  const batch = execFileSync("git", ["cat-file", "--batch"], {
343
386
  cwd: tmp,
344
387
  env: { ...env, GIT_TERMINAL_PROMPT: "0" },
@@ -352,15 +395,30 @@ function download(url, commit, paths, env) {
352
395
  if (header[1] === "missing") throw new Error(`${blob.path} is missing from ${commit}`);
353
396
  const size = Number(header[2]);
354
397
  const start = newline + 1;
355
- files.set(blob.path, Buffer.from(batch.subarray(start, start + size)));
398
+ const contents = batch.subarray(start, start + size);
399
+ const reason = sniffedBinaryReason(contents);
400
+ if (reason) skipped.push({ path: blob.path, size, reason });
401
+ else files.set(blob.path, Buffer.from(contents));
356
402
  offset = start + size + 1;
357
403
  }
358
- return files;
404
+ return { files, skipped };
359
405
  } finally {
360
406
  rmSync(tmp, { recursive: true, force: true });
361
407
  }
362
408
  }
363
409
 
410
+ function knownBinaryReason(path) {
411
+ const lower = String(path).toLowerCase();
412
+ const suffix = BINARY_SUFFIXES.find((candidate) => lower.endsWith(candidate));
413
+ return suffix ? `known binary extension ${suffix}` : "";
414
+ }
415
+
416
+ function sniffedBinaryReason(contents) {
417
+ if (contents.subarray(0, SNIFF_BYTES).includes(0)) return `NUL byte in first ${SNIFF_BYTES} bytes`;
418
+ const text = contents.toString("utf8");
419
+ return Buffer.from(text, "utf8").equals(contents) ? "" : "invalid UTF-8";
420
+ }
421
+
364
422
  /** Accumulates a response the way plugin.Builder does. */
365
423
  class Builder {
366
424
  files = [];
@@ -372,11 +430,10 @@ class Builder {
372
430
  return;
373
431
  }
374
432
  const text = contents.toString("utf8");
375
- this.files.push(
376
- Buffer.from(text, "utf8").equals(contents)
377
- ? { name, contents: text }
378
- : { name, contents: contents.toString("base64"), encoding: "base64" },
379
- );
433
+ if (!Buffer.from(text, "utf8").equals(contents) || contents.subarray(0, SNIFF_BYTES).includes(0)) {
434
+ throw new Error(`${name} is binary and cannot be emitted by fetch-git`);
435
+ }
436
+ this.files.push({ name, contents: text });
380
437
  }
381
438
 
382
439
  warn(ref, message) {
@@ -1,11 +1,12 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
4
5
  import { basename, dirname, join } from "node:path";
5
6
  import { afterEach, describe, expect, it } from "vitest";
6
7
 
7
8
  import { runPlugin } from "../plugin-host.mjs";
8
- import { OFFLINE_ENV, encodeLock, pin, run, splitRepo, webRepo } from "./fetch-git.mjs";
9
+ import { LOCK_NAME, OFFLINE_ENV, encodeLock, pin, run, splitRepo, webRepo } from "./fetch-git.mjs";
9
10
 
10
11
  const created = [];
11
12
  afterEach(() => {
@@ -105,18 +106,71 @@ describe("fetch-git", () => {
105
106
  expect(inCi.warnings).toHaveLength(1);
106
107
  });
107
108
 
108
- it("preserves binary blobs online and from the offline cache", () => {
109
+ it("skips binary blobs online and from the offline cache", () => {
109
110
  const remote = repository();
110
111
  const cacheDir = cache();
111
112
  const first = fetch(options(remote, cacheDir, ["docs/example.png"]), online);
112
113
  const name = `${remote.copyDir}/docs/example.png`;
113
- const image = first.files.find((file) => file.name === name);
114
- expect(image?.encoding).toBe("base64");
114
+ expect(first.files.find((file) => file.name === name)).toBeUndefined();
115
+ expect(first.files.every((file) => file.encoding === undefined)).toBe(true);
116
+ const lockName = `${remote.copyDir}/${LOCK_NAME}`;
117
+ expect(JSON.parse(contentsOf(first, lockName)).repos[0].skipped).toEqual([
118
+ { path: "docs/example.png", size: 10, reason: "known binary extension .png" },
119
+ ]);
120
+ expect(first.warnings[0].message).toContain("skipped 1 binary file (10 bytes)");
115
121
  write(cacheDir, first);
116
- expect(readFileSync(join(cacheDir, name))).toEqual(readFileSync(join(remote.dir, "docs/example.png")));
122
+ expect(() => readFileSync(join(cacheDir, name))).toThrow();
117
123
 
118
124
  const replayed = fetch(options(remote, cacheDir, ["docs/example.png"]), offline);
119
- expect(replayed.files.find((file) => file.name === name)).toEqual(image);
125
+ expect(replayed.files.find((file) => file.name === name)).toBeUndefined();
126
+ expect(JSON.parse(contentsOf(replayed, lockName)).repos[0].skipped).toEqual([
127
+ { path: "docs/example.png", size: 10, reason: "known binary extension .png" },
128
+ ]);
129
+ });
130
+
131
+ it("does not read a large known binary into the plugin response and sniffs unknown binary files", () => {
132
+ const remote = repository();
133
+ const mmdb = "tests/integration/resources/GeoLite2-Country.mmdb";
134
+ const unknown = "tests/integration/resources/blob.data";
135
+ mkdirSync(dirname(join(remote.dir, mmdb)), { recursive: true });
136
+ writeFileSync(join(remote.dir, mmdb), Buffer.alloc(10 * 1024 * 1024, 0xab));
137
+ writeFileSync(join(remote.dir, unknown), Buffer.from([1, 0, 2]));
138
+ remote.git(["add", "."]);
139
+ remote.git(["commit", "--quiet", "-m", "binary fixtures"]);
140
+ remote.commit = remote.git(["rev-parse", "HEAD"]);
141
+
142
+ const response = fetch(options(remote, cache(), [mmdb, unknown]), online);
143
+ expect(response.files.every((file) => file.encoding === undefined)).toBe(true);
144
+ expect(JSON.stringify(response).length).toBeLessThan(20_000);
145
+ const lock = JSON.parse(contentsOf(response, `${remote.copyDir}/${LOCK_NAME}`)).repos[0];
146
+ expect(lock.files).toEqual([]);
147
+ expect(lock.skipped).toEqual([
148
+ { path: mmdb, size: 10 * 1024 * 1024, reason: "known binary extension .mmdb" },
149
+ { path: unknown, size: 3, reason: "NUL byte in first 8192 bytes" },
150
+ ]);
151
+ });
152
+
153
+ it("drops binary files from a cache written by an older Portolan", () => {
154
+ const remote = repository();
155
+ const cacheDir = cache();
156
+ const at = join(cacheDir, remote.copyDir);
157
+ const path = "docs/example.png";
158
+ const contents = readFileSync(join(remote.dir, path));
159
+ mkdirSync(dirname(join(at, path)), { recursive: true });
160
+ writeFileSync(join(at, path), contents);
161
+ writeFileSync(join(at, LOCK_NAME), encodeLock({
162
+ repo: remote.url,
163
+ commit: remote.commit,
164
+ paths: [path],
165
+ files: [{ path, size: contents.length, sha256: createHash("sha256").update(contents).digest("hex") }],
166
+ }));
167
+
168
+ const replayed = fetch(options(remote, cacheDir, [path]), offline);
169
+ expect(replayed.files.some((file) => file.name.endsWith(path))).toBe(false);
170
+ expect(JSON.parse(contentsOf(replayed, `${remote.copyDir}/${LOCK_NAME}`)).repos[0]).toMatchObject({
171
+ files: [],
172
+ skipped: [{ path, size: contents.length, reason: "known binary extension .png" }],
173
+ });
120
174
  });
121
175
 
122
176
  it("names the edited file when a vendored copy no longer matches its lock", () => {
@@ -223,8 +277,8 @@ describe("names", () => {
223
277
  repos: [{ repo: "github.com/acme/shop", commit: "c1d2e3f" }],
224
278
  });
225
279
 
226
- const lock = encodeLock({ repo: "github.com/acme/shop", commit: "abc", paths: ["services/oms", "proto"], files: [{ path: "b", sha256: "2", size: 1 }, { path: "a", sha256: "1", size: 1 }] });
227
- expect(JSON.parse(lock)).toEqual({ repos: [{ repo: "github.com/acme/shop", commit: "abc", paths: ["proto", "services/oms"], files: [{ path: "a", sha256: "1", size: 1 }, { path: "b", sha256: "2", size: 1 }] }] });
280
+ const lock = encodeLock({ repo: "github.com/acme/shop", commit: "abc", paths: ["services/oms", "proto"], files: [{ path: "b", sha256: "2", size: 1 }, { path: "a", sha256: "1", size: 1 }], skipped: [{ path: "z.png", size: 2, reason: "known binary extension .png" }] });
281
+ expect(JSON.parse(lock)).toEqual({ repos: [{ repo: "github.com/acme/shop", commit: "abc", paths: ["proto", "services/oms"], files: [{ path: "a", sha256: "1", size: 1 }, { path: "b", sha256: "2", size: 1 }], skipped: [{ path: "z.png", size: 2, reason: "known binary extension .png" }] }] });
228
282
  expect(JSON.parse(encodeLock({ repo: "r", commit: "c", paths: [], files: [] })).repos[0]).not.toHaveProperty("paths");
229
283
  });
230
284
  });