From 72c976529edaa67810caa775c4d99b2695a43b49 Mon Sep 17 00:00:00 2001 From: Animesh Kumar Date: Fri, 11 Sep 2026 00:42:47 +0530 Subject: [PATCH] fix(trueforge-ui): encode artifact paths in download links Artifact paths come from the model's sandbox_artifacts fence, so a generated file name can contain characters that change what the URL means: a space or % breaks the request, # silently truncates it, and ?/& inject query parameters. The href was built by raw concatenation, so those reached the anchor verbatim. Encode each path segment, which leaves / doing its structural job and escapes everything else. Percent-encoding a path segment is what the server already expects, so existing links are unaffected. Fixes #424 --- .changeset/encode-artifact-download-path.md | 5 ++++ .../src/atoms/ChatFileDownload.tsx | 8 +++++- .../test/atoms/ChatFileDownload.test.tsx | 26 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 .changeset/encode-artifact-download-path.md diff --git a/.changeset/encode-artifact-download-path.md b/.changeset/encode-artifact-download-path.md new file mode 100644 index 000000000..9efa812d8 --- /dev/null +++ b/.changeset/encode-artifact-download-path.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge-ui': patch +--- + +Encode sandbox artifact paths when building file-download links, so names containing spaces, `#`, `?`, `&`, `%` or non-ASCII characters resolve to the intended file instead of a truncated or misparsed URL. diff --git a/packages/trueforge-ui/src/atoms/ChatFileDownload.tsx b/packages/trueforge-ui/src/atoms/ChatFileDownload.tsx index 19c0a1eb1..c7e7b2457 100644 --- a/packages/trueforge-ui/src/atoms/ChatFileDownload.tsx +++ b/packages/trueforge-ui/src/atoms/ChatFileDownload.tsx @@ -18,6 +18,12 @@ export type ChatFileDownloadProps = { readOnly?: boolean; }; +// Artifact paths are model-authored, so a name may carry characters that change what the +// URL means. Encode per segment so `/` keeps its structural role and the rest stays literal. +function encodeArtifactPath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/'); +} + export function ChatFileDownload({ files, fileDownloadBaseUrl, onDownloadArtifact, readOnly }: ChatFileDownloadProps) { const [downloadingPath, setDownloadingPath] = useState(null); @@ -49,7 +55,7 @@ export function ChatFileDownload({ files, fileDownloadBaseUrl, onDownloadArtifac ); } - const href = fileDownloadBaseUrl ? `${fileDownloadBaseUrl}${path}` : undefined; + const href = fileDownloadBaseUrl ? `${fileDownloadBaseUrl}${encodeArtifactPath(path)}` : undefined; const canDownload = Boolean(onDownloadArtifact || href); const isDownloading = downloadingPath === path; diff --git a/packages/trueforge-ui/test/atoms/ChatFileDownload.test.tsx b/packages/trueforge-ui/test/atoms/ChatFileDownload.test.tsx index e0aa91908..a004f5c46 100644 --- a/packages/trueforge-ui/test/atoms/ChatFileDownload.test.tsx +++ b/packages/trueforge-ui/test/atoms/ChatFileDownload.test.tsx @@ -30,6 +30,32 @@ describe('ChatFileDownload', () => { expect(screen.getByRole('link', { name: 'Download data.csv' })).toHaveAttribute('href', '/downloads/data.csv'); }); + it('encodes artifact names that would otherwise break or rewrite the URL', () => { + render( + , + ); + + expect(screen.getByRole('link', { name: 'Download my report#final,v2.csv' })).toHaveAttribute( + 'href', + 'https://files.example.com/tmp/my%20report%23final%2Cv2.csv', + ); + expect(screen.getByRole('link', { name: 'Download a&b?c%d.txt' })).toHaveAttribute( + 'href', + 'https://files.example.com/tmp/a%26b%3Fc%25d.txt', + ); + expect(screen.getByRole('link', { name: 'Download ünïcode.txt' })).toHaveAttribute( + 'href', + 'https://files.example.com/tmp/%C3%BCn%C3%AFcode.txt', + ); + }); + it('shows per-file progress and suppresses duplicate artifact downloads', async () => { let resolveDownload: (() => void) | undefined; const downloadPromise = new Promise(resolve => {