@stream44.studio/t44-blockchaincommons.com 0.1.0-rc.2
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.
- package/.dco-signatures +9 -0
- package/.github/workflows/dco.yml +12 -0
- package/.github/workflows/gordian-open-integrity.yml +13 -0
- package/.o/GordianOpenIntegrity-CurrentLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity-InceptionLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity.yaml +25 -0
- package/DCO.md +34 -0
- package/README.md +210 -0
- package/action.yml +47 -0
- package/bin/oi +152 -0
- package/caps/GordianOpenIntegrity.test.ts +879 -0
- package/caps/GordianOpenIntegrity.ts +821 -0
- package/caps/XidDocumentLedger.test.ts +687 -0
- package/caps/XidDocumentLedger.ts +545 -0
- package/caps/__snapshots__/XidDocumentLedger.test.ts.snap +11 -0
- package/caps/__snapshots__/XidLedger.test.ts.snap +11 -0
- package/caps/lifehash.test.ts +302 -0
- package/caps/lifehash.ts +142 -0
- package/caps/open-integrity-js.test.ts +252 -0
- package/caps/open-integrity-js.ts +485 -0
- package/caps/open-integrity-sh.test.ts +188 -0
- package/caps/open-integrity-sh.ts +187 -0
- package/caps/open-integrity.test.ts +259 -0
- package/caps/provenance-mark-cli.test.ts +387 -0
- package/caps/provenance-mark-cli.ts +174 -0
- package/caps/provenance-mark.test.ts +233 -0
- package/caps/provenance-mark.ts +223 -0
- package/caps/xid.test.ts +828 -0
- package/caps/xid.ts +565 -0
- package/examples/01-XID-DocumentLedger/__snapshots__/main.test.ts.snap +10 -0
- package/examples/01-XID-DocumentLedger/main.test.ts +182 -0
- package/examples/02-XID-Rotate-InceptionKey/__snapshots__/main.test.ts.snap +53 -0
- package/examples/02-XID-Rotate-InceptionKey/main.test.ts +232 -0
- package/examples/03-GordianOpenIntegrity/main.test.ts +176 -0
- package/examples/04-GordianOpenIntegrityCli/main.test.ts +119 -0
- package/package.json +37 -0
- package/tsconfig.json +28 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { mkdir, writeFile, readFile, rm, access } from 'fs/promises'
|
|
5
|
+
import { tmpdir } from 'os'
|
|
6
|
+
|
|
7
|
+
type AllowedSigner = { email: string; publicKey: string }
|
|
8
|
+
|
|
9
|
+
async function withAllowedSigners<T>(signers: AllowedSigner[], fn: (gitArgs: string[]) => Promise<T>): Promise<T> {
|
|
10
|
+
const lines = signers.map(s => `${s.email} namespaces="git" ${s.publicKey}`)
|
|
11
|
+
const tmpFile = join(tmpdir(), `oi-allowed-signers-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
|
12
|
+
await writeFile(tmpFile, lines.join('\n') + '\n')
|
|
13
|
+
try {
|
|
14
|
+
return await fn(['-c', `gpg.ssh.allowedSignersFile=${tmpFile}`])
|
|
15
|
+
} finally {
|
|
16
|
+
await rm(tmpFile, { force: true })
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async function exec(cmd: string[], options: { cwd: string; env?: Record<string, string> }): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
22
|
+
const proc = Bun.spawn(cmd, {
|
|
23
|
+
cwd: options.cwd,
|
|
24
|
+
env: { ...process.env, ...options.env },
|
|
25
|
+
stdout: 'pipe',
|
|
26
|
+
stderr: 'pipe',
|
|
27
|
+
})
|
|
28
|
+
const stdout = await new Response(proc.stdout).text()
|
|
29
|
+
const stderr = await new Response(proc.stderr).text()
|
|
30
|
+
const exitCode = await proc.exited
|
|
31
|
+
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function git(args: string[], options: { cwd: string; env?: Record<string, string> }): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
35
|
+
return exec(['git', ...args], options)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function sshKeygen(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
39
|
+
return exec(['ssh-keygen', ...args], { cwd: '/tmp' })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
export async function capsule({
|
|
44
|
+
encapsulate,
|
|
45
|
+
CapsulePropertyTypes,
|
|
46
|
+
makeImportStack
|
|
47
|
+
}: {
|
|
48
|
+
encapsulate: any
|
|
49
|
+
CapsulePropertyTypes: any
|
|
50
|
+
makeImportStack: any
|
|
51
|
+
}) {
|
|
52
|
+
|
|
53
|
+
return encapsulate({
|
|
54
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
55
|
+
'#@stream44.studio/encapsulate/structs/Capsule': {},
|
|
56
|
+
'#': {
|
|
57
|
+
|
|
58
|
+
readSigningKey: {
|
|
59
|
+
type: CapsulePropertyTypes.Function,
|
|
60
|
+
value: async function (this: any, context: {
|
|
61
|
+
privateKeyPath: string
|
|
62
|
+
}) {
|
|
63
|
+
const keyPath = context.privateKeyPath
|
|
64
|
+
const publicKey = (await readFile(`${keyPath}.pub`, 'utf-8')).trim()
|
|
65
|
+
|
|
66
|
+
const fpResult = await sshKeygen(['-E', 'sha256', '-lf', keyPath])
|
|
67
|
+
if (fpResult.exitCode !== 0) {
|
|
68
|
+
throw new Error(`ssh-keygen fingerprint failed: ${fpResult.stderr}`)
|
|
69
|
+
}
|
|
70
|
+
const fingerprint = fpResult.stdout.split(' ')[1] || ''
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
privateKeyPath: keyPath,
|
|
74
|
+
publicKeyPath: `${keyPath}.pub`,
|
|
75
|
+
publicKey,
|
|
76
|
+
fingerprint,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
generateSigningKey: {
|
|
82
|
+
type: CapsulePropertyTypes.Function,
|
|
83
|
+
value: async function (this: any, context: {
|
|
84
|
+
keyDir: string
|
|
85
|
+
keyName?: string
|
|
86
|
+
passphrase?: string
|
|
87
|
+
}) {
|
|
88
|
+
const keyName = context.keyName || 'sign_id_ed25519'
|
|
89
|
+
const keyPath = join(context.keyDir, keyName)
|
|
90
|
+
const passphrase = context.passphrase || ''
|
|
91
|
+
|
|
92
|
+
await mkdir(context.keyDir, { recursive: true })
|
|
93
|
+
|
|
94
|
+
const result = await sshKeygen([
|
|
95
|
+
'-t', 'ed25519',
|
|
96
|
+
'-f', keyPath,
|
|
97
|
+
'-N', passphrase,
|
|
98
|
+
'-C', keyName,
|
|
99
|
+
])
|
|
100
|
+
|
|
101
|
+
if (result.exitCode !== 0) {
|
|
102
|
+
throw new Error(`ssh-keygen failed: ${result.stderr}`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const publicKey = await readFile(`${keyPath}.pub`, 'utf-8')
|
|
106
|
+
|
|
107
|
+
// Get fingerprint
|
|
108
|
+
const fpResult = await sshKeygen(['-E', 'sha256', '-lf', keyPath])
|
|
109
|
+
const fingerprint = fpResult.stdout.split(' ')[1] || ''
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
privateKeyPath: keyPath,
|
|
113
|
+
publicKeyPath: `${keyPath}.pub`,
|
|
114
|
+
publicKey: publicKey.trim(),
|
|
115
|
+
fingerprint,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
createInceptionCommit: {
|
|
121
|
+
type: CapsulePropertyTypes.Function,
|
|
122
|
+
value: async function (this: any, context: {
|
|
123
|
+
repoDir: string
|
|
124
|
+
signingKeyPath: string
|
|
125
|
+
authorName: string
|
|
126
|
+
authorEmail: string
|
|
127
|
+
message?: string
|
|
128
|
+
contract?: string
|
|
129
|
+
}) {
|
|
130
|
+
// Init repo
|
|
131
|
+
await mkdir(context.repoDir, { recursive: true })
|
|
132
|
+
const initResult = await git(['init'], { cwd: context.repoDir })
|
|
133
|
+
if (initResult.exitCode !== 0) {
|
|
134
|
+
throw new Error(`git init failed: ${initResult.stderr}`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Get key fingerprint for committer name
|
|
138
|
+
const fpResult = await sshKeygen(['-E', 'sha256', '-lf', context.signingKeyPath])
|
|
139
|
+
if (fpResult.exitCode !== 0) {
|
|
140
|
+
throw new Error(`ssh-keygen fingerprint failed: ${fpResult.stderr}`)
|
|
141
|
+
}
|
|
142
|
+
const fingerprint = fpResult.stdout.split(' ')[1] || ''
|
|
143
|
+
|
|
144
|
+
const dateStr = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
|
|
145
|
+
|
|
146
|
+
const env: Record<string, string> = {
|
|
147
|
+
GIT_AUTHOR_NAME: context.authorName,
|
|
148
|
+
GIT_AUTHOR_EMAIL: context.authorEmail,
|
|
149
|
+
GIT_COMMITTER_NAME: fingerprint,
|
|
150
|
+
GIT_COMMITTER_EMAIL: context.authorEmail,
|
|
151
|
+
GIT_AUTHOR_DATE: dateStr,
|
|
152
|
+
GIT_COMMITTER_DATE: dateStr,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const inceptionMessage = context.message || '[GordianOpenIntegrity] Establish a SHA-1 root of trust for origin and future commit verification.'
|
|
156
|
+
const ricardianContract = context.contract || 'Trust established using https://github.com/Stream44/t44-BlockchainCommons.com'
|
|
157
|
+
|
|
158
|
+
const commitResult = await git([
|
|
159
|
+
'-c', 'gpg.format=ssh',
|
|
160
|
+
'-c', `user.signingkey=${context.signingKeyPath}`,
|
|
161
|
+
'commit',
|
|
162
|
+
'--allow-empty',
|
|
163
|
+
'--no-edit',
|
|
164
|
+
'--gpg-sign',
|
|
165
|
+
'-m', inceptionMessage,
|
|
166
|
+
'-m', `Signed-off-by: ${context.authorName} <${context.authorEmail}>`,
|
|
167
|
+
'-m', ricardianContract,
|
|
168
|
+
], { cwd: context.repoDir, env })
|
|
169
|
+
|
|
170
|
+
if (commitResult.exitCode !== 0) {
|
|
171
|
+
throw new Error(`Inception commit failed: ${commitResult.stderr}`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Get the commit hash
|
|
175
|
+
const hashResult = await git(['rev-list', '--max-parents=0', 'HEAD'], { cwd: context.repoDir })
|
|
176
|
+
const commitHash = hashResult.stdout.trim()
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
commitHash,
|
|
180
|
+
did: `did:repo:${commitHash}`,
|
|
181
|
+
fingerprint,
|
|
182
|
+
message: inceptionMessage,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
getRepoDid: {
|
|
188
|
+
type: CapsulePropertyTypes.Function,
|
|
189
|
+
value: async function (this: any, context: { repoDir: string }) {
|
|
190
|
+
const hashResult = await git(['rev-list', '--max-parents=0', 'HEAD'], { cwd: context.repoDir })
|
|
191
|
+
if (hashResult.exitCode !== 0) {
|
|
192
|
+
throw new Error(`Failed to get first commit: ${hashResult.stderr}`)
|
|
193
|
+
}
|
|
194
|
+
const commitHash = hashResult.stdout.split('\n')[0].trim()
|
|
195
|
+
return `did:repo:${commitHash}`
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
getInceptionCommit: {
|
|
200
|
+
type: CapsulePropertyTypes.Function,
|
|
201
|
+
value: async function (this: any, context: { repoDir: string }) {
|
|
202
|
+
const hashResult = await git(['rev-list', '--max-parents=0', 'HEAD'], { cwd: context.repoDir })
|
|
203
|
+
if (hashResult.exitCode !== 0) {
|
|
204
|
+
throw new Error(`Failed to get inception commit: ${hashResult.stderr}`)
|
|
205
|
+
}
|
|
206
|
+
const commitHash = hashResult.stdout.split('\n')[0].trim()
|
|
207
|
+
|
|
208
|
+
// Get full details
|
|
209
|
+
const showResult = await git(['show', '--pretty=fuller', commitHash], { cwd: context.repoDir })
|
|
210
|
+
|
|
211
|
+
// Get committer details
|
|
212
|
+
const committerResult = await git(['log', '--format=%cn <%ce>', '-1', commitHash], { cwd: context.repoDir })
|
|
213
|
+
|
|
214
|
+
// Get key fingerprint
|
|
215
|
+
const keyResult = await git(['log', '--format=%GK', '-1', commitHash], { cwd: context.repoDir })
|
|
216
|
+
|
|
217
|
+
// Get signature status
|
|
218
|
+
const sigStatusResult = await git(['log', '--format=%G?', '-1', commitHash], { cwd: context.repoDir })
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
commitHash,
|
|
222
|
+
did: `did:repo:${commitHash}`,
|
|
223
|
+
fullDetails: showResult.stdout,
|
|
224
|
+
committer: committerResult.stdout.trim(),
|
|
225
|
+
keyFingerprint: keyResult.stdout.trim(),
|
|
226
|
+
signatureStatus: sigStatusResult.stdout.trim(),
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
verifyInceptionCommit: {
|
|
232
|
+
type: CapsulePropertyTypes.Function,
|
|
233
|
+
value: async function (this: any, context: {
|
|
234
|
+
repoDir: string
|
|
235
|
+
allowedSigners: AllowedSigner[]
|
|
236
|
+
}) {
|
|
237
|
+
const hashResult = await git(['rev-list', '--max-parents=0', 'HEAD'], { cwd: context.repoDir })
|
|
238
|
+
if (hashResult.exitCode !== 0) {
|
|
239
|
+
throw new Error(`Failed to get inception commit: ${hashResult.stderr}`)
|
|
240
|
+
}
|
|
241
|
+
const commitHash = hashResult.stdout.split('\n')[0].trim()
|
|
242
|
+
|
|
243
|
+
return withAllowedSigners(context.allowedSigners, async (cfg) => {
|
|
244
|
+
const verifyResult = await git([...cfg, 'verify-commit', commitHash], { cwd: context.repoDir })
|
|
245
|
+
const output = verifyResult.stderr || verifyResult.stdout
|
|
246
|
+
const isGood = output.includes('Good "git" signature')
|
|
247
|
+
|
|
248
|
+
const verboseResult = await git([...cfg, 'verify-commit', '-v', commitHash], { cwd: context.repoDir })
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
commitHash,
|
|
252
|
+
valid: isGood,
|
|
253
|
+
exitCode: verifyResult.exitCode,
|
|
254
|
+
output,
|
|
255
|
+
verboseOutput: verboseResult.stderr || verboseResult.stdout,
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
verifyCommit: {
|
|
262
|
+
type: CapsulePropertyTypes.Function,
|
|
263
|
+
value: async function (this: any, context: {
|
|
264
|
+
repoDir: string
|
|
265
|
+
commitHash?: string
|
|
266
|
+
allowedSigners: AllowedSigner[]
|
|
267
|
+
}) {
|
|
268
|
+
const hash = context.commitHash || 'HEAD'
|
|
269
|
+
|
|
270
|
+
return withAllowedSigners(context.allowedSigners, async (cfg) => {
|
|
271
|
+
const verifyResult = await git([...cfg, 'verify-commit', hash], { cwd: context.repoDir })
|
|
272
|
+
const output = verifyResult.stderr || verifyResult.stdout
|
|
273
|
+
const isGood = output.includes('Good "git" signature')
|
|
274
|
+
|
|
275
|
+
const sigStatusResult = await git(['log', '--format=%G?|%GK|%GS', '-1', hash], { cwd: context.repoDir })
|
|
276
|
+
const [status, keyFingerprint, signer] = sigStatusResult.stdout.split('|')
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
commitHash: hash,
|
|
280
|
+
valid: isGood,
|
|
281
|
+
exitCode: verifyResult.exitCode,
|
|
282
|
+
output,
|
|
283
|
+
signatureStatus: status,
|
|
284
|
+
keyFingerprint,
|
|
285
|
+
signer,
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
listCommits: {
|
|
292
|
+
type: CapsulePropertyTypes.Function,
|
|
293
|
+
value: async function (this: any, context: {
|
|
294
|
+
repoDir: string
|
|
295
|
+
reverse?: boolean
|
|
296
|
+
}) {
|
|
297
|
+
const args = ['log', '--oneline', '--format=%H|%s|%G?|%GK']
|
|
298
|
+
if (context.reverse) {
|
|
299
|
+
args.push('--reverse')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const result = await git(args, { cwd: context.repoDir })
|
|
303
|
+
if (result.exitCode !== 0) {
|
|
304
|
+
throw new Error(`Failed to list commits: ${result.stderr}`)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const commits = result.stdout.split('\n')
|
|
308
|
+
.filter(l => l.trim() !== '')
|
|
309
|
+
.map(line => {
|
|
310
|
+
const [hash, message, sigStatus, keyFingerprint] = line.split('|')
|
|
311
|
+
return { hash, message, signatureStatus: sigStatus, keyFingerprint }
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
return { commits, count: commits.length }
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
getCommitDetails: {
|
|
319
|
+
type: CapsulePropertyTypes.Function,
|
|
320
|
+
value: async function (this: any, context: {
|
|
321
|
+
repoDir: string
|
|
322
|
+
commitHash?: string
|
|
323
|
+
}) {
|
|
324
|
+
const hash = context.commitHash || 'HEAD'
|
|
325
|
+
|
|
326
|
+
const showResult = await git(['show', '--pretty=fuller', hash], { cwd: context.repoDir })
|
|
327
|
+
const formatResult = await git([
|
|
328
|
+
'log', '--format=%H|%an|%ae|%cn|%ce|%s|%G?|%GK', '-1', hash
|
|
329
|
+
], { cwd: context.repoDir })
|
|
330
|
+
|
|
331
|
+
const parts = formatResult.stdout.split('|')
|
|
332
|
+
return {
|
|
333
|
+
commitHash: parts[0],
|
|
334
|
+
authorName: parts[1],
|
|
335
|
+
authorEmail: parts[2],
|
|
336
|
+
committerName: parts[3],
|
|
337
|
+
committerEmail: parts[4],
|
|
338
|
+
message: parts[5],
|
|
339
|
+
signatureStatus: parts[6],
|
|
340
|
+
keyFingerprint: parts[7],
|
|
341
|
+
fullDetails: showResult.stdout,
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
createSignedCommit: {
|
|
347
|
+
type: CapsulePropertyTypes.Function,
|
|
348
|
+
value: async function (this: any, context: {
|
|
349
|
+
repoDir: string
|
|
350
|
+
signingKeyPath: string
|
|
351
|
+
message: string
|
|
352
|
+
authorName: string
|
|
353
|
+
authorEmail: string
|
|
354
|
+
files?: Array<{ path: string; content: string }>
|
|
355
|
+
allowEmpty?: boolean
|
|
356
|
+
}) {
|
|
357
|
+
// Write files and stage them
|
|
358
|
+
if (context.files && context.files.length > 0) {
|
|
359
|
+
for (const file of context.files) {
|
|
360
|
+
const absPath = join(context.repoDir, file.path)
|
|
361
|
+
await mkdir(dirname(absPath), { recursive: true })
|
|
362
|
+
await writeFile(absPath, file.content, 'utf-8')
|
|
363
|
+
|
|
364
|
+
const addResult = await git(['add', file.path], { cwd: context.repoDir })
|
|
365
|
+
if (addResult.exitCode !== 0) {
|
|
366
|
+
throw new Error(`git add failed for ${file.path}: ${addResult.stderr}`)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const env: Record<string, string> = {
|
|
372
|
+
GIT_AUTHOR_NAME: context.authorName,
|
|
373
|
+
GIT_AUTHOR_EMAIL: context.authorEmail,
|
|
374
|
+
GIT_COMMITTER_NAME: context.authorName,
|
|
375
|
+
GIT_COMMITTER_EMAIL: context.authorEmail,
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const args = [
|
|
379
|
+
'-c', 'gpg.format=ssh',
|
|
380
|
+
'-c', `user.signingkey=${context.signingKeyPath}`,
|
|
381
|
+
'commit',
|
|
382
|
+
'--gpg-sign',
|
|
383
|
+
'--signoff',
|
|
384
|
+
'-m', context.message,
|
|
385
|
+
]
|
|
386
|
+
|
|
387
|
+
if (context.allowEmpty) {
|
|
388
|
+
args.push('--allow-empty')
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const commitResult = await git(args, { cwd: context.repoDir, env })
|
|
392
|
+
|
|
393
|
+
if (commitResult.exitCode !== 0) {
|
|
394
|
+
throw new Error(`Signed commit failed: ${commitResult.stderr}`)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const hashResult = await git(['rev-parse', 'HEAD'], { cwd: context.repoDir })
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
commitHash: hashResult.stdout.trim(),
|
|
401
|
+
message: context.message,
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
auditRepository: {
|
|
407
|
+
type: CapsulePropertyTypes.Function,
|
|
408
|
+
value: async function (this: any, context: {
|
|
409
|
+
repoDir: string
|
|
410
|
+
allowedSigners: AllowedSigner[]
|
|
411
|
+
}) {
|
|
412
|
+
return withAllowedSigners(context.allowedSigners, async (cfg) => {
|
|
413
|
+
const results: any[] = []
|
|
414
|
+
|
|
415
|
+
// Get all commits oldest first
|
|
416
|
+
const listResult = await git([
|
|
417
|
+
'log', '--oneline', '--format=%H|%s|%G?|%GK|%an|%cn', '--reverse'
|
|
418
|
+
], { cwd: context.repoDir })
|
|
419
|
+
|
|
420
|
+
if (listResult.exitCode !== 0) {
|
|
421
|
+
throw new Error(`Failed to list commits: ${listResult.stderr}`)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const commits = listResult.stdout.split('\n').filter(l => l.trim() !== '')
|
|
425
|
+
|
|
426
|
+
// Check inception commit
|
|
427
|
+
const inceptionHash = commits[0]?.split('|')[0]
|
|
428
|
+
const inceptionVerify = await git([...cfg, 'verify-commit', inceptionHash], { cwd: context.repoDir })
|
|
429
|
+
const inceptionOutput = inceptionVerify.stderr || inceptionVerify.stdout
|
|
430
|
+
const inceptionValid = inceptionOutput.includes('Good "git" signature')
|
|
431
|
+
|
|
432
|
+
// Check if inception commit is empty (empty tree)
|
|
433
|
+
const treeResult = await git(['log', '--format=%T', '-1', inceptionHash], { cwd: context.repoDir })
|
|
434
|
+
const isEmptyTree = treeResult.stdout.trim() === '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
|
|
435
|
+
|
|
436
|
+
for (const line of commits) {
|
|
437
|
+
const [hash, message, sigStatus, keyFingerprint, authorName, committerName] = line.split('|')
|
|
438
|
+
const verifyResult = await git([...cfg, 'verify-commit', hash], { cwd: context.repoDir })
|
|
439
|
+
const verifyOutput = verifyResult.stderr || verifyResult.stdout
|
|
440
|
+
const valid = verifyOutput.includes('Good "git" signature')
|
|
441
|
+
|
|
442
|
+
results.push({
|
|
443
|
+
hash,
|
|
444
|
+
message,
|
|
445
|
+
signatureStatus: sigStatus,
|
|
446
|
+
keyFingerprint,
|
|
447
|
+
authorName,
|
|
448
|
+
committerName,
|
|
449
|
+
signatureValid: valid,
|
|
450
|
+
verifyOutput,
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
totalCommits: results.length,
|
|
456
|
+
validSignatures: results.filter(r => r.signatureValid).length,
|
|
457
|
+
invalidSignatures: results.filter(r => !r.signatureValid).length,
|
|
458
|
+
inceptionCommitValid: inceptionValid,
|
|
459
|
+
inceptionCommitEmpty: isEmptyTree,
|
|
460
|
+
did: `did:repo:${inceptionHash}`,
|
|
461
|
+
commits: results,
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
|
|
467
|
+
exec: {
|
|
468
|
+
type: CapsulePropertyTypes.Function,
|
|
469
|
+
value: async function (this: any, context: {
|
|
470
|
+
args: string[]
|
|
471
|
+
cwd: string
|
|
472
|
+
}) {
|
|
473
|
+
return git(context.args, { cwd: context.cwd })
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}, {
|
|
480
|
+
importMeta: import.meta,
|
|
481
|
+
importStack: makeImportStack(),
|
|
482
|
+
capsuleName: capsule['#'],
|
|
483
|
+
})
|
|
484
|
+
}
|
|
485
|
+
capsule['#'] = 't44/caps/providers/blockchaincommons.com/open-integrity-js'
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env bun test
|
|
2
|
+
|
|
3
|
+
import * as bunTest from 'bun:test'
|
|
4
|
+
import { run } from 't44/workspace-rt'
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
import { rm, mkdir } from 'fs/promises'
|
|
7
|
+
|
|
8
|
+
const WORK_DIR = join(import.meta.dir, '.~open-integrity-sh')
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
test: { describe, it, expect },
|
|
12
|
+
oi
|
|
13
|
+
} = await run(async ({ encapsulate, CapsulePropertyTypes, makeImportStack }: any) => {
|
|
14
|
+
const spine = await encapsulate({
|
|
15
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
16
|
+
'#@stream44.studio/encapsulate/structs/Capsule': {},
|
|
17
|
+
'#': {
|
|
18
|
+
test: {
|
|
19
|
+
type: CapsulePropertyTypes.Mapping,
|
|
20
|
+
value: 't44/caps/WorkspaceTest',
|
|
21
|
+
options: {
|
|
22
|
+
'#': {
|
|
23
|
+
bunTest,
|
|
24
|
+
env: {}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
oi: {
|
|
29
|
+
type: CapsulePropertyTypes.Mapping,
|
|
30
|
+
value: './open-integrity-sh'
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}, {
|
|
35
|
+
importMeta: import.meta,
|
|
36
|
+
importStack: makeImportStack(),
|
|
37
|
+
capsuleName: 't44/caps/providers/blockchaincommons.com/open-integrity-sh.test'
|
|
38
|
+
})
|
|
39
|
+
return { spine }
|
|
40
|
+
}, async ({ spine, apis }: any) => {
|
|
41
|
+
return apis[spine.capsuleSourceLineRef]
|
|
42
|
+
}, {
|
|
43
|
+
importMeta: import.meta
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Clean up before tests
|
|
47
|
+
await rm(WORK_DIR, { recursive: true, force: true })
|
|
48
|
+
await mkdir(WORK_DIR, { recursive: true })
|
|
49
|
+
|
|
50
|
+
describe('Open Integrity SH (shell script delegation)', function () {
|
|
51
|
+
|
|
52
|
+
const repoDir = join(WORK_DIR, 'test-repo')
|
|
53
|
+
|
|
54
|
+
describe('1. setup_git_inception_repo.sh', function () {
|
|
55
|
+
|
|
56
|
+
it('should create an inception repo via the shell script', async function () {
|
|
57
|
+
const result = await oi.createInceptionRepo({
|
|
58
|
+
repoDir,
|
|
59
|
+
force: false,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
expect(result.exitCode).toBe(0)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('should fail when repo already exists without --force', async function () {
|
|
66
|
+
try {
|
|
67
|
+
await oi.createInceptionRepo({
|
|
68
|
+
repoDir,
|
|
69
|
+
force: false,
|
|
70
|
+
})
|
|
71
|
+
expect(true).toBe(false) // should not reach here
|
|
72
|
+
} catch (err: any) {
|
|
73
|
+
expect(err.message).toContain('setup_git_inception_repo.sh failed')
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should succeed with --force on existing repo', async function () {
|
|
78
|
+
const forceRepoDir = join(WORK_DIR, 'test-repo-force')
|
|
79
|
+
await oi.createInceptionRepo({ repoDir: forceRepoDir })
|
|
80
|
+
const result = await oi.createInceptionRepo({ repoDir: forceRepoDir, force: true })
|
|
81
|
+
expect(result.exitCode).toBe(0)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('2. get_repo_did.sh', function () {
|
|
86
|
+
|
|
87
|
+
it('should retrieve the repo DID', async function () {
|
|
88
|
+
const did = await oi.getRepoDid({ repoDir })
|
|
89
|
+
|
|
90
|
+
expect(did).toStartWith('did:repo:')
|
|
91
|
+
expect(did.length).toBeGreaterThan('did:repo:'.length)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should fail on a non-repo directory', async function () {
|
|
95
|
+
const badDir = join('/tmp', 'not-a-git-repo-' + Date.now())
|
|
96
|
+
await mkdir(badDir, { recursive: true })
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await oi.getRepoDid({ repoDir: badDir })
|
|
100
|
+
expect(true).toBe(false) // should not reach here
|
|
101
|
+
} catch (err: any) {
|
|
102
|
+
expect(err.message).toContain('get_repo_did.sh failed')
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('3. audit_inception_commit-POC.sh', function () {
|
|
108
|
+
|
|
109
|
+
it('should pass audit on a valid inception repo', async function () {
|
|
110
|
+
const result = await oi.auditInceptionCommit({
|
|
111
|
+
repoDir,
|
|
112
|
+
quiet: true,
|
|
113
|
+
noPrompt: true,
|
|
114
|
+
noColor: true,
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
expect(result.passed).toBe(true)
|
|
118
|
+
expect(result.exitCode).toBe(0)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('should return verbose output when requested', async function () {
|
|
122
|
+
const result = await oi.auditInceptionCommit({
|
|
123
|
+
repoDir,
|
|
124
|
+
verbose: true,
|
|
125
|
+
noPrompt: true,
|
|
126
|
+
noColor: true,
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
expect(result.exitCode).toBe(0)
|
|
130
|
+
expect(result.stdout.length).toBeGreaterThan(0)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('4. snippet_template.sh', function () {
|
|
135
|
+
|
|
136
|
+
it('should show file status in default format', async function () {
|
|
137
|
+
const testFile = join(WORK_DIR, 'test-file.txt')
|
|
138
|
+
const { writeFile } = await import('fs/promises')
|
|
139
|
+
await writeFile(testFile, 'hello world')
|
|
140
|
+
|
|
141
|
+
const result = await oi.showFileStatus({
|
|
142
|
+
filePath: testFile,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
expect(result.exitCode).toBe(0)
|
|
146
|
+
expect(result.stdout).toContain('File:')
|
|
147
|
+
expect(result.stdout).toContain('Size:')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('should show file status in json format', async function () {
|
|
151
|
+
const testFile = join(WORK_DIR, 'test-file.txt')
|
|
152
|
+
|
|
153
|
+
const result = await oi.showFileStatus({
|
|
154
|
+
filePath: testFile,
|
|
155
|
+
format: 'json',
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
expect(result.exitCode).toBe(0)
|
|
159
|
+
expect(result.stdout).toContain('"file"')
|
|
160
|
+
expect(result.stdout).toContain('"size"')
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('5. Cross-script workflow', function () {
|
|
165
|
+
|
|
166
|
+
it('should create repo, get DID, and audit in sequence', async function () {
|
|
167
|
+
const workflowRepoDir = join(WORK_DIR, 'workflow-repo')
|
|
168
|
+
|
|
169
|
+
// Create inception repo
|
|
170
|
+
const createResult = await oi.createInceptionRepo({ repoDir: workflowRepoDir })
|
|
171
|
+
expect(createResult.exitCode).toBe(0)
|
|
172
|
+
|
|
173
|
+
// Get DID
|
|
174
|
+
const did = await oi.getRepoDid({ repoDir: workflowRepoDir })
|
|
175
|
+
expect(did).toStartWith('did:repo:')
|
|
176
|
+
|
|
177
|
+
// Audit
|
|
178
|
+
const auditResult = await oi.auditInceptionCommit({
|
|
179
|
+
repoDir: workflowRepoDir,
|
|
180
|
+
quiet: true,
|
|
181
|
+
noPrompt: true,
|
|
182
|
+
noColor: true,
|
|
183
|
+
})
|
|
184
|
+
expect(auditResult.passed).toBe(true)
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
})
|