Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 31 additions & 8 deletions src/templates.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -99,19 +99,30 @@ export function safeEntry(entry) {

/** Every file under `dir`, relative to it. */
async function walk(dir, base = dir) {
const out = [];
const files = [];
const unsafe = [];
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return out;
return { files, unsafe };
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...(await walk(full, base)));
else out.push(path.relative(base, full));
const relative = path.relative(base, full);
if (entry.isSymbolicLink()) {
unsafe.push(relative);
} else if (entry.isDirectory()) {
const nested = await walk(full, base);
files.push(...nested.files);
unsafe.push(...nested.unsafe);
} else if (entry.isFile()) {
files.push(relative);
} else {
unsafe.push(relative);
}
}
return out;
return { files, unsafe };
}

/**
Expand All @@ -122,7 +133,8 @@ async function walk(dir, base = dir) {
* stopping is worse than not starting.
*/
export async function installPlan(from, into) {
const files = (await walk(from)).filter((f) => path.basename(f) !== MANIFEST);
const walked = await walk(from);
const files = walked.files.filter((f) => path.basename(f) !== MANIFEST);
const conflicts = [];
for (const file of files) {
try {
Expand All @@ -132,11 +144,15 @@ export async function installPlan(from, into) {
/* absent, which is what we want */
}
}
return { files: files.sort(), conflicts: conflicts.sort() };
return { files: files.sort(), conflicts: conflicts.sort(), unsafe: walked.unsafe.sort() };
}

/** Copy the planned files. Directories are created as needed. */
export async function applyInstall(from, into, files) {
for (const file of files) {
const stat = await fs.lstat(path.join(from, file));
if (!stat.isFile()) throw new Error(`template entry is not a regular file: ${file}`);
}
for (const file of files) {
const target = path.join(into, file);
await fs.mkdir(path.dirname(target), { recursive: true });
Expand Down Expand Up @@ -314,7 +330,14 @@ export async function templateCommand(args = [], out = console.log) {
}

try {
const { files, conflicts } = await installPlan(from, into);
const { files, conflicts, unsafe } = await installPlan(from, into);
if (unsafe.length) {
out(`moshcode template install: links and special files are not allowed (${unsafe.length} found):`);
for (const file of unsafe.slice(0, 10)) out(` ${file}`);
if (unsafe.length > 10) out(` … and ${unsafe.length - 10} more`);
out("nothing was written. Replace them with ordinary files and try again.");
return 1;
}
if (!files.length) {
out("moshcode template install: that template has no files in it");
return 1;
Expand Down
28 changes: 28 additions & 0 deletions test/templates.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,34 @@ test("an install that would clobber something writes nothing at all", async () =
}
});

test("symbolic links are identified before template files are copied", async (t) => {
const root = await tmp();
const from = path.join(root, "from");
const into = path.join(root, "into");
const synthetic = path.join(root, "synthetic.txt");
try {
await fs.mkdir(from);
await fs.mkdir(into);
await fs.writeFile(synthetic, "synthetic test data\n");
try {
await fs.symlink(synthetic, path.join(from, "copied.txt"), "file");
} catch (error) {
if (error?.code === "EPERM") {
t.skip("symbolic links require elevated privileges on this Windows host");
return;
}
throw error;
}

const plan = await installPlan(from, into);
assert.deepEqual(plan.files, []);
assert.deepEqual(plan.unsafe, ["copied.txt"]);
assert.deepEqual(await fs.readdir(into), []);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});

test("--force overwrites, and only then", async () => {
const into = await tmp();
try {
Expand Down
Loading