Skip to content
Draft
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
1 change: 1 addition & 0 deletions dev-docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- [fix] Disallow self-inheritance for contracts and traits: PR [#3094](https://github.com/tact-lang/tact/pull/3094)
- [fix] Added fixed-bytes support to bounced message size calculations: PR [#3129](https://github.com/tact-lang/tact/pull/3129)
- [fix] Fixed compiler crashes when a contract name exceeds filesystem limits: PR [#3219](https://github.com/tact-lang/tact/pull/3219)
- Compiler now generates more efficient code for serialization: PR [#3213](https://github.com/tact-lang/tact/pull/3213)
- [fix] Compiler now doesn't generate `__tact_nop()` for `dump()` and `dumpStack()` in default mode: PR [#3218](https://github.com/tact-lang/tact/pull/3218)

Expand Down
69 changes: 69 additions & 0 deletions src/vfs/createNodeFileSystem.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import path from "path";
import fs from "fs";
import { createNodeFileSystem } from "@/vfs/createNodeFileSystem";
import { makeSafeName } from "@/vfs/utils";

describe("createNodeFileSystem", () => {
it("should open file system", () => {
Expand Down Expand Up @@ -46,4 +47,72 @@ describe("createNodeFileSystem", () => {
fs.rmSync(realPathDir2, { recursive: true, force: true });
}
});

it("should truncate and hash long filenames", () => {
const baseDir = path.resolve(__dirname, "./__testdata");
const vfs = createNodeFileSystem(baseDir, false);

const longName = "A".repeat(300);
const content = "Test content";
const ext = ".md";

const inputPath = vfs.resolve(`${longName}${ext}`);
const dir = path.dirname(inputPath);
const expectedSafeName = makeSafeName(longName, ext);
const expectedFullPath = path.join(dir, expectedSafeName);

try {
if (fs.existsSync(expectedFullPath)) {
fs.unlinkSync(expectedFullPath);
}

vfs.writeFile(inputPath, content);
expect(fs.existsSync(expectedFullPath)).toBe(true);

const actualContent = fs.readFileSync(expectedFullPath, "utf8");
expect(actualContent).toBe(content);

expect(expectedSafeName.length).toBeLessThanOrEqual(255);
expect(expectedSafeName).toMatch(
new RegExp(
`^${longName.slice(0, 255 - ext.length - 9)}_[0-9a-f]{8}${ext}$`,
),
);
} finally {
if (fs.existsSync(expectedFullPath)) {
fs.unlinkSync(expectedFullPath);
}
}
});
it("should not truncate or hash short filenames", () => {
const baseDir = path.resolve(__dirname, "./__testdata");
const vfs = createNodeFileSystem(baseDir, false);

const shortName = "short-filename";
const content = "Test content";
const ext = ".md";

const inputPath = vfs.resolve(`${shortName}${ext}`);
const dir = path.dirname(inputPath);
const expectedSafeName = makeSafeName(shortName, ext);
const expectedFullPath = path.join(dir, expectedSafeName);

try {
if (fs.existsSync(expectedFullPath)) {
fs.unlinkSync(expectedFullPath);
}

vfs.writeFile(inputPath, content);
expect(fs.existsSync(expectedFullPath)).toBe(true);

const actualContent = fs.readFileSync(expectedFullPath, "utf8");
expect(actualContent).toBe(content);

expect(expectedSafeName).toBe(`${shortName}${ext}`);
} finally {
if (fs.existsSync(expectedFullPath)) {
fs.unlinkSync(expectedFullPath);
}
}
});
});
7 changes: 7 additions & 0 deletions src/vfs/createNodeFileSystem.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { VirtualFileSystem } from "@/vfs/VirtualFileSystem";
import fs from "fs";
import path from "path";
import { getFullExtension, makeSafeName } from "@/vfs/utils";

function ensureInsideProjectRoot(filePath: string, root: string): void {
if (!filePath.startsWith(root)) {
Expand Down Expand Up @@ -47,6 +48,12 @@ export function createNodeFileSystem(
if (readonly) {
throw new Error("File system is readonly");
}

const ext = getFullExtension(filePath);
const name = path.basename(filePath, ext);
const safeBase = makeSafeName(name, ext);
filePath = path.join(path.dirname(filePath), safeBase);

ensureInsideProjectRoot(filePath, normalizedRoot);
if (fs.existsSync(filePath)) {
ensureNotSymlink(filePath);
Expand Down
49 changes: 49 additions & 0 deletions src/vfs/createVirtualFileSystem.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,53 @@ describe("createVirtualFileSystem", () => {
expect(vfs.exists(realPath)).toBe(true);
expect(vfs.readFile(realPath).toString()).toBe("");
});

it("should truncate and hash long filenames", () => {
const fs: Record<string, string> = {};
const vfs = createVirtualFileSystem("/", fs, false);

const longName = "A".repeat(300);
const content = "Test content";
const ext = ".md";

const inputPath = vfs.resolve("./", `${longName}${ext}`);

vfs.writeFile(inputPath, content);

const storedPaths = Object.keys(fs);
expect(storedPaths.length).toBe(1);

const storedPath = storedPaths[0]!;
expect(storedPath).toBeDefined();
expect(storedPath.length).toBeLessThanOrEqual(255);

const regex = new RegExp(
`^${longName.slice(0, 255 - ext.length - 9)}_[0-9a-f]{8}${ext}$`,
);
expect(storedPath).toMatch(regex);

expect(fs[storedPath]).toBe(Buffer.from(content).toString("base64"));
});

it("should not truncate or hash short filenames", () => {
const fs: Record<string, string> = {};
const vfs = createVirtualFileSystem("/", fs, false);

const shortName = "short-filename";
const content = "Test content";
const ext = ".md";

const inputPath = vfs.resolve("./", `${shortName}${ext}`);

vfs.writeFile(inputPath, content);

const storedPaths = Object.keys(fs);
expect(storedPaths.length).toBe(1);

const storedPath = storedPaths[0]!;
expect(storedPath).toBeDefined();

expect(storedPath).toBe(`${shortName}${ext}`);
expect(fs[storedPath]).toBe(Buffer.from(content).toString("base64"));
});
});
10 changes: 9 additions & 1 deletion src/vfs/createVirtualFileSystem.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import normalize from "path-normalize";
import type { VirtualFileSystem } from "@/vfs/VirtualFileSystem";
import path from "path";
import { getFullExtension, makeSafeName } from "@/vfs/utils";

export function createVirtualFileSystem(
root: string,
Expand Down Expand Up @@ -47,7 +49,13 @@ export function createVirtualFileSystem(
`Path '${filePath}' is outside of the root directory '${normalizedRoot}'`,
);
}
const name = filePath.slice(normalizedRoot.length);
const relPath = filePath.slice(normalizedRoot.length);
const dir = path.dirname(relPath);
const ext = getFullExtension(relPath);
const base = path.basename(relPath, ext);

const safeName = makeSafeName(base, ext);
const name = path.join(dir, safeName);
fs[name] =
typeof content === "string"
? Buffer.from(content).toString("base64")
Expand Down
41 changes: 41 additions & 0 deletions src/vfs/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { sha256_sync } from "@ton/crypto";
import path from "path";

/**
* Ensures the resulting file name does not exceed the given maximum length.
* If too long, trims the name and appends a short hash to avoid collisions.
*
* @param name - The base file name without extension.
* @param ext - The file extension.
* @param maxLen - Maximum allowed length for the full file name (default: 255).
* @returns A safe file name within the specified length.
*/
export const makeSafeName = (
name: string,
ext: string,
maxLen = 255,
): string => {
const full = name + ext;

if (full.length <= maxLen) {
return full;
}

const hash = sha256_sync(Buffer.from(name)).toString("hex").slice(0, 8);
Copy link
Contributor

@verytactical verytactical May 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2^32 hashes are still possible to mine, possibly creating a name clash

I think it would rather be better to just err whenever len(projectName) + len(contractName) + 1 >= 255, or even len(projectName) > 100 || len(contractName) > 100.

These are not the kind of situations that might happen in non-malicious code, and emitting an error would be completely fine.

const suffix = `_${hash}${ext}`;
const maxNameLen = maxLen - suffix.length;
const safeName = name.slice(0, maxNameLen);

return `${safeName}${suffix}`;
};

/**
* Returns the full extension of a file, including all parts after the first dot.
* - "file.txt" => ".txt"
* - "archive.tar.gz" => ".tar.gz"
*/
export const getFullExtension = (filename: string): string => {
const base = path.basename(filename);
const firstDotIndex = base.indexOf(".");
return firstDotIndex !== -1 ? base.slice(firstDotIndex) : "";
};