stackbite 0.5.45 → 0.5.47
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/package.json
CHANGED
|
@@ -21,6 +21,15 @@ async function readAt(file, offset, length) {
|
|
|
21
21
|
}
|
|
22
22
|
function validateEntries(entries, root, windows) {
|
|
23
23
|
if (!entries.length || entries.length > LIMITS.members) fail('Invalid archive member count');
|
|
24
|
+
const names = memberNames(entries, root, windows);
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
checkMemberParents(entry, names, windows);
|
|
27
|
+
if (entry.kind === 'link') checkMemberLink(entry, names, root, windows);
|
|
28
|
+
}
|
|
29
|
+
return entries;
|
|
30
|
+
}
|
|
31
|
+
// Normalizes names in place, then charges root, duplicate (case-folded on Windows) and size checks in member order.
|
|
32
|
+
function memberNames(entries, root, windows) {
|
|
24
33
|
const names = new Map();
|
|
25
34
|
let expanded = 0;
|
|
26
35
|
for (const entry of entries) {
|
|
@@ -32,100 +41,143 @@ function validateEntries(entries, root, windows) {
|
|
|
32
41
|
expanded += entry.size;
|
|
33
42
|
if (entry.size < 0 || entry.size > LIMITS.member || expanded > LIMITS.expanded) fail('Archive expansion exceeds limit');
|
|
34
43
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
if (windows || !entry.link || entry.link.length > 1024 || /[\\:\x00-\x1f\x7f]/.test(entry.link)
|
|
42
|
-
|| path.posix.isAbsolute(entry.link)) fail('Unsafe archive link');
|
|
43
|
-
const target = path.posix.normalize(path.posix.join(path.posix.dirname(entry.name), entry.link));
|
|
44
|
-
const destination = names.get(target);
|
|
45
|
-
if (!target.startsWith(root + '/') || !destination || destination.kind !== 'file') fail('Archive link is escaping, dangling or chained');
|
|
46
|
-
}
|
|
44
|
+
return names;
|
|
45
|
+
}
|
|
46
|
+
function checkMemberParents(entry, names, windows) {
|
|
47
|
+
for (let parent = path.posix.dirname(entry.name); parent !== '.'; parent = path.posix.dirname(parent)) {
|
|
48
|
+
const ancestor = names.get(windows ? parent.toLowerCase() : parent);
|
|
49
|
+
if (ancestor && ancestor.kind !== 'directory') fail('Archive link/file is another member parent');
|
|
47
50
|
}
|
|
48
|
-
|
|
51
|
+
}
|
|
52
|
+
function checkMemberLink(entry, names, root, windows) {
|
|
53
|
+
if (windows || !entry.link || entry.link.length > 1024 || /[\\:\x00-\x1f\x7f]/.test(entry.link)
|
|
54
|
+
|| path.posix.isAbsolute(entry.link)) fail('Unsafe archive link');
|
|
55
|
+
const target = path.posix.normalize(path.posix.join(path.posix.dirname(entry.name), entry.link));
|
|
56
|
+
const destination = names.get(target);
|
|
57
|
+
if (!target.startsWith(root + '/') || !destination || destination.kind !== 'file') fail('Archive link is escaping, dangling or chained');
|
|
58
|
+
}
|
|
59
|
+
// End of central directory: one disk, no ZIP64 count, directory ending exactly at the EOCD.
|
|
60
|
+
async function zipDirectory(file) {
|
|
61
|
+
const size = (await file.stat()).size;
|
|
62
|
+
const tailStart = Math.max(0, size - 65557);
|
|
63
|
+
const tail = await readAt(file, tailStart, size - tailStart);
|
|
64
|
+
const end = tail.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
|
|
65
|
+
if (end < 0 || end + 22 > tail.length) fail('Invalid ZIP directory');
|
|
66
|
+
const count = tail.readUInt16LE(end + 10), directorySize = tail.readUInt32LE(end + 12), offset = tail.readUInt32LE(end + 16);
|
|
67
|
+
if (tail.readUInt16LE(end + 4) || tail.readUInt16LE(end + 6) || tail.readUInt16LE(end + 8) !== count
|
|
68
|
+
|| count === 65535 || count > LIMITS.members || directorySize > 16 * 1024 * 1024
|
|
69
|
+
|| offset + directorySize !== tailStart + end || end + 22 + tail.readUInt16LE(end + 20) !== tail.length) fail('Unsafe ZIP directory');
|
|
70
|
+
return { count, offset, central: await readAt(file, offset, directorySize) };
|
|
71
|
+
}
|
|
72
|
+
function zipSupported(central, position, { flags, method, expanded, nameSize, extraSize, commentSize, mode }) {
|
|
73
|
+
if (!nameSize || nameSize > 1024 || position + 46 + nameSize + extraSize + commentSize > central.length
|
|
74
|
+
|| central.readUInt16LE(position + 34) || (flags & ~0x080e) || ![0, 8].includes(method)
|
|
75
|
+
|| expanded > LIMITS.member || ![0, 0o100000, 0o040000].includes(mode & 0o170000) || (mode & 0o7000)) fail('Unsupported ZIP member');
|
|
76
|
+
}
|
|
77
|
+
function zipName(rawName, flags) {
|
|
78
|
+
if (!(flags & 0x0800) && rawName.some(value => value > 127)) fail('Ambiguous ZIP filename encoding');
|
|
79
|
+
return utf8.decode(rawName);
|
|
80
|
+
}
|
|
81
|
+
function zipDirectoryIdentity({ directory, expanded, compressed, mode, attributes }) {
|
|
82
|
+
if (directory && (expanded || compressed)) fail('Invalid ZIP directory member');
|
|
83
|
+
const directoryMode = (mode & 0o170000) === 0o040000 || !!(attributes & 16);
|
|
84
|
+
if (directoryMode && !directory) fail('ZIP directory identity mismatch');
|
|
85
|
+
}
|
|
86
|
+
// One central record; the running expansion total is charged before member support checks.
|
|
87
|
+
function zipCentralRecord(central, position, totalExpanded) {
|
|
88
|
+
if (position + 46 > central.length || central.readUInt32LE(position) !== 0x02014b50) fail('Invalid ZIP member');
|
|
89
|
+
const expanded = central.readUInt32LE(position + 24), attributes = central.readUInt32LE(position + 38);
|
|
90
|
+
const record = { flags: central.readUInt16LE(position + 8), method: central.readUInt16LE(position + 10),
|
|
91
|
+
crc: central.readUInt32LE(position + 16), compressed: central.readUInt32LE(position + 20), expanded,
|
|
92
|
+
total: totalExpanded + expanded };
|
|
93
|
+
if (record.total > LIMITS.expanded) fail('ZIP total expansion exceeds limit');
|
|
94
|
+
Object.assign(record, { nameSize: central.readUInt16LE(position + 28), extraSize: central.readUInt16LE(position + 30),
|
|
95
|
+
commentSize: central.readUInt16LE(position + 32), localOffset: central.readUInt32LE(position + 42),
|
|
96
|
+
mode: attributes >>> 16, attributes });
|
|
97
|
+
zipSupported(central, position, record);
|
|
98
|
+
record.rawName = central.subarray(position + 46, position + 46 + record.nameSize);
|
|
99
|
+
record.name = zipName(record.rawName, record.flags);
|
|
100
|
+
record.directory = record.name.endsWith('/');
|
|
101
|
+
zipDirectoryIdentity(record);
|
|
102
|
+
record.extra = central.subarray(position + 46 + record.nameSize, position + 46 + record.nameSize + record.extraSize);
|
|
103
|
+
record.next = position + 46 + record.nameSize + record.extraSize + record.commentSize;
|
|
104
|
+
return record;
|
|
105
|
+
}
|
|
106
|
+
// The local header must repeat the central identity; sizes too unless a data descriptor follows.
|
|
107
|
+
async function zipLocalHeader(file, record, directoryOffset) {
|
|
108
|
+
const { localOffset, flags, method, nameSize, compressed } = record;
|
|
109
|
+
const local = await readAt(file, localOffset, 30);
|
|
110
|
+
const localNameSize = local.readUInt16LE(26), localExtraSize = local.readUInt16LE(28);
|
|
111
|
+
const dataStart = localOffset + 30 + localNameSize + localExtraSize;
|
|
112
|
+
if (local.readUInt32LE(0) !== 0x04034b50 || local.readUInt16LE(6) !== flags || local.readUInt16LE(8) !== method
|
|
113
|
+
|| localNameSize !== nameSize || dataStart + compressed > directoryOffset
|
|
114
|
+
|| !(await readAt(file, localOffset + 30, localNameSize)).equals(record.rawName)) fail('ZIP local/central identity mismatch');
|
|
115
|
+
if (!(flags & 8) && (local.readUInt32LE(14) !== record.crc
|
|
116
|
+
|| local.readUInt32LE(18) !== compressed || local.readUInt32LE(22) !== record.expanded)) fail('ZIP local size mismatch');
|
|
117
|
+
return { dataStart, extraStart: localOffset + 30 + localNameSize, extraSize: localExtraSize };
|
|
118
|
+
}
|
|
119
|
+
function zipDigest(expanded) {
|
|
120
|
+
const digest = { size: 0, crc: 0xffffffff };
|
|
121
|
+
digest.sink = new Writable({ write(chunk, encoding, callback) {
|
|
122
|
+
digest.size += chunk.length;
|
|
123
|
+
if (digest.size > expanded) return callback(new Error('ZIP expansion exceeds declared size'));
|
|
124
|
+
for (const byte of chunk) digest.crc = (digest.crc >>> 8) ^ crcTable[(digest.crc ^ byte) & 255];
|
|
125
|
+
callback();
|
|
126
|
+
} });
|
|
127
|
+
return digest;
|
|
128
|
+
}
|
|
129
|
+
// Bound the actual inflated bytes, not only the central-directory claim,
|
|
130
|
+
// before handing the same archive to the native extraction command.
|
|
131
|
+
async function zipContent(archive, { method, compressed, expanded, crc }, dataStart, signal) {
|
|
132
|
+
if (method === 0 && compressed !== expanded) fail('Invalid stored ZIP size');
|
|
133
|
+
const digest = zipDigest(expanded);
|
|
134
|
+
if (compressed) {
|
|
135
|
+
const input = createReadStream(archive, { start: dataStart, end: dataStart + compressed - 1 });
|
|
136
|
+
if (method === 8) await pipeline(input, createInflateRaw(), digest.sink, { signal });
|
|
137
|
+
else await pipeline(input, digest.sink, { signal });
|
|
138
|
+
}
|
|
139
|
+
if (digest.size !== expanded || ((digest.crc ^ 0xffffffff) >>> 0) !== crc) fail('ZIP content size/CRC mismatch');
|
|
140
|
+
}
|
|
141
|
+
// Reject Unicode-path/ZIP64 extra fields: native tar must use the same name
|
|
142
|
+
// and sizes checked here, without a second interpretation of this entry.
|
|
143
|
+
function zipExtraFields(extra) {
|
|
144
|
+
for (let at = 0; at < extra.length;) {
|
|
145
|
+
if (at + 4 > extra.length) fail('Invalid ZIP extra field');
|
|
146
|
+
const kind = extra.readUInt16LE(at), length = extra.readUInt16LE(at + 2);
|
|
147
|
+
if (![0x5455, 0x7875].includes(kind) || at + 4 + length > extra.length) fail('Unsupported ZIP extension');
|
|
148
|
+
at += 4 + length;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Local identity, then content, then both extra fields; returns the member's byte range.
|
|
152
|
+
async function zipMember(file, archive, record, directoryOffset, signal) {
|
|
153
|
+
const local = await zipLocalHeader(file, record, directoryOffset);
|
|
154
|
+
if (!record.directory) await zipContent(archive, record, local.dataStart, signal);
|
|
155
|
+
const localExtra = await readAt(file, local.extraStart, local.extraSize);
|
|
156
|
+
zipExtraFields(record.extra);
|
|
157
|
+
zipExtraFields(localExtra);
|
|
158
|
+
return [record.localOffset, local.dataStart + record.compressed];
|
|
159
|
+
}
|
|
160
|
+
function zipRanges(ranges) {
|
|
161
|
+
ranges.sort((a, b) => a[0] - b[0]);
|
|
162
|
+
if (ranges.some((range, index) => index && range[0] < ranges[index - 1][1])) fail('Overlapping ZIP members');
|
|
49
163
|
}
|
|
50
164
|
async function zipEntries(archive) {
|
|
51
165
|
const file = await fs.open(archive, 'r');
|
|
52
166
|
const signal = AbortSignal.timeout(LIMITS.timeout);
|
|
53
167
|
try {
|
|
54
|
-
const
|
|
55
|
-
const tailStart = Math.max(0, size - 65557);
|
|
56
|
-
const tail = await readAt(file, tailStart, size - tailStart);
|
|
57
|
-
const end = tail.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
|
|
58
|
-
if (end < 0 || end + 22 > tail.length) fail('Invalid ZIP directory');
|
|
59
|
-
const count = tail.readUInt16LE(end + 10), directorySize = tail.readUInt32LE(end + 12), offset = tail.readUInt32LE(end + 16);
|
|
60
|
-
if (tail.readUInt16LE(end + 4) || tail.readUInt16LE(end + 6) || tail.readUInt16LE(end + 8) !== count
|
|
61
|
-
|| count === 65535 || count > LIMITS.members || directorySize > 16 * 1024 * 1024
|
|
62
|
-
|| offset + directorySize !== tailStart + end || end + 22 + tail.readUInt16LE(end + 20) !== tail.length) fail('Unsafe ZIP directory');
|
|
63
|
-
const central = await readAt(file, offset, directorySize);
|
|
168
|
+
const { count, offset, central } = await zipDirectory(file);
|
|
64
169
|
const entries = [], ranges = [];
|
|
65
170
|
let position = 0, totalExpanded = 0;
|
|
66
171
|
for (let index = 0; index < count; index++) {
|
|
67
172
|
signal.throwIfAborted();
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
totalExpanded
|
|
72
|
-
|
|
73
|
-
const nameSize = central.readUInt16LE(position + 28), extraSize = central.readUInt16LE(position + 30), commentSize = central.readUInt16LE(position + 32);
|
|
74
|
-
const localOffset = central.readUInt32LE(position + 42), mode = central.readUInt32LE(position + 38) >>> 16;
|
|
75
|
-
if (!nameSize || nameSize > 1024 || position + 46 + nameSize + extraSize + commentSize > central.length
|
|
76
|
-
|| central.readUInt16LE(position + 34) || (flags & ~0x080e) || ![0, 8].includes(method)
|
|
77
|
-
|| expanded > LIMITS.member || ![0, 0o100000, 0o040000].includes(mode & 0o170000) || (mode & 0o7000)) fail('Unsupported ZIP member');
|
|
78
|
-
const rawName = central.subarray(position + 46, position + 46 + nameSize);
|
|
79
|
-
if (!(flags & 0x0800) && rawName.some(value => value > 127)) fail('Ambiguous ZIP filename encoding');
|
|
80
|
-
const name = utf8.decode(rawName);
|
|
81
|
-
const directory = name.endsWith('/');
|
|
82
|
-
if (directory && (expanded || compressed)) fail('Invalid ZIP directory member');
|
|
83
|
-
const directoryMode = (mode & 0o170000) === 0o040000 || !!(central.readUInt32LE(position + 38) & 16);
|
|
84
|
-
if (directoryMode && !directory) fail('ZIP directory identity mismatch');
|
|
85
|
-
const local = await readAt(file, localOffset, 30);
|
|
86
|
-
const localNameSize = local.readUInt16LE(26), localExtraSize = local.readUInt16LE(28);
|
|
87
|
-
const dataStart = localOffset + 30 + localNameSize + localExtraSize;
|
|
88
|
-
if (local.readUInt32LE(0) !== 0x04034b50 || local.readUInt16LE(6) !== flags || local.readUInt16LE(8) !== method
|
|
89
|
-
|| localNameSize !== nameSize || dataStart + compressed > offset
|
|
90
|
-
|| !(await readAt(file, localOffset + 30, localNameSize)).equals(rawName)) fail('ZIP local/central identity mismatch');
|
|
91
|
-
if (!(flags & 8) && (local.readUInt32LE(14) !== central.readUInt32LE(position + 16)
|
|
92
|
-
|| local.readUInt32LE(18) !== compressed || local.readUInt32LE(22) !== expanded)) fail('ZIP local size mismatch');
|
|
93
|
-
if (!directory) {
|
|
94
|
-
// Bound the actual inflated bytes, not only the central-directory claim,
|
|
95
|
-
// before handing the same archive to the native extraction command.
|
|
96
|
-
if (method === 0 && compressed !== expanded) fail('Invalid stored ZIP size');
|
|
97
|
-
let actualSize = 0, crc = 0xffffffff;
|
|
98
|
-
const sink = new Writable({ write(chunk, encoding, callback) {
|
|
99
|
-
actualSize += chunk.length;
|
|
100
|
-
if (actualSize > expanded) return callback(new Error('ZIP expansion exceeds declared size'));
|
|
101
|
-
for (const byte of chunk) crc = (crc >>> 8) ^ crcTable[(crc ^ byte) & 255];
|
|
102
|
-
callback();
|
|
103
|
-
} });
|
|
104
|
-
if (compressed) {
|
|
105
|
-
const input = createReadStream(archive, { start: dataStart, end: dataStart + compressed - 1 });
|
|
106
|
-
if (method === 8) await pipeline(input, createInflateRaw(), sink, { signal });
|
|
107
|
-
else await pipeline(input, sink, { signal });
|
|
108
|
-
}
|
|
109
|
-
if (actualSize !== expanded || ((crc ^ 0xffffffff) >>> 0) !== central.readUInt32LE(position + 16)) fail('ZIP content size/CRC mismatch');
|
|
110
|
-
}
|
|
111
|
-
// Reject Unicode-path/ZIP64 extra fields: native tar must use the same name
|
|
112
|
-
// and sizes checked here, without a second interpretation of this entry.
|
|
113
|
-
for (const extra of [central.subarray(position + 46 + nameSize, position + 46 + nameSize + extraSize),
|
|
114
|
-
await readAt(file, localOffset + 30 + localNameSize, localExtraSize)]) {
|
|
115
|
-
for (let at = 0; at < extra.length;) {
|
|
116
|
-
if (at + 4 > extra.length) fail('Invalid ZIP extra field');
|
|
117
|
-
const kind = extra.readUInt16LE(at), length = extra.readUInt16LE(at + 2);
|
|
118
|
-
if (![0x5455, 0x7875].includes(kind) || at + 4 + length > extra.length) fail('Unsupported ZIP extension');
|
|
119
|
-
at += 4 + length;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
ranges.push([localOffset, dataStart + compressed]);
|
|
123
|
-
entries.push({ name, kind: directory ? 'directory' : 'file', size: expanded });
|
|
124
|
-
position += 46 + nameSize + extraSize + commentSize;
|
|
173
|
+
const record = zipCentralRecord(central, position, totalExpanded);
|
|
174
|
+
ranges.push(await zipMember(file, archive, record, offset, signal));
|
|
175
|
+
entries.push({ name: record.name, kind: record.directory ? 'directory' : 'file', size: record.expanded });
|
|
176
|
+
totalExpanded = record.total;
|
|
177
|
+
position = record.next;
|
|
125
178
|
}
|
|
126
179
|
if (position !== central.length) fail('Extra ZIP directory records');
|
|
127
|
-
ranges
|
|
128
|
-
if (ranges.some((range, index) => index && range[0] < ranges[index - 1][1])) fail('Overlapping ZIP members');
|
|
180
|
+
zipRanges(ranges);
|
|
129
181
|
return entries;
|
|
130
182
|
} finally { await file.close(); }
|
|
131
183
|
}
|
|
@@ -153,6 +205,33 @@ function paxFields(bytes) {
|
|
|
153
205
|
}
|
|
154
206
|
return fields;
|
|
155
207
|
}
|
|
208
|
+
// The first zero block ends the archive: at least one more block, then only zero bytes to EOF.
|
|
209
|
+
async function tarTrailer(file, offset, total, pax) {
|
|
210
|
+
if (pax || offset + 512 > total) fail('Truncated TAR trailer');
|
|
211
|
+
for (let at = offset; at < total; at += 65536) {
|
|
212
|
+
if (!(await readAt(file, at, Math.min(65536, total - at))).every(value => value === 0)) fail('Extra TAR payload after trailer');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// ustar identity, checksum, then size and permission bounds for the member data after offset.
|
|
216
|
+
function tarHeader(header, offset, total) {
|
|
217
|
+
if (header.subarray(257, 263).toString('ascii') !== 'ustar\0') fail('Unsupported TAR header format');
|
|
218
|
+
let checksum = 0;
|
|
219
|
+
for (let index = 0; index < 512; index++) checksum += index >= 148 && index < 156 ? 32 : header[index];
|
|
220
|
+
if (checksum !== tarNumber(header.subarray(148, 156))) fail('Invalid TAR checksum');
|
|
221
|
+
const size = tarNumber(header.subarray(124, 136));
|
|
222
|
+
const kind = String.fromCharCode(header[156]);
|
|
223
|
+
if (size > (kind === 'x' ? 65536 : LIMITS.member) || offset + Math.ceil(size / 512) * 512 > total
|
|
224
|
+
|| tarNumber(header.subarray(100, 108)) & 0o7000) fail('Unsafe TAR size or permissions');
|
|
225
|
+
return { size, kind };
|
|
226
|
+
}
|
|
227
|
+
// A pending PAX record overrides the header name and link target.
|
|
228
|
+
function tarMember(header, kind, size, pax) {
|
|
229
|
+
if (!['\0', '0', '5', '2'].includes(kind) || (kind !== '0' && kind !== '\0' && size)) fail('Unsupported TAR member');
|
|
230
|
+
const prefix = tarText(header.subarray(345, 500));
|
|
231
|
+
const name = pax && Object.hasOwn(pax, 'path') ? pax.path : (prefix ? prefix + '/' : '') + tarText(header.subarray(0, 100));
|
|
232
|
+
return { name, kind: kind === '5' ? 'directory' : kind === '2' ? 'link' : 'file', size,
|
|
233
|
+
link: pax && Object.hasOwn(pax, 'linkpath') ? pax.linkpath : tarText(header.subarray(157, 257)) };
|
|
234
|
+
}
|
|
156
235
|
async function tarEntries(archive) {
|
|
157
236
|
const file = await fs.open(archive, 'r');
|
|
158
237
|
try {
|
|
@@ -163,30 +242,16 @@ async function tarEntries(archive) {
|
|
|
163
242
|
const header = await readAt(file, offset, 512);
|
|
164
243
|
offset += 512;
|
|
165
244
|
if (header.every(value => value === 0)) {
|
|
166
|
-
|
|
167
|
-
for (let at = offset; at < total; at += 65536) {
|
|
168
|
-
if (!(await readAt(file, at, Math.min(65536, total - at))).every(value => value === 0)) fail('Extra TAR payload after trailer');
|
|
169
|
-
}
|
|
245
|
+
await tarTrailer(file, offset, total, pax);
|
|
170
246
|
return entries;
|
|
171
247
|
}
|
|
172
248
|
if (++headers > LIMITS.members * 2) fail('Too many TAR headers');
|
|
173
|
-
|
|
174
|
-
let checksum = 0;
|
|
175
|
-
for (let index = 0; index < 512; index++) checksum += index >= 148 && index < 156 ? 32 : header[index];
|
|
176
|
-
if (checksum !== tarNumber(header.subarray(148, 156))) fail('Invalid TAR checksum');
|
|
177
|
-
const size = tarNumber(header.subarray(124, 136));
|
|
178
|
-
const kind = String.fromCharCode(header[156]);
|
|
179
|
-
if (size > (kind === 'x' ? 65536 : LIMITS.member) || offset + Math.ceil(size / 512) * 512 > total
|
|
180
|
-
|| tarNumber(header.subarray(100, 108)) & 0o7000) fail('Unsafe TAR size or permissions');
|
|
249
|
+
const { size, kind } = tarHeader(header, offset, total);
|
|
181
250
|
if (kind === 'x') {
|
|
182
251
|
if (pax) fail('Chained TAR PAX headers');
|
|
183
252
|
pax = paxFields(await readAt(file, offset, size));
|
|
184
253
|
} else {
|
|
185
|
-
|
|
186
|
-
const prefix = tarText(header.subarray(345, 500));
|
|
187
|
-
const name = pax && Object.hasOwn(pax, 'path') ? pax.path : (prefix ? prefix + '/' : '') + tarText(header.subarray(0, 100));
|
|
188
|
-
entries.push({ name, kind: kind === '5' ? 'directory' : kind === '2' ? 'link' : 'file', size,
|
|
189
|
-
link: pax && Object.hasOwn(pax, 'linkpath') ? pax.linkpath : tarText(header.subarray(157, 257)) });
|
|
254
|
+
entries.push(tarMember(header, kind, size, pax));
|
|
190
255
|
pax = null;
|
|
191
256
|
}
|
|
192
257
|
offset += Math.ceil(size / 512) * 512;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Operator backend pin for a standalone StackBite state directory: the
|
|
3
|
+
// <state>/workstack-backend.json document that private_pty/workstack_deploy.py
|
|
4
|
+
// read_backend_id validates. Same schema, file and create-once rule as the
|
|
5
|
+
// WorkBite launcher's ensureBackendId; the ID pins this owner only and grants no
|
|
6
|
+
// phone execution or Work Stack Task authority. Nothing runs on import: callers
|
|
7
|
+
// (Phone enablement) invoke ensureBackendPin with an explicit trusted state directory.
|
|
8
|
+
const fs = require('node:fs/promises');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const { randomBytes } = require('node:crypto');
|
|
11
|
+
|
|
12
|
+
const SCHEMA = 'stackbite.workstack-backend.v1';
|
|
13
|
+
const FILE = 'workstack-backend.json';
|
|
14
|
+
const MAXIMUM = 4096;
|
|
15
|
+
const BACKEND_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
16
|
+
const PREFIX = /^[a-z][a-z0-9]{0,30}-$/;
|
|
17
|
+
const KEY = /"(?:[^"\\]|\\.)*"\s*:/g;
|
|
18
|
+
|
|
19
|
+
class BackendPinError extends Error {
|
|
20
|
+
constructor(code, message) { super(message); this.name = 'BackendPinError'; this.code = code; }
|
|
21
|
+
}
|
|
22
|
+
function refuse(code, message) { throw new BackendPinError(code, message); }
|
|
23
|
+
|
|
24
|
+
// The state directory must already exist: the native runtime owns state discovery and creation.
|
|
25
|
+
async function stateFile(stateDir) {
|
|
26
|
+
if (typeof stateDir !== 'string' || !path.isAbsolute(stateDir) || /[\x00-\x1f]/.test(stateDir)) {
|
|
27
|
+
refuse('state_invalid', 'The StackBite state directory must be an explicit absolute path.');
|
|
28
|
+
}
|
|
29
|
+
let info;
|
|
30
|
+
try { info = await fs.lstat(stateDir); } catch { info = null; }
|
|
31
|
+
if (!info || !info.isDirectory() || info.isSymbolicLink()) {
|
|
32
|
+
refuse('state_invalid', 'The StackBite state directory is missing or not a real directory; initialize StackBite first.');
|
|
33
|
+
}
|
|
34
|
+
return path.join(stateDir, FILE);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Mirrors read_backend_id: strict UTF-8 JSON, exactly {schema, backend_id}, no duplicate keys.
|
|
38
|
+
function parsePin(bytes) {
|
|
39
|
+
let text, document;
|
|
40
|
+
try {
|
|
41
|
+
text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
42
|
+
document = JSON.parse(text);
|
|
43
|
+
} catch { return null; }
|
|
44
|
+
if (document === null || typeof document !== 'object' || Array.isArray(document)) return null;
|
|
45
|
+
const keys = Object.keys(document);
|
|
46
|
+
if (keys.length !== 2 || (text.match(KEY) || []).length !== 2 || document.schema !== SCHEMA) return null;
|
|
47
|
+
return typeof document.backend_id === 'string' && BACKEND_ID.test(document.backend_id) ? document.backend_id : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readBytes(file) {
|
|
51
|
+
let info;
|
|
52
|
+
try { info = await fs.lstat(file); } catch (error) {
|
|
53
|
+
if (error.code === 'ENOENT') return null;
|
|
54
|
+
refuse('pin_unreadable', 'The StackBite backend pin is unreadable; inspect it rather than replacing it.');
|
|
55
|
+
}
|
|
56
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > MAXIMUM) {
|
|
57
|
+
refuse('pin_invalid', 'The StackBite backend pin is not a small regular file; inspect it rather than replacing it.');
|
|
58
|
+
}
|
|
59
|
+
try { return await fs.readFile(file); } catch {
|
|
60
|
+
return refuse('pin_unreadable', 'The StackBite backend pin is unreadable; inspect it rather than replacing it.');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function readExisting(file) {
|
|
65
|
+
const bytes = await readBytes(file);
|
|
66
|
+
if (bytes === null) return null;
|
|
67
|
+
const backendId = bytes.length <= MAXIMUM ? parsePin(bytes) : null;
|
|
68
|
+
if (!backendId) refuse('pin_invalid', 'The StackBite backend pin is invalid; inspect it rather than replacing it.');
|
|
69
|
+
return backendId;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Returns the configured backend ID, or null when this state has no pin yet. Never writes.
|
|
73
|
+
async function readBackendPin(stateDir) {
|
|
74
|
+
return readExisting(await stateFile(stateDir));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Publishes complete bytes with a create-new hard link: a reader sees no pin or the whole
|
|
78
|
+
// pin, and an existing destination (valid or not) is never replaced. Returns false if raced.
|
|
79
|
+
// A link error other than EEXIST leaves the outcome unknown (the link may have landed), so it
|
|
80
|
+
// is never reported as absence and the destination is not removed, rewritten or retried here.
|
|
81
|
+
async function publish(file, bytes) {
|
|
82
|
+
const temporary = path.join(path.dirname(file), `.${FILE}.${randomBytes(8).toString('hex')}.tmp`);
|
|
83
|
+
let handle, linking = false;
|
|
84
|
+
try {
|
|
85
|
+
handle = await fs.open(temporary, 'wx', 0o600);
|
|
86
|
+
await handle.writeFile(bytes);
|
|
87
|
+
await handle.sync();
|
|
88
|
+
await handle.close(); handle = null;
|
|
89
|
+
linking = true;
|
|
90
|
+
await fs.link(temporary, file);
|
|
91
|
+
return true;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (linking && error.code === 'EEXIST') return false;
|
|
94
|
+
if (linking) {
|
|
95
|
+
refuse('pin_outcome_unknown', 'The StackBite backend pin publication outcome is unknown; read the pin again before retrying.');
|
|
96
|
+
}
|
|
97
|
+
return refuse('pin_unpublished', 'The StackBite backend pin could not be prepared; this attempt did not configure a pin.');
|
|
98
|
+
} finally {
|
|
99
|
+
if (handle) await handle.close().catch(() => {});
|
|
100
|
+
await fs.unlink(temporary).catch(() => {});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Creates the pin once or reuses the existing valid one. Result state is 'created' only when
|
|
105
|
+
// the published file reads back byte-identical; invalid or unreadable pins are refused, not repaired.
|
|
106
|
+
async function ensureBackendPin(stateDir, { prefix = 'stackbite-' } = {}) {
|
|
107
|
+
if (typeof prefix !== 'string' || !PREFIX.test(prefix)) refuse('prefix_invalid', 'Backend pin prefix is invalid.');
|
|
108
|
+
const file = await stateFile(stateDir);
|
|
109
|
+
const existing = await readExisting(file);
|
|
110
|
+
if (existing) return { backendId: existing, state: 'reused' };
|
|
111
|
+
const backendId = `${prefix}${randomBytes(12).toString('hex')}`;
|
|
112
|
+
const bytes = Buffer.from(JSON.stringify({ schema: SCHEMA, backend_id: backendId }) + '\n');
|
|
113
|
+
if (!await publish(file, bytes)) {
|
|
114
|
+
const raced = await readExisting(file);
|
|
115
|
+
if (!raced) refuse('pin_unverified', 'The StackBite backend pin disappeared during creation; inspect the state directory.');
|
|
116
|
+
return { backendId: raced, state: 'reused' };
|
|
117
|
+
}
|
|
118
|
+
const published = await readBytes(file);
|
|
119
|
+
if (!published || !published.equals(bytes)) {
|
|
120
|
+
refuse('pin_unverified', 'The StackBite backend pin changed during creation; inspect it before use.');
|
|
121
|
+
}
|
|
122
|
+
return { backendId, state: 'created' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { ensureBackendPin, readBackendPin, BackendPinError, SCHEMA, FILE };
|
|
@@ -10,9 +10,8 @@ const { cacheDirectory, existingCacheDirectory } = require('./cache');
|
|
|
10
10
|
const { parseSetup, stageReporter } = require('./setup');
|
|
11
11
|
const packageVersion = require('../../../package.json').version;
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if (!['win32-x64', 'linux-x64'].includes(key)) fail(`Stackbite does not have a native release for ${key}; supported platforms are Windows x64 and Linux x64.`);
|
|
13
|
+
// Only a glibc Linux host (or an injected runtime report) may select the Linux release.
|
|
14
|
+
function checkLinuxRuntime(platform, runtime) {
|
|
16
15
|
if (platform === 'linux' && (process.platform === 'linux' || Object.hasOwn(runtime, 'glibcVersionRuntime'))) {
|
|
17
16
|
const glibc = Object.hasOwn(runtime, 'glibcVersionRuntime')
|
|
18
17
|
? runtime.glibcVersionRuntime : process.report.getReport().header.glibcVersionRuntime;
|
|
@@ -20,28 +19,46 @@ function releaseSpec(manifest, platform = process.platform, arch = process.arch,
|
|
|
20
19
|
fail('Stackbite requires Linux x64 with glibc; Alpine/musl Linux is unsupported. No release was downloaded.');
|
|
21
20
|
}
|
|
22
21
|
}
|
|
22
|
+
}
|
|
23
|
+
function checkReleaseIdentity(manifest) {
|
|
23
24
|
if (manifest.schema !== 1 || manifest.version !== packageVersion || !/^[a-f0-9]{40}$/.test(manifest.commit)
|
|
24
25
|
|| manifest.repository !== 'Shinick-Han/stackbite') fail('Invalid pinned Stackbite release identity');
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const asset = root + (platform === 'win32' ? '.zip' : '.tar.gz');
|
|
26
|
+
}
|
|
27
|
+
// The platform entry must name exactly the derived root, asset and GitHub release URL.
|
|
28
|
+
function checkPinnedAsset(spec, manifest, root, asset) {
|
|
29
29
|
if (!spec || spec.root !== root || spec.asset !== asset
|
|
30
30
|
|| spec.url !== `https://github.com/${manifest.repository}/releases/download/v${manifest.version}/${asset}`
|
|
31
31
|
|| ![spec.sha256, spec.manifestSha256, spec.metadataSha256].every(value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value))
|
|
32
32
|
|| !Number.isSafeInteger(spec.size) || spec.size <= 0 || spec.size > LIMITS.download) fail('Invalid pinned release asset');
|
|
33
|
+
}
|
|
34
|
+
function releaseSpec(manifest, platform = process.platform, arch = process.arch, runtime = {}) {
|
|
35
|
+
const key = `${platform}-${arch}`;
|
|
36
|
+
if (!['win32-x64', 'linux-x64'].includes(key)) fail(`Stackbite does not have a native release for ${key}; supported platforms are Windows x64 and Linux x64.`);
|
|
37
|
+
checkLinuxRuntime(platform, runtime);
|
|
38
|
+
checkReleaseIdentity(manifest);
|
|
39
|
+
const spec = manifest.platforms?.[key];
|
|
40
|
+
const host = platform === 'win32' ? 'windows-x64' : 'linux-x86_64';
|
|
41
|
+
const root = `stackbite-${manifest.version}-${host}-${manifest.commit.slice(0, 12)}`;
|
|
42
|
+
const asset = root + (platform === 'win32' ? '.zip' : '.tar.gz');
|
|
43
|
+
checkPinnedAsset(spec, manifest, root, asset);
|
|
33
44
|
return { ...spec, key, platform, host, version: manifest.version, commit: manifest.commit };
|
|
34
45
|
}
|
|
35
|
-
async function
|
|
46
|
+
async function payloadRoot(directory) {
|
|
36
47
|
const info = await fs.lstat(directory);
|
|
37
48
|
if (!info.isDirectory() || info.isSymbolicLink()) fail('Cached release root is not a regular directory');
|
|
38
|
-
|
|
49
|
+
return fs.realpath(directory);
|
|
50
|
+
}
|
|
51
|
+
// Both pinned documents are read before either digest is compared, then parsed in the same order.
|
|
52
|
+
async function pinnedDocuments(root, spec) {
|
|
39
53
|
const manifestPath = path.join(root, 'SHA256SUMS.json'), metadataPath = path.join(root, 'portable.json');
|
|
40
54
|
const manifestBytes = await readSmall(manifestPath), metadataBytes = await readSmall(metadataPath, 16384);
|
|
41
55
|
if (createHash('sha256').update(manifestBytes).digest('hex') !== spec.manifestSha256
|
|
42
56
|
|| createHash('sha256').update(metadataBytes).digest('hex') !== spec.metadataSha256) fail('Pinned release manifest or metadata SHA-256 mismatch');
|
|
43
57
|
const hashes = JSON.parse(manifestBytes.toString('utf8'));
|
|
44
58
|
const metadata = JSON.parse(metadataBytes.toString('utf8'));
|
|
59
|
+
return { hashes, metadata };
|
|
60
|
+
}
|
|
61
|
+
function expectedMembers(hashes) {
|
|
45
62
|
if (!hashes || Array.isArray(hashes) || typeof hashes !== 'object' || !Object.keys(hashes).length
|
|
46
63
|
|| Object.keys(hashes).length > LIMITS.members) fail('Invalid release file manifest');
|
|
47
64
|
const expected = new Set();
|
|
@@ -50,6 +67,10 @@ async function validatePayload(directory, spec) {
|
|
|
50
67
|
expected.add(name);
|
|
51
68
|
}
|
|
52
69
|
if (!expected.has('portable.json') || !expected.has('runtime/build-info.json')) fail('Required release metadata is missing');
|
|
70
|
+
return expected;
|
|
71
|
+
}
|
|
72
|
+
// Walks the payload; every file or in-root link must resolve to a bounded regular file and pinned members must match.
|
|
73
|
+
async function payloadFiles(root, expected, hashes) {
|
|
53
74
|
const actual = new Set();
|
|
54
75
|
let total = 0, entries = 0;
|
|
55
76
|
async function visit(directoryPath) {
|
|
@@ -71,6 +92,10 @@ async function validatePayload(directory, spec) {
|
|
|
71
92
|
}
|
|
72
93
|
}
|
|
73
94
|
await visit(root);
|
|
95
|
+
return actual;
|
|
96
|
+
}
|
|
97
|
+
// The checksum documents themselves are the only unhashed members; Linux also ships the text form.
|
|
98
|
+
async function checkMemberSet(root, spec, hashes, expected, actual) {
|
|
74
99
|
expected.add('SHA256SUMS.json');
|
|
75
100
|
if (spec.platform === 'linux') {
|
|
76
101
|
expected.add('SHA256SUMS');
|
|
@@ -78,12 +103,16 @@ async function validatePayload(directory, spec) {
|
|
|
78
103
|
if (text.toString('utf8') !== Object.entries(hashes).map(([name, digest]) => `${digest} ${name}\n`).join('')) fail('Linux checksum text disagrees with pinned manifest');
|
|
79
104
|
}
|
|
80
105
|
if (actual.size !== expected.size || [...actual].some(name => !expected.has(name))) fail('Release files disagree with pinned manifest');
|
|
106
|
+
}
|
|
107
|
+
async function checkProductIdentity(root, spec, metadata) {
|
|
81
108
|
const build = JSON.parse((await readSmall(path.join(root, 'runtime', 'build-info.json'), 16384)).toString('utf8'));
|
|
82
109
|
for (const value of [metadata, build]) {
|
|
83
110
|
if (value.version !== spec.version || value.commit !== spec.commit || value.product !== 'Stackbite'
|
|
84
111
|
|| (value.command !== undefined && value.command !== 'stackbite')
|
|
85
112
|
|| (value.platform !== undefined && value.platform !== spec.host)) fail('Release product/version/commit identity mismatch');
|
|
86
113
|
}
|
|
114
|
+
}
|
|
115
|
+
async function nativeExecutable(root, spec) {
|
|
87
116
|
const executable = path.join(root, spec.platform === 'win32' ? 'stackbite.exe' : 'stackbite');
|
|
88
117
|
const image = await fs.lstat(executable);
|
|
89
118
|
if (!image.isFile() || image.isSymbolicLink()) fail('Native release executable is missing or a link');
|
|
@@ -96,6 +125,15 @@ async function validatePayload(directory, spec) {
|
|
|
96
125
|
if (spec.platform === 'linux' && process.platform !== 'win32' && !(image.mode & 0o111)) fail('Native release executable has no execute permission');
|
|
97
126
|
return executable;
|
|
98
127
|
}
|
|
128
|
+
async function validatePayload(directory, spec) {
|
|
129
|
+
const root = await payloadRoot(directory);
|
|
130
|
+
const { hashes, metadata } = await pinnedDocuments(root, spec);
|
|
131
|
+
const expected = expectedMembers(hashes);
|
|
132
|
+
const actual = await payloadFiles(root, expected, hashes);
|
|
133
|
+
await checkMemberSet(root, spec, hashes, expected, actual);
|
|
134
|
+
await checkProductIdentity(root, spec, metadata);
|
|
135
|
+
return nativeExecutable(root, spec);
|
|
136
|
+
}
|
|
99
137
|
async function ensureBinary(spec, { cache = cacheDirectory(), downloadImpl = download, extractImpl = extractArchive,
|
|
100
138
|
checkOnly = false, reportCached = false, progress = () => {} } = {}) {
|
|
101
139
|
cache = path.resolve(cache);
|
|
@@ -13,6 +13,21 @@ function secureURL(value) {
|
|
|
13
13
|
}
|
|
14
14
|
return url;
|
|
15
15
|
}
|
|
16
|
+
// The redirect response is discarded; its bounded location must resolve to an approved origin.
|
|
17
|
+
function redirectTarget(response, redirects, current) {
|
|
18
|
+
const location = response.headers.location;
|
|
19
|
+
response.destroy();
|
|
20
|
+
if (redirects === 5 || typeof location !== 'string' || location.length > 4096) fail('Invalid release redirect');
|
|
21
|
+
return secureURL(new URL(location, current).href);
|
|
22
|
+
}
|
|
23
|
+
// Only an identity-encoded 200 whose declared length (if any) is the pinned size is read.
|
|
24
|
+
function acceptResponse(response, spec) {
|
|
25
|
+
if (response.statusCode !== 200 || (response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity')) {
|
|
26
|
+
response.destroy(); fail('Release download was not available');
|
|
27
|
+
}
|
|
28
|
+
const length = response.headers['content-length'];
|
|
29
|
+
if (length !== undefined && String(spec.size) !== length) { response.destroy(); fail('Release download size mismatch'); }
|
|
30
|
+
}
|
|
16
31
|
async function download(spec, destination, { request = https.get, timeout = LIMITS.timeout } = {}) {
|
|
17
32
|
if (!Number.isSafeInteger(spec.size) || spec.size <= 0 || spec.size > LIMITS.download) fail('Invalid pinned download size');
|
|
18
33
|
const controller = new AbortController();
|
|
@@ -27,17 +42,10 @@ async function download(spec, destination, { request = https.get, timeout = LIMI
|
|
|
27
42
|
req.once('error', () => reject(new Error('Release download failed or timed out')));
|
|
28
43
|
});
|
|
29
44
|
if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
|
|
30
|
-
|
|
31
|
-
response.destroy();
|
|
32
|
-
if (redirects === 5 || typeof location !== 'string' || location.length > 4096) fail('Invalid release redirect');
|
|
33
|
-
current = secureURL(new URL(location, current).href);
|
|
45
|
+
current = redirectTarget(response, redirects, current);
|
|
34
46
|
continue;
|
|
35
47
|
}
|
|
36
|
-
|
|
37
|
-
response.destroy(); fail('Release download was not available');
|
|
38
|
-
}
|
|
39
|
-
const length = response.headers['content-length'];
|
|
40
|
-
if (length !== undefined && String(spec.size) !== length) { response.destroy(); fail('Release download size mismatch'); }
|
|
48
|
+
acceptResponse(response, spec);
|
|
41
49
|
let size = 0;
|
|
42
50
|
const bounded = new Transform({ transform(chunk, encoding, callback) {
|
|
43
51
|
size += chunk.length;
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"commit": "
|
|
3
|
+
"version": "0.5.47",
|
|
4
|
+
"commit": "45b2a86d7ec77c91c442b4421712d2e44d54e8fd",
|
|
5
5
|
"repository": "Shinick-Han/stackbite",
|
|
6
6
|
"platforms": {
|
|
7
7
|
"win32-x64": {
|
|
8
|
-
"asset": "stackbite-0.5.
|
|
9
|
-
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.
|
|
10
|
-
"sha256": "
|
|
11
|
-
"size":
|
|
12
|
-
"root": "stackbite-0.5.
|
|
13
|
-
"manifestSha256": "
|
|
14
|
-
"metadataSha256": "
|
|
8
|
+
"asset": "stackbite-0.5.47-windows-x64-45b2a86d7ec7.zip",
|
|
9
|
+
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.47/stackbite-0.5.47-windows-x64-45b2a86d7ec7.zip",
|
|
10
|
+
"sha256": "1be4c2a63e46a7058c0b9df2bd2796e1d896f83f502306739e58dff526c1169b",
|
|
11
|
+
"size": 44970086,
|
|
12
|
+
"root": "stackbite-0.5.47-windows-x64-45b2a86d7ec7",
|
|
13
|
+
"manifestSha256": "66d2fc15af6bd94f5b802b4aa6167e6e73364e0e3e703dbd1e3bf13477a486a2",
|
|
14
|
+
"metadataSha256": "fa6b69cb61140845bf078aec9876f0657305f3449cbb421f2fef038e72324334"
|
|
15
15
|
},
|
|
16
16
|
"linux-x64": {
|
|
17
|
-
"asset": "stackbite-0.5.
|
|
18
|
-
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.
|
|
19
|
-
"sha256": "
|
|
20
|
-
"size":
|
|
21
|
-
"root": "stackbite-0.5.
|
|
22
|
-
"manifestSha256": "
|
|
23
|
-
"metadataSha256": "
|
|
17
|
+
"asset": "stackbite-0.5.47-linux-x86_64-45b2a86d7ec7.tar.gz",
|
|
18
|
+
"url": "https://github.com/Shinick-Han/stackbite/releases/download/v0.5.47/stackbite-0.5.47-linux-x86_64-45b2a86d7ec7.tar.gz",
|
|
19
|
+
"sha256": "4bcce878c688dbe6011463e5cd33bbcba367f2b20f4d193743302b6a43cb333c",
|
|
20
|
+
"size": 32030305,
|
|
21
|
+
"root": "stackbite-0.5.47-linux-x86_64-45b2a86d7ec7",
|
|
22
|
+
"manifestSha256": "e69a3711e69c9ca4dee1523bebfeab16766569f7a19f85571edf5637db4dde23",
|
|
23
|
+
"metadataSha256": "fdccba94e7df1d30ea86f9eaee4c74986c53d71428db8a05778c42bd4092ed20"
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
}
|