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
5 changes: 5 additions & 0 deletions .changeset/encode-artifact-download-path.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion packages/trueforge-ui/src/atoms/ChatFileDownload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);

Expand Down Expand Up @@ -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;

Expand Down
26 changes: 26 additions & 0 deletions packages/trueforge-ui/test/atoms/ChatFileDownload.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ChatFileDownload
files={[
{ name: 'my report#final,v2.csv', path: '/tmp/my report#final,v2.csv' },
{ name: 'a&b?c%d.txt', path: '/tmp/a&b?c%d.txt' },
{ name: 'ünïcode.txt', path: '/tmp/ünïcode.txt' },
]}
fileDownloadBaseUrl="https://files.example.com"
/>,
);

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<void>(resolve => {
Expand Down