Compare commits

...
21 changed files with 608 additions and 169 deletions

No files matched your search

@@ -71,9 +71,6 @@ export interface IStorageService {
contentLength: number;
contentRange?: string | null;
contentType?: string | null;
cacheControl?: string | null;
contentDisposition?: string | null;
expires?: Date | null;
etag?: string | null;
lastModified?: Date | null;
} | null>;
@@ -107,6 +107,7 @@ function extractStreamFromGet(out: GetObjectCommandOutput): Readable {
}
const REJECTED_SERVER_SIDE_COPY_ERRORS = new Set([
'NoSuchBucket',
'NoSuchKey',
'NotFound',
'NotImplemented',
@@ -239,6 +240,8 @@ export class StorageService implements IStorageService {
contentType,
contentLength,
expiresAt,
cacheControl,
contentDisposition,
}: {
bucket: string;
key: string;
@@ -246,6 +249,8 @@ export class StorageService implements IStorageService {
contentType?: string;
contentLength?: number;
expiresAt?: Date;
cacheControl?: string;
contentDisposition?: string;
}): Promise<void> {
const size = contentLength ?? (await fs.promises.stat(filePath)).size;
if (size <= STREAM_UPLOAD_PART_BYTES) {
@@ -258,6 +263,8 @@ export class StorageService implements IStorageService {
ContentType: contentType,
ContentLength: size,
Expires: expiresAt,
CacheControl: cacheControl,
ContentDisposition: contentDisposition,
}),
);
return;
@@ -278,6 +285,8 @@ export class StorageService implements IStorageService {
Body: stream,
ContentType: contentType,
Expires: expiresAt,
CacheControl: cacheControl,
ContentDisposition: contentDisposition,
},
partSize: STREAM_UPLOAD_PART_BYTES,
queueSize: STREAM_UPLOAD_CONCURRENCY,
@@ -426,9 +435,6 @@ export class StorageService implements IStorageService {
contentLength: number;
contentRange?: string | null;
contentType?: string | null;
cacheControl?: string | null;
contentDisposition?: string | null;
expires?: Date | null;
etag?: string | null;
lastModified?: Date | null;
} | null> {
@@ -441,9 +447,6 @@ export class StorageService implements IStorageService {
contentLength: out.ContentLength ?? 0,
contentRange: out.ContentRange ?? null,
contentType: out.ContentType ?? null,
cacheControl: out.CacheControl ?? null,
contentDisposition: out.ContentDisposition ?? null,
expires: out.Expires ?? null,
etag: out.ETag ?? null,
lastModified: out.LastModified ?? null,
};
@@ -468,6 +471,10 @@ export class StorageService implements IStorageService {
async writeObjectToDisk(bucket: string, key: string, filePath: string): Promise<void> {
await this.ensureDirectoryExists(path.dirname(filePath));
await this.downloadObject(bucket, key, filePath);
}
private async downloadObject(bucket: string, key: string, filePath: string): Promise<GetObjectCommandOutput> {
const out = await this.client.send(new GetObjectCommand({Bucket: bucket, Key: key}));
const stream = extractStreamFromGet(out);
const writeStream = fs.createWriteStream(filePath);
@@ -477,6 +484,7 @@ export class StorageService implements IStorageService {
writeStream.destroy();
throw error;
}
return out;
}
async copyObject({
@@ -507,7 +515,7 @@ export class StorageService implements IStorageService {
}),
);
} catch (copyError) {
if (sourceBucket === destinationBucket || !isRejectedServerSideCopy(copyError)) {
if (!isRejectedServerSideCopy(copyError)) {
throw copyError;
}
await this.copyObjectThroughApi(
@@ -541,38 +549,28 @@ export class StorageService implements IStorageService {
},
copyError: unknown,
): Promise<void> {
const source = await this.streamObject({bucket: sourceBucket, key: sourceKey});
if (!source) {
throw copyError;
}
Logger.warn(
{sourceBucket, destinationBucket, error: copyError},
'Object storage rejected a cross-bucket copy, copying through the API instead',
);
const upload = new Upload({
client: this.client,
params: {
Bucket: destinationBucket,
Key: destinationKey,
Body: source.body,
ContentType: newContentType ?? source.contentType ?? undefined,
const downloadedSource = temporaryFile();
try {
const source = await this.downloadObject(sourceBucket, sourceKey, downloadedSource);
Logger.warn(
{sourceBucket, destinationBucket, error: copyError},
'Object storage rejected a server-side copy, copying through the API instead',
);
await this.uploadObjectFromFile({
bucket: destinationBucket,
key: destinationKey,
filePath: downloadedSource,
contentType: newContentType ?? source.ContentType,
...(newContentType
? {}
: {
CacheControl: source.cacheControl ?? undefined,
ContentDisposition: source.contentDisposition ?? undefined,
Expires: source.expires ?? undefined,
cacheControl: source.CacheControl,
contentDisposition: source.ContentDisposition,
expiresAt: source.Expires,
}),
},
partSize: STREAM_UPLOAD_PART_BYTES,
queueSize: STREAM_UPLOAD_CONCURRENCY,
leavePartsOnError: false,
});
try {
await upload.done();
} catch (error) {
source.body.destroy();
throw error;
});
} finally {
await fs.promises.unlink(downloadedSource).catch(() => undefined);
}
}
@@ -187,7 +187,7 @@ describe('StorageService copies on providers that reject cross-bucket CopyObject
expect(ceph.objects.has(`${CDN}/attachments/1/4/missing.pdf`)).toBe(false);
});
it('does not retry a failed same-bucket copy through the API', async () => {
it('reports a missing same-bucket source as a missing object', async () => {
await expect(
storage().copyObject({
sourceBucket: CDN,
@@ -20,7 +20,7 @@ function splitBucket(path: string): {bucket: string; key: string} {
return {bucket, key: key.join('/')};
}
function singleBucketStore() {
function singleBucketStore(unresolvedSourceCode = 'NoSuchBucket') {
const objects = new Map<string, {body: Uint8Array; contentType: string}>();
const resolve = (url: URL): {bucket: string; key: string} => {
const path = decodeURIComponent(url.pathname.slice(1));
@@ -58,7 +58,7 @@ function singleBucketStore() {
}
const source = splitBucket(decodeURIComponent(copySource.replace(/^\//u, '')));
if (source.bucket !== REAL_BUCKET) {
return errorResponse('NoSuchBucket', 404);
return errorResponse(unresolvedSourceCode, unresolvedSourceCode === 'AccessDenied' ? 403 : 404);
}
const object = objects.get(source.key);
if (!object) {
@@ -127,3 +127,59 @@ describe('StorageService with every bucket inside one real bucket', () => {
await expect(service.readObject(REAL_BUCKET, 'attachments/1/3/file.pdf')).resolves.toEqual(PDF);
});
});
describe.each(['NoSuchBucket', 'AccessDenied'])(
'StorageService with the bucket in the endpoint hostname, store answers %s',
(code) => {
let objects: ReturnType<typeof singleBucketStore>;
beforeEach(() => {
objects = singleBucketStore(code);
});
it('copies a non-media attachment out of the uploads prefix', async () => {
const service = storage(`https://${REAL_BUCKET}.${HOST}`, true);
await service.uploadObject({bucket: 'fluxer-uploads', key: 'upload 1', body: PDF});
await expect(
service.copyObjectWithMetadataStripping({
sourceBucket: 'fluxer-uploads',
sourceKey: 'upload 1',
destinationBucket: 'fluxer',
destinationKey: 'attachments/1/2/file.pdf',
contentType: 'application/pdf',
}),
).resolves.toBeNull();
expect(objects.get('fluxer/attachments/1/2/file.pdf')).toEqual({body: PDF, contentType: 'application/pdf'});
});
it('copies within the cdn prefix', async () => {
const service = storage(`https://${REAL_BUCKET}.${HOST}`, true);
await service.uploadObject({bucket: 'fluxer', key: 'emojis/1', body: PDF, contentType: 'image/png'});
await service.copyObject({
sourceBucket: 'fluxer',
sourceKey: 'emojis/1',
destinationBucket: 'fluxer',
destinationKey: 'emojis/2',
});
expect(objects.get('fluxer/emojis/2')).toEqual({body: PDF, contentType: 'image/png'});
});
it('reports a missing source as a missing object', async () => {
const service = storage(`https://${REAL_BUCKET}.${HOST}`, true);
await expect(
service.copyObject({
sourceBucket: 'fluxer',
sourceKey: 'emojis/missing',
destinationBucket: 'fluxer',
destinationKey: 'emojis/3',
}),
).rejects.toMatchObject({name: 'NoSuchKey'});
expect(objects.has('fluxer/emojis/3')).toBe(false);
});
},
);
@@ -216,9 +216,6 @@ export class MockStorageService implements IStorageService {
contentLength: number;
contentRange?: string | null;
contentType?: string | null;
cacheControl?: string | null;
contentDisposition?: string | null;
expires?: Date | null;
etag?: string | null;
lastModified?: Date | null;
} | null> {
@@ -247,9 +244,6 @@ export class MockStorageService implements IStorageService {
contentLength: slice.length,
contentRange,
contentType: obj?.contentType ?? 'application/octet-stream',
cacheControl: null,
contentDisposition: null,
expires: null,
etag: computeEtag(data),
lastModified: obj?.lastModified ?? null,
};
@@ -38,12 +38,10 @@ interface RemoteSpeakingAnalyserState {
export type VoiceRemoteSpeakingCommand =
| {type: 'setAudioLevelSpeaking'; identity: string; speaking: boolean}
| {type: 'setPlaybackBoost'; identity: string; boost: number}
| {type: 'clearPlaybackBoost'; identity: string}
| {type: 'rehydrateRemoteAnalysers'};
| {type: 'clearPlaybackBoost'; identity: string};
interface VoiceRemoteSpeakingMachineContext {
analysers: ReadonlyMap<string, RemoteSpeakingAnalyserState>;
analyserSuspendedByVisibility: boolean;
commands: ReadonlyArray<VoiceRemoteSpeakingCommand>;
}
@@ -51,8 +49,6 @@ export type VoiceRemoteSpeakingEvent =
| {type: 'remote.attach'; identity: string; track: unknown}
| {type: 'remote.detach'; identity: string}
| {type: 'remote.tick'; identity: string; rms: number; threshold: number; nowMs: number; trackEnded?: boolean}
| {type: 'remote.visibilityHidden'}
| {type: 'remote.visibilityVisible'}
| {type: 'remote.clear'}
| {type: 'remote.clearCommands'};
const EMPTY_REMOTE_COMMANDS: ReadonlyArray<VoiceRemoteSpeakingCommand> = [];
@@ -68,10 +64,9 @@ const REMOTE_PLAYBACK_UNITY_EPSILON = 0.01;
function remoteContext(
analysers: ReadonlyMap<string, RemoteSpeakingAnalyserState> = new Map(),
analyserSuspendedByVisibility = false,
commands: ReadonlyArray<VoiceRemoteSpeakingCommand> = EMPTY_REMOTE_COMMANDS,
): VoiceRemoteSpeakingMachineContext {
return {analysers, analyserSuspendedByVisibility, commands};
return {analysers, commands};
}
function appendRemoteCommands(
@@ -210,26 +205,14 @@ function tickRemoteAnalyser(
return appendRemoteCommands({...context, analysers}, commands);
}
function detachAllRemoteAnalysers(
context: VoiceRemoteSpeakingMachineContext,
analyserSuspendedByVisibility: boolean,
): VoiceRemoteSpeakingMachineContext {
if (context.analysers.size === 0) {
return context.analyserSuspendedByVisibility === analyserSuspendedByVisibility
? context
: {...context, analyserSuspendedByVisibility};
}
function detachAllRemoteAnalysers(context: VoiceRemoteSpeakingMachineContext): VoiceRemoteSpeakingMachineContext {
if (context.analysers.size === 0) return context;
const commands: Array<VoiceRemoteSpeakingCommand> = [];
for (const identity of context.analysers.keys()) {
commands.push({type: 'setAudioLevelSpeaking', identity, speaking: false});
commands.push({type: 'clearPlaybackBoost', identity});
}
return appendRemoteCommands(remoteContext(new Map(), analyserSuspendedByVisibility, context.commands), commands);
}
function showRemoteAnalysers(context: VoiceRemoteSpeakingMachineContext): VoiceRemoteSpeakingMachineContext {
if (!context.analyserSuspendedByVisibility) return context;
return appendRemoteCommands({...context, analyserSuspendedByVisibility: false}, [{type: 'rehydrateRemoteAnalysers'}]);
return appendRemoteCommands(remoteContext(new Map(), context.commands), commands);
}
const voiceRemoteSpeakingStateMachine = setup({
@@ -245,9 +228,7 @@ const voiceRemoteSpeakingStateMachine = setup({
event.type === 'remote.detach' ? detachRemoteAnalyser(context, event.identity) : context,
),
tick: assign(({context, event}) => (event.type === 'remote.tick' ? tickRemoteAnalyser(context, event) : context)),
hide: assign(({context}) => detachAllRemoteAnalysers(context, true)),
show: assign(({context}) => showRemoteAnalysers(context)),
clear: assign(({context}) => detachAllRemoteAnalysers(context, false)),
clear: assign(({context}) => detachAllRemoteAnalysers(context)),
clearCommands: assign(({context}) => ({...context, commands: EMPTY_REMOTE_COMMANDS})),
},
}).createMachine({
@@ -257,8 +238,6 @@ const voiceRemoteSpeakingStateMachine = setup({
'remote.attach': {actions: 'attach'},
'remote.detach': {actions: 'detach'},
'remote.tick': {actions: 'tick'},
'remote.visibilityHidden': {actions: 'hide'},
'remote.visibilityVisible': {actions: 'show'},
'remote.clear': {actions: 'clear'},
'remote.clearCommands': {actions: 'clearCommands'},
},
@@ -64,8 +64,7 @@ class VoiceEngineV2AppRemoteSpeakingAdapter {
private audioContext: AudioContext | null = null;
private audioContextIsShared = false;
private analysers = new Map<string, AnalyserHandle>();
private installedVisibilityListener = false;
private roomForRehydrate: Room | null = null;
private hydratedRoom: Room | null = null;
private machineSnapshot: VoiceRemoteSpeakingSnapshot = createVoiceRemoteSpeakingSnapshot();
private autoScheduleTimerId: number | null = null;
private readonly autoSchedule: boolean;
@@ -96,9 +95,6 @@ class VoiceEngineV2AppRemoteSpeakingAdapter {
clearRemoteVoicePlaybackBoost(command.identity);
this.reapplyParticipantVolume(command.identity);
break;
case 'rehydrateRemoteAnalysers':
if (this.roomForRehydrate) this.hydrateFromRoom(this.roomForRehydrate);
break;
}
}
}
@@ -112,22 +108,6 @@ class VoiceEngineV2AppRemoteSpeakingAdapter {
this.stopAutoSchedule();
}
private ensureVisibilityListener(): void {
if (this.installedVisibilityListener) return;
if (typeof document === 'undefined') return;
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (this.machineSnapshot.context.analyserSuspendedByVisibility) return;
this.detachAllAdapterAnalysers();
this.applyCommands(this.transition({type: 'remote.visibilityHidden'}));
} else {
if (!this.machineSnapshot.context.analyserSuspendedByVisibility) return;
this.applyCommands(this.transition({type: 'remote.visibilityVisible'}));
}
});
this.installedVisibilityListener = true;
}
private getAudioContext(): AudioContext | null {
if (typeof window === 'undefined') return null;
if (this.audioContext && this.audioContext.state !== 'closed') {
@@ -166,8 +146,6 @@ class VoiceEngineV2AppRemoteSpeakingAdapter {
this.analysers.size <= REMOTE_SPEAKING_ANALYSER_HANDLES_CAP,
'remote speaking analyser registry exceeded cap',
);
this.ensureVisibilityListener();
if (this.machineSnapshot.context.analyserSuspendedByVisibility) return;
if (participant.isLocal) return;
if (publication.source !== VoiceTrackSource.Microphone) return;
if (track.kind !== VoiceTrackKind.Audio) return;
@@ -249,12 +227,12 @@ class VoiceEngineV2AppRemoteSpeakingAdapter {
);
this.detachAllAdapterAnalysers();
this.applyCommands(this.transition({type: 'remote.clear'}));
this.roomForRehydrate = null;
this.hydratedRoom = null;
assert.equal(this.analysers.size, 0, 'clear post-condition: no analysers');
}
private reapplyParticipantVolume(identity: string): void {
const room = getRoomFromMediaEngine() ?? this.roomForRehydrate;
const room = getRoomFromMediaEngine() ?? this.hydratedRoom;
const participant = room?.remoteParticipants.get(identity);
if (!participant) return;
try {
@@ -267,9 +245,7 @@ class VoiceEngineV2AppRemoteSpeakingAdapter {
hydrateFromRoom(room: Room): void {
assertObjectLike<Room>(room, 'hydrateFromRoom.room');
assert.ok(room.remoteParticipants instanceof Map, 'hydrateFromRoom.room.remoteParticipants must be a Map');
this.ensureVisibilityListener();
this.roomForRehydrate = room;
if (this.machineSnapshot.context.analyserSuspendedByVisibility) return;
this.hydratedRoom = room;
room.remoteParticipants.forEach((participant) => {
participant.audioTrackPublications.forEach((publication) => {
const remotePub = publication as RemoteTrackPublication;
@@ -287,7 +263,6 @@ class VoiceEngineV2AppRemoteSpeakingAdapter {
this.analysers.size <= REMOTE_SPEAKING_ANALYSER_HANDLES_CAP,
'tickHost pre-condition: analyser cap respected',
);
if (this.machineSnapshot.context.analyserSuspendedByVisibility) return;
const sizeBefore = this.analysers.size;
const expired: Array<AnalyserHandle> = [];
for (const handle of this.analysers.values()) {
@@ -183,11 +183,15 @@ This contract controls every range the Media Proxy resolves itself. A range is r
A reversed range, a zero-length suffix, a start outside the representation, or any range over an empty representation returns 416 with `Content-Range: bytes */{size}` and `Accept-Ranges: bytes`. A satisfiable range returns 206, `Accept-Ranges: bytes`, and exact `Content-Range` and `Content-Length` values.
### Transformed representations
A transformed representation is never served by range. Two Media Proxy instances can encode the same source to different bytes, so ranges answered by different instances do not join into one valid image. A response whose bytes a transformation produced ignores `Range`, returns the complete representation with 200, and has no `Accept-Ranges` header. `HEAD` does the same with an empty body. A rasterised SVG is a transformed representation, including the one an `mp` endpoint returns for a read that requests no transformation. A response that returns stored or origin bytes unchanged follows the contract above.
### Ranges on the signed external route
The route forwards a range to the origin only when no transformation is requested. It sends the range verbatim when the value after `bytes=` is non-empty and every byte of it is an ASCII graphic character. A multiple range therefore reaches the origin, and the origin decides how to answer it. A value with a space anywhere is dropped, and no range is sent. The route relays the origin partial response with the origin `Content-Range` unchanged.
A range on a transformed response selects bytes from the result and returns 206 or 416. Without a transformation, an origin that ignores the range can produce a complete 200 response. Always check the response status and range headers.
A transformed response ignores the range and returns the complete result, as [Transformed representations](#transformed-representations) states. Without a transformation, an origin that ignores the range can produce a complete 200 response. Always check the response status and range headers.
:::caution[A mislabelled SVG reaches the client as bytes]
An origin that answers a forwarded range with SVG bytes under another media type can produce a 206 containing raw SVG.
@@ -203,7 +207,7 @@ Content disposition follows the media type the origin declared, so SVG mislabell
| Content-Length?<sup>2</sup> | integer | The selected body length, including on HEAD |
| Content-Range? | string | Present on 206 as `bytes {start}-{end}/{size}` and on 416 as `bytes */{size}` |
| Content-Disposition?<sup>3</sup> | string | The route-selected inline or attachment disposition |
| Accept-Ranges | string | The literal `bytes` on every media representation |
| Accept-Ranges? | string | The literal `bytes`. Absent on a [transformed representation](#transformed-representations) |
| Access-Control-Allow-Origin? | string | The literal `*` on media responses. Under `enforce` it is the request origin, or absent with no `Origin` |
| Cache-Control<sup>4</sup> | string | The browser cache policy for the representation |
| CDN-Cache-Control | string | The matching shared cache policy |
@@ -130,7 +130,7 @@ Every route-produced error response uses `Cache-Control: no-store`, and a succes
## Range response headers
A complete media response has `Accept-Ranges: bytes`, the representation `Content-Type`, and an exact `Content-Length`<sup>1</sup>. Outside `static` mode that `Content-Type` is `text/plain; charset=utf-8` when it names a JavaScript media type, and a 200 or 206 with no `Content-Type` gets the same value. [Content detection](/media-proxy/overview/#content-detection) defines both rules. A 206 also sets `Content-Range` to the selected interval over the complete size and `Content-Length` to the selected byte count. A 416 has `Content-Range: bytes */{size}` and `Accept-Ranges: bytes` with an empty body.
A complete media response has `Accept-Ranges: bytes`, the representation `Content-Type`, and an exact `Content-Length`<sup>1</sup>. A [transformed representation](/media-proxy/overview/#transformed-representations) has no `Accept-Ranges` and is always complete. Outside `static` mode that `Content-Type` is `text/plain; charset=utf-8` when it names a JavaScript media type, and a 200 or 206 with no `Content-Type` gets the same value. [Content detection](/media-proxy/overview/#content-detection) defines both rules. A 206 also sets `Content-Range` to the selected interval over the complete size and `Content-Length` to the selected byte count. A 416 has `Content-Range: bytes */{size}` and `Accept-Ranges: bytes` with an empty body.
<sup>1</sup> A streamed signed external response omits `Content-Length` when the origin declared none
@@ -68,7 +68,7 @@ Anyone holding the URL reads the object from any origin the [cross-origin read p
[When a transformation runs](/media-proxy/transformations/#when-a-transformation-runs) defines the trigger.
When a transformation is requested, `Range` selects bytes from the transformed result.
A response with transformed bytes ignores `Range` and is always complete, as [Transformed representations](/media-proxy/overview/#transformed-representations) states.
An SVG attachment with a transformation parameter is rasterised, and `format` and `quality` select the output as they do for any other source, defaulting to WebP. Without a transformation parameter, only a Media Proxy in [`mp` deployment mode](/media-proxy/overview/#deployment-modes) rasterises it, always as lossless WebP. A Media Proxy in `upload` mode returns the stored SVG bytes.
@@ -79,11 +79,11 @@ A video source requires an explicit image `format`. A video request with `width`
| Status | Body | Condition |
| --- | --- | --- |
| 200 | Complete media representation | The object and selected representation are available |
| 206 | Selected media bytes | One range is satisfiable |
| 206 | Selected media bytes | One range over the stored bytes is satisfiable |
| 400 | Bad request | The decoded key, a dimension, the format, or the transformation request is invalid, or the transformation failed |
| 404 | Not found | The attachment object does not exist and the signed attachment URL policy is `off` or `report` |
| 404 | This content is no longer available. | The signed attachment URL policy refuses the signature, or the object does not exist under `enforce` |
| 416 | empty | The range is unsatisfiable |
| 416 | empty | A range over the stored bytes is unsatisfiable |
| 504 | Gateway timeout | Transformation capacity was unavailable or the transformation exceeded its deadline |
[Common responses](#common-responses) also apply.
@@ -140,7 +140,7 @@ A redirect beyond the [five-redirect bound](/media-proxy/responses-and-limits/#r
`effort` is read only on [Get attachment](#get-attachment) and is ignored here.
A transformation begins when `width`, `height`, `format`, or `quality` is present, when `animated` resolves to true, when the target filename ends in `.svg`, or when the origin body is SVG by media type or by its first bytes. A transforming request forwards no `Range` to the origin and applies the client range to the transformed bytes, so a satisfiable range returns 206 and an unsatisfiable range returns 416.
A transformation begins when `width`, `height`, `format`, or `quality` is present, when `animated` resolves to true, when the target filename ends in `.svg`, or when the origin body is SVG by media type or by its first bytes. A transforming request forwards no `Range` to the origin. A response with transformed bytes ignores the client range and is always complete, as [Transformed representations](/media-proxy/overview/#transformed-representations) states. A transforming request that returns the origin bytes unchanged applies the client range to those buffered bytes, so a satisfiable range returns 206 and an unsatisfiable range returns 416.
A non-transforming request can forward the range to the origin. Handle both 200 and 206 responses and use their range headers, as described in [Byte ranges](/media-proxy/overview/#byte-ranges).
@@ -153,7 +153,7 @@ A video target requires an explicit image `format` to produce a thumbnail. Witho
| Status | Body | Condition |
| --- | --- | --- |
| 200 | Complete external representation | The validated origin returned a complete representation |
| 206<sup>1</sup> | Selected external bytes | The origin answered a forwarded range, or a range over buffered or transformed bytes is satisfiable |
| 206<sup>1</sup> | Selected external bytes | The origin answered a forwarded range, or a range over buffered origin bytes is satisfiable |
| 400 | Bad request | The path shape, decoded URL, redirect target, transformation request, or transformation is invalid or prohibited |
| 401 | Unauthorized | The path signature is invalid |
| 413 | Payload too large | The origin declared or delivered more than 500 MiB |
@@ -162,7 +162,7 @@ A video target requires an explicit image `format` to produce a thumbnail. Witho
| 503 | Service unavailable | Fluxer could not reserve or allocate a buffer for the origin body |
| 504 | Gateway timeout | Transformation capacity was unavailable or the transformation exceeded its deadline |
<sup>1</sup> A forwarded range relays the origin `Content-Range` and `Content-Length` unchanged and omits either header the origin did not send, so a chunked origin 206 produces a 206 with no `Content-Length`. A local range over buffered or transformed bytes always has both
<sup>1</sup> A forwarded range relays the origin `Content-Range` and `Content-Length` unchanged and omits either header the origin did not send, so a chunked origin 206 produces a 206 with no `Content-Length`. A local range over buffered origin bytes always has both
An origin status of 400, 401, 403, 404, 405, 406, 408, 409, 410, 411, 412, 413, 414, 415, 416, 428, or 429 reaches the client unchanged with the plain-text body `Upstream fetch failed`, and the origin body and headers are not returned. Any other unsuccessful origin status becomes 502. [Common responses](#common-responses) also apply.
@@ -258,7 +258,7 @@ An owner segment of the literal `.` or `..` parses as an asset path but produces
<sup>3</sup> An absent value keeps the hash-derived default, which is animated only for an `a_` hash. PNG and JPEG output has no animation
`width`, `height`, and `effort` are not read on an asset path, and no asset query value produces a 400. The route accepts a `Range` over the selected representation.
`width`, `height`, and `effort` are not read on an asset path, and no asset query value produces a 400. The route accepts a `Range` when it returns the stored bytes unchanged. A transformed asset ignores `Range` and is always complete, as [Transformed representations](/media-proxy/overview/#transformed-representations) states.
<a id="image-asset-response"></a>
@@ -267,10 +267,10 @@ An owner segment of the literal `.` or `..` parses as an asset path but produces
| Status | Body | Condition |
| --- | --- | --- |
| 200 | Complete asset representation | The original or transformed asset is available |
| 206 | Selected asset bytes | One range is satisfiable |
| 206 | Selected asset bytes | One range over the stored bytes is satisfiable |
| 400 | Bad request | An owner segment of `.` or `..`, or a hash of `a_` on a path other than an emoji or sticker path, makes the storage key unsafe |
| 404 | Not found | The path is not a valid asset path or the asset does not exist |
| 416 | empty | The range is unsatisfiable |
| 416 | empty | A range over the stored bytes is unsatisfiable |
| 500<sup>1</sup> | Transcode failed | A required transcode failed and the source is not directly displayable |
| 504 | Gateway timeout | Transformation capacity was unavailable or the transformation exceeded its deadline |
@@ -524,6 +524,10 @@ Default `http://localhost:3900`. The S3 API address. `media-proxy` defaults to e
An endpoint with a bucket in its path, such as `https://s3.example.com/my-bucket` with `FLUXER_S3_FORCE_PATH_STYLE=true`, keeps every Fluxer bucket inside that one bucket. Each bucket name becomes a key prefix there, so uploads land under `fluxer-uploads/` in `my-bucket`.
An endpoint with the bucket in its hostname, such as `https://my-bucket.s3.example.com` with `FLUXER_S3_FORCE_PATH_STYLE=true`, turns each bucket name into a key prefix the same way. The store cannot resolve a Fluxer bucket name as the source of a server-side copy there. The API downloads the object to its temporary directory and uploads the copy from there.
The API cannot list objects through either kind of endpoint, because the store reads the bucket name in a listing request as a key. [Report deletion](/admin-api/reports/#report-retention) lists a report's profile snapshot images first. On a store that answers the listing with an error, the deletion fails. Separate buckets do not have this limit.
#### `FLUXER_S3_PUBLIC_ENDPOINT`
No default. The host substituted into presigned URLs, which are the only place it appears. Compose forwards it from `.env`, and uses `FLUXER_S3_ENDPOINT` when it is unset, except on `app-proxy`, which gets only a value `.env` sets and adds it to its Content Security Policy. `admin` reads it to find the origin that report downloads are presigned against. See `FLUXER_S3_BUCKET_REPORTS`.
+1 -3
View File
@@ -95,7 +95,6 @@ fn external_cached_transform(
url: &str,
method: &Method,
params: &HashMap<String, String>,
headers: &HeaderMap,
) -> Option<Response> {
let hint = app.media.external_hints().get(url)?;
let filename = url_filename(&hint.url);
@@ -109,7 +108,6 @@ fn external_cached_transform(
filename: &filename,
route: TransformRoute::External,
params,
headers,
},
)
}
@@ -295,7 +293,7 @@ pub(in crate::server) async fn serve_external(
};
if prepared.wants_transform
&& let Some(cached) =
external_cached_transform(app, &prepared.url, &prepared.method, params, headers)
external_cached_transform(app, &prepared.url, &prepared.method, params)
{
return cached;
}
@@ -69,6 +69,41 @@ pub(in crate::server) fn media_response(response: MediaResponse<'_>) -> Response
)
}
pub(in crate::server) struct GeneratedMediaResponse<'a> {
pub(in crate::server) method: Method,
pub(in crate::server) data: BudgetedBytes,
pub(in crate::server) content_type: &'a str,
pub(in crate::server) disposition: Option<HeaderValue>,
}
pub(in crate::server) fn generated_media_response(
response: GeneratedMediaResponse<'_>,
) -> Response {
let GeneratedMediaResponse {
method,
data,
content_type,
disposition,
} = response;
let total_len = data.len();
let body = if method == Method::HEAD {
Body::empty()
} else {
Body::from(retained_response_bytes(data))
};
let mut response = media_body_response(
body,
StatusCode::OK,
total_len,
content_type,
None,
Some(total_len as u64),
disposition,
);
response.headers_mut().remove(header::ACCEPT_RANGES);
response
}
pub(in crate::server) fn media_body_response(
body: Body,
status: StatusCode,
@@ -14,11 +14,11 @@ use crate::{
load_media_input, read_sniff_prefix, resolve_metadata,
},
response::{
MediaResponse,
GeneratedMediaResponse,
error::{
canonical_reason_str, json_response, storage_error_response, text, text_with_source,
},
media_response,
generated_media_response,
},
state::AppState,
transform::execution::{
@@ -231,11 +231,10 @@ pub(in crate::server) async fn thumbnail_handler(
}
}
};
media_response(MediaResponse {
generated_media_response(GeneratedMediaResponse {
method: Method::GET,
data: BudgetedBytes::from(Bytes::from(media.bytes)),
content_type: media.content_type,
range_header: None,
disposition: None,
})
}
+4 -7
View File
@@ -27,9 +27,9 @@ use crate::{
filename_from_storage_key,
},
response::{
MediaResponse, content_disposition_header,
GeneratedMediaResponse, MediaResponse, content_disposition_header,
error::{storage_error_response, text_with_source},
media_response,
generated_media_response, media_response,
},
state::AppState,
transform::{
@@ -146,11 +146,10 @@ pub(in crate::server) async fn serve_asset_image(
resize_mode: Some(resize_mode),
});
if let Some(hit) = cached_transform_hit(runtime, &cache_key) {
return media_response(MediaResponse {
return generated_media_response(GeneratedMediaResponse {
method,
data: hit.data.as_bytes().clone().into(),
content_type: hit.format.mime(),
range_header,
disposition: Some(content_disposition_header(
hit.format.mime(),
requested_download,
@@ -202,11 +201,10 @@ pub(in crate::server) async fn serve_asset_image(
});
}
};
media_response(MediaResponse {
generated_media_response(GeneratedMediaResponse {
method,
data: transformed.into(),
content_type: out_ext.mime(),
range_header,
disposition: Some(content_disposition_header(
out_ext.mime(),
requested_download,
@@ -390,7 +388,6 @@ async fn serve_spooled_attachment_video(
filename,
route: TransformRoute::Attachment,
params,
headers,
},
)
};
@@ -18,8 +18,8 @@ use crate::{
content_type_is_trustworthy, image_extension_from_filename, is_svg_content_type,
},
response::{
MediaResponse, content_disposition_header, error::storage_error_response,
error::text_with_source, media_response,
GeneratedMediaResponse, content_disposition_header, error::storage_error_response,
error::text_with_source, generated_media_response,
},
state::AppState,
transform::{
@@ -86,7 +86,7 @@ pub(in crate::server) async fn serve_stored_passthrough_stream(
if app.cfg.mode == DeploymentMode::Mp
&& image_extension_from_filename(key) == Some(AssetExtension::Svg)
{
return serve_stored_passthrough_svg(app, method, bucket, key, headers, &disposition).await;
return serve_stored_passthrough_svg(app, method, bucket, key, &disposition).await;
}
let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok());
let forwarded_range = match range::classify_request_range(range_header) {
@@ -105,7 +105,7 @@ pub(in crate::server) async fn serve_stored_passthrough_stream(
}
let content_type = passthrough_content_type(&object.content_type, key);
if app.cfg.mode == DeploymentMode::Mp && is_svg_content_type(&content_type) {
return serve_stored_passthrough_svg(app, method, bucket, key, headers, &disposition).await;
return serve_stored_passthrough_svg(app, method, bucket, key, &disposition).await;
}
let total_len = match passthrough_total_len(app, bucket, key, object.total_length).await {
Ok(value) => value,
@@ -137,8 +137,7 @@ async fn serve_stored_passthrough_head(
&& (is_svg_content_type(&content_type)
|| image_extension_from_filename(key) == Some(AssetExtension::Svg))
{
return serve_stored_passthrough_svg(app, Method::HEAD, bucket, key, headers, disposition)
.await;
return serve_stored_passthrough_svg(app, Method::HEAD, bucket, key, disposition).await;
}
let total_len = match usize::try_from(head.content_length) {
Ok(value) => value,
@@ -163,7 +162,6 @@ async fn serve_stored_passthrough_svg(
method: Method,
bucket: &str,
key: &str,
headers: &HeaderMap,
disposition: &PassthroughDisposition<'_>,
) -> Response {
let object = match app.store.read_object(bucket, key).await {
@@ -171,15 +169,7 @@ async fn serve_stored_passthrough_svg(
Err(err) => return storage_error_response(key, err),
};
let cache_identity = format!("{bucket}/{key}");
serve_stored_svg_rasterized(
app,
method,
object.data,
&cache_identity,
headers,
disposition,
)
.await
serve_stored_svg_rasterized(app, method, object.data, &cache_identity, disposition).await
}
/// Resolves the object's full length, preferring what the store already reported on the streamed
@@ -259,7 +249,6 @@ async fn serve_stored_svg_rasterized(
method: Method,
data: Bytes,
cache_identity: &str,
headers: &HeaderMap,
disposition: &PassthroughDisposition<'_>,
) -> Response {
let runtime = app.media.transforms();
@@ -287,11 +276,10 @@ async fn serve_stored_svg_rasterized(
resize_mode: Some(options.resize_mode),
});
if let Some(hit) = cached_transform_hit(runtime, &cache_key) {
return media_response(MediaResponse {
return generated_media_response(GeneratedMediaResponse {
method,
data: hit.data.as_bytes().clone().into(),
content_type: hit.format.mime(),
range_header: headers.get(header::RANGE).and_then(|v| v.to_str().ok()),
disposition: passthrough_disposition_header(disposition, hit.format.mime()),
});
}
@@ -322,11 +310,10 @@ async fn serve_stored_svg_rasterized(
);
}
};
media_response(MediaResponse {
generated_media_response(GeneratedMediaResponse {
method,
data: transformed.into(),
content_type: format.mime(),
range_header: headers.get(header::RANGE).and_then(|v| v.to_str().ok()),
disposition: passthrough_disposition_header(disposition, format.mime()),
})
}
@@ -230,6 +230,47 @@ async fn an_asset_image_transform_is_served_and_then_reused_from_its_content_ide
);
}
#[tokio::test]
async fn a_ranged_request_for_an_asset_transform_is_answered_with_the_whole_body() {
let tmp = tempfile::tempdir().unwrap();
let storage_root = tmp.path().canonicalize().unwrap();
let app = test_app_state(asset_test_config(&storage_root));
let asset = parse_standard_asset_path(ASSET_TEST_PATH).unwrap();
app.store
.write_object(
&app.cfg.storage.bucket_cdn,
&asset.storage_key,
&crate::test_fixtures::synthetic_png(512, 512),
"image/png",
)
.await
.unwrap();
let params = HashMap::new();
let mut range = HeaderMap::new();
range.insert(header::RANGE, "bytes=0-3".parse().unwrap());
let mut bodies = Vec::new();
for headers in [&range, &range, &HeaderMap::new()] {
let response = serve_asset_image(
&app,
Method::GET,
parse_standard_asset_path(ASSET_TEST_PATH).unwrap(),
&params,
headers,
)
.await;
assert_eq!(StatusCode::OK, response.status());
assert!(!response.headers().contains_key(header::ACCEPT_RANGES));
assert!(!response.headers().contains_key(header::CONTENT_RANGE));
bodies.push(response_body(response).await);
}
assert!(bodies[2].len() > 4);
assert_eq!(bodies[2], bodies[0]);
assert_eq!(bodies[2], bodies[1]);
let rendered = app.metrics.render();
assert!(rendered.contains("fluxer_media_proxy_transform_cache_misses_total 1\n"));
assert!(rendered.contains("fluxer_media_proxy_transform_cache_hits_total 2\n"));
}
#[test]
fn a_transform_failure_on_a_source_the_browser_cannot_show_stays_an_error() {
let svg_failure = |error| {
@@ -381,6 +422,70 @@ async fn a_stored_passthrough_serves_objects_larger_than_the_buffer_cap() {
assert_eq!(&b"head"[..], &response_body(get).await[..]);
}
#[tokio::test]
async fn a_ranged_request_for_a_rasterised_stored_svg_is_answered_with_the_whole_body() {
let tmp = tempfile::tempdir().unwrap();
let storage_root = tmp.path().canonicalize().unwrap();
let app = test_app_state(asset_test_config(&storage_root));
let bucket = app.cfg.storage.bucket_cdn.clone();
let key = "attachments/1/2/pic.svg";
app.store
.write_object(
&bucket,
key,
br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8" fill="red"/></svg>"#,
"image/svg+xml",
)
.await
.unwrap();
let mut range = HeaderMap::new();
range.insert(header::RANGE, "bytes=0-3".parse().unwrap());
let mut past_the_end = HeaderMap::new();
past_the_end.insert(header::RANGE, "bytes=1048576-2097151".parse().unwrap());
let mut bodies = Vec::new();
for (method, headers) in [
(Method::GET, &range),
(Method::GET, &past_the_end),
(Method::HEAD, &range),
(Method::GET, &HeaderMap::new()),
] {
let response = passthrough::serve_stored_raw(&app, method, &bucket, key, headers).await;
assert_eq!(StatusCode::OK, response.status());
assert_eq!(
Some("image/webp"),
response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
);
assert!(!response.headers().contains_key(header::ACCEPT_RANGES));
assert!(!response.headers().contains_key(header::CONTENT_RANGE));
let declared_len = response
.headers()
.get(header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<usize>().ok());
bodies.push((declared_len, response_body(response).await));
}
let [
(fresh_len, fresh_body),
(cached_len, cached_body),
(head_len, head_body),
(whole_len, whole_body),
] = bodies.try_into().expect("four responses");
assert_eq!("image/webp", mime::sniff(&whole_body).mime);
assert_eq!(Some(whole_body.len()), whole_len);
assert_eq!(whole_len, fresh_len);
assert_eq!(whole_len, cached_len);
assert_eq!(whole_len, head_len);
assert_eq!(whole_body, fresh_body);
assert_eq!(whole_body, cached_body);
assert!(head_body.is_empty());
let rendered = app.metrics.render();
assert!(rendered.contains("fluxer_media_proxy_transform_cache_misses_total 1\n"));
assert!(rendered.contains("fluxer_media_proxy_transform_cache_hits_total 3\n"));
}
fn poster_params() -> HashMap<String, String> {
HashMap::from([
("format".to_owned(), "webp".to_owned()),
+3 -17
View File
@@ -31,9 +31,9 @@ use crate::{
},
native_task_executor::{NativeTaskExecutor, NativeTaskExecutorSettings},
response::{
MediaResponse, content_disposition_header,
GeneratedMediaResponse, MediaResponse, content_disposition_header,
error::{text_with_reason, text_with_source},
media_response,
generated_media_response, media_response,
},
transform::{
cache::{
@@ -350,7 +350,6 @@ pub(in crate::server) struct CachedVideoTransformRequest<'a> {
pub(in crate::server) filename: &'a str,
pub(in crate::server) route: TransformRoute,
pub(in crate::server) params: &'a HashMap<String, String>,
pub(in crate::server) headers: &'a HeaderMap,
}
pub(in crate::server) fn cached_video_transform_response(
@@ -371,10 +370,6 @@ pub(in crate::server) fn cached_video_transform_response(
data: hit.data.as_bytes().clone(),
content_type: hit.format.mime(),
filename: request.filename,
range_header: request
.headers
.get(header::RANGE)
.and_then(|v| v.to_str().ok()),
requested_download: transform.requested_download,
}))
}
@@ -432,7 +427,6 @@ async fn serve_video_transform(
data: hit.data.as_bytes().clone(),
content_type: hit.format.mime(),
filename,
range_header,
requested_download: transform.requested_download,
});
}
@@ -486,7 +480,6 @@ async fn serve_video_transform(
data: transformed,
content_type: format.mime(),
filename,
range_header,
requested_download: transform.requested_download,
})
}
@@ -571,7 +564,6 @@ async fn serve_image_transform(
data: hit.data.as_bytes().clone(),
content_type: response_content_type,
filename,
range_header,
requested_download: transform.requested_download,
});
}
@@ -636,7 +628,6 @@ async fn serve_image_transform(
data: transformed,
content_type: response_content_type,
filename,
range_header,
requested_download: transform.requested_download,
})
}
@@ -725,7 +716,6 @@ pub(in crate::server) struct CachedTransformProbe<'a> {
pub(in crate::server) filename: &'a str,
pub(in crate::server) route: TransformRoute,
pub(in crate::server) params: &'a HashMap<String, String>,
pub(in crate::server) headers: &'a HeaderMap,
}
// Answers a repeat transform from the cache without the caller fetching the source
@@ -742,7 +732,6 @@ pub(in crate::server) fn cached_transform_response(
filename,
route,
params,
headers,
} = probe;
let transform = validate_transform_parameters(params, &runtime.limits(), route).ok()?;
let source_is_svg = is_svg_content_type(content_type)
@@ -787,7 +776,6 @@ pub(in crate::server) fn cached_transform_response(
data: hit.data.as_bytes().clone(),
content_type: &response_content_type,
filename,
range_header: headers.get(header::RANGE).and_then(|v| v.to_str().ok()),
requested_download: transform.requested_download,
}))
}
@@ -797,7 +785,6 @@ struct TransformedBytes<'a> {
data: Bytes,
content_type: &'a str,
filename: &'a str,
range_header: Option<&'a str>,
requested_download: bool,
}
@@ -807,11 +794,10 @@ fn transformed_response(request: TransformedBytes<'_>) -> Response {
request.requested_download,
Some(request.filename),
));
media_response(MediaResponse {
generated_media_response(GeneratedMediaResponse {
method: request.method,
data: request.data.into(),
content_type: request.content_type,
range_header: request.range_header,
disposition,
})
}
@@ -335,6 +335,81 @@ async fn a_full_size_jxl_above_the_static_output_cap_is_downscaled() {
);
}
async fn jxl_attachment(
runtime: &TransformRuntime,
method: Method,
params: &HashMap<String, String>,
headers: &HeaderMap,
) -> Response {
serve_bytes_or_transform(
runtime,
ServeBytesRequest {
method,
data: Bytes::from_static(JXL_64_LOSSY_ALPHA),
content_type: "image/jxl".to_owned(),
cache_identity: "art.jxl",
filename: "art.jxl",
route: TransformRoute::Attachment,
params,
headers,
},
)
.await
}
#[tokio::test]
async fn a_ranged_request_for_a_transform_is_answered_with_the_whole_body() {
let runtime = test_runtime();
let transform = HashMap::from([("format".to_owned(), "webp".to_owned())]);
let mut ranged = HeaderMap::new();
ranged.insert(header::RANGE, "bytes=0-3".parse().unwrap());
let mut bodies = Vec::new();
for (method, headers) in [
(Method::GET, &ranged),
(Method::HEAD, &ranged),
(Method::GET, &HeaderMap::new()),
] {
let response = jxl_attachment(&runtime, method, &transform, headers).await;
assert_eq!(StatusCode::OK, response.status());
assert!(!response.headers().contains_key(header::ACCEPT_RANGES));
assert!(!response.headers().contains_key(header::CONTENT_RANGE));
let declared_len = response
.headers()
.get(header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<usize>().ok());
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
bodies.push((declared_len, body));
}
let [
(ranged_len, ranged_body),
(head_len, head_body),
(whole_len, whole_body),
] = bodies.try_into().expect("three responses");
assert!(whole_body.len() > 4);
assert_eq!(Some(whole_body.len()), whole_len);
assert_eq!(whole_len, ranged_len);
assert_eq!(whole_len, head_len);
assert_eq!(whole_body, ranged_body);
assert!(head_body.is_empty());
let original = jxl_attachment(&runtime, Method::GET, &HashMap::new(), &ranged).await;
assert_eq!(StatusCode::PARTIAL_CONTENT, original.status());
assert_eq!(
Some("bytes"),
original
.headers()
.get(header::ACCEPT_RANGES)
.and_then(|value| value.to_str().ok())
);
let original_body = to_bytes(original.into_body(), usize::MAX)
.await
.expect("body");
assert_eq!(&JXL_64_LOSSY_ALPHA[..4], &original_body[..]);
}
#[test]
fn the_budget_leaves_room_for_the_cache_and_headroom() {
assert_eq!(
+1 -1
View File
@@ -49,7 +49,7 @@ const LINUX_LIBFIDO2_VERSION: &str = "1.16.0";
const LINUX_LIBFIDO2_SOURCE_SHA256: &str =
"8c2b6fb279b5b42e9ac92ade71832e485852647b53607c43baaafbbcecea04e4";
pub(crate) const MACOS_UNIVERSAL_ARCH: &str = "universal";
const MACOS_MINIMUM_SYSTEM_VERSION: &str = "13.0";
pub(crate) const MACOS_MINIMUM_SYSTEM_VERSION: &str = "13.0";
const DESKTOP_PAYLOAD_PREFIX: &str = "desktop";
const DESKTOP_SHARED_ASSETS_DIR_NAME: &str = "desktop-shared-assets";
const DESKTOP_SHARED_ASSETS_PAYLOAD_DIR_NAME: &str = "renderer";
+251 -1
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::common::{CommandSpec, command_succeeds, output_text, run_command};
use crate::desktop::MACOS_UNIVERSAL_ARCH;
use crate::desktop::{MACOS_MINIMUM_SYSTEM_VERSION, MACOS_UNIVERSAL_ARCH};
use anyhow::{Context, Result, anyhow, bail, ensure};
use clap::Args;
use sha2::{Digest, Sha256};
@@ -382,6 +382,9 @@ fn build_rust_node_addon_for_arch(
if platform == "win32" && arch == "arm64" {
command = configure_windows_arm64_msvc_cc(command);
}
if platform == "darwin" {
command = command.env("MACOSX_DEPLOYMENT_TARGET", MACOS_MINIMUM_SYSTEM_VERSION);
}
println!("[buildRustNodeAddon] cwd={}", addon_root.display());
if env::var_os("CARGO_TARGET_DIR").is_some() || platform == "win32" {
@@ -415,6 +418,7 @@ fn build_rust_node_addon_for_arch(
out_file.display()
);
sign_macos_node_addon(&out_file, platform)?;
assert_macos_addon_loads_on_minimum_system(&out_file, &source, platform)?;
assert_no_redistributable_runtime_imports(&out_file, platform)?;
assert_system32_dependent_load_flag(&out_file, platform)?;
assert_no_disabled_win_game_capture_capabilities(&out_file, addon, platform)?;
@@ -728,6 +732,149 @@ fn assert_no_redistributable_runtime_imports(node_file_path: &Path, platform: &s
)
}
const MACHO_MAGIC_64: u32 = 0xfeed_facf;
const MACHO_HEADER_64_SIZE: usize = 32;
const MACHO_PLATFORM_MACOS: u32 = 1;
const MACHO_LC_REQ_DYLD: u32 = 0x8000_0000;
const MACHO_LC_LOAD_DYLIB: u32 = 0x0c;
const MACHO_LC_LOAD_WEAK_DYLIB: u32 = 0x18 | MACHO_LC_REQ_DYLD;
const MACHO_LC_REEXPORT_DYLIB: u32 = 0x1f | MACHO_LC_REQ_DYLD;
const MACHO_LC_LAZY_LOAD_DYLIB: u32 = 0x20;
const MACHO_LC_LOAD_UPWARD_DYLIB: u32 = 0x23 | MACHO_LC_REQ_DYLD;
const MACHO_LC_VERSION_MIN_MACOSX: u32 = 0x24;
const MACHO_LC_BUILD_VERSION: u32 = 0x32;
const MACOS_SYSTEM_LIBRARY_ROOTS: &[&str] = &["/System/Library/", "/usr/lib/"];
#[derive(Debug, Default, PartialEq, Eq)]
struct MachOLoadInfo {
minimum_macos: Option<u32>,
dylibs: Vec<String>,
}
fn read_macho_load_info(buffer: &[u8]) -> Option<MachOLoadInfo> {
let u32_at = |offset: usize| -> Option<u32> {
let bytes = buffer.get(offset..offset.checked_add(4)?)?;
Some(u32::from_le_bytes(bytes.try_into().ok()?))
};
if u32_at(0)? != MACHO_MAGIC_64 {
return None;
}
let command_count = u32_at(16)?;
let commands_end = MACHO_HEADER_64_SIZE.checked_add(u32_at(20)? as usize)?;
if commands_end > buffer.len() {
return None;
}
let mut info = MachOLoadInfo::default();
let mut offset = MACHO_HEADER_64_SIZE;
for _ in 0..command_count {
let command = u32_at(offset)?;
let end = offset.checked_add(u32_at(offset + 4)? as usize)?;
if end < offset + 8 || end > commands_end {
return None;
}
let field = |index: usize| -> Option<u32> {
let at = offset + index * 4;
if at + 4 > end {
return None;
}
u32_at(at)
};
match command {
MACHO_LC_LOAD_DYLIB
| MACHO_LC_LOAD_WEAK_DYLIB
| MACHO_LC_REEXPORT_DYLIB
| MACHO_LC_LAZY_LOAD_DYLIB
| MACHO_LC_LOAD_UPWARD_DYLIB => {
let name_start = offset.checked_add(field(2)? as usize)?;
let name = buffer.get(name_start..end)?;
let name = name.split(|byte| *byte == 0).next()?;
info.dylibs.push(String::from_utf8_lossy(name).into_owned());
}
MACHO_LC_VERSION_MIN_MACOSX => info.minimum_macos = Some(field(2)?),
MACHO_LC_BUILD_VERSION if field(2)? == MACHO_PLATFORM_MACOS => {
info.minimum_macos = Some(field(3)?);
}
_ => {}
}
offset = end;
}
Some(info)
}
fn pack_macos_version(version: &str) -> Option<u32> {
let mut parts = version.split('.');
let mut packed = 0;
for (shift, limit) in [(16, 0xffff), (8, 0xff), (0, 0xff)] {
let part = parts
.next()
.map_or(Some(0), |part| part.parse::<u32>().ok())?;
if part > limit {
return None;
}
packed |= part << shift;
}
parts.next().is_none().then_some(packed)
}
fn format_macos_version(packed: u32) -> String {
format!(
"{}.{}.{}",
packed >> 16,
(packed >> 8) & 0xff,
packed & 0xff
)
}
fn assert_macos_addon_loads_on_minimum_system(
node_file_path: &Path,
cargo_output_path: &Path,
platform: &str,
) -> Result<()> {
if platform != "darwin" {
return Ok(());
}
let buffer = fs::read(node_file_path)
.with_context(|| format!("Failed to read {}", node_file_path.display()))?;
let info = read_macho_load_info(&buffer).ok_or_else(|| {
anyhow!(
"{} is not a single-architecture 64-bit Mach-O file",
node_file_path.display()
)
})?;
let expected = pack_macos_version(MACOS_MINIMUM_SYSTEM_VERSION).ok_or_else(|| {
anyhow!("Unsupported macOS minimum system version: {MACOS_MINIMUM_SYSTEM_VERSION}")
})?;
ensure!(
info.minimum_macos == Some(expected),
"{} is linked for macOS {}, expected {}.\nThe addon build sets MACOSX_DEPLOYMENT_TARGET to the oldest macOS the app supports, and the linker picks library install names for that version.\nCargo keeps earlier output for crates that do not track that variable. Delete {} and build again.",
node_file_path.display(),
info.minimum_macos
.map_or_else(|| "an unknown version".to_string(), format_macos_version),
format_macos_version(expected),
cargo_output_path
.parent()
.unwrap_or(cargo_output_path)
.display()
);
let unresolved = info
.dylibs
.iter()
.filter(|dylib| {
!MACOS_SYSTEM_LIBRARY_ROOTS
.iter()
.any(|root| dylib.starts_with(root))
})
.map(|dylib| format!(" - {dylib}"))
.collect::<Vec<_>>();
ensure!(
unresolved.is_empty(),
"{} depends on libraries that macOS cannot find when the addon is loaded:\n{}\nA shipped addon has no LC_RPATH and bundles no dylib, so every dependency must be an absolute path under /System/Library/ or /usr/lib/.",
node_file_path.display(),
unresolved.join("\n")
);
Ok(())
}
const DISABLED_WIN_GAME_CAPTURE_BINARY_MARKERS: &[&[u8]] = &[
b"CreateRemoteThread",
b"VirtualAllocEx",
@@ -1146,4 +1293,107 @@ mod tests {
assert!(assert_system32_dependent_load_flag(&unset, "linux").is_ok());
}
}
fn synthetic_macho(version_command: u32, minimum_macos: u32, dylibs: &[&str]) -> Vec<u8> {
let words = |values: &[u32]| {
values
.iter()
.flat_map(|value| value.to_le_bytes())
.collect::<Vec<u8>>()
};
let mut commands = vec![if version_command == MACHO_LC_BUILD_VERSION {
words(&[
version_command,
24,
MACHO_PLATFORM_MACOS,
minimum_macos,
0,
0,
])
} else {
words(&[version_command, 16, minimum_macos, 0])
}];
for dylib in dylibs {
let size = (24 + dylib.len() + 1).next_multiple_of(8);
let mut command = words(&[MACHO_LC_LOAD_DYLIB, size as u32, 24, 2, 0, 0]);
command.extend_from_slice(dylib.as_bytes());
command.resize(size, 0);
commands.push(command);
}
let body = commands.concat();
let mut buffer = words(&[
MACHO_MAGIC_64,
0,
0,
6,
commands.len() as u32,
body.len() as u32,
0,
0,
]);
buffer.extend(body);
buffer
}
#[test]
fn macos_addons_must_target_the_app_minimum_and_link_only_system_libraries() {
let minimum = pack_macos_version(MACOS_MINIMUM_SYSTEM_VERSION).expect("minimum");
let sierra = pack_macos_version("10.12").expect("sierra");
let core_media = "/System/Library/Frameworks/CoreMedia.framework/Versions/A/CoreMedia";
let swift_overlay = "@rpath/libswiftCoreMedia.dylib";
let dir = tempfile::tempdir().expect("tempdir");
let cargo_output = dir.path().join("release/libaddon.dylib");
let check = |bytes: Vec<u8>, platform: &str| {
let path = dir.path().join("addon.node");
fs::write(&path, bytes).expect("write");
assert_macos_addon_loads_on_minimum_system(&path, &cargo_output, platform)
.map_err(|error| error.to_string())
};
assert_eq!(pack_macos_version("10.14.4"), Some(0x000a_0e04));
for version_command in [MACHO_LC_BUILD_VERSION, MACHO_LC_VERSION_MIN_MACOSX] {
assert_eq!(
read_macho_load_info(&synthetic_macho(
version_command,
sierra,
&[swift_overlay, "/usr/lib/libSystem.B.dylib"]
)),
Some(MachOLoadInfo {
minimum_macos: Some(sierra),
dylibs: vec![
swift_overlay.to_string(),
"/usr/lib/libSystem.B.dylib".to_string()
],
}),
"version_command={version_command:#x}"
);
assert_eq!(
check(
synthetic_macho(
version_command,
minimum,
&[core_media, "/usr/lib/libSystem.B.dylib"]
),
"darwin"
),
Ok(())
);
let rewritten = check(
synthetic_macho(version_command, minimum, &[core_media, swift_overlay]),
"darwin",
)
.expect_err("an @rpath dependency must fail the build");
assert!(rewritten.contains(" - @rpath/libswiftCoreMedia.dylib"));
assert!(!rewritten.contains(" - /System/Library/"));
let older = check(
synthetic_macho(version_command, sierra, &[core_media]),
"darwin",
)
.expect_err("an addon linked for an older macOS must fail the build");
assert!(older.contains("is linked for macOS 10.12.0"));
assert!(older.contains(&dir.path().join("release").display().to_string()));
}
assert!(check(vec![0; 64], "darwin").is_err());
assert_eq!(check(vec![0; 64], "linux"), Ok(()));
}
}