Skip to content
Open
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
6 changes: 3 additions & 3 deletions src/client/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ export function videoGenerateV2Endpoint(baseUrl: string): string {
}

export function videoTaskEndpoint(baseUrl: string, taskId: string): string {
return `${baseUrl}/v1/query/video_generation?task_id=${taskId}`;
return `${baseUrl}/v1/query/video_generation?task_id=${encodeURIComponent(taskId)}`;
}

export function videoTaskV2Endpoint(baseUrl: string, taskId: string): string {
return `${baseUrl}/v2/query/video_generation/${taskId}`;
return `${baseUrl}/v2/query/video_generation/${encodeURIComponent(taskId)}`;
}

export function fileRetrieveEndpoint(baseUrl: string, fileId: string): string {
return `${baseUrl}/v1/files/retrieve?file_id=${fileId}`;
return `${baseUrl}/v1/files/retrieve?file_id=${encodeURIComponent(fileId)}`;
}

export function searchEndpoint(baseUrl: string): string {
Expand Down
2 changes: 2 additions & 0 deletions src/commands/speech/synthesize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { writeFileSync } from 'fs';
import { readTextFromPathOrStdin } from '../../utils/fs';
import { T2A_FORMATS, formatList, validateAudioFormat, validateT2AStreaming, t2aDefaultSampleRate } from '../../utils/audio-formats';
import { pipeAudioStream } from '../../utils/audio-stream';
import { validateSafeUrl } from '../../utils/network';
import type { Config } from '../../config/schema';
import type { GlobalFlags } from '../../types/flags';
import type { SpeechRequest, SpeechResponse } from '../../types/api';
Expand Down Expand Up @@ -126,6 +127,7 @@ export default defineCommand({
// Download and save subtitle file when --subtitles is requested
if (flags.subtitles && response.data.subtitle_file) {
try {
await validateSafeUrl(response.data.subtitle_file);
// Download the subtitle JSON file from the URL
const subtitleRes = await fetch(response.data.subtitle_file);
if (!subtitleRes.ok) {
Expand Down
13 changes: 10 additions & 3 deletions src/config/loader.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { copyFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
import { randomBytes } from 'crypto';
import { parseConfigFile, REGIONS, type Config, type ConfigFile, type Region } from './schema';
import { ensureConfigDir, getConfigPath } from './paths';
import { detectOutputFormat, type OutputFormat } from '../output/formatter';
Expand Down Expand Up @@ -56,9 +57,15 @@ export async function writeConfigFile(
): Promise<void> {
await ensureConfigDir();
const path = getConfigPath();
const tmp = path + '.tmp';
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
renameWithCrossDeviceFallback(tmp, path, renameOps);
const uniqueSuffix = randomBytes(8).toString('hex');
const tmp = `${path}.${process.pid}.${Date.now()}.${uniqueSuffix}.tmp`;
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
try {
renameWithCrossDeviceFallback(tmp, path, renameOps);
} catch (err) {
try { unlinkSync(tmp); } catch { /* best effort */ }
throw err;
}
}

export function loadConfig(flags: GlobalFlags): Config {
Expand Down
5 changes: 4 additions & 1 deletion src/files/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { WriteStream } from 'fs';
import { createProgressBar } from '../output/progress';
import { CLIError } from '../errors/base';
import { ExitCode } from '../errors/codes';
import { validateSafeUrl } from '../utils/network';

const DEFAULT_OVERALL_TIMEOUT_MS = 30 * 60 * 1000;
const DEFAULT_IDLE_TIMEOUT_MS = 60 * 1000;
Expand All @@ -17,6 +18,7 @@ export interface DownloadOpts {
idleTimeoutMs?: number;
maxBytes?: number;
signal?: AbortSignal;
allowPrivate?: boolean;
}

class RetryableDownloadError extends CLIError {
Expand Down Expand Up @@ -296,7 +298,7 @@ async function attemptDownload(
? undefined
: declaredLength;
tmpPath = `${destPath}.tmp-${process.pid}-${Date.now()}-${attempt}-${Math.random().toString(36).slice(2)}`;
writer = createWriteStream(tmpPath);
writer = createWriteStream(tmpPath, { flags: 'wx', mode: 0o600 });
progress = expectedLength && !opts.quiet
? createProgressBar(expectedLength, 'Downloading')
: null;
Expand Down Expand Up @@ -394,6 +396,7 @@ export async function downloadFile(
): Promise<{ size: number }> {
// Alibaba Cloud OSS US East blocks HTTP from certain regions.
const downloadUrl = url.startsWith('http://') ? url.replace('http://', 'https://') : url;
await validateSafeUrl(downloadUrl, { allowPrivate: opts?.allowPrivate });
const maxRetries = nonNegativeInteger(opts?.retries ?? 3, 'retries');
const baseDelay = nonNegativeNumber(opts?.retryDelayMs ?? 1000, 'retryDelayMs');
const overallTimeoutMs = positiveNumber(
Expand Down
74 changes: 41 additions & 33 deletions src/update/self-update.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createWriteStream, renameSync, chmodSync, existsSync } from 'fs';
import { createWriteStream, renameSync, chmodSync, existsSync, mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { CLIError } from '../errors/base';
Expand Down Expand Up @@ -110,7 +110,7 @@ export async function downloadFile(url: string, dest: string, onProgress?: (pct:
const total = Number(res.headers.get('content-length') ?? 0);
let received = 0;

const writer = createWriteStream(dest);
const writer = createWriteStream(dest, { flags: 'wx', mode: 0o700 });
const reader = res.body!.getReader();

try {
Expand Down Expand Up @@ -150,7 +150,10 @@ export async function downloadFile(url: string, dest: string, onProgress?: (pct:
});
}
// Don't leave a half-downloaded binary in /tmp on failure.
try { (await import('fs')).unlinkSync(dest); } catch { /* best-effort — race with concurrent cleanup is fine */ }
// If the error was EEXIST, dest already existed and was not created by this download.
if ((err as { code?: string })?.code !== 'EEXIST') {
try { (await import('fs')).unlinkSync(dest); } catch { /* best-effort — race with concurrent cleanup is fine */ }
}
throw err;
} finally {
// Always release the Web Streams reader lock — the API contract requires
Expand All @@ -177,40 +180,45 @@ export async function resolveUpdateTarget(channel: Channel): Promise<UpdateTarge
}

export async function applySelfUpdate(target: UpdateTarget, currentBin: string): Promise<void> {
const tmp = join(tmpdir(), `mmx-update-${Date.now()}`);

process.stderr.write(`Downloading ${target.version}...\n`);
let lastPct = -1;
await downloadFile(target.downloadUrl, tmp, (pct) => {
if (pct !== lastPct && pct % 10 === 0) {
process.stderr.write(` ${pct}%\r`);
lastPct = pct;
}
});
process.stderr.write(' \r');
const tempDir = mkdtempSync(join(tmpdir(), 'mmx-update-'));
const tmp = join(tempDir, 'mmx-bin');

process.stderr.write('Verifying checksum...\n');
await verifySha256(tmp, target.checksum);
try {
process.stderr.write(`Downloading ${target.version}...\n`);
let lastPct = -1;
await downloadFile(target.downloadUrl, tmp, (pct) => {
if (pct !== lastPct && pct % 10 === 0) {
process.stderr.write(` ${pct}%\r`);
lastPct = pct;
}
});
process.stderr.write(' \r');

chmodSync(tmp, 0o755);
process.stderr.write('Verifying checksum...\n');
await verifySha256(tmp, target.checksum);

// Atomic replace: rename works on same filesystem
// If cross-device, fall back to copy+rename
try {
renameSync(tmp, currentBin);
} catch {
const { copyFileSync, unlinkSync } = await import('fs');
const backup = `${currentBin}.bak`;
copyFileSync(currentBin, backup);
chmodSync(tmp, 0o755);

// Atomic replace: rename works on same filesystem
// If cross-device, fall back to copy+rename
try {
copyFileSync(tmp, currentBin);
chmodSync(currentBin, 0o755);
unlinkSync(tmp);
if (existsSync(backup)) unlinkSync(backup);
} catch (e) {
// Restore backup
if (existsSync(backup)) renameSync(backup, currentBin);
throw e;
renameSync(tmp, currentBin);
} catch {
const { copyFileSync, unlinkSync } = await import('fs');
const backup = `${currentBin}.bak`;
copyFileSync(currentBin, backup);
try {
copyFileSync(tmp, currentBin);
chmodSync(currentBin, 0o755);
unlinkSync(tmp);
if (existsSync(backup)) unlinkSync(backup);
} catch (e) {
// Restore backup
if (existsSync(backup)) renameSync(backup, currentBin);
throw e;
}
}
} finally {
try { rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
}
}
41 changes: 35 additions & 6 deletions src/utils/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readFileSync, existsSync, statSync } from 'fs';
import { extname } from 'path';
import { CLIError } from '../errors/base';
import { ExitCode } from '../errors/codes';
import { validateSafeUrl } from './network';

export const IMAGE_MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
Expand Down Expand Up @@ -41,18 +42,46 @@ export async function toDataUri(image: string): Promise<string> {
if (image.startsWith('data:')) return image;

if (image.startsWith('http://') || image.startsWith('https://')) {
await validateSafeUrl(image);
const res = await fetch(image);
if (!res.ok) throw new CLIError(`Failed to download image: HTTP ${res.status}`, ExitCode.GENERAL);
const contentType = res.headers.get('content-type') || 'image/jpeg';
const mime = contentType.split(';')[0]!.trim();
const buf = await res.arrayBuffer();
if (buf.byteLength > MAX_IMAGE_SIZE_BYTES) {

const declaredLength = res.headers.get('content-length');
if (declaredLength && Number(declaredLength) > MAX_IMAGE_SIZE_BYTES) {
throw new CLIError(
`Image too large (${(buf.byteLength / 1024 / 1024).toFixed(1)} MB). Maximum is 50 MB.`,
`Image too large (${(Number(declaredLength) / 1024 / 1024).toFixed(1)} MB). Maximum is 50 MB.`,
ExitCode.USAGE,
);
}
return `data:${mime};base64,${Buffer.from(buf).toString('base64')}`;

const contentType = res.headers.get('content-type') || 'image/jpeg';
const mime = contentType.split(';')[0]!.trim();

const reader = res.body?.getReader();
if (!reader) throw new CLIError('Failed to read image response body.', ExitCode.GENERAL);

const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > MAX_IMAGE_SIZE_BYTES) {
await reader.cancel();
throw new CLIError(
`Image too large (${(totalBytes / 1024 / 1024).toFixed(1)} MB). Maximum is 50 MB.`,
ExitCode.USAGE,
);
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}

const buf = Buffer.concat(chunks);
return `data:${mime};base64,${buf.toString('base64')}`;
}

if (!existsSync(image)) throw new CLIError(`File not found: ${image}`, ExitCode.USAGE);
Expand Down
Loading