Repository navigation
Description
Describe the bug
Studio can create and open a file inside a normal nested project folder, then refuse every save and delete because its backup filename is too long. The file in this reproduction has a 180-byte relative path with short individual folder names. Its timestamped backup name is 265 bytes, beyond the filesystem's 255-byte component limit.
The backup journal base64url-encodes the entire relative path into one filename. This expands the directory path into a single component. The write routes correctly stop when the backup fails, leaving the original bytes intact, but the user cannot edit or delete that file through Studio. UTF-8 filenames can hit the same limit with fewer visible characters.
Minimal reproduction and steps
Run from this repository on Node 24 and Bun, using upstream main 8b1f88b3c06175f43fbdbe1af6e53933847cb195 for the baseline:
git checkout 8b1f88b3c06175f43fbdbe1af6e53933847cb195
bun install --frozen-lockfile
bun run buildSave the following as repro.mjs in the repository root, then run node repro.mjs. It creates a synthetic temporary project and exercises the public createStudioApi() over loopback HTTP with real filesystem operations. No browser, renderer, mocked filesystem or external media is involved.
import { createServer } from 'node:http';
import { once } from 'node:events';
import { mkdtempSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
const { createStudioApi } = await import(pathToFileURL(join(process.cwd(), 'packages/studio-server/dist/index.js')));
const projectDir = mkdtempSync(join(tmpdir(), 'hf-file-operations-repro-'));
const project = { id: 'demo', dir: projectDir };
const unavailable = () => { throw new Error('Unexpected unrelated adapter call'); };
const api = createStudioApi({
listProjects: () => [project],
resolveProject: id => id === 'demo' ? project : null,
bundle: unavailable,
lint: unavailable,
runtimeUrl: '/runtime.js',
rendersDir: unavailable,
startRender: unavailable,
});
const server = createServer(async (req, res) => {
try {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const response = await api.fetch(new Request(`http://${req.headers.host}${req.url}`, {
method: req.method,
headers: req.headers,
...(chunks.length ? { body: Buffer.concat(chunks) } : {}),
}));
res.writeHead(response.status, Object.fromEntries(response.headers));
res.end(Buffer.from(await response.arrayBuffer()));
} catch (error) {
res.writeHead(500);
res.end(String(error));
}
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const origin = `http://127.0.0.1:${server.address().port}`;
const request = async (path, init) => {
const response = await fetch(`${origin}/projects/demo/${path}`, init);
return { status: response.status, body: await response.json() };
};
const longPath = 'campaigns/2026/autumn-product-launch/client-review/round-03/approved-deliverables/social-media/vertical/english-united-kingdom/subtitles-enabled/product-onboarding/introduction.txt';
try {
const url = `files/${longPath}`;
const created = await request(url, { method: 'POST', body: 'before' });
const opened = await request(url);
const saved = await request(url, { method: 'PUT', headers: { 'If-Match': opened.body.version }, body: 'after' });
const afterSave = readFileSync(join(projectDir, longPath), 'utf8');
const saveBackup = saved.body.backupPath ? readFileSync(join(projectDir, saved.body.backupPath), 'utf8') : null;
const deleted = await request(url, { method: 'DELETE' });
const deleteBackup = deleted.body.backupPath ? readFileSync(join(projectDir, deleted.body.backupPath), 'utf8') : null;
const result = { projectDir, longPath, pathBytes: Buffer.byteLength(longPath), created, opened, saved, afterSave, saveBackup, deleted, existsAfterDelete: existsSync(join(projectDir, longPath)), deleteBackup };
console.log(JSON.stringify(result, null, 2));
} finally {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
}For the fixed comparison, keep the same repro.mjs and run:
git fetch https://github.com/AksharP5/hyperframes.git studio-file-paths
git checkout --detach FETCH_HEAD
bun run --cwd packages/studio-server build
node repro.mjsExpected behavior
A file that Studio can create and read at this legal source path can also be saved and deleted, with the previous bytes preserved in a backup.
Actual behavior and verified correction
| Same request sequence and file | Baseline | Fixed |
|---|---|---|
Create introduction.txt at the 180-byte relative path shown here |
HTTP 201 | HTTP 201 |
| Read it | HTTP 200, before |
HTTP 200, before |
Save after using the read ETag |
HTTP 500, backup failed: ENAMETOOLONG |
HTTP 200 |
| Bytes after save | before |
after |
| Save backup bytes | No backup | before |
| Delete that file | HTTP 500, backup failed: ENAMETOOLONG |
HTTP 200 |
| File exists after delete | true |
false |
| Delete backup bytes | No backup | after |
The correction uses a fixed-size SHA-256 key when the encoded path exceeds 200 characters. Shorter keys stay unchanged, and existing base64-named snapshots count toward the same retention limit. The same reproduction above was run against the baseline and this correction.
Environment
- Hyperframes 0.8.210, upstream main
8b1f88b3c06175f43fbdbe1af6e53933847cb195. - Linux arm64, Node v24.22.1, Bun 1.4.4, local ext filesystem.
- Full workspace build succeeded. No browser or FFmpeg is needed for these file API requests.
Related work
#1388 introduced the backup journal; #3957 and #3961 require failed backups to prevent mutations, which remains correct. #4777 documents a separate limit on very long individual source filenames caused by atomic temp files. This reproduction's source filename is only 16 bytes. #6492 addresses Windows mkdtemp total-path limits and does not change this backup filename. Public issue, PR, review-comment and Discussion searches found no report of this backup-key length failure.
Activity
docs(upstream): record the bf7ebe9 batch, advance commit, PR and issu…