mirror of
https://github.com/smogon/sprites.git
synced 2026-10-24 09:09:23 -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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user