diff --git a/src/backend/controllers/fs/LegacyFSController.test.ts b/src/backend/controllers/fs/LegacyFSController.test.ts index 27e2407b0..57da35aa4 100644 --- a/src/backend/controllers/fs/LegacyFSController.test.ts +++ b/src/backend/controllers/fs/LegacyFSController.test.ts @@ -765,6 +765,69 @@ describe('LegacyFSController.copy', () => { `/${username}/Pictures/renamed-copy`, ); }); + + it('copies an empty file (no backing S3 object) without erroring', async () => { + // Empty files created via /touch have size 0 and no S3 object — + // bucket is null. Copy must clone them as empty-file entries rather + // than issuing a CopyObject, which would throw S3 NoSuchKey. + const { actor } = await makeUser(); + const username = actor.user!.username!; + const src = `/${username}/Documents/empty.txt`; + await withActor(actor, () => + controller.touch( + makeReq({ body: { path: src }, actor }), + makeRes().res, + ), + ); + + const { res, captured } = makeRes(); + await withActor(actor, () => + controller.copy( + makeReq({ + body: { source: src, destination: `/${username}/Pictures` }, + actor, + }), + res, + ), + ); + + const body = captured.body as Array<{ copied: { path: string } }>; + expect(body[0].copied.path).toBe(`/${username}/Pictures/empty.txt`); + const copied = await server.stores.fsEntry.getEntryByPath( + `/${username}/Pictures/empty.txt`, + ); + expect(copied).not.toBeNull(); + expect(copied!.size).toBe(0); + // Original is untouched. + expect(await server.stores.fsEntry.getEntryByPath(src)).not.toBeNull(); + }); + + it('reads an empty file as empty content without deleting it', async () => { + // Reading an empty file (no S3 object) must not throw NoSuchKey nor + // trip the ghost-file cleanup, which would delete the entry. + const { actor } = await makeUser(); + const username = actor.user!.username!; + const src = `/${username}/Documents/readme-empty.txt`; + await withActor(actor, () => + controller.touch( + makeReq({ body: { path: src }, actor }), + makeRes().res, + ), + ); + const entry = (await server.stores.fsEntry.getEntryByPath(src))!; + + const download = await server.services.fs.readContent(entry); + const chunks: Buffer[] = []; + for await (const chunk of download.body) { + chunks.push(Buffer.from(chunk as Uint8Array)); + } + expect(Buffer.concat(chunks).byteLength).toBe(0); + expect(download.contentLength).toBe(0); + // The entry must still exist — the ghost handler must NOT have run. + expect( + await server.stores.fsEntry.getEntryByPath(src), + ).not.toBeNull(); + }); }); // ── move ──────────────────────────────────────────────────────────── diff --git a/src/backend/drivers/util/fileInput.test.ts b/src/backend/drivers/util/fileInput.test.ts index 5b8d152f2..4878951d9 100644 --- a/src/backend/drivers/util/fileInput.test.ts +++ b/src/backend/drivers/util/fileInput.test.ts @@ -292,6 +292,22 @@ describe('loadFileInput FS path', () => { expect(result.fsEntry?.path).toBe(path); }); + it('returns empty bytes for an empty file with no backing S3 object', async () => { + // Files created via touch have size 0 and a null bucket — no S3 + // object exists. loadFileInput must return empty content rather than + // throwing NoSuchKey from getObjectStream. + const { actor, userId } = await makeUser(); + const username = actor.user!.username!; + const path = `/${username}/Documents/empty.txt`; + const entry = await withActor(actor, () => + server.services.fs.touch(userId, { path }), + ); + + const result = await callLoadFileInput(actor, path); + expect(result.buffer.byteLength).toBe(0); + expect(result.fsEntry?.uuid).toBe(entry.uuid); + }); + it('also accepts a `{ uuid }` object reference', async () => { const { actor, userId } = await makeUser(); const username = actor.user!.username!; diff --git a/src/backend/drivers/util/fileInput.ts b/src/backend/drivers/util/fileInput.ts index 0e8b91347..89b6d795b 100644 --- a/src/backend/drivers/util/fileInput.ts +++ b/src/backend/drivers/util/fileInput.ts @@ -22,6 +22,7 @@ import type { Actor } from '../../core/actor.js'; import { HttpError } from '../../core/http/HttpError.js'; import type { FSService } from '../../services/fs/FSService.js'; import { expandTildePath, resolveNode } from '../../services/fs/resolveNode.js'; +import { hasNoBackingS3Object } from '../../stores/fs/FSEntry.js'; import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js'; import type { S3ObjectStore } from '../../stores/fs/S3ObjectStore.js'; import { mimeFromName } from '../../util/fileSigning.js'; @@ -175,6 +176,23 @@ export async function loadFileInput( // `path`/`uid`/`uuid` (e.g. AI chat `puter_path` content parts) could // exfiltrate any user's file. Must run before the S3 read below. await fsService.checkFSAccess(entry, actor, 'read'); + // Empty files (created via `touch`) have no backing S3 object — + // getObjectStream would throw NoSuchKey, so return empty content. + if (hasNoBackingS3Object(entry)) { + return { + buffer: Buffer.alloc(0), + filename: entry.name, + mimeType: mimeFromName(entry.name) ?? 'application/octet-stream', + fsEntry: { + uuid: entry.uuid, + path: entry.path, + bucket: entry.bucket, + bucketRegion: entry.bucketRegion, + size: entry.size, + sqlId: entry.id, + }, + }; + } const objectKey = entry.uuid; const { body, contentType, contentLength } = await stores.s3Object.getObjectStream( diff --git a/src/backend/server.ts b/src/backend/server.ts index 753e2c47c..64081e7e1 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -729,11 +729,13 @@ export class PuterServer { if (status < 500) return; if (SKIP_ALERT_PREFIXES.test(legacyCode)) return; } - const signature = isHttp - ? err.legacyCode || err.code || err.message - : err instanceof Error - ? err.message - : String(err); + const signature = !isHttp + ? err instanceof Error + ? err.message + : String(err) + : status >= 500 + ? `${err.legacyCode || err.code || 'http'}:${err.message}` + : err.legacyCode || err.code || err.message; const routePath = (req as unknown as { route?: { path?: string } }).route ?.path ?? req.path; diff --git a/src/backend/services/fs/FSService.ts b/src/backend/services/fs/FSService.ts index adfea5e65..aab2825d0 100644 --- a/src/backend/services/fs/FSService.ts +++ b/src/backend/services/fs/FSService.ts @@ -31,6 +31,7 @@ import { FSEntry, FSEntryCreateInput, FSEntryWriteInput, + hasNoBackingS3Object, PendingUploadCreateInput, PendingUploadSession, } from '../../stores/fs/FSEntry.js'; @@ -2806,6 +2807,24 @@ export class FSService extends PuterService { { legacyCode: 'shortcut_target_not_found' }, ); } + // Empty files (created via `touch`/`createNonFileEntry` with kind + // 'empty-file') have no backing S3 object — `bucket` is null. Reading + // one would throw NoSuchKey and, worse, trip #handleGhostFile, which + // deletes the entry as if it were an orphan. Return an empty stream + // instead. (A real file whose object is genuinely missing keeps a + // non-null bucket, so it still falls through to the ghost path below.) + if (hasNoBackingS3Object(entry)) { + return { + body: Readable.from([]), + contentLength: 0, + contentType: null, + contentRange: null, + etag: null, + lastModified: entry.modified + ? new Date(entry.modified * 1000) + : null, + }; + } const objectKey = entry.uuid; try { return await this.stores.s3Object.getObjectStream( @@ -3646,6 +3665,24 @@ export class FSService extends PuterService { }); } + // Empty files (created via `touch`/`createNonFileEntry` with kind + // 'empty-file') have no backing S3 object — `bucket` is null and there + // is nothing to CopyObject. Issuing one would throw NoSuchKey, so clone + // the source as another empty-file entry instead of touching S3. + if (hasNoBackingS3Object(source)) { + return this.stores.fsEntry.createNonFileEntry({ + userId, + parent: destinationParent, + name: newName, + kind: 'empty-file', + metadata: source.metadata, + thumbnail: source.thumbnail, + associatedAppId: source.associatedAppId, + isPublic: source.isPublic, + immutable: source.immutable, + }); + } + const newUuid = uuidv4(); const sourceObjectKey = source.uuid; const resolvedBucket = this.stores.s3Object.resolveBucket( diff --git a/src/backend/stores/fs/FSEntry.ts b/src/backend/stores/fs/FSEntry.ts index c5de50905..6a2e537d0 100644 --- a/src/backend/stores/fs/FSEntry.ts +++ b/src/backend/stores/fs/FSEntry.ts @@ -53,6 +53,17 @@ export interface FSEntry { suggestedApps: unknown[]; // TODO DS: type with app row } +/** + * True when an entry has no backing S3 object. Empty files (size 0) created + * via `touch`/`createNonFileEntry` never upload to S3 and leave `bucket` null; + * real files always store a non-null bucket on write, so a null bucket on a + * size-0 entry reliably means there is nothing in S3 to read or copy. A real + * file whose object went missing keeps its bucket, so this stays false and the + * usual missing-object handling still applies. + */ +export const hasNoBackingS3Object = (entry: FSEntry): boolean => + (entry.size ?? 0) === 0 && entry.bucket === null; + export interface FSEntrySubdomain { uuid: string; address: string; // `${config.protocol}://${subdomain}.${'puter.site'|'puter.work'}` depending on wether dir or file