Path & filename

Install paths

Install paths

Helpers for code that creates per-name install directories under a trusted base — typical for plugins, packages, snapshots, anywhere you want <base>/<safe-name>/. The combination of resolveSafeInstallDir and assertCanonicalPathWithinBase gives you "compute the install path safely, then re-verify after creation."

import {
  assertCanonicalPathWithinBase,
  resolveSafeInstallDir,
  safeDirName,
  safePathSegmentHashed,
} from "@openclaw/fs-safe/advanced";

#resolveSafeInstallDir

function resolveSafeInstallDir(params: {
  baseDir: string;
  id: string;
  invalidNameMessage: string;
  nameEncoder?: (id: string) => string;   // default safeDirName
}): { ok: true; path: string } | { ok: false; error: string };

Computes the absolute install directory for id under baseDir, after running id through nameEncoder (safeDirName by default). Verifies the result stays inside baseDir — anything that would escape returns { ok: false, error: invalidNameMessage }.

const r = resolveSafeInstallDir({
  baseDir: "/srv/plugins",
  id: "@scope/my-plugin",
  invalidNameMessage: "invalid plugin name",
});
if (!r.ok) return reply(400, r.error);

await fs.mkdir(r.path, { recursive: true });

For ids whose default-sanitized form might collide (e.g. "foo/bar" and "foo\\bar" both map to "foo__bar"), pass nameEncoder: safePathSegmentHashed to append a content hash:

const r = resolveSafeInstallDir({
  baseDir: "/srv/plugins",
  id: untrustedId,
  invalidNameMessage: "invalid plugin name",
  nameEncoder: safePathSegmentHashed,
});

The helper does not create the directory — it returns the path. Pair with fs.mkdir, Root.mkdir, or assertCanonicalPathWithinBase before/after creation as needed.

#assertCanonicalPathWithinBase

Async. Verifies that a candidate absolute path's canonical real path stays inside the base. Useful as a post-mkdir check, or when you have an existing path you didn't compute yourself.

function assertCanonicalPathWithinBase(params: {
  baseDir: string;
  candidatePath: string;
  boundaryLabel: string;
}): Promise<void>;

Throws if the candidate resolves outside baseDir after realpath. The boundaryLabel is included in the error message ("Invalid path: must stay within {boundaryLabel}").

await assertCanonicalPathWithinBase({
  baseDir: "/srv/plugins",
  candidatePath: "/srv/plugins/my-plugin",
  boundaryLabel: "plugin install dir",
});

If the candidate does not exist, the helper validates the parent directory instead — useful for "the directory I'm about to create" semantics.

#Segment sanitizers

#safeDirName

Returns a directory-safe segment derived from input by replacing / and \ with __. Trims whitespace; returns an empty string if the input was only whitespace.

safeDirName("@scope/my-plugin");       // "@scope__my-plugin"
safeDirName("../../etc");              // "..__..__etc"
safeDirName("plugin-v1");              // "plugin-v1"
safeDirName("");                       // ""

safeDirName does not try to be exhaustive about Windows-reserved names or special characters. It is purely a separator-stripping pass — resolveSafeInstallDir adds the boundary check on top so an "../../etc" input cannot escape baseDir.

For stricter sanitization, use safePathSegmentHashed.

#safePathSegmentHashed

Returns a directory-safe segment plus a short content hash when sanitization changed the input or when the safe form is too long. Use this when input collisions matter:

safePathSegmentHashed("plugin-v1");                  // "plugin-v1"  (unchanged short input)
safePathSegmentHashed("plugin/v1");                  // "plugin-v1-3f2a..."
safePathSegmentHashed("plugin\\v1");                 // "plugin-v1-91c4..."  (different hash; same safe form)
safePathSegmentHashed("Über@");                       // "ber-9aae..."
safePathSegmentHashed("");                           // "skill"  (empty fallback)
safePathSegmentHashed(".");                          // "skill"

The sanitization is more aggressive than safeDirName: any character not in [A-Za-z0-9._-] becomes -, runs of - collapse, leading and trailing - are stripped, the empty/./.. fallback is "skill". Long results are truncated to 50 chars before the hash is appended.

The hash is the first 10 hex chars of sha256(originalInput). It guarantees that two distinct inputs which sanitize to the same string yield distinct outputs.

#Common patterns

#Install a plugin

import { resolveSafeInstallDir, assertCanonicalPathWithinBase, safePathSegmentHashed } from "@openclaw/fs-safe/advanced";
import { extractArchive } from "@openclaw/fs-safe/archive";
import fs from "node:fs/promises";

const r = resolveSafeInstallDir({
  baseDir: "/srv/plugins",
  id: untrustedName,
  invalidNameMessage: "invalid plugin name",
  nameEncoder: safePathSegmentHashed,
});
if (!r.ok) return reply(400, r.error);

await fs.mkdir(r.path, { recursive: true, mode: 0o755 });
await assertCanonicalPathWithinBase({
  baseDir: "/srv/plugins",
  candidatePath: r.path,
  boundaryLabel: "plugin install dir",
});

await extractArchive({
  archivePath: pluginZip,
  destDir: r.path,
  kind: "zip",
  timeoutMs: 30_000,
});

#Per-version snapshot directories

const snap = resolveSafeInstallDir({
  baseDir: "/srv/snapshots",
  id: `${runId}-${version}`,
  invalidNameMessage: "invalid snapshot id",
});
if (!snap.ok) throw new Error(snap.error);
await fs.mkdir(snap.path, { recursive: true });

#Reject and log on bad input

const r = resolveSafeInstallDir({ baseDir, id, invalidNameMessage: "bad name" });
if (!r.ok) {
  logger.warn({ id, base: baseDir, error: r.error }, "rejected install attempt");
  return reply(400, r.error);
}

#See also

  • root() — when the install dir becomes a root for further writes.
  • FilenamessanitizeUntrustedFileName for file-name (not directory-name) sanitization.
  • Archive extraction — extract into the install dir computed by these helpers.