commit 5ebe1e5917062b49755bb7294dbba67547c0fdcd
parent c1f15aa4fe43272e29b1d0b9255fd9008ac0778b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 11 Sep 2026 12:08:49 -0400
relocate: the two ways a move ended by deleting the only copy
Both were reachable from the shipped panel, and both ended in `rm -r` on media
nothing else held.
MOVE BACK HAD NO STATE PRECONDITION. `moveOut` refuses unless the channel is
in-place; `moveBack` never asked at all. `inspect()` reports `relocated: true`
for `inconsistent` and `unreachable` as well as `ok` — config.dataDir is set in
all three — so the panel offered "Move back in place" for a channel whose config
records a target while `data/` is a REAL directory. That state is not
hypothetical: it is what `rsync --copy-links` of a channel produces, which
WORKTREES.md documents as the way to carry media into a shard. The run then
copied the target to `data.incoming`, verified it, found `data/` already real,
logged "the swap had completed", cleared the config, `rm -r`'d the target, and
finally swept `data.incoming`. Three copies in, zero out. `moveBack` now takes
the mirror refusal — not resumed, and status neither `ok` nor `in-transition`,
is a throw before anything is read — and the panel disables Move back for
`inconsistent`/`unreachable` with the reason, because a disabled button is a
courtesy and the controller is the guard.
NOTHING CHECKED WHERE THE ROOT WAS. `root = <transcriptsDir>/channels` makes
`relocatedDataDir(root, slug)` the SOURCE. `rsync -a src/ src/` succeeds,
`verifyCopy` compares the tree with itself and passes, the swap parks `data/` —
which is the "target" it just verified — links to a path that no longer exists,
and the reclaim sweep deletes the only copy. Every check in the happy path is
the tree against itself, so none of them can notice. The panel's help text ("an
absolute directory that already exists") describes `transcripts/channels` almost
word for word.
`relocationRootProblem` is the one list — blank, relative, inside the corpus,
resolving into the channel dir — and all three callers ask it: the preview the
operator reads, the action that enqueues, and the job that moves. It compares
REAL paths, and resolves the target separately from the root, because a root
outside the corpus whose `<slug>` level links back into it is invisible to a
realpath of the root alone. Absoluteness moves into the action with it, so a
relative root is an inline refusal rather than a job log.
The unit harness nested its "platter" INSIDE the corpus, which is the shape now
refused — and is why 16 green tests never saw this. Corpus and platter are
siblings now. Five cases added: the inconsistent and unreachable refusals with
both copies asserted intact, five bad roots against both the job and the
preview, the symlinked-slug root, and a relative root through the preview.
common 967/967.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 348 insertions(+), 8 deletions(-)
diff --git a/common/controller/relocateChannelMedia.test.ts b/common/controller/relocateChannelMedia.test.ts
@@ -37,12 +37,19 @@ async function withTmp(
fn: (paths: Paths, root: string, dir: string) => Promise<void>,
): Promise<void> {
const dir = await mkdtemp(path.join(tmpdir(), "ttb-relocate-"));
+ // THE CORPUS AND THE PLATTER ARE SIBLINGS under the tmpdir, never nested. A
+ // root inside the corpus is refused by design (relocationRootProblem: it
+ // would copy the channel onto itself and then reclaim the only copy), so a
+ // harness that nested them would be exercising a shape the controller
+ // rejects — and would have hidden exactly the bug that check exists for.
+ const transcriptsDir = path.join(dir, "corpus");
const paths = {
- transcriptsDir: dir,
- channelsDir: path.join(dir, "channels"),
+ transcriptsDir,
+ channelsDir: path.join(transcriptsDir, "channels"),
rsyncBin: "rsync",
} as Paths;
const root = path.join(dir, "platter");
+ await mkdir(paths.channelsDir, { recursive: true });
await mkdir(root, { recursive: true });
try {
await fn(paths, root, dir);
@@ -662,3 +669,184 @@ async function pathIsThere(p: string): Promise<boolean> {
return false;
}
}
+
+// ---------------------------------------------------------------------------
+// TWO WAYS THE MOVE USED TO EAT THE MEDIA. Both were reachable from the shipped
+// UI and both ended with rm -r on the only copy, so both get their own cases.
+// ---------------------------------------------------------------------------
+
+// `inconsistent` — config.json records a target while `data/` is a REAL
+// directory — is what `rsync --copy-links` of a channel produces, which
+// WORKTREES.md documents as the way to carry media into a shard. inspect()
+// reports relocated:true for it, so the panel offered "Move back in place", and
+// the run copied the target to data.incoming, verified it, found data/ already a
+// real dir, logged "the swap had completed", cleared the config, rm -r'd the
+// target and then swept data.incoming. Three copies in, zero out.
+test("back: an inconsistent channel is refused, and the target keeps its bytes", async () => {
+ await withTmp(async (paths, root) => {
+ const channelDir = await seed(paths, "alpha", {
+ v1: { "audio.m4a": "one" },
+ });
+ const target = relocatedDataDir(root, "alpha");
+ await relocateChannelMedia({
+ paths,
+ slug: "alpha",
+ direction: "out",
+ root,
+ onLog: () => {},
+ });
+ // Turn the link back into a real directory WITHOUT clearing config.dataDir —
+ // the --copy-links shape, reproduced.
+ await rm(path.join(channelDir, "data"));
+ await copyTree(target, path.join(channelDir, "data"));
+ assert.equal(
+ (await inspectChannelMedia(paths, "alpha")).status,
+ "inconsistent",
+ );
+
+ await assert.rejects(
+ () =>
+ relocateChannelMedia({
+ paths,
+ slug: "alpha",
+ direction: "back",
+ onLog: () => {},
+ }),
+ /not in a movable state/,
+ );
+
+ // The relocated copy is still there, the local one is still there, and
+ // nothing was parked or marked.
+ assert.equal(
+ await readFile(path.join(target, "v1", "audio.m4a"), "utf8"),
+ "one",
+ );
+ assert.equal(
+ await readFile(path.join(channelDir, "data", "v1", "audio.m4a"), "utf8"),
+ "one",
+ );
+ assert.equal((await readChannelConfig(paths, "alpha"))?.dataDir, target);
+ assert.equal(await readRelocationMarker(paths, "alpha"), null);
+ assert.equal((await readdir(channelDir)).includes("data.incoming"), false);
+ });
+});
+
+// An `unreachable` channel (the drive is not mounted) is refused for the same
+// reason: nothing can vouch for what the target holds, and the run ends by
+// deleting it.
+test("back: an unreachable channel is refused", async () => {
+ await withTmp(async (paths, root) => {
+ await seed(paths, "alpha", { v1: { "audio.m4a": "one" } });
+ const target = relocatedDataDir(root, "alpha");
+ await relocateChannelMedia({
+ paths,
+ slug: "alpha",
+ direction: "out",
+ root,
+ onLog: () => {},
+ });
+ // The platter goes away. The link dangles; config still names the target.
+ await rm(path.dirname(target), { recursive: true, force: true });
+ assert.equal(
+ (await inspectChannelMedia(paths, "alpha")).status,
+ "unreachable",
+ );
+ await assert.rejects(
+ () =>
+ relocateChannelMedia({
+ paths,
+ slug: "alpha",
+ direction: "back",
+ onLog: () => {},
+ }),
+ /not in a movable state|not reachable/,
+ );
+ });
+});
+
+// THE SELF-RELOCATION. root = <transcriptsDir>/channels makes the target the
+// source: rsync src/ src/ succeeds, verifyCopy compares the tree with itself,
+// the swap parks `data/` (which IS the verified "target") and links to a path
+// that no longer exists, and the reclaim sweep deletes the only copy. The
+// panel's own help text describes transcripts/channels almost word for word.
+test("a root inside the corpus is refused by the job and by the preview", async () => {
+ await withTmp(async (paths) => {
+ const channelDir = await seed(paths, "alpha", {
+ v1: { "audio.m4a": "one" },
+ });
+ const roots = [
+ paths.channelsDir,
+ paths.transcriptsDir,
+ channelDir,
+ path.join(channelDir, "data"),
+ path.join(paths.channelsDir, "beta"),
+ ];
+ for (const bad of roots) {
+ await assert.rejects(
+ () =>
+ relocateChannelMedia({
+ paths,
+ slug: "alpha",
+ direction: "out",
+ root: bad,
+ onLog: () => {},
+ }),
+ /inside the corpus|inside the channel directory/,
+ `job accepted ${bad}`,
+ );
+ await assert.rejects(
+ () => previewRelocation({ paths, slug: "alpha", root: bad }),
+ /inside the corpus|inside the channel directory/,
+ `preview accepted ${bad}`,
+ );
+ }
+ // Untouched: still a real directory, no config, no marker, no parked copy.
+ assert.ok((await lstat(path.join(channelDir, "data"))).isDirectory());
+ assert.equal(
+ await readFile(path.join(channelDir, "data", "v1", "audio.m4a"), "utf8"),
+ "one",
+ );
+ assert.equal((await readChannelConfig(paths, "alpha"))?.dataDir, undefined);
+ assert.equal(await readRelocationMarker(paths, "alpha"), null);
+ assert.equal(
+ (await readdir(channelDir)).filter((n) => n.startsWith("data.")).length,
+ 0,
+ );
+ });
+});
+
+// A root OUTSIDE the corpus whose `<slug>` level is a symlink back into it. The
+// root's own realpath cannot see this — the link is one level down — which is
+// why the target is resolved separately.
+test("a root that links back into the channel dir is refused", async () => {
+ await withTmp(async (paths, _root, dir) => {
+ const channelDir = await seed(paths, "alpha", {
+ v1: { "audio.m4a": "one" },
+ });
+ const sneaky = path.join(dir, "sneaky");
+ await mkdir(sneaky, { recursive: true });
+ await symlink(channelDir, path.join(sneaky, "alpha"));
+ await assert.rejects(
+ () =>
+ relocateChannelMedia({
+ paths,
+ slug: "alpha",
+ direction: "out",
+ root: sneaky,
+ onLog: () => {},
+ }),
+ /inside the channel directory|inside the corpus/,
+ );
+ assert.ok((await lstat(path.join(channelDir, "data"))).isDirectory());
+ });
+});
+
+test("a relative root is refused by the preview, not only by the job", async () => {
+ await withTmp(async (paths) => {
+ await seed(paths, "alpha", { v1: { "audio.m4a": "one" } });
+ await assert.rejects(
+ () => previewRelocation({ paths, slug: "alpha", root: "platter/media" }),
+ /must be an absolute path/,
+ );
+ });
+});
diff --git a/common/controller/relocateChannelMedia.ts b/common/controller/relocateChannelMedia.ts
@@ -7,6 +7,7 @@ import {
readdir,
readFile,
readlink,
+ realpath,
rename,
rm,
stat,
@@ -136,6 +137,80 @@ async function isDirectory(p: string): Promise<boolean> {
}
}
+// Resolved through every symlink when the path exists, lexically when it does
+// not. A destination root that does not exist yet is refused elsewhere; a root
+// that exists and is a symlink back into the corpus is exactly what this is for.
+async function realOrResolved(p: string): Promise<string> {
+ return await realpath(p).catch(() => path.resolve(p));
+}
+
+// `child` IS `parent`, or lives under it.
+function isWithin(parent: string, child: string): boolean {
+ const rel = path.relative(parent, child);
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
+}
+
+// WHY A ROOT INSIDE THE CORPUS IS NOT MERELY POINTLESS BUT DESTRUCTIVE.
+//
+// Take `root = <transcriptsDir>/channels`. Then `relocatedDataDir(root, slug)`
+// is `<channels>/<slug>/data` — the SOURCE. `rsync -a src/ src/` succeeds,
+// verifyCopy compares the tree with itself and passes, the swap renames `data/`
+// to `data.relocated-<ts>` (which moves the "target" it just verified), creates
+// a symlink pointing at a path that no longer exists, and the reclaim sweep then
+// `rm -r`s the parked directory — the only copy of the media. Nothing in the
+// happy path can notice, because every check it runs is comparing the tree with
+// itself. The panel's own help text ("an absolute directory that already
+// exists") describes `transcripts/channels` almost word for word.
+//
+// So containment is checked, in one place, and called from all three: the
+// preview the operator reads, the action that enqueues, and the job that moves.
+// A root inside the corpus is refused whether it frees anything or not — the
+// same-device hint stays a hint, and this is a refusal.
+export async function relocationRootProblem(opts: {
+ paths: Paths;
+ slug: string;
+ root: string;
+}): Promise<string | null> {
+ const root = opts.root.trim();
+ if (!root) return "No destination root given";
+ if (!path.isAbsolute(root)) {
+ return `The destination root must be an absolute path (got "${root}")`;
+ }
+ const channelDir = path.join(opts.paths.channelsDir, opts.slug);
+ const [realRoot, realCorpus, realChannel] = await Promise.all([
+ realOrResolved(root),
+ realOrResolved(opts.paths.transcriptsDir),
+ realOrResolved(channelDir),
+ ]);
+ if (isWithin(realCorpus, realRoot)) {
+ return (
+ `The destination root ${root} is inside the corpus at ` +
+ `${opts.paths.transcriptsDir}. A relocation there would copy the channel ` +
+ `onto itself and then reclaim the only copy — pick a directory on the ` +
+ `other drive.`
+ );
+ }
+ // Belt and braces for a root that is outside the corpus but whose `<slug>`
+ // level is a link back into it: the target is resolved separately, because
+ // realpath of the root cannot see through a link one level down.
+ const realTarget = await realOrResolved(relocatedDataDir(realRoot, opts.slug));
+ if (isWithin(realChannel, realTarget) || isWithin(realTarget, realChannel)) {
+ return (
+ `The destination ${relocatedDataDir(root, opts.slug)} resolves inside ` +
+ `the channel directory ${channelDir} — the media would be copied onto ` +
+ `itself and then reclaimed.`
+ );
+ }
+ if (isWithin(realCorpus, realTarget)) {
+ return (
+ `The destination ${relocatedDataDir(root, opts.slug)} resolves inside ` +
+ `the corpus at ${opts.paths.transcriptsDir}. Pick a directory on the ` +
+ `other drive.`
+ );
+ }
+ return null;
+}
+
// WHAT `data/` ACTUALLY IS RIGHT NOW. lstat, never stat: a dangling symlink —
// the exact state a half-finished move or an unmounted drive leaves behind —
// reads as ABSENT through stat, and the code that then tries to create the link
@@ -307,6 +382,12 @@ export async function previewRelocation({
slug: string;
root: string;
}): Promise<RelocationPreview> {
+ // The preview REFUSES rather than reporting numbers for a root the job will
+ // reject: its whole job is to answer "is this root usable" before the
+ // operator commits, and a plausible pair of figures for a destructive root is
+ // the worst possible answer.
+ const problem = await relocationRootProblem({ paths, slug, root });
+ if (problem) throw new Error(problem);
const source = path.join(paths.channelsDir, slug, "data");
const target = relocatedDataDir(root, slug);
const [measured, freeOnRoot, freeOnSource, existingPartial] = await Promise.all([
@@ -383,6 +464,7 @@ export async function relocateChannelMedia(
return moveBack({
paths,
slug,
+ config,
channelDir,
dataDir,
target,
@@ -393,11 +475,13 @@ export async function relocateChannelMedia(
});
}
- const root = opts.root?.trim();
- if (!root) throw new Error("No destination root given");
- if (!path.isAbsolute(root)) {
- throw new Error(`The destination root must be an absolute path (got "${root}")`);
- }
+ const root = opts.root?.trim() ?? "";
+ // Blank, relative, and inside-the-corpus, in one list — see
+ // relocationRootProblem. The action and the preview ask the same question
+ // earlier so the operator does not find out from a job log, but this is the
+ // one that is load-bearing.
+ const rootProblem = await relocationRootProblem({ paths, slug, root });
+ if (rootProblem) throw new Error(rootProblem);
const target = relocatedDataDir(root, slug);
if (existingMarker && existingMarker.target !== target) {
throw new Error(
@@ -648,6 +732,7 @@ async function moveOut(args: {
async function moveBack(args: {
paths: Paths;
slug: string;
+ config: NonNullable<Awaited<ReturnType<typeof readChannelConfig>>>;
channelDir: string;
dataDir: string;
target: string;
@@ -658,6 +743,34 @@ async function moveBack(args: {
}): Promise<RelocateChannelMediaResult> {
const { paths, slug, channelDir, dataDir, target, log, signal } = args;
const incoming = path.join(channelDir, "data.incoming");
+
+ // THE MIRROR OF moveOut's "must be in-place", and it is not symmetry for its
+ // own sake: move back is the only direction that ENDS by deleting the target.
+ //
+ // `relocated` is true for `inconsistent` and `unreachable` as well as `ok` —
+ // config.dataDir is set in all three — so without this the UI offers Move back
+ // for a channel whose config records a target while `data/` is a REAL
+ // directory. That state is not hypothetical: it is what `rsync --copy-links`
+ // of a channel produces, which WORKTREES.md documents as the way to carry
+ // media into a shard. The run then copies the target to `data.incoming`,
+ // verifies it, finds `data/` already a real dir, logs "the swap had
+ // completed", clears the config, `rm -r`s the target and finally sweeps
+ // `data.incoming` — three copies in, zero out.
+ //
+ // `in-transition` is allowed because a marker is what a resume carries, and a
+ // rerun is the caller this precondition must not refuse.
+ const location = await inspectChannelMedia(paths, slug, args.config);
+ if (
+ !args.resumed &&
+ location.status !== "ok" &&
+ location.status !== "in-transition"
+ ) {
+ throw new Error(
+ `Channel "${slug}" is not in a movable state: ${
+ location.detail ?? location.status
+ }. Nothing has been touched.`,
+ );
+ }
// THE PRECONDITION BELONGS TO THE COPY, NOT TO THE RERUN. Past the swap the
// media is already back in the channel dir and the target may well be gone —
// demanding it be reachable there would refuse the very run that finishes
diff --git a/editor/app/channels/[slug]/components/stages/StorageStage.tsx b/editor/app/channels/[slug]/components/stages/StorageStage.tsx
@@ -139,6 +139,22 @@ export function StorageStage({
relocated={location.relocated}
target={location.target}
blockedReason={blockedReason}
+ // MOVE BACK IS THE ONE DIRECTION THAT ENDS BY DELETING THE TARGET, so
+ // it is not offered for a location nobody can vouch for. `relocated` is
+ // true for `inconsistent` and `unreachable` as well as `ok` — config
+ // records a target in all three — and an inconsistent channel (config
+ // set, `data/` a real directory, which is what `rsync --copy-links`
+ // produces) would have its relocated copy reclaimed while the local one
+ // is swept too. The controller refuses it; this is the same refusal with
+ // a reason, one click earlier.
+ unvouched={
+ location.status === "inconsistent" ||
+ location.status === "unreachable"
+ ? `This channel's media location is ${location.status}: ${
+ location.detail ?? "disk and config do not agree"
+ } Moving back would delete the relocated copy, so it is refused until the location reads "relocated · reachable".`
+ : null
+ }
/>
</div>
);
@@ -299,11 +315,15 @@ function MoveBack({
relocated,
target,
blockedReason,
+ unvouched,
}: {
slug: string;
relocated: boolean;
target: string | undefined;
blockedReason: string | null;
+ // Why moving back is refused for THIS location, or null. Separate from
+ // blockedReason, which is about jobs and markers and disables both halves.
+ unvouched: string | null;
}) {
const [ranHere, setRanHere] = useState(false);
if (!relocated && !ranHere) return null;
@@ -317,6 +337,15 @@ function MoveBack({
: "This channel's media is in place."}
</p>
</div>
+ {unvouched && (
+ <p
+ role="status"
+ aria-label="move back refused"
+ className="text-sm rounded border border-destructive/50 bg-destructive/5 px-3 py-2"
+ >
+ {unvouched}
+ </p>
+ )}
<StreamActionLog
key="move-back-log"
trigger={() => {
@@ -327,7 +356,7 @@ function MoveBack({
buttonLabel="Move back in place"
runningLabel="Moving back…"
label="Move back in place"
- disabled={!relocated || blockedReason !== null}
+ disabled={!relocated || blockedReason !== null || unvouched !== null}
/>
</section>
);
diff --git a/editor/app/channels/[slug]/storageActions.ts b/editor/app/channels/[slug]/storageActions.ts
@@ -35,6 +35,7 @@ import { clearRelocationMarker } from "yt-dlp-transcript-common/lib/channelMedia
import {
previewRelocation,
relocateChannelMedia,
+ relocationRootProblem,
type RelocationPreview,
} from "yt-dlp-transcript-common/controller/relocateChannelMedia";
@@ -87,6 +88,15 @@ export async function relocateChannelMediaAction(
): Promise<StreamActionResult> {
const trimmed = root.trim();
if (!trimmed) return { ok: false, error: "Enter a destination root." };
+ // Relative, or inside the corpus. The job refuses both too — it is the guard —
+ // but a root that would copy the channel onto itself should not become a job
+ // record and a log the operator has to open to read the reason.
+ const rootProblem = await relocationRootProblem({
+ paths: getPaths(),
+ slug,
+ root: trimmed,
+ });
+ if (rootProblem) return { ok: false, error: rootProblem };
const refusal = activeJobsRefusal(slug, "moving its media");
if (refusal) return { ok: false, error: refusal };
return runMove(slug, "out", trimmed);