Skip to content

Studio cannot save or delete legal long paths because backup filenames exceed filesystem limits #6610

Closed

Description

@AksharP5

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 build

Save 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.mjs

Expected 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

  1. added a commit that references this issue on Oct 28, 2026
  2. added
    bugSomething isn't working
    triage/needs-triageNeeds current-behavior, related-work, and scope evaluation.
    on Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtriage/needs-triageNeeds current-behavior, related-work, and scope evaluation.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions