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:
Christopher Monsanto
2026-08-23 15:22:45 -04:00
parent 79f0ef6497
commit a648c9e622
4 changed files with 136 additions and 5 deletions

View File

@@ -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