u
This commit is contained in:
@@ -50,8 +50,8 @@ export function isReadOnlyDatabasePath(filePath) {
|
||||
|
||||
function assertNotReadOnlyDatabasePath(filePath) {
|
||||
if (isReadOnlyDatabasePath(filePath)) {
|
||||
throw new Error(
|
||||
`Access denied: "${filePath}" matches the read-only database image pattern and cannot be written to. Save artifacts under custom filenames (e.g. "candidate.avif" or "images/candidate.avif").`
|
||||
throw new Error(
|
||||
`Access denied: "${filePath}" matches the read-only database image pattern and cannot be written to. Save artifacts under custom filenames (e.g. "candidate.avif" or "images/candidate.avif").`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -134,15 +134,32 @@ export async function syncVfsToDb({ googleAuthId, vfsRoot }) {
|
||||
export function createVfs(vfsRoot, options = {}) {
|
||||
const { googleAuthId } = options;
|
||||
|
||||
// Node fs errors embed the REAL absolute on-disk path, which leaks the server's directory
|
||||
// layout into the model-visible transcript. Rewrite them to use a stable `vfs://` prefix.
|
||||
const sanitizeFsError = (err, virtualPath) => {
|
||||
if (!err || typeof err.message !== 'string') return err;
|
||||
const virtual = typeof virtualPath === 'string' ? virtualPath : String(virtualPath);
|
||||
const message = err.message
|
||||
.split(vfsRoot)
|
||||
.join('vfs://')
|
||||
.split(path.sep)
|
||||
.join('/')
|
||||
.replace(/vfs:\/\/\/+/g, 'vfs:///');
|
||||
return Object.assign(Object.create(Object.getPrototypeOf(err)), err, {
|
||||
message,
|
||||
path: virtual,
|
||||
});
|
||||
};
|
||||
|
||||
const safeResolve = (p) => {
|
||||
if (typeof p !== 'string' || !p.trim()) {
|
||||
throw new Error('Path must be a non-empty string');
|
||||
if (typeof p !== 'string' || !p.trim()) {
|
||||
throw new Error('Path must be a non-empty string');
|
||||
}
|
||||
const cleanVirtual = p.trim().replace(/^([a-zA-Z]:|\/)+/, '');
|
||||
const resolved = path.resolve(vfsRoot, cleanVirtual);
|
||||
const relative = path.relative(vfsRoot, resolved);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`Access denied: path "${p}" escapes virtual filesystem boundary.`);
|
||||
throw new Error(`Access denied: path "${p}" escapes virtual filesystem boundary.`);
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
@@ -161,32 +178,43 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
|
||||
const vfs = {
|
||||
readFileSync: (p, opt) => {
|
||||
const hash = extractFullHash(p);
|
||||
if (hash) {
|
||||
const buf = syncFetchImage(hash);
|
||||
if (buf) {
|
||||
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
|
||||
return encoding ? buf.toString(encoding) : buf;
|
||||
const hash = extractFullHash(p);
|
||||
if (hash) {
|
||||
const buf = syncFetchImage(hash);
|
||||
if (buf) {
|
||||
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
|
||||
return encoding ? buf.toString(encoding) : buf;
|
||||
}
|
||||
}
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.readFileSync(diskPath, opt);
|
||||
}
|
||||
const diskPath = safeResolve(p);
|
||||
try {
|
||||
return fs.readFileSync(diskPath, opt);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
|
||||
writeFileSync: (p, data, opt) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
return fs.writeFileSync(diskPath, data, opt);
|
||||
try {
|
||||
return fs.writeFileSync(diskPath, data, opt);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
|
||||
appendFileSync: (p, data, opt) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
|
||||
return fs.appendFileSync(diskPath, data, opt);
|
||||
try {
|
||||
return fs.appendFileSync(diskPath, data, opt);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
|
||||
existsSync: (p) => {
|
||||
const hash = extractFullHash(p);
|
||||
if (hash) {
|
||||
@@ -233,11 +261,14 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
},
|
||||
|
||||
mkdirSync: (p, opt) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.mkdirSync(diskPath, opt);
|
||||
},
|
||||
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
try {
|
||||
return fs.mkdirSync(diskPath, opt);
|
||||
} catch (err) {
|
||||
throw sanitizeFsError(err, p);
|
||||
}
|
||||
},
|
||||
unlinkSync: (p) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
@@ -245,12 +276,12 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
},
|
||||
|
||||
rmdirSync: (p, opt) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.rmdirSync(diskPath, opt);
|
||||
},
|
||||
|
||||
rmSync: (p, opt) => {
|
||||
rmSync: (p, opt) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
const diskPath = safeResolve(p);
|
||||
return fs.rmSync(diskPath, opt);
|
||||
@@ -262,9 +293,24 @@ export function createVfs(vfsRoot, options = {}) {
|
||||
appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt),
|
||||
stat: async (p, opt) => vfs.statSync(p, opt),
|
||||
readdir: async (p, opt) => vfs.readdirSync(p, opt),
|
||||
mkdir: async (p, opt) => vfs.mkdirSync(p, opt),
|
||||
mkdir: async (p, opt) => vfs.mkdirSync(p, opt),
|
||||
unlink: async (p) => vfs.unlinkSync(p),
|
||||
rm: async (p, opt) => vfs.rmSync(p, opt),
|
||||
rm: async (p, opt) => vfs.rmSync(p, opt),
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve a virtual path to an absolute on-disk path for writing, applying the SAME
|
||||
* containment + read-only guards as `writeFileSync`. Exposed so non-`fs` writers that
|
||||
* bypass the VFS (e.g. `sharp(...).toFile()`) can be funnelled back inside the sandbox
|
||||
* boundary instead of writing wherever the process CWD happens to be.
|
||||
*
|
||||
* Deliberately does NOT create parent directories: callers such as `sharp().toFile()`
|
||||
* must behave like `fs.writeFileSync`, which errors with ENOENT when the parent is
|
||||
* missing. Auto-creating the whole tree turns a typo'd path into silent success.
|
||||
*/
|
||||
resolveWritePath: (p) => {
|
||||
assertNotReadOnlyDatabasePath(p);
|
||||
return safeResolve(p);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user