mirror of
https://github.com/smogon/sprites.git
synced 2026-09-10 11:05:36 -05:00
Let a deploy publish a link as well as a file
A served name carries a content hash, so a reader that composes a fixed path can't find one. The way out is to publish the set twice, once under the stamped names and once under the bare ones, and a link is the one thing the queue couldn't say: Copy dereferences and Write has bytes. Add a Symlink op beside them. What ctx.symlink takes is not a link target but the destination of the thing being named, since a published tree is unpacked somewhere else and its halves can land apart; the path between the two is computed here and is what gets written. It takes the same duplicate-dst and absolute-dst checks the other ops do, plus one of its own -- resolved lexically against the directory it sits in, a target has to stay inside the tree, since the receiving side extracts as root and a link that walks out of it is the same write to anywhere that an absolute member name is. run() makes a real symlink in every mode, clearing the name first so a rerun over an existing tree behaves the way copy and write already do, and pack() emits a symlink member with no body, which is what a stream reader on the other end sees. refactor digests bytes, and a link has none; what it publishes is the name it points at, so that is what it records and what a retarget shows up as. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as nodePath from 'node:path';
|
||||
|
||||
import b32encode from 'base32-encode';
|
||||
|
||||
@@ -24,6 +25,11 @@ export type DeployCtx = {
|
||||
// read, list, and hash do file I/O.
|
||||
copy(src: CopySource, dst: string): void,
|
||||
write(dst: string, data: string): void,
|
||||
// A second name for something else this tree publishes, carried as a link
|
||||
// rather than a second copy. `names` is that thing's destination, not a
|
||||
// link target: what gets written is the path from `dst` to it, since the
|
||||
// two ends can be moved apart by whoever unpacks the tree.
|
||||
symlink(dst: string, names: string): void,
|
||||
read(src: CopySource): Promise<string>,
|
||||
list(dir: string): Promise<SrcFile[]>,
|
||||
// 8-char base32 content stamp. One source: the digest of its bytes,
|
||||
@@ -76,6 +82,9 @@ export function makeCtx(casDir: string, queue: ActionQueue): DeployCtx {
|
||||
write(dst: string, data: string): void {
|
||||
queue.write(data, dst);
|
||||
},
|
||||
symlink(dst: string, names: string): void {
|
||||
queue.symlink(nodePath.relative(nodePath.dirname(nodePath.normalize(dst)), names), dst);
|
||||
},
|
||||
async read(src: CopySource): Promise<string> {
|
||||
return await fs.readFile(srcPath(src), 'utf8');
|
||||
},
|
||||
|
||||
@@ -337,6 +337,12 @@ async function outputs(aq: ActionQueue): Promise<Map<string, string>> {
|
||||
out.set(e.dst, crypto.createHash('sha256').update(e.op.data).digest('hex'));
|
||||
continue;
|
||||
}
|
||||
// A link has no bytes; what it publishes is the name it points at, so
|
||||
// that is what a retarget has to show up as.
|
||||
if (e.op.type === 'Symlink') {
|
||||
out.set(e.dst, `symlink:${e.op.target}`);
|
||||
continue;
|
||||
}
|
||||
// A CAS path spells its own digest, so only raw sources are read.
|
||||
let cas = new RegExp(`^${CAS_DIR}/[0-9a-f]{2}/([0-9a-f]{64})\\.`).exec(e.op.src);
|
||||
out.set(e.dst, cas ? cas[1]! : (await hashFile(e.op.src)).toString('hex'));
|
||||
|
||||
@@ -10,13 +10,16 @@ type Op = {
|
||||
} | {
|
||||
type: 'Copy',
|
||||
src: string,
|
||||
} | {
|
||||
type: 'Symlink',
|
||||
target: string,
|
||||
};
|
||||
|
||||
type OpEntry = {
|
||||
type: 'Op',
|
||||
op: Op,
|
||||
dst: string,
|
||||
valid: 'Success' | 'Absolute' | 'Multiple',
|
||||
valid: 'Success' | 'Absolute' | 'Multiple' | 'Escapes',
|
||||
debugObjs: unknown[]
|
||||
};
|
||||
|
||||
@@ -28,6 +31,27 @@ type DebugEntry = {
|
||||
|
||||
export type LogEntry = OpEntry | DebugEntry;
|
||||
|
||||
// What a link names, resolved lexically against the directory it sits in: the
|
||||
// tree it describes isn't on disk anywhere yet, so there is nothing else to
|
||||
// resolve against. Null where it walks out of the tree, or names the tree
|
||||
// itself, neither of which is a name this tree can publish.
|
||||
function resolveLink(dst: string, target: string): string | null {
|
||||
let parts = nodePath.dirname(dst).split('/').filter(p => p !== '' && p !== '.');
|
||||
for (let part of target.split('/')) {
|
||||
if (part === '' || part === '.') {
|
||||
continue;
|
||||
}
|
||||
if (part !== '..') {
|
||||
parts.push(part);
|
||||
} else if (parts.length > 0) {
|
||||
parts.pop();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parts.length === 0 ? null : parts.join('/');
|
||||
}
|
||||
|
||||
export class ActionQueue {
|
||||
#seen: Map<string, OpEntry | 'MoreThan1'>;
|
||||
// Have an accessor for this in the future? idk
|
||||
@@ -55,7 +79,7 @@ export class ActionQueue {
|
||||
this.log.push({type: 'Debug', obj, stray});
|
||||
}
|
||||
|
||||
#pushOp(op: Op, dst: string) {
|
||||
#pushOp(op: Op, dst: string): OpEntry {
|
||||
dst = nodePath.normalize(dst);
|
||||
let entry: OpEntry = {
|
||||
type: 'Op',
|
||||
@@ -81,6 +105,7 @@ export class ActionQueue {
|
||||
}
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
copy(src: string, dst: string) {
|
||||
@@ -91,6 +116,16 @@ export class ActionQueue {
|
||||
this.#pushOp({type: 'Write', data}, dst);
|
||||
}
|
||||
|
||||
// A link the receiving side extracts as root, so its target may only name
|
||||
// something else in this tree.
|
||||
symlink(target: string, dst: string) {
|
||||
let entry = this.#pushOp({type: 'Symlink', target}, dst);
|
||||
if (nodePath.isAbsolute(target) || resolveLink(entry.dst, target) === null) {
|
||||
this.valid = false;
|
||||
entry.valid = 'Escapes';
|
||||
}
|
||||
}
|
||||
|
||||
skip() {
|
||||
for (let obj of this.#debugBuffer) {
|
||||
this.gdebug(obj, true);
|
||||
@@ -115,6 +150,8 @@ export class ActionQueue {
|
||||
console.error(`COPY${addendum}: ${op.src} ==> ${entry.dst}`);
|
||||
} else if (op.type === 'Write') {
|
||||
console.error(`WRITE${addendum}: ${op.data.length} characters ==> ${entry.dst}`);
|
||||
} else if (op.type === 'Symlink') {
|
||||
console.error(`SYMLINK${addendum}: ${entry.dst} -> ${op.target}`);
|
||||
}
|
||||
} else if (entry.type === 'Debug') {
|
||||
let addendum = '';
|
||||
@@ -148,6 +185,14 @@ export class ActionQueue {
|
||||
}
|
||||
} else if (op.type === 'Write') {
|
||||
await fs.writeFile(dst, op.data);
|
||||
} else if (op.type === 'Symlink') {
|
||||
// A link and not the file it names, in every mode: what
|
||||
// the tar carries is what a directory has to hold too.
|
||||
// symlink() has no truncating open behind it, so the name
|
||||
// is cleared first to keep a rerun over an existing tree
|
||||
// working the way copy and write already do.
|
||||
await fs.rm(dst, {force: true});
|
||||
await fs.symlink(op.target, dst);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -169,6 +214,10 @@ export class ActionQueue {
|
||||
if (entry.type !== 'Op' || (filter !== undefined && !filter(entry.dst)))
|
||||
continue;
|
||||
let op = entry.op;
|
||||
if (op.type === 'Symlink') {
|
||||
t.entry({name: entry.dst, type: 'symlink', linkname: op.target}).on('error', () => {});
|
||||
continue;
|
||||
}
|
||||
let data = op.type === 'Copy' ? await fs.readFile(op.src) : op.data;
|
||||
// A dying consumer destroys the pack and every pending entry
|
||||
// sink, and each sink emits the error; the consumer is the one
|
||||
|
||||
@@ -88,17 +88,24 @@ test('ctx.list sorts, parses extensions, skips dotfiles and directories', async
|
||||
]);
|
||||
});
|
||||
|
||||
type PackedEntry = {name: string, data: string, type?: string, linkname?: string};
|
||||
|
||||
async function packedEntries(aq: ActionQueue, filter?: (dst: string) => boolean)
|
||||
: Promise<{name: string, data: string}[]> {
|
||||
: Promise<PackedEntry[]> {
|
||||
let packed = await aq.pack(filter);
|
||||
return new Promise((resolve, reject) => {
|
||||
let extract = tar.extract();
|
||||
let entries: {name: string, data: string}[] = [];
|
||||
let entries: PackedEntry[] = [];
|
||||
extract.on('entry', (header, stream, next) => {
|
||||
let chunks: Buffer[] = [];
|
||||
stream.on('data', c => chunks.push(c));
|
||||
stream.on('end', () => {
|
||||
entries.push({name: header.name, data: Buffer.concat(chunks).toString()});
|
||||
let entry: PackedEntry = {name: header.name, data: Buffer.concat(chunks).toString()};
|
||||
if (header.type === 'symlink') {
|
||||
entry.type = header.type;
|
||||
entry.linkname = header.linkname ?? undefined;
|
||||
}
|
||||
entries.push(entry);
|
||||
next();
|
||||
});
|
||||
});
|
||||
@@ -164,3 +171,63 @@ test('copy-mode materialization restores 0644 on read-only sources', async () =>
|
||||
await aq.run(out, 'copy');
|
||||
assert.equal(fs.statSync(pathlib.join(out, 'out/x.png')).mode & 0o777, 0o644);
|
||||
});
|
||||
|
||||
test('pack carries a symlink as a link, not the bytes it names', async () => {
|
||||
let aq = new ActionQueue();
|
||||
aq.write('1', 'links/xy/a-XXXX.gif');
|
||||
aq.symlink('a-XXXX.gif', 'links/xy/a.gif');
|
||||
assert.deepEqual(await packedEntries(aq), [
|
||||
{name: 'links/xy/a-XXXX.gif', data: '1'},
|
||||
{name: 'links/xy/a.gif', data: '', type: 'symlink', linkname: 'a-XXXX.gif'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('run materializes a symlink resolving beside its target', async () => {
|
||||
let dir = tmpdir();
|
||||
let aq = new ActionQueue();
|
||||
aq.write('bytes', 'xy/a-XXXX.gif');
|
||||
aq.symlink('a-XXXX.gif', 'xy/a.gif');
|
||||
let out = pathlib.join(dir, 'deploy');
|
||||
await aq.run(out, 'copy');
|
||||
let link = pathlib.join(out, 'xy/a.gif');
|
||||
assert.ok(fs.lstatSync(link).isSymbolicLink());
|
||||
assert.equal(fs.readlinkSync(link), 'a-XXXX.gif');
|
||||
assert.equal(fs.readFileSync(link, 'utf8'), 'bytes');
|
||||
// A second pass over the same tree is what a rerun looks like
|
||||
await aq.run(out, 'copy');
|
||||
assert.equal(fs.readlinkSync(link), 'a-XXXX.gif');
|
||||
});
|
||||
|
||||
test('a symlink target naming anything outside the tree invalidates the queue', async () => {
|
||||
let escaping = new ActionQueue();
|
||||
escaping.symlink('../../etc/passwd', 'xy/a.gif');
|
||||
assert.ok(!escaping.valid);
|
||||
|
||||
let absolute = new ActionQueue();
|
||||
absolute.symlink('/etc/passwd', 'xy/a.gif');
|
||||
assert.ok(!absolute.valid);
|
||||
|
||||
let tree = new ActionQueue();
|
||||
tree.symlink('..', 'xy/a.gif');
|
||||
assert.ok(!tree.valid);
|
||||
|
||||
// A target that walks up and back down again stays in the tree, which is
|
||||
// what a link between two of its subtrees looks like.
|
||||
let across = new ActionQueue();
|
||||
across.symlink('../sprites/a-XXXX.gif', '__meta/a.gif');
|
||||
across.symlink('a-XXXX.gif', 'xy/a.gif');
|
||||
assert.ok(across.valid);
|
||||
});
|
||||
|
||||
test('ctx.symlink writes the path from the link to what it names', async () => {
|
||||
let aq = new ActionQueue();
|
||||
let ctx = makeCtx('cas', aq);
|
||||
ctx.symlink('__meta/links/sprites/xy/a.gif', 'sprites/xy/a-XXXX.gif');
|
||||
ctx.symlink('__meta/a.gif', 'sprites/a-XXXX.gif');
|
||||
let ops = aq.log.filter(e => e.type === 'Op');
|
||||
assert.ok(aq.valid);
|
||||
assert.deepEqual(ops.map(e => (e.op as {target: string}).target), [
|
||||
'../../../../sprites/xy/a-XXXX.gif',
|
||||
'../sprites/a-XXXX.gif',
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user