Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"type-check": "./scripts/tsc-max-errors.sh",
"prepare": "husky install",
"generate_schema": "openapi-typescript http://localhost:3005/api-json -o ./src/infra/schemas.d.ts",
"generate_schema:prod": "openapi-typescript https://gateway.internxt.com/drive/api-json -o ./src/infra/schemas.d.ts",
"generate_schema:prod": "openapi-typescript https://drive-server.internxt.com/api-json -o ./src/infra/schemas.d.ts",
"find-deadcode": "knip --max-issues=95"
},
"build": {
Expand Down
3 changes: 3 additions & 0 deletions src/apps/drive/fuse/callbacks/FuseCodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export const FuseCodes = {
// Input/output error
EIO: 5,

// File too large
EFBIG: 27,

// Invalid argument
EINVAL: 22,

Expand Down
9 changes: 9 additions & 0 deletions src/apps/main/auth/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ describe('saveConfig and canHisConfigBeRestored', () => {
lastOnboardingShown: '2025-01-01',
nautilusExtensionVersion: 0,
discoveredBackup: 1,
maxUploadFileSizeInBytes: 1024,
savedConfigs: {},
newToken: 'fake-new-token',
newTokenEncrypted: false,
Expand All @@ -113,6 +114,7 @@ describe('saveConfig and canHisConfigBeRestored', () => {
expect(savedConfigs[fakeUuid].backupList).toStrictEqual(fakeBackupList);

expect(savedConfigs[fakeUuid].deviceUUID).toBe(fakeDeviceUUID);
expect(savedConfigs[fakeUuid].maxUploadFileSizeInBytes).toBe(1024);

for (const field of fieldsToSave) {
expect(savedConfigs[fakeUuid]).toHaveProperty(field);
Expand All @@ -121,6 +123,7 @@ describe('saveConfig and canHisConfigBeRestored', () => {
expect(configState.backupList).toStrictEqual(defaults.backupList);

expect(configState.deviceUUID).toStrictEqual(defaults.deviceUUID);
expect(configState.maxUploadFileSizeInBytes).toStrictEqual(defaults.maxUploadFileSizeInBytes);
});

it('should restore backupList and deviceUUID from savedConfigs on re-login', () => {
Expand All @@ -138,13 +141,15 @@ describe('saveConfig and canHisConfigBeRestored', () => {
lastOnboardingShown: '2025-01-01',
nautilusExtensionVersion: 0,
discoveredBackup: 1,
maxUploadFileSizeInBytes: 1024,
},
};

const configState = createConfigState({
savedConfigs,
backupList: {},
deviceUUID: '',
maxUploadFileSizeInBytes: defaults.maxUploadFileSizeInBytes,
});

mockStoreForState(configState);
Expand All @@ -156,6 +161,7 @@ describe('saveConfig and canHisConfigBeRestored', () => {
expect(configState.backupList).toStrictEqual(fakeBackupList);

expect(configState.deviceUUID).toStrictEqual(fakeDeviceUUID);
expect(configState.maxUploadFileSizeInBytes).toBe(1024);
});

it('should return false when no saved config exists for uuid', () => {
Expand Down Expand Up @@ -188,6 +194,7 @@ describe('saveConfig and canHisConfigBeRestored', () => {
lastOnboardingShown: '2025-01-01',
nautilusExtensionVersion: 0,
discoveredBackup: 1,
maxUploadFileSizeInBytes: 1024,
savedConfigs: {},
newToken: 'fake-new-token',
newTokenEncrypted: false,
Expand All @@ -201,12 +208,14 @@ describe('saveConfig and canHisConfigBeRestored', () => {

expect(configState.backupList).toStrictEqual({});
expect(configState.deviceUUID).toStrictEqual('');
expect(configState.maxUploadFileSizeInBytes).toBe(defaults.maxUploadFileSizeInBytes);

const restored = canHisConfigBeRestored({ uuid: fakeUuid });

expect(restored).toBe(true);
expect(configState.backupList).toStrictEqual(fakeBackupList);
expect(configState.deviceUUID).toStrictEqual(fakeDeviceUUID);
expect(configState.backupsEnabled).toBe(true);
expect(configState.maxUploadFileSizeInBytes).toBe(1024);
});
});
1 change: 1 addition & 0 deletions src/apps/main/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const schema: Schema<AppStore> = {
nautilusExtensionVersion: { type: 'number' },
discoveredBackup: { type: 'number' },
availableUserProducts: { type: 'object' },
maxUploadFileSizeInBytes: { type: 'number' },
} as const;

const configStore = new Store<AppStore>({
Expand Down
2 changes: 2 additions & 0 deletions src/apps/main/config/save-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const savedConfigFields = [
'backupList',
'nautilusExtensionVersion',
'discoveredBackup',
'maxUploadFileSizeInBytes',
] as (keyof SavedConfig)[];

export function saveConfig({ uuid }: { uuid: string }) {
Expand All @@ -33,6 +34,7 @@ export function saveConfig({ uuid }: { uuid: string }) {
backupList: ConfigStore.get('backupList'),
nautilusExtensionVersion: ConfigStore.get('nautilusExtensionVersion'),
discoveredBackup: ConfigStore.get('discoveredBackup'),
maxUploadFileSizeInBytes: ConfigStore.get('maxUploadFileSizeInBytes'),
};

ConfigStore.set('savedConfigs', {
Expand Down
2 changes: 2 additions & 0 deletions src/apps/main/realtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import eventBus from './event-bus';
import { broadcastToWindows } from './windows';
import { logger } from '@internxt/drive-desktop-core/build/backend';
import { getUserAvailableProductsAndStore } from '../../backend/features/payments/services/get-user-available-products-and-store';
import { resolveUserFileSizeLimit } from '../../backend/features/user/file-size-limit/resolve-user-file-size-limit';

type XHRRequest = {
getResponseHeader: (headerName: string) => string[] | null;
Expand Down Expand Up @@ -102,6 +103,7 @@ function cleanAndStartRemoteNotifications() {

if (eventPayload.eventName === 'PLAN_UPDATED') {
void getUserAvailableProductsAndStore();
void resolveUserFileSizeLimit();
}

broadcastToWindows('remote-changes', eventPayload);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { calculateProjectedWriteSize } from './calculate-projected-write-size';

describe('calculateProjectedWriteSize', () => {
it('should return offset plus incoming bytes when the write extends the file', () => {
expect(calculateProjectedWriteSize({ currentSize: 10, offset: 10, incomingBytes: 5 })).toBe(15);
});

it('should keep current size when the write stays inside the existing file', () => {
expect(calculateProjectedWriteSize({ currentSize: 10, offset: 2, incomingBytes: 5 })).toBe(10);
});

it('should account for sparse writes at a large offset', () => {
expect(calculateProjectedWriteSize({ currentSize: 0, offset: 1_000, incomingBytes: 5 })).toBe(1_005);
});

it('should account for out-of-order writes after a larger write already happened', () => {
expect(calculateProjectedWriteSize({ currentSize: 1_005, offset: 0, incomingBytes: 5 })).toBe(1_005);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
type Props = {
currentSize: number;
offset: number;
incomingBytes: number;
};

/**
* Calculates the logical file size that would exist after accepting a FUSE write.
*
* FUSE write calls are chunk based: the app receives "write these bytes at this
* offset", not "here is the full source file". For copies into the mounted drive
* we also cannot rely on the origin path or origin size being available.
*
* Because writes can be sequential, out of order, or sparse, upload-size-limit
* validation must use the resulting logical size rather than the current chunk
* length. A small chunk written at a large offset can still create an oversized
* file.
*/
export function calculateProjectedWriteSize({ currentSize, offset, incomingBytes }: Props): number {
return Math.max(currentSize, offset + incomingBytes);
}
7 changes: 7 additions & 0 deletions src/backend/features/user/file-size-limit/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { calculateProjectedWriteSize } from './calculate-projected-write-size';
export { resolveUserFileSizeLimit } from './resolve-user-file-size-limit';
export {
clearUploadSizeLimitBlockedPath,
isUploadSizeLimitBlockedPath,
markUploadSizeLimitBlockedPath,
} from './upload-size-limit-blocked-paths';
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { loggerMock } from '../../../../../tests/vitest/mocks.helper';
import { partialSpyOn, call } from '../../../../../tests/vitest/utils.helper';
import configStore from '../../../../apps/main/config';
import { DriveServerError } from '../../../../infra/drive-server/drive-server.error';
import * as getUserFileSizeLimitModule from '../../../../infra/drive-server/services/files/services/get-user-file-size-limit';
import { resolveUserFileSizeLimit } from './resolve-user-file-size-limit';

describe('resolveUserFileSizeLimit', () => {
const getUserFileSizeLimitMock = partialSpyOn(getUserFileSizeLimitModule, 'getUserFileSizeLimit');
const configGetMock = partialSpyOn(configStore, 'get');
const configSetMock = partialSpyOn(configStore, 'set');

it('should return and store a fresh valid user file size limit', async () => {
getUserFileSizeLimitMock.mockResolvedValue({ data: { maxUploadFileSize: 1024 } });

const result = await resolveUserFileSizeLimit();

expect(result).toStrictEqual({
data: {
maxUploadFileSize: 1024,
},
});
call(configSetMock).toStrictEqual(['maxUploadFileSizeInBytes', 1024]);
call(loggerMock.debug).toMatchObject({
tag: 'SYNC-ENGINE',
msg: 'Resolved user file size limit from API',
});
});

it('should return stored limit when fresh request fails', async () => {
getUserFileSizeLimitMock.mockResolvedValue({ error: new DriveServerError('NETWORK_ERROR', 500, 'Network error') });
configGetMock.mockReturnValue(1024);

const result = await resolveUserFileSizeLimit();

expect(result).toStrictEqual({
data: {
maxUploadFileSize: 1024,
},
});
expect(configSetMock).not.toBeCalled();
call(loggerMock.warn).toMatchObject({
tag: 'SYNC-ENGINE',
msg: 'Using stored user file size limit',
});
});

it('should return stored limit when fresh limit is invalid', async () => {
getUserFileSizeLimitMock.mockResolvedValue({ data: { maxUploadFileSize: 0 } });
configGetMock.mockReturnValue(1024);

const result = await resolveUserFileSizeLimit();

expect(result).toStrictEqual({
data: {
maxUploadFileSize: 1024,
},
});
expect(configSetMock).not.toBeCalled();
});

it('should not return invalid stored limit', async () => {
const error = new DriveServerError('NETWORK_ERROR', 500, 'Network error');
getUserFileSizeLimitMock.mockResolvedValue({ error });
configGetMock.mockReturnValue(-1);

const result = await resolveUserFileSizeLimit();

expect(result.error).toBe(error);
expect(result.data).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { logger } from '@internxt/drive-desktop-core/build/backend';
import configStore from '../../../../apps/main/config';
import { Result } from '../../../../context/shared/domain/Result';
import { getUserFileSizeLimit } from '../../../../infra/drive-server/services/files/services/get-user-file-size-limit';
import { ResolvedUserFileSizeLimit } from './types';
import { isValidMaxUploadFileSize } from './validate-upload-file-size';

export async function resolveUserFileSizeLimit(): Promise<Result<ResolvedUserFileSizeLimit, Error>> {
const { data, error } = await getUserFileSizeLimit();

if (data && isValidMaxUploadFileSize(data.maxUploadFileSize)) {
configStore.set('maxUploadFileSizeInBytes', data.maxUploadFileSize);
logger.debug({
tag: 'SYNC-ENGINE',
msg: 'Resolved user file size limit from API',
});

return {
data: {
maxUploadFileSize: data.maxUploadFileSize,
},
};
}

const lastKnownFileSizeLimit = configStore.get('maxUploadFileSizeInBytes');
if (isValidMaxUploadFileSize(lastKnownFileSizeLimit)) {
logger.warn({
tag: 'SYNC-ENGINE',
msg: 'Using stored user file size limit',
});

return { data: { maxUploadFileSize: lastKnownFileSizeLimit } };
}

logger.error({
tag: 'SYNC-ENGINE',
msg: 'Unable to resolve user file size limit',
error,
});

return { error: error || new Error('Unable to resolve user file size limit') };
}
3 changes: 3 additions & 0 deletions src/backend/features/user/file-size-limit/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type ResolvedUserFileSizeLimit = {
maxUploadFileSize: number;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {
clearUploadSizeLimitBlockedPath,
isUploadSizeLimitBlockedPath,
markUploadSizeLimitBlockedPath,
} from './upload-size-limit-blocked-paths';

describe('upload-size-limit-blocked-paths', () => {
const path = '/Documents/oversized.pdf';

beforeEach(() => {
clearUploadSizeLimitBlockedPath(path);
});

it('should track paths blocked by upload size limit', () => {
markUploadSizeLimitBlockedPath(path);

expect(isUploadSizeLimitBlockedPath(path)).toBe(true);
});

it('should clear blocked paths', () => {
markUploadSizeLimitBlockedPath(path);

clearUploadSizeLimitBlockedPath(path);

expect(isUploadSizeLimitBlockedPath(path)).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const uploadSizeLimitBlockedPaths = new Set<string>();

export function markUploadSizeLimitBlockedPath(path: string): void {
uploadSizeLimitBlockedPaths.add(path);
}

export function isUploadSizeLimitBlockedPath(path: string): boolean {
return uploadSizeLimitBlockedPaths.has(path);
}

export function clearUploadSizeLimitBlockedPath(path: string): void {
uploadSizeLimitBlockedPaths.delete(path);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export type UploadSizeLimitErrorCause = 'SIZE_LIMIT_UNAVAILABLE' | 'UPLOAD_SIZE_LIMIT_EXCEEDED';

export class UploadSizeLimitError extends Error {
constructor(
public readonly cause: UploadSizeLimitErrorCause,
public readonly path: string,
public readonly size: number,
public readonly maxUploadFileSize?: number,
) {
super(cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { partialSpyOn } from '../../../../../tests/vitest/utils.helper';
import configStore from '../../../../apps/main/config';
import { UploadSizeLimitError } from './upload-size-limit-error';
import { validateUploadFileSize } from './validate-upload-file-size';

describe('validateUploadFileSize', () => {
const configGetMock = partialSpyOn(configStore, 'get');
beforeEach(() => {
configGetMock.mockReturnValue(100);
});

it('should pass when file size is under the stored limit', () => {
expect(validateUploadFileSize({ path: '/file.txt', size: 99 })).toStrictEqual({ data: true });
});

it('should pass when file size is equal to the stored limit', () => {
expect(validateUploadFileSize({ path: '/file.txt', size: 100 })).toStrictEqual({ data: true });
});

it('should return error when file size is over the stored limit', () => {
const result = validateUploadFileSize({ path: '/file.txt', size: 101 });

expect(result.error).toBeInstanceOf(UploadSizeLimitError);
expect(result.error?.cause).toBe('UPLOAD_SIZE_LIMIT_EXCEEDED');
expect(result.error?.maxUploadFileSize).toBe(100);
});

it('should return unavailable error when stored limit is invalid', () => {
configGetMock.mockReturnValue(-1);

const result = validateUploadFileSize({ path: '/file.txt', size: 101 });

expect(result.error).toBeInstanceOf(UploadSizeLimitError);
expect(result.error?.cause).toBe('SIZE_LIMIT_UNAVAILABLE');
});

it('should ignore zero-byte files', () => {
expect(validateUploadFileSize({ path: '/empty.txt', size: 0 })).toStrictEqual({ data: true });
expect(configGetMock).not.toHaveBeenCalled();
});
});
Loading