diff --git a/packages/sync-providers/src/file-based/content-rev.ts b/packages/sync-providers/src/file-based/content-rev.ts new file mode 100644 index 0000000000..cdf12f29fc --- /dev/null +++ b/packages/sync-providers/src/file-based/content-rev.ts @@ -0,0 +1,13 @@ +import { md5 } from 'hash-wasm'; + +/** + * Compute the content-addressable revision (md5 hash) for a sync payload. + * + * Used by file-based providers (WebDAV, local-file) that have no + * server-supplied revision and must derive one from the file contents. + * + * Callers are responsible for any wrapping in provider-specific API errors + * (e.g. `FileHashCreationAPIError`) — this helper just forwards the + * underlying `hash-wasm` rejection. + */ +export const computeContentRev = (content: string): Promise => md5(content); diff --git a/packages/sync-providers/src/file-based/dropbox/dropbox.ts b/packages/sync-providers/src/file-based/dropbox/dropbox.ts index 75c1bd0e3a..0ab4d73e27 100644 --- a/packages/sync-providers/src/file-based/dropbox/dropbox.ts +++ b/packages/sync-providers/src/file-based/dropbox/dropbox.ts @@ -129,21 +129,13 @@ export class Dropbox implements FileSyncProvider< localRev: string | null, ): Promise<{ rev: string }> { try { - const r = await this._api.getMetaData( - this._getPath(targetPath), - localRev, - targetPath, + const r = await this._withTokenRefresh(() => + this._api.getMetaData(this._getPath(targetPath), localRev, targetPath), ); return { rev: r.rev, }; } catch (e) { - if (this._isTokenError(e)) { - this._deps.logger.critical('EXPIRED or INVALID TOKEN, trying to refresh'); - await this._api.updateAccessTokenFromRefreshTokenIfAvailable(); - return this.getFileRev(targetPath, localRev); - } - if (this._isPathNotFoundError(e)) { throw new RemoteFileNotFoundAPIError(targetPath); } @@ -165,40 +157,33 @@ export class Dropbox implements FileSyncProvider< * @throws InvalidDataSPError if the data is invalid */ async downloadFile(targetPath: string): Promise<{ rev: string; dataStr: string }> { - try { - const r = await this._api.download({ + const r = await this._withTokenRefresh(() => + this._api.download({ path: this._getPath(targetPath), targetPath, - }); + }), + ); - if (!r.meta.rev) { - throw new NoRevAPIError(); - } - - if (!r.data) { - throw new RemoteFileNotFoundAPIError(targetPath); - } - - if (typeof r.data !== 'string') { - // A1: never log the raw user blob — only its shape. - this._deps.logger.critical(`${Dropbox.L}.downloadFile got non-string data`, { - dataType: typeof r.data, - }); - throw new InvalidDataSPError('Dropbox download returned non-string data'); - } - - return { - rev: r.meta.rev, - dataStr: r.data, - }; - } catch (e) { - if (this._isTokenError(e)) { - this._deps.logger.critical('EXPIRED or INVALID TOKEN, trying to refresh'); - await this._api.updateAccessTokenFromRefreshTokenIfAvailable(); - return this.downloadFile(targetPath); - } - throw e; + if (!r.meta.rev) { + throw new NoRevAPIError(); } + + if (!r.data) { + throw new RemoteFileNotFoundAPIError(targetPath); + } + + if (typeof r.data !== 'string') { + // A1: never log the raw user blob — only its shape. + this._deps.logger.critical(`${Dropbox.L}.downloadFile got non-string data`, { + dataType: typeof r.data, + }); + throw new InvalidDataSPError('Dropbox download returned non-string data'); + } + + return { + rev: r.meta.rev, + dataStr: r.data, + }; } /** @@ -238,30 +223,23 @@ export class Dropbox implements FileSyncProvider< } } - try { - const r = await this._api.upload({ + const r = await this._withTokenRefresh(() => + this._api.upload({ path: this._getPath(targetPath), data: dataStr, revToMatch: effectiveRev, isForceOverwrite, targetPath, - }); + }), + ); - if (!r.rev) { - throw new NoRevAPIError(); - } - - return { - rev: r.rev, - }; - } catch (e) { - if (this._isTokenError(e)) { - this._deps.logger.critical('EXPIRED or INVALID TOKEN, trying to refresh'); - await this._api.updateAccessTokenFromRefreshTokenIfAvailable(); - return this.uploadFile(targetPath, dataStr, revToMatch, isForceOverwrite); - } - throw e; + if (!r.rev) { + throw new NoRevAPIError(); } + + return { + rev: r.rev, + }; } /** @@ -272,14 +250,10 @@ export class Dropbox implements FileSyncProvider< */ async removeFile(targetPath: string): Promise { try { - await this._api.remove(this._getPath(targetPath), targetPath); + await this._withTokenRefresh(() => + this._api.remove(this._getPath(targetPath), targetPath), + ); } catch (e) { - if (this._isTokenError(e)) { - this._deps.logger.critical('EXPIRED or INVALID TOKEN, trying to refresh'); - await this._api.updateAccessTokenFromRefreshTokenIfAvailable(); - return this.removeFile(targetPath); - } - if (this._isPathNotFoundError(e)) { throw new RemoteFileNotFoundAPIError(targetPath); } @@ -296,14 +270,10 @@ export class Dropbox implements FileSyncProvider< this._deps.logger.normal(`${Dropbox.L}.listFiles()`, { dirPath }); try { // DropboxApi.listFiles now returns full paths, so no need to prepend _getPath - return await this._api.listFiles(this._getPath(dirPath), dirPath); + return await this._withTokenRefresh(() => + this._api.listFiles(this._getPath(dirPath), dirPath), + ); } catch (e) { - if (this._isTokenError(e)) { - this._deps.logger.critical('EXPIRED or INVALID TOKEN, trying to refresh'); - await this._api.updateAccessTokenFromRefreshTokenIfAvailable(); - return this.listFiles(dirPath); - } - if (this._isPathNotFoundError(e)) { // If the directory doesn't exist, return empty array return []; @@ -420,4 +390,27 @@ export class Dropbox implements FileSyncProvider< apiError.response.data?.error_summary?.includes(INVALID_TOKEN_ERROR)) ); } + + /** + * Runs `fn` and, if it throws an expired/invalid-token error, refreshes the + * access token once and retries. Any other error (or a second token error + * on retry) is rethrown unchanged so the caller's existing classifier can + * map it to `RemoteFileNotFoundAPIError` / `AuthFailSPError` / etc. + * + * Single retry only — matches the previous hand-rolled recursion which + * would have re-entered the same site on a still-expired token and looped + * forever; bounding to one retry is the documented behavior here. + */ + private async _withTokenRefresh(fn: () => Promise): Promise { + try { + return await fn(); + } catch (e) { + if (this._isTokenError(e)) { + this._deps.logger.critical('EXPIRED or INVALID TOKEN, trying to refresh'); + await this._api.updateAccessTokenFromRefreshTokenIfAvailable(); + return await fn(); + } + throw e; + } + } } diff --git a/packages/sync-providers/src/file-based/local-file/local-file-sync-base.ts b/packages/sync-providers/src/file-based/local-file/local-file-sync-base.ts index 83bf84689d..96a69d8571 100644 --- a/packages/sync-providers/src/file-based/local-file/local-file-sync-base.ts +++ b/packages/sync-providers/src/file-based/local-file/local-file-sync-base.ts @@ -1,4 +1,3 @@ -import { md5 } from 'hash-wasm'; import type { SyncLogger } from '@sp/sync-core'; import type { SyncCredentialStorePort } from '../../credential-store-port'; import type { FileAdapter } from '../../file-adapter'; @@ -11,6 +10,7 @@ import { RemoteFileNotFoundAPIError, UploadRevToMatchMismatchAPIError, } from '../../errors'; +import { computeContentRev } from '../content-rev'; import { PROVIDER_ID_LOCAL_FILE, type LocalFileSyncPrivateCfg } from './local-file.model'; export interface LocalFileSyncBaseDeps { @@ -190,7 +190,7 @@ export abstract class LocalFileSyncBase implements FileSyncProvider< let hash: string; try { - hash = await md5(dataStr); + hash = await computeContentRev(dataStr); } catch (e) { throw new FileHashCreationAPIError(e); } diff --git a/packages/sync-providers/src/file-based/webdav/webdav-api.ts b/packages/sync-providers/src/file-based/webdav/webdav-api.ts index 8e8214ae7f..0fbdfdc448 100644 --- a/packages/sync-providers/src/file-based/webdav/webdav-api.ts +++ b/packages/sync-providers/src/file-based/webdav/webdav-api.ts @@ -1,5 +1,4 @@ import type { SyncLogger } from '@sp/sync-core'; -import { md5 as md5HashWasm } from 'hash-wasm'; import { EmptyRemoteBodySPError, HttpNotOkAPIError, @@ -9,6 +8,7 @@ import { RemoteFileNotFoundAPIError, } from '../../errors'; import { errorMeta } from '../../log/error-meta'; +import { computeContentRev } from '../content-rev'; import { WebDavHttpHeader, WebDavHttpMethod, WebDavHttpStatus } from './webdav.const'; import type { WebDavHttpAdapter, WebDavHttpResponse } from './webdav-http-adapter'; import { FileMeta, WebdavXmlParser } from './webdav-xml-parser'; @@ -35,7 +35,7 @@ export class WebdavApi { } private async _computeContentHash(data: string): Promise { - return md5HashWasm(data); + return computeContentRev(data); } // ==============================