fix: fsService copy (#3304)

This commit is contained in:
Daniel Salazar
2026-06-24 16:32:34 -07:00
committed by GitHub
parent 22b499af20
commit 7d1c2e7f0a
2 changed files with 81 additions and 9 deletions
@@ -802,6 +802,58 @@ describe('LegacyFSController.copy', () => {
expect(await server.stores.fsEntry.getEntryByPath(src)).not.toBeNull();
});
it('copying a ghost file (S3 object missing) 404s and cleans up the orphan', async () => {
// A real file whose backing S3 object has vanished keeps a non-null
// bucket, so it isn't an empty file. CopyObject would throw S3
// NoSuchKey; copy must surface a clean 404 and remove the orphan row
// rather than bubbling a 500. Mirrors readContent's ghost handling.
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const src = `/${username}/Documents/ghost.txt`;
const content = Buffer.from('i will be deleted from s3');
await server.services.fs.write(userId, {
fileMetadata: {
path: src,
size: content.byteLength,
contentType: 'text/plain',
},
fileContent: content,
});
// Delete the backing S3 object directly, leaving the DB row behind.
const entry = (await server.stores.fsEntry.getEntryByPath(src))!;
await server.stores.s3Object.deleteObject(
server.stores.s3Object.resolveBucket(entry.bucket),
entry.uuid,
server.stores.s3Object.resolveRegion(entry.bucketRegion),
);
const { res } = makeRes();
await expect(
withActor(actor, () =>
controller.copy(
makeReq({
body: {
source: src,
destination: `/${username}/Pictures`,
},
actor,
}),
res,
),
),
).rejects.toMatchObject({ statusCode: 404 });
// The ghost handler removed the orphaned source row.
expect(await server.stores.fsEntry.getEntryByPath(src)).toBeNull();
// No partial copy was left behind.
expect(
await server.stores.fsEntry.getEntryByPath(
`/${username}/Pictures/ghost.txt`,
),
).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.
+29 -9
View File
@@ -3688,15 +3688,35 @@ export class FSService extends PuterService {
const resolvedBucket = this.stores.s3Object.resolveBucket(
source.bucket,
);
await this.stores.s3Object.copyObject(
{
sourceBucket: resolvedBucket,
sourceKey: sourceObjectKey,
destinationBucket: resolvedBucket,
destinationKey: newUuid,
},
this.stores.s3Object.resolveRegion(source.bucketRegion),
);
// A ghost file — DB row present with a non-null bucket but its backing
// S3 object gone — would make CopyObject throw NoSuchKey and bubble up
// as a 500. Mirror `readContent`: clean up the orphan and surface a
// 404 instead. (`hasNoBackingS3Object` above only covers legitimately
// empty files, which keep a null bucket.)
try {
await this.stores.s3Object.copyObject(
{
sourceBucket: resolvedBucket,
sourceKey: sourceObjectKey,
destinationBucket: resolvedBucket,
destinationKey: newUuid,
},
this.stores.s3Object.resolveRegion(source.bucketRegion),
);
} catch (err) {
if (isNoSuchKeyError(err)) {
await this.#handleGhostFile(source, sourceObjectKey);
throw new HttpError(404, 'File contents are missing', {
legacyCode: 'subject_does_not_exist',
cause: err,
fields: {
path: source.path,
uid: source.uuid,
},
});
}
throw err;
}
const nextMetadata = this.#sanitizeClientMetadata(source.metadata);