@heyquilt/seam 0.27.0 → 0.131.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 (42) hide show
  1. package/bin/seam.js +118 -17
  2. package/lib/quilts/agent-tokens.js +148 -0
  3. package/lib/quilts/archiver.js +241 -0
  4. package/lib/quilts/attest.js +212 -0
  5. package/lib/quilts/bisect-run.js +3 -2
  6. package/lib/quilts/block.js +497 -55
  7. package/lib/quilts/cas.js +78 -0
  8. package/lib/quilts/cbor.js +2 -0
  9. package/lib/quilts/claims.js +409 -0
  10. package/lib/quilts/cli.js +612 -69
  11. package/lib/quilts/client.js +306 -19
  12. package/lib/quilts/conflict-format.js +266 -0
  13. package/lib/quilts/daemon.js +174 -0
  14. package/lib/quilts/describe.js +296 -38
  15. package/lib/quilts/dict.js +34 -0
  16. package/lib/quilts/exit-archive.js +184 -0
  17. package/lib/quilts/export-format.js +52 -0
  18. package/lib/quilts/export-git.js +27 -3
  19. package/lib/quilts/export-lfs.js +52 -0
  20. package/lib/quilts/export-sign.js +49 -0
  21. package/lib/quilts/fastcdc.js +186 -0
  22. package/lib/quilts/hash.js +17 -2
  23. package/lib/quilts/ignored.js +46 -0
  24. package/lib/quilts/import-git.js +54 -2
  25. package/lib/quilts/journal.js +105 -0
  26. package/lib/quilts/limits.js +197 -0
  27. package/lib/quilts/linepolicy.js +109 -0
  28. package/lib/quilts/pack.js +146 -18
  29. package/lib/quilts/persona.js +70 -0
  30. package/lib/quilts/records.js +7 -3
  31. package/lib/quilts/recover.js +37 -0
  32. package/lib/quilts/runner.js +79 -12
  33. package/lib/quilts/sealed.js +231 -0
  34. package/lib/quilts/secrets.js +86 -0
  35. package/lib/quilts/signing.js +7 -3
  36. package/lib/quilts/tarball.js +107 -0
  37. package/lib/quilts/textdiff.js +7 -5
  38. package/lib/quilts/tools.js +404 -83
  39. package/lib/quilts/tree.js +351 -0
  40. package/lib/quilts/want.js +51 -0
  41. package/lib/quilts/webhook.js +116 -0
  42. package/package.json +3 -2
@@ -0,0 +1,184 @@
1
+ // The whole quilt as an archive (14, "Export as the exit path"; D39): the owner's way out that keeps
2
+ // everything Seam stores. One plain .tar holding
3
+ // MANIFEST.json { format, quilt: { id, slug }, archive_seq, last_segment, members, files }
4
+ // w/<from>-<to>.qws every log segment (archiver.js), so the log and its commit rows replay
5
+ // p/<pack id> every pack as stored (sealed packs stay ciphertext, 11.11)
6
+ // members.json the members the log names: { member_id, kind, name } and nothing else
7
+ // git_shas.json [[git sha, commit id], …] sorted by sha: every translation an import or export kept
8
+ // `files` lists every other entry with its size and BLAKE3, sorted by path. The bytes are a pure
9
+ // function of what is stored, so exporting twice without a write in between gives the same file.
10
+ // readArchive is the reader: it checks every size and hash, that nothing is missing or extra, and
11
+ // replays the segments (their chain, hashes and seq continuity) into the log, commits and lines;
12
+ // snapshotIn takes any commit's files out of it, with no server.
13
+ import { blake3 } from '@noble/hashes/blake3.js'
14
+ import { tar, untar } from './tarball.js'
15
+ import { replaySegments } from './archiver.js'
16
+ import { readIndex, readEntry, unsqueeze, deltaBase, usesTrained, trainedDictOf } from './pack.js'
17
+ import { BUILTIN_DICT, BUILTIN_DICT_ID } from './dict.js'
18
+ import { applyChanges } from './tree.js'
19
+ import { decodeManifest } from './fastcdc.js'
20
+ import { blobId } from './records.js'
21
+ import { exportAuthor, exportMessage, COMMITTER, EXPORT_VERSION } from './export-format.js'
22
+
23
+ export const ARCHIVE_FORMAT = 'seam-archive/1'
24
+ // The archive is built in memory and answered as one download (ruling in 14, 2026-10-27).
25
+ export const MAX_ARCHIVE = 512 * 1024 * 1024
26
+
27
+ const hashOf = (bytes) => Buffer.from(blake3(bytes)).toString('hex')
28
+
29
+ /** Builds the archive bytes from a quilt row, its segments, packs and members. */
30
+ export function buildArchive ({ quilt, segments, packs, members, gitShas = [], attestation = false, stackedOn = null }) {
31
+ const entries = [
32
+ ...segments.map(({ name, bytes }) => ({ path: `w/${name}`, mode: 'file', bytes })),
33
+ ...packs.map(({ name, bytes }) => ({ path: `p/${name}`, mode: 'file', bytes })),
34
+ { path: 'git_shas.json', mode: 'file', bytes: Buffer.from(JSON.stringify(gitShas.map((g) => [g.sha, g.commit_id]).sort((a, b) => (a[0] < b[0] ? -1 : 1))) + '\n') },
35
+ { path: 'members.json', mode: 'file', bytes: Buffer.from(JSON.stringify(members.map(({ member_id, kind, name }) => ({ member_id, kind: kind || null, name: name || null })).sort((a, b) => (a.member_id < b.member_id ? -1 : 1)), null, 2) + '\n') }
36
+ ].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
37
+ const manifest = {
38
+ format: ARCHIVE_FORMAT,
39
+ quilt: { id: quilt.id, slug: quilt.slug || null },
40
+ archive_seq: quilt.archive_seq || 0,
41
+ last_segment: quilt.last_segment || null,
42
+ // How the server exports (14): with the Quilt-Attestation trailer, so the offline rebuild writes the same shas.
43
+ // With Quilt-Stacked-On (stackedOn: square id → the square it was stacked on), the map the trailer reads.
44
+ ...(attestation || stackedOn ? { export: { ...(attestation ? { attestation: true } : {}), ...(stackedOn ? { stacked_on: stackedOn } : {}) } } : {}),
45
+ files: entries.map((e) => ({ path: e.path, size: e.bytes.length, blake3: hashOf(e.bytes) }))
46
+ }
47
+ return tar([{ path: 'MANIFEST.json', mode: 'file', bytes: Buffer.from(JSON.stringify(manifest, null, 2) + '\n') }, ...entries])
48
+ }
49
+
50
+ /**
51
+ * Reads and checks an archive. Answers { manifest, files, log, commits, lines, squares, members }
52
+ * or throws naming the first thing wrong (a size, a hash, a missing or extra file, a broken chain).
53
+ */
54
+ export async function readArchive (bytes) {
55
+ const files = new Map(untar(bytes).map((f) => [f.path, f.bytes]))
56
+ const raw = files.get('MANIFEST.json')
57
+ if (!raw) throw new Error('not a Seam archive: it has no MANIFEST.json')
58
+ const manifest = JSON.parse(raw.toString('utf8'))
59
+ if (manifest.format !== ARCHIVE_FORMAT) throw new Error(`unknown archive format ${manifest.format} (this reader reads ${ARCHIVE_FORMAT})`)
60
+ const listed = new Set()
61
+ for (const f of manifest.files) {
62
+ const got = files.get(f.path)
63
+ if (!got) throw new Error(`${f.path} is in the manifest but not in the archive`)
64
+ if (got.length !== f.size) throw new Error(`${f.path} is ${got.length} bytes, the manifest says ${f.size}`)
65
+ if (hashOf(got) !== f.blake3) throw new Error(`${f.path} does not match its BLAKE3 in the manifest`)
66
+ listed.add(f.path)
67
+ }
68
+ for (const p of files.keys()) if (p !== 'MANIFEST.json' && !listed.has(p)) throw new Error(`${p} is in the archive but not in the manifest`)
69
+ const q = manifest.quilt.id
70
+ const under = (kind) => [...files.keys()].filter((p) => p.startsWith(kind + '/')).map((p) => p.slice(kind.length + 1)).sort()
71
+ const objects = { listFiles: async (_q, kind) => under(kind), readFile: async (_q, kind, name) => files.get(`${kind}/${name}`) }
72
+ const r = await replaySegments(objects, q)
73
+ if (r.archive_seq !== manifest.archive_seq) throw new Error(`the segments end at row ${r.archive_seq}, the manifest says ${manifest.archive_seq}`)
74
+ if ((r.last_segment || null) !== (manifest.last_segment || null)) throw new Error('the last segment is not the one the manifest names')
75
+ const members = JSON.parse((files.get('members.json') || Buffer.from('[]')).toString('utf8'))
76
+ const gitShas = new Map(JSON.parse((files.get('git_shas.json') || Buffer.from('[]')).toString('utf8')))
77
+ return { manifest, files, log: r.log, commits: r.commits, lines: r.lines, squares: r.squares, members, gitShas, packs: under('p') }
78
+ }
79
+
80
+ /** The commit id `rev` names in a read archive: a line's tip, a whole id, or a unique prefix (4+). */
81
+ export function resolveIn (a, rev) {
82
+ const s = String(rev || 'main').trim()
83
+ if (a.lines.has(s)) { const tip = a.lines.get(s).tip; if (!tip) throw new Error(`line ${s} is empty`); return tip }
84
+ if (a.commits.has(s)) return s
85
+ if (/^[0-9a-f]{4,63}$/.test(s)) {
86
+ const hits = [...a.commits.keys()].filter((id) => id.startsWith(s))
87
+ if (hits.length === 1) return hits[0]
88
+ if (hits.length > 1) throw new Error(`${s} matches ${hits.length} commits: give more of it`)
89
+ }
90
+ throw new Error(`no line or commit ${s} in this archive`)
91
+ }
92
+
93
+ /**
94
+ * The files of commit `rev` from a read archive alone: its first-parent chain's changes applied
95
+ * from the start (each commit's changes are against its first parent), each blob read from the
96
+ * packs and checked against its id. Answers [{ path, mode, bytes }] sorted by path; a file in
97
+ * conflict refuses, as a tarball does. A sealed quilt's files are ciphertext and refuse.
98
+ */
99
+ export function snapshotIn (a, rev) {
100
+ const id = resolveIn(a, rev)
101
+ const chain = []
102
+ for (let c = a.commits.get(id); c; c = c.parents?.[0] ? a.commits.get(c.parents[0]) : null) chain.push(c)
103
+ const last = chain[chain.length - 1]
104
+ if (last.parents?.length) throw new Error(`commit ${last.id.slice(0, 12)}'s parent is not in this archive`)
105
+ let entries = []
106
+ for (const c of chain.reverse()) entries = applyChanges(entries, c.changes || [])
107
+ const blob = blobReader(a)
108
+ const out = []
109
+ for (const x of entries) {
110
+ if (x.mode === 'conflict') throw new Error(`${x.path} is in conflict at ${rev}: take a settled commit`)
111
+ if (x.mode !== 'file' && x.mode !== 'exec' && x.mode !== 'symlink') continue
112
+ out.push({ path: x.path, mode: x.mode, bytes: blob(x.blob, x.path) })
113
+ }
114
+ return { commit: id, files: out }
115
+ }
116
+
117
+ /** blob(id, path) → the file's bytes from the archive's packs, checked against its id. */
118
+ export function blobReader (a) {
119
+ const stored = new Map()
120
+ for (const name of a.packs) {
121
+ const bytes = a.files.get(`p/${name}`)
122
+ for (const e of readIndex(bytes).index) if (!stored.has(e.id)) stored.set(e.id, { e, raw: bytes.subarray(e.offset, e.offset + e.len) })
123
+ }
124
+ const object = (oid, what) => { const s = stored.get(oid); if (!s) throw new Error(`${what} ${oid.slice(0, 12)} is in no pack of this archive`); return s }
125
+ const blob = (bid, p, depth = 0) => {
126
+ const { e, raw } = object(bid, `the file ${p}:`)
127
+ // A delta (03 The background squeeze): its base read the same way, then the target rebuilt and checked.
128
+ if (e.kind === 'delta') {
129
+ if (depth >= 50) throw new Error(`${p}: its delta chain is over 50 deep`)
130
+ try { return readEntry(e, raw, { base: blob(deltaBase(raw), p, depth + 1) }) } catch (err) { throw new Error(`${p} does not rebuild from its delta: ${err.message}`) }
131
+ }
132
+ if (e.kind === 'blob') {
133
+ // An entry with the quilt's trained dictionary (03 Dictionaries) names it; the archive holds its pack.
134
+ let dict = null
135
+ if (usesTrained(e.flags) && raw.length >= 32) {
136
+ const did = trainedDictOf(raw)
137
+ if (did === BUILTIN_DICT_ID) dict = BUILTIN_DICT
138
+ else { const d = object(did, `the dictionary of ${p}:`); dict = readEntry(d.e, d.raw) }
139
+ }
140
+ try { return readEntry(e, raw, { dict }) } catch { throw new Error(`${p} does not match its id (a sealed quilt's files need its key, 11.11)`) }
141
+ }
142
+ if (e.kind !== 'chunked') throw new Error(`${p} is stored as ${e.kind}, which this reader cannot read`)
143
+ const root = decodeManifest(unsqueeze(e.flags, raw))
144
+ const chunks = root.chunks || root.children.flatMap((c) => { const m = object(c.id, 'manifest'); return decodeManifest(readEntry(m.e, m.raw)).chunks })
145
+ const bytes = Buffer.concat(chunks.map((c) => { const k = object(c.id, 'chunk'); return readEntry(k.e, k.raw) }))
146
+ if (blobId(bytes) !== bid) throw new Error(`${p} does not match its id`)
147
+ return bytes
148
+ }
149
+ return blob
150
+ }
151
+
152
+ /**
153
+ * A stand-in for the server client that export-git.js drives, answered from a read archive alone,
154
+ * so `seam export git` can rebuild a line's git history offline with the same commit shas as the
155
+ * server's export (the same patch fields, messages and authors from export.js).
156
+ */
157
+ export function archiveClient (a) {
158
+ const blob = blobReader(a)
159
+ const shaOf = new Map([...a.gitShas].map(([sha, id]) => [id, sha]))
160
+ const merges = (line) => a.log.filter((r) => r.kind === 'merge' && r.line === line).sort((x, y) => x.patch_no - y.patch_no)
161
+ return {
162
+ quilt: async () => ({ id: a.manifest.quilt.id }),
163
+ blobs: async (_q, ids) => ({ blobs: Object.fromEntries(ids.map((id) => [id, blob(id, id.slice(0, 12)).toString('base64')])) }),
164
+ exportPatches: async (_q, { line = 'main', after = -1, limit = 200 } = {}) => {
165
+ if (!a.lines.has(line)) throw new Error(`no line ${line} in this archive`)
166
+ const patches = merges(line).filter((r) => r.patch_no > after).slice(0, limit).map((row) => {
167
+ const id = row.commit || row.to || row.commit_id
168
+ const c = a.commits.get(id)
169
+ if (!c) throw new Error(`patch ${row.patch_no}'s commit ${String(id).slice(0, 12)} is not in this archive`)
170
+ const au = exportAuthor(c.author)
171
+ return {
172
+ patch_no: row.patch_no,
173
+ commit: c.id,
174
+ parent: c.parents[0] || null,
175
+ author: { name: au.name, email: au.email, at: Math.floor((c.authored_at || 0) / 1000) },
176
+ committer: { ...COMMITTER, at: Math.floor((row.at || 0) / 1000) },
177
+ message: exportMessage({ commit: c, line, patch_no: row.patch_no, git_sha: shaOf.get(c.id) || null, attestation: a.manifest.export && a.manifest.export.attestation ? row.seq : null, stacked_on: (a.manifest.export && a.manifest.export.stacked_on && a.manifest.export.stacked_on[c.square_id]) || null, stack_map: row.payload?.stack || null }),
178
+ changes: (c.changes || []).map((ch) => (ch.op === 'delete' ? { path: ch.path, op: 'delete' } : { path: ch.path, op: 'put', blob: ch.blob, mode: ch.mode }))
179
+ }
180
+ })
181
+ return { version: EXPORT_VERSION, quilt: a.manifest.quilt.id, line, patch_count: a.lines.get(line).patch_count, patches }
182
+ }
183
+ }
184
+ }
@@ -0,0 +1,52 @@
1
+ // The git fields of an exported commit (plans/quilt-source-control/14, Export, Determinism): worked
2
+ // out from the commit record and the merge row only, so the same patch always gives the same commit
3
+ // id. Shared by the server's export (export.js) and the archive reader (exit-archive.js), which
4
+ // rebuilds the same git history offline; no server code here.
5
+ export const EXPORT_VERSION = 1
6
+ export const COMMITTER = { name: 'Quilt', email: 'export@heyquilt.com' }
7
+
8
+ /** A member slug safe for an email local part. */
9
+ const slugOf = (id) => String(id || 'unknown').replace(/^[a-z]+:/, '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'unknown'
10
+
11
+ /**
12
+ * The git identity of a commit's author, frozen in the commit record. A stub member from a git
13
+ * import keeps the email git had (it was public there); everyone else gets the noreply address
14
+ * of 14 (`<slug>@people|agents.noreply.heyquilt.com`), since no member has opted in to a
15
+ * public email yet.
16
+ */
17
+ export function exportAuthor (author = {}) {
18
+ const id = String(author.member_id || '')
19
+ const kind = author.kind === 'agent' ? 'agent' : 'person'
20
+ const stub = id.startsWith('stub:') ? id.slice(5) : ''
21
+ const email = stub.includes('@') ? stub : `${slugOf(id)}@${kind === 'agent' ? 'agents' : 'people'}.noreply.heyquilt.com`
22
+ const name = String(author.name || stub || slugOf(id)).replace(/[<>\n]/g, '').trim() || slugOf(id)
23
+ return { name, email, slug: slugOf(id), kind, ...(author.tool ? { tool: author.tool } : {}), ...(author.model ? { model: author.model } : {}) }
24
+ }
25
+
26
+ /** A stack key as a trailer name part (10): `db_migration` → `Db-Migration`. */
27
+ export const trainCase = (k) => String(k).split(/[_\-\s]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1).toLowerCase()).join('-')
28
+
29
+ /** The commit message with 14's trailers, in their fixed order. */
30
+ export function exportMessage ({ commit, line, patch_no: patchNo, git_sha: gitSha, attestation = null, stacked_on: stackedOn = null, stack_map: stackMap = null }) {
31
+ const a = exportAuthor(commit.author)
32
+ const trailers = [
33
+ `Quilt-Commit: ${commit.id}`,
34
+ `Quilt-Square: ${commit.square_id}`,
35
+ `Quilt-Patch: ${line}/${patchNo}`,
36
+ `Quilt-Author: ${a.slug} (${[a.kind, a.tool, a.model].filter(Boolean).join(', ')})`,
37
+ ...(commit.on_behalf_of ? [`Quilt-On-Behalf-Of: ${slugOf(commit.on_behalf_of)}`] : []),
38
+ ...(commit.task_id ? [`Quilt-Task: ${commit.task_id}`] : []),
39
+ // The square the patch's square was stacked on when it merged (SEAM_EXPORT_STACKED_ON).
40
+ ...(stackedOn ? [`Quilt-Stacked-On: ${stackedOn}`] : []),
41
+ // 10, Export: a patch merged under SEAM_STACK_EXPORT has its full map (inherited and derived keys
42
+ // too) in its merge row, written in Train-Case; older patches keep the keys their commit set.
43
+ ...(stackMap
44
+ ? Object.keys(stackMap).sort().map((k) => `Quilt-Stack-${trainCase(k)}: ${stackMap[k].value}`)
45
+ : Object.keys(commit.stack || {}).sort().filter((k) => commit.stack[k] !== null).map((k) => `Quilt-Stack-${k}: ${commit.stack[k]}`)),
46
+ ...(gitSha ? [`Quilt-Git-Sha: ${gitSha}`] : []),
47
+ // 11.7: the seq of the patch's merge row, whose server attestation proves it (SEAM_EXPORT_ATTESTATION).
48
+ ...(attestation != null ? [`Quilt-Attestation: ${attestation}`] : [])
49
+ ]
50
+ const body = String(commit.message || '').replace(/\s+$/, '')
51
+ return `${body || '(no message)'}\n\n${trailers.join('\n')}\n`
52
+ }
@@ -8,6 +8,8 @@
8
8
  import { spawn, execFileSync } from 'node:child_process'
9
9
  import fs from 'node:fs'
10
10
  import path from 'node:path'
11
+ import { signCommits } from './export-sign.js'
12
+ import { lfsPatterns } from './export-lfs.js'
11
13
 
12
14
  const MODES = { file: '100644', exec: '100755', symlink: '120000', gitlink: '160000' }
13
15
  const GIT_ENV = { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_CONFIG_NOSYSTEM: '1' }
@@ -56,9 +58,12 @@ async function fetchBlobs (client, q, ids) {
56
58
 
57
59
  /**
58
60
  * Exports `line` of quilt `q` to `branch` of the repository at `dir` (created bare when
59
- * missing). Returns { exported, patch_no, sha, branch }.
61
+ * missing). Returns { exported, patch_no, sha, branch }. With `sign` (export-sign.js exportKey), every
62
+ * commit carries an SSH signature with that key. With `lfs` ({ threshold, upload(bytes) → pointer
63
+ * text }, export-lfs.js), a file over the threshold or named by a `filter=lfs` line of the root
64
+ * `.gitattributes` is written as an LFS pointer and its bytes uploaded.
60
65
  */
61
- export async function exportGit (client, { q, dir, line = 'main', branch, page = 200, log = () => {} }) {
66
+ export async function exportGit (client, { q, dir, line = 'main', branch, page = 200, sign = null, lfs = null, log = () => {} }) {
62
67
  branch = branch || line
63
68
  const gitDir = openRepo(dir)
64
69
  const stateFile = path.join(gitDir, 'quilt-export.json')
@@ -78,6 +83,7 @@ export async function exportGit (client, { q, dir, line = 'main', branch, page =
78
83
  let exported = 0
79
84
  let version = state ? state.version : null
80
85
  let quiltId = state ? state.quilt : null
86
+ let lfsMatch = lfs && sha ? lfsPatterns(tryGit(dir, ['show', `${sha}:.gitattributes`])) : null
81
87
  for (;;) {
82
88
  const r = await client.exportPatches(q, { line, after, limit: page })
83
89
  if (version !== null && version !== r.version) throw new Error(`export format ${r.version} differs from the ${version} this branch was exported with: export to a new branch`)
@@ -105,9 +111,21 @@ export async function exportGit (client, { q, dir, line = 'main', branch, page =
105
111
  await send(s); await send(msg); s = '\n'
106
112
  if (first && sha) s += `from ${sha}\n`
107
113
  first = false
114
+ if (lfs) {
115
+ const attrs = p.changes.find((c) => c.path === '.gitattributes')
116
+ if (attrs) lfsMatch = attrs.op === 'delete' ? null : lfsPatterns(blobs.get(attrs.blob)?.toString('utf8'))
117
+ }
108
118
  for (const c of p.changes) {
109
- if (c.op === 'delete') s += `D ${fiPath(c.path)}\n`
119
+ const bytes = lfs && c.op === 'put' && (!c.mode || c.mode === 'file' || c.mode === 'exec') && c.path !== '.gitattributes' ? blobs.get(c.blob) : null
120
+ if (bytes && (bytes.length > lfs.threshold || lfsMatch?.ignores(c.path))) {
121
+ const pointer = await lfs.upload(bytes)
122
+ s += `M ${MODES[c.mode || 'file']} inline ${fiPath(c.path)}\ndata ${Buffer.byteLength(pointer)}\n${pointer}\n`
123
+ } else if (c.op === 'delete') s += `D ${fiPath(c.path)}\n`
110
124
  else if (c.mode === 'gitlink') s += `M 160000 ${blobMark.get(c.blob)} ${fiPath(c.path)}\n`
125
+ // 02 mode `quilt`: a folder holding one file, .quilt-pointer, with the pointer record.
126
+ // With the target's git sha (its translation table), a gitlink to it, as git had it.
127
+ else if (c.mode === 'quilt' && c.git_sha) s += `M 160000 ${c.git_sha} ${fiPath(c.path)}\n`
128
+ else if (c.mode === 'quilt') s += `M 100644 :${blobMark.get(c.blob)} ${fiPath(`${c.path}/.quilt-pointer`)}\n`
111
129
  else s += `M ${MODES[c.mode || 'file'] || MODES.file} :${blobMark.get(c.blob)} ${fiPath(c.path)}\n`
112
130
  }
113
131
  await send(s + '\n')
@@ -115,6 +133,12 @@ export async function exportGit (client, { q, dir, line = 'main', branch, page =
115
133
  await fi.done()
116
134
  const marks = new Map(fs.readFileSync(marksFile, 'utf8').split('\n').filter(Boolean).map((l) => { const [m, h] = l.split(' '); return [Number(m.slice(1)), h] }))
117
135
  fs.rmSync(marksFile, { force: true })
136
+ if (sign) {
137
+ const olds = r.patches.map((p) => marks.get(commitMark.get(p.patch_no)))
138
+ const signed = signCommits(dir, olds, sign, GIT_ENV)
139
+ r.patches.forEach((p, i) => marks.set(commitMark.get(p.patch_no), signed[i]))
140
+ git(dir, ['update-ref', `refs/heads/${branch}`, signed[signed.length - 1], olds[olds.length - 1]])
141
+ }
118
142
  const lines = r.patches.map((p) => `${line} ${p.patch_no} ${marks.get(commitMark.get(p.patch_no))}`)
119
143
  fs.appendFileSync(shaFile, lines.join('\n') + '\n')
120
144
  const last = r.patches[r.patches.length - 1]
@@ -0,0 +1,52 @@
1
+ // Git LFS on the export to a remote (plans/quilt-source-control/14, Export: settings.export.lfs).
2
+ // A file over `threshold_bytes`, or whose path a `filter=lfs` line of the exported tree's root
3
+ // `.gitattributes` names, is written to git as an LFS pointer, and its bytes go to the LFS store
4
+ // at `url` through the batch API (upload, then verify when asked). Without `lfs`, nothing changes.
5
+ import crypto from 'node:crypto'
6
+ import ignore from 'ignore'
7
+
8
+ export const LFS_THRESHOLD = 50 * 1024 * 1024
9
+
10
+ /** The pointer git keeps for `bytes`: { oid, size, text }. */
11
+ export function lfsPointer (bytes) {
12
+ const oid = crypto.createHash('sha256').update(bytes).digest('hex')
13
+ return { oid, size: bytes.length, text: `version https://git-lfs.github.com/spec/v1\noid sha256:${oid}\nsize ${bytes.length}\n` }
14
+ }
15
+
16
+ /** A matcher for the `filter=lfs` patterns of a `.gitattributes` text (null when it names none). */
17
+ export function lfsPatterns (text) {
18
+ const pats = String(text || '').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
19
+ .map((l) => l.split(/\s+/)).filter(([, ...attrs]) => attrs.includes('filter=lfs')).map(([p]) => p)
20
+ return pats.length ? ignore().add(pats) : null
21
+ }
22
+
23
+ /**
24
+ * The uploader for `lfs` ({ url, threshold_bytes? }): upload(bytes) stores them at the LFS endpoint
25
+ * `url` (…/info/lfs) signed in with `secret` (a token, or user:password), and resolves the pointer.
26
+ */
27
+ export function lfsUploader ({ url, secret = null, fetch = globalThis.fetch }) {
28
+ const base = String(url).replace(/\/+$/, '')
29
+ const auth = secret ? { authorization: `Basic ${Buffer.from(secret.includes(':') ? secret : `x-access-token:${secret}`).toString('base64')}` } : {}
30
+ const json = { accept: 'application/vnd.git-lfs+json', 'content-type': 'application/vnd.git-lfs+json' }
31
+ const done = new Set()
32
+ return async function upload (bytes) {
33
+ const p = lfsPointer(bytes)
34
+ if (done.has(p.oid)) return p.text
35
+ const res = await fetch(`${base}/objects/batch`, { method: 'POST', headers: { ...json, ...auth }, body: JSON.stringify({ operation: 'upload', transfers: ['basic'], objects: [{ oid: p.oid, size: p.size }] }) })
36
+ if (!res.ok) throw new Error(`Git LFS at ${base} answered ${res.status}`)
37
+ const o = ((await res.json().catch(() => ({}))).objects || [])[0] || {}
38
+ if (o.error) throw new Error(`Git LFS: ${o.error.message || o.error.code}`)
39
+ const up = o.actions?.upload
40
+ if (up?.href) {
41
+ const r = await fetch(up.href, { method: 'PUT', headers: { 'content-type': 'application/octet-stream', ...(up.header || {}) }, body: bytes })
42
+ if (!r.ok) throw new Error(`Git LFS upload answered ${r.status}`)
43
+ }
44
+ const verify = o.actions?.verify
45
+ if (verify?.href) {
46
+ const r = await fetch(verify.href, { method: 'POST', headers: { ...json, ...(verify.header || {}) }, body: JSON.stringify({ oid: p.oid, size: p.size }) })
47
+ if (!r.ok) throw new Error(`Git LFS verify answered ${r.status}`)
48
+ }
49
+ done.add(p.oid)
50
+ return p.text
51
+ }
52
+ }
@@ -0,0 +1,49 @@
1
+ // Signing of exported commits (plans/quilt-source-control/14, Export: "signature"): an SSH
2
+ // signature (SSHSIG, namespace `git`) with the quilt's export key, Ed25519, whose signatures are
3
+ // deterministic for a fixed key and message, so the same patch still gives the same commit id.
4
+ // The key is derived from the service's secret (SEAM_EXPORT_SIGNING_SECRET) and the quilt id with
5
+ // HKDF: nothing to store, one key per quilt. Off when no secret is set (it changes every sha).
6
+ import crypto from 'node:crypto'
7
+ import { execFileSync } from 'node:child_process'
8
+
9
+ const PKCS8_ED25519 = Buffer.from('302e020100300506032b657004220420', 'hex')
10
+ const str = (b) => { b = Buffer.from(b); const n = Buffer.alloc(4); n.writeUInt32BE(b.length); return Buffer.concat([n, b]) }
11
+
12
+ /** The quilt's export key: { privateKey, publicKey: 'ssh-ed25519 AAAA… seam-export:<quilt>' }. */
13
+ export function exportKey (secret, quiltId) {
14
+ const seed = Buffer.from(crypto.hkdfSync('sha256', String(secret), 'seam-export-key', String(quiltId), 32))
15
+ const privateKey = crypto.createPrivateKey({ key: Buffer.concat([PKCS8_ED25519, seed]), format: 'der', type: 'pkcs8' })
16
+ const raw = Buffer.from(crypto.createPublicKey(privateKey).export({ format: 'jwk' }).x, 'base64url')
17
+ const blob = Buffer.concat([str('ssh-ed25519'), str(raw)])
18
+ return { privateKey, blob, publicKey: `ssh-ed25519 ${blob.toString('base64')} seam-export:${quiltId}` }
19
+ }
20
+
21
+ /** An armored SSH signature of `bytes` (what `ssh-keygen -Y sign -n git` writes). */
22
+ export function sshsig (bytes, key) {
23
+ const signed = Buffer.concat([Buffer.from('SSHSIG'), str('git'), str(''), str('sha512'), str(crypto.createHash('sha512').update(bytes).digest())])
24
+ const sig = Buffer.concat([str('ssh-ed25519'), str(crypto.sign(null, signed, key.privateKey))])
25
+ const n = Buffer.alloc(4); n.writeUInt32BE(1)
26
+ const body = Buffer.concat([Buffer.from('SSHSIG'), n, str(key.blob), str('git'), str(''), str('sha512'), str(sig)]).toString('base64')
27
+ return ['-----BEGIN SSH SIGNATURE-----', ...body.match(/.{1,70}/g), '-----END SSH SIGNATURE-----'].join('\n')
28
+ }
29
+
30
+ /**
31
+ * Rewrites `shas` (fast-import's commits, oldest first, each the child of the one before) as signed
32
+ * commits: each parent that was rewritten is replaced, then the `gpgsig` header goes after the
33
+ * committer. Returns the signed shas in the same order.
34
+ */
35
+ export function signCommits (dir, shas, key, env) {
36
+ const run = (args, input) => execFileSync('git', ['-C', dir, ...args], { env, input, maxBuffer: 256 * 1024 * 1024 })
37
+ const map = new Map()
38
+ return shas.map((old) => {
39
+ const raw = run(['cat-file', 'commit', old]).toString('utf8')
40
+ const cut = raw.indexOf('\n\n')
41
+ const head = raw.slice(0, cut).split('\n').map((l) => (l.startsWith('parent ') && map.has(l.slice(7)) ? `parent ${map.get(l.slice(7))}` : l))
42
+ const unsigned = head.join('\n') + raw.slice(cut)
43
+ const at = head.findIndex((l) => l.startsWith('committer ')) + 1
44
+ head.splice(at, 0, 'gpgsig ' + sshsig(Buffer.from(unsigned, 'utf8'), key).split('\n').join('\n '))
45
+ const sha = run(['hash-object', '-t', 'commit', '-w', '--stdin'], head.join('\n') + raw.slice(cut)).toString().trim()
46
+ map.set(old, sha)
47
+ return sha
48
+ })
49
+ }
@@ -0,0 +1,186 @@
1
+ // FastCDC content-defined chunking (plans/quilt-source-control/03, "FastCDC parameters"): files
2
+ // over 1 MiB are cut where the bytes say, not at fixed offsets, so an edited binary or an
3
+ // appended dataset shares all but its changed chunks with its previous version. Every client and
4
+ // the server must cut identically, so everything here is part of the format, "quilt cdc v1":
5
+ //
6
+ // - Gear table: 256 values of 48 bits, the first 1,536 bytes BLAKE3 derives under the context
7
+ // "quilt cdc v1" from the bytes "gear", 6 bytes each, big-endian.
8
+ // - Rolling hash: h = (2h + gear[byte]) mod 2^48, so each bit of h depends on at most the last 48
9
+ // bytes (the 48-byte window). Kept exactly in a double, no BigInt.
10
+ // - Normalised chunking, two levels: no cut before 16 KiB (the hash starts there); from 16 KiB to
11
+ // 64 KiB a cut needs the 18 bits of MASK_S clear, from 64 KiB to 256 KiB the 14 bits of MASK_L;
12
+ // a chunk never passes 256 KiB. Both masks sit in bits 16..47 of h, spread evenly (zero-padded).
13
+ // - Chunk id: idOf('chunk', bytes), BLAKE3 under "quilt chunk v1".
14
+ //
15
+ // A manifest (02, 03) lists a chunked file's chunks as (chunk id, size), CBOR { v: 1, c: [[id,
16
+ // size], …] }, at most MANIFEST_MAX entries. A file with more chunks has a root manifest
17
+ // { v: 1, m: [[child manifest id, bytes it covers], …] } over leaf manifests: two levels cover
18
+ // 16,384² chunks, far past the 500 GiB file limit. Manifest id: idOf('manifest', bytes).
19
+ import { blake3 } from '@noble/hashes/blake3.js'
20
+ import { idOf, idBytes } from './hash.js'
21
+ import { encode, decode } from './cbor.js'
22
+
23
+ export const CDC_MIN = 16 * 1024
24
+ export const CDC_AVG = 64 * 1024
25
+ export const CDC_MAX = 256 * 1024
26
+ /** Files at or under this size are stored whole, never chunked (03 Blob encodings). */
27
+ export const CDC_THRESHOLD = 1024 * 1024
28
+
29
+ const TWO48 = 2 ** 48
30
+ const GEAR = (() => {
31
+ const raw = blake3(new TextEncoder().encode('gear'), { context: new TextEncoder().encode('quilt cdc v1'), dkLen: 256 * 6 })
32
+ const g = new Float64Array(256)
33
+ for (let i = 0; i < 256; i++) { let v = 0; for (let j = 0; j < 6; j++) v = v * 256 + raw[i * 6 + j]; g[i] = v }
34
+ return g
35
+ })()
36
+
37
+ // `bits` one-bits spread evenly over the 32 bits 16..47 of h, as a mask on hi = floor(h / 2^16).
38
+ function spread (bits) {
39
+ let m = 0
40
+ for (let i = 0; i < bits; i++) m |= 1 << Math.floor(i * 32 / bits)
41
+ return m >>> 0
42
+ }
43
+ export const MASK_S = spread(18)
44
+ export const MASK_L = spread(14)
45
+
46
+ /** Where the first chunk of `bytes` ends: an offset in (0, min(len, CDC_MAX)]. */
47
+ export function cutPoint (bytes, start = 0, end = bytes.length) {
48
+ const n = end - start
49
+ if (n <= CDC_MIN) return n
50
+ const max = Math.min(n, CDC_MAX)
51
+ const normal = Math.min(n, CDC_AVG)
52
+ let h = 0
53
+ let i = CDC_MIN
54
+ for (; i < normal; i++) {
55
+ h = h * 2 + GEAR[bytes[start + i]]; if (h >= TWO48) { h -= TWO48; if (h >= TWO48) h -= TWO48 }
56
+ if ((Math.floor(h / 65536) & MASK_S) === 0) return i + 1
57
+ }
58
+ for (; i < max; i++) {
59
+ h = h * 2 + GEAR[bytes[start + i]]; if (h >= TWO48) { h -= TWO48; if (h >= TWO48) h -= TWO48 }
60
+ if ((Math.floor(h / 65536) & MASK_L) === 0) return i + 1
61
+ }
62
+ return max
63
+ }
64
+
65
+ /** The chunks of a whole buffer: [{ id, offset, size }], in order, covering every byte. */
66
+ export function chunksOf (bytes) {
67
+ const out = []
68
+ for (let off = 0; off < bytes.length;) {
69
+ const size = cutPoint(bytes, off)
70
+ out.push({ id: idOf('chunk', bytes.subarray(off, off + size)), offset: off, size })
71
+ off += size
72
+ }
73
+ return out
74
+ }
75
+
76
+ /**
77
+ * The chunks of a stream of byte pieces (an async or sync iterable of Uint8Array), for files too
78
+ * large to hold: yields { id, offset, size, bytes } with the same cuts chunksOf would make.
79
+ */
80
+ export async function * chunkStream (pieces) {
81
+ let buf = new Uint8Array(0)
82
+ let offset = 0
83
+ for await (const piece of pieces) {
84
+ const next = new Uint8Array(buf.length + piece.length)
85
+ next.set(buf); next.set(piece, buf.length)
86
+ buf = next
87
+ // A cut is final once CDC_MAX bytes are held: no later byte can move it.
88
+ let at = 0
89
+ while (buf.length - at >= CDC_MAX) {
90
+ const size = cutPoint(buf, at)
91
+ const bytes = buf.slice(at, at + size)
92
+ yield { id: idOf('chunk', bytes), offset, size, bytes }
93
+ offset += size; at += size
94
+ }
95
+ if (at) buf = buf.slice(at)
96
+ }
97
+ for (let at = 0; at < buf.length;) {
98
+ const size = cutPoint(buf, at)
99
+ const bytes = buf.slice(at, at + size)
100
+ yield { id: idOf('chunk', bytes), offset, size, bytes }
101
+ offset += size; at += size
102
+ }
103
+ }
104
+
105
+ export const MANIFEST_MAX = 16384
106
+ const hex = (b) => Buffer.from(b).toString('hex')
107
+
108
+ /**
109
+ * The manifests of a file's chunks ([{ id, size }] in order): { id, bytes, manifests }, where
110
+ * `id` and `bytes` are the root's and `manifests` is every manifest to store, root last.
111
+ */
112
+ export function manifestsOf (chunks) {
113
+ const leaf = (cs) => encode({ v: 1, c: cs.map((c) => [idBytes(c.id), c.size]) })
114
+ if (chunks.length <= MANIFEST_MAX) {
115
+ const bytes = leaf(chunks)
116
+ const m = { id: idOf('manifest', bytes), bytes }
117
+ return { ...m, manifests: [m] }
118
+ }
119
+ if (chunks.length > MANIFEST_MAX * MANIFEST_MAX) throw new Error('file_too_large: more chunks than two manifest levels hold')
120
+ const children = []
121
+ for (let i = 0; i < chunks.length; i += MANIFEST_MAX) {
122
+ const part = chunks.slice(i, i + MANIFEST_MAX)
123
+ const bytes = leaf(part)
124
+ children.push({ id: idOf('manifest', bytes), bytes, size: part.reduce((n, c) => n + c.size, 0) })
125
+ }
126
+ const bytes = encode({ v: 1, m: children.map((c) => [idBytes(c.id), c.size]) })
127
+ const root = { id: idOf('manifest', bytes), bytes }
128
+ return { ...root, manifests: [...children.map(({ id, bytes }) => ({ id, bytes })), root] }
129
+ }
130
+
131
+ /**
132
+ * manifestsOf for a file cut as a stream (10, streaming the cut): chunks go in one at a time and
133
+ * only one leaf's ids and sizes are held. add(chunk) answers a finished leaf manifest ({ id, bytes })
134
+ * to store once its 16,384 chunks are in, else null; finish() answers what manifestsOf would, with
135
+ * `manifests` the ones not yet handed out (the last leaf and the root, or the single leaf). The
136
+ * bytes and ids are exactly manifestsOf's.
137
+ */
138
+ export function manifestBuilder () {
139
+ const leaf = (cs) => encode({ v: 1, c: cs.map((c) => [idBytes(c.id), c.size]) })
140
+ let part = []; let partSize = 0; let count = 0
141
+ const children = []
142
+ return {
143
+ add (c) {
144
+ if (++count > MANIFEST_MAX * MANIFEST_MAX) throw new Error('file_too_large: more chunks than two manifest levels hold')
145
+ let done = null
146
+ if (part.length === MANIFEST_MAX) {
147
+ const bytes = leaf(part)
148
+ done = { id: idOf('manifest', bytes), bytes }
149
+ children.push({ id: done.id, size: partSize }); part = []; partSize = 0
150
+ }
151
+ part.push({ id: c.id, size: c.size }); partSize += c.size
152
+ return done
153
+ },
154
+ finish () {
155
+ const bytes = leaf(part)
156
+ const last = { id: idOf('manifest', bytes), bytes }
157
+ if (!children.length) return { ...last, manifests: [last] }
158
+ const rootBytes = encode({ v: 1, m: [...children, { id: last.id, size: partSize }].map((c) => [idBytes(c.id), c.size]) })
159
+ const root = { id: idOf('manifest', rootBytes), bytes: rootBytes }
160
+ return { ...root, manifests: [last, root] }
161
+ }
162
+ }
163
+ }
164
+
165
+ /** A manifest's entries: { chunks: [{ id, size }] } for a leaf, { children: [{ id, size }] } for a root. */
166
+ export function decodeManifest (bytes) {
167
+ const r = decode(bytes)
168
+ if (!r || r.v !== 1 || (!Array.isArray(r.c) && !Array.isArray(r.m))) throw new Error('bad manifest')
169
+ const entries = (r.c || r.m).map(([id, size]) => ({ id: hex(id), size }))
170
+ return r.c ? { chunks: entries } : { children: entries }
171
+ }
172
+
173
+ /**
174
+ * A large file as a client uploads it (10, 03 "Chunk dedup"): pack entries for its chunks, its
175
+ * manifests (root included, so dedup finds it) and a `chunked` entry, the root manifest under the
176
+ * file's own id, last. A client asks `have` for the chunk ids and leaves out the ones the quilt holds.
177
+ */
178
+ export function chunkedEntries (bytes) {
179
+ const file = Buffer.from(bytes)
180
+ const chunks = chunksOf(file)
181
+ const m = manifestsOf(chunks)
182
+ const out = new Map()
183
+ for (const c of chunks) if (!out.has(c.id)) out.set(c.id, { kind: 'chunk', id: c.id, bytes: file.subarray(c.offset, c.offset + c.size) })
184
+ for (const x of m.manifests) if (!out.has(x.id)) out.set(x.id, { kind: 'manifest', id: x.id, bytes: x.bytes })
185
+ return [...out.values(), { kind: 'chunked', id: idOf('blob', file), bytes: m.bytes }]
186
+ }
@@ -13,11 +13,11 @@ function context (kind) {
13
13
  return c
14
14
  }
15
15
 
16
- export const KINDS = ['commit', 'blob', 'tree', 'chunk', 'pack', 'term', 'manifest']
16
+ export const KINDS = ['commit', 'blob', 'tree', 'chunk', 'pack', 'term', 'manifest', 'dict']
17
17
 
18
18
  /** The id of `bytes` as an object of `kind`: BLAKE3 derive-key mode, 32 bytes, as hex. */
19
19
  // Not object kinds: keys made with their own contexts so they can never equal an object id.
20
- const KEY_KINDS = ['idempotency', 'resolution']
20
+ const KEY_KINDS = ['idempotency', 'resolution', 'resolution-hunk']
21
21
 
22
22
  /** A key (not an object id) in its own context, e.g. 'idempotency' → "quilt idempotency v1". */
23
23
  export function keyOf (kind, bytes) {
@@ -30,6 +30,21 @@ export function idOf (kind, bytes) {
30
30
  return Buffer.from(blake3(bytes, { context: context(kind) })).toString('hex')
31
31
  }
32
32
 
33
+ /**
34
+ * idOf(kind, …) computed piece by piece, for bytes too large to hold at once (10, streaming the
35
+ * cut): update(piece) as the pieces come, then digest() is the hex id of all of them in order.
36
+ */
37
+ export function idHasher (kind) {
38
+ if (!KINDS.includes(kind)) throw new Error(`unknown object kind ${kind}`)
39
+ const h = blake3.create({ context: context(kind) })
40
+ let size = 0
41
+ return {
42
+ update (piece) { h.update(piece); size += piece.length; return this },
43
+ get size () { return size },
44
+ digest: () => Buffer.from(h.digest()).toString('hex')
45
+ }
46
+ }
47
+
33
48
  export const ID = /^[0-9a-f]{64}$/
34
49
 
35
50
  /** Whether `s` looks like an id (64 lowercase hex). */