|
1 | 1 | import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; |
2 | | -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; |
| 2 | +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; |
3 | 3 | import { homedir, tmpdir } from "node:os"; |
4 | 4 | import { join, resolve } from "node:path"; |
5 | 5 | import { |
@@ -27,7 +27,7 @@ import { |
27 | 27 | } from "../src/config"; |
28 | 28 |
|
29 | 29 | import * as windowsAcl from "../src/lib/windows-secret-acl"; |
30 | | -import { AtomicWriteResidualTempError, atomicWriteFile, hardenConfigDir, hardenExistingSecret, renameAtomicFile, saveConfig } from "../src/config"; |
| 30 | +import { AtomicWriteResidualTempError, atomicWriteFile, atomicWriteFileAsync, hardenConfigDir, hardenExistingSecret, renameAtomicFile, saveConfig } from "../src/config"; |
31 | 31 | let testDir = ""; |
32 | 32 |
|
33 | 33 | beforeEach(() => { |
@@ -1738,5 +1738,115 @@ describe("config.ts – sync writer timeout keying (#840 refinement)", () => { |
1738 | 1738 | if (previousUsername === undefined) delete process.env.USERNAME; |
1739 | 1739 | else process.env.USERNAME = previousUsername; |
1740 | 1740 | } |
| 1741 | +||||||| parent of d8261a286 (fix(config): preserve symlinked destinations in atomic writes) |
| 1742 | +describe("config.ts – atomic writes preserve symlinked destinations", () => { |
| 1743 | + test("a symlinked destination survives the write and the real file receives it", () => { |
| 1744 | + // Dotfiles shape: ~/.codex/config.toml -> ~/dotfiles/.codex/config.toml |
| 1745 | + const repoDir = join(testDir, "dotfiles"); |
| 1746 | + mkdirSync(repoDir, { recursive: true }); |
| 1747 | + const realFile = join(repoDir, "config.toml"); |
| 1748 | + writeFileSync(realFile, "original", "utf-8"); |
| 1749 | + const link = join(testDir, "config.toml"); |
| 1750 | + symlinkSync(realFile, link); |
| 1751 | + |
| 1752 | + atomicWriteFile(link, "rewritten"); |
| 1753 | + |
| 1754 | + expect(lstatSync(link).isSymbolicLink()).toBe(true); |
| 1755 | + expect(readlinkSync(link)).toBe(realFile); |
| 1756 | + expect(readFileSync(realFile, "utf8")).toBe("rewritten"); |
| 1757 | + expect(readFileSync(link, "utf8")).toBe("rewritten"); |
| 1758 | + }); |
| 1759 | + |
| 1760 | + test("no temp file is left beside the link or its target", () => { |
| 1761 | + const repoDir = join(testDir, "dotfiles-clean"); |
| 1762 | + mkdirSync(repoDir, { recursive: true }); |
| 1763 | + const realFile = join(repoDir, "config.toml"); |
| 1764 | + writeFileSync(realFile, "original", "utf-8"); |
| 1765 | + const link = join(testDir, "config-clean.toml"); |
| 1766 | + symlinkSync(realFile, link); |
| 1767 | + |
| 1768 | + atomicWriteFile(link, "rewritten"); |
| 1769 | + |
| 1770 | + expect(readdirSync(repoDir).filter(name => name.includes(".ocx."))).toEqual([]); |
| 1771 | + expect(readdirSync(testDir).filter(name => name.includes(".ocx."))).toEqual([]); |
| 1772 | + }); |
| 1773 | + |
| 1774 | + test("a plain destination is unaffected", () => { |
| 1775 | + const destination = join(testDir, "plain.toml"); |
| 1776 | + atomicWriteFile(destination, "first"); |
| 1777 | + atomicWriteFile(destination, "second"); |
| 1778 | + |
| 1779 | + expect(lstatSync(destination).isSymbolicLink()).toBe(false); |
| 1780 | + expect(readFileSync(destination, "utf8")).toBe("second"); |
| 1781 | + }); |
| 1782 | + |
| 1783 | + test("a destination that does not exist yet is created at the literal path", () => { |
| 1784 | + const destination = join(testDir, "created.toml"); |
| 1785 | + expect(existsSync(destination)).toBe(false); |
| 1786 | + |
| 1787 | + atomicWriteFile(destination, "fresh"); |
| 1788 | + |
| 1789 | + expect(readFileSync(destination, "utf8")).toBe("fresh"); |
| 1790 | + }); |
| 1791 | + |
| 1792 | + test("a dangling symlink is preserved and the write is refused", () => { |
| 1793 | + const link = join(testDir, "dangling.toml"); |
| 1794 | + symlinkSync(join(testDir, "gone", "config.toml"), link); |
| 1795 | + |
| 1796 | + // The target volume may only be temporarily unavailable; replacing the link |
| 1797 | + // would recreate the dotfiles divergence this fix exists to prevent. |
| 1798 | + expect(() => atomicWriteFile(link, "recovered")).toThrow(/unresolvable symlinked write target/); |
| 1799 | + expect(lstatSync(link).isSymbolicLink()).toBe(true); |
| 1800 | + expect(existsSync(join(testDir, "gone"))).toBe(false); |
| 1801 | + }); |
| 1802 | +}); |
| 1803 | + |
| 1804 | +describe("config.ts – async atomic writes preserve symlinked destinations", () => { |
| 1805 | + test("a symlinked destination survives the write and the real file receives it", async () => { |
| 1806 | + const repoDir = join(testDir, "dotfiles-async"); |
| 1807 | + mkdirSync(repoDir, { recursive: true }); |
| 1808 | + const realFile = join(repoDir, "config.toml"); |
| 1809 | + writeFileSync(realFile, "original", "utf-8"); |
| 1810 | + const link = join(testDir, "config-async.toml"); |
| 1811 | + symlinkSync(realFile, link); |
| 1812 | + |
| 1813 | + await atomicWriteFileAsync(link, "rewritten"); |
| 1814 | + |
| 1815 | + expect(lstatSync(link).isSymbolicLink()).toBe(true); |
| 1816 | + expect(readlinkSync(link)).toBe(realFile); |
| 1817 | + expect(readFileSync(realFile, "utf8")).toBe("rewritten"); |
| 1818 | + expect(readFileSync(link, "utf8")).toBe("rewritten"); |
| 1819 | + }); |
| 1820 | + |
| 1821 | + test("no temp file is left beside the link or its target", async () => { |
| 1822 | + const repoDir = join(testDir, "dotfiles-async-clean"); |
| 1823 | + mkdirSync(repoDir, { recursive: true }); |
| 1824 | + const realFile = join(repoDir, "config.toml"); |
| 1825 | + writeFileSync(realFile, "original", "utf-8"); |
| 1826 | + const link = join(testDir, "config-async-clean.toml"); |
| 1827 | + symlinkSync(realFile, link); |
| 1828 | + |
| 1829 | + await atomicWriteFileAsync(link, "rewritten"); |
| 1830 | + |
| 1831 | + expect(readdirSync(repoDir).filter(name => name.includes(".ocx."))).toEqual([]); |
| 1832 | + expect(readdirSync(testDir).filter(name => name.includes(".ocx."))).toEqual([]); |
| 1833 | + }); |
| 1834 | + |
| 1835 | + test("a plain destination is unaffected", async () => { |
| 1836 | + const destination = join(testDir, "plain-async.toml"); |
| 1837 | + await atomicWriteFileAsync(destination, "first"); |
| 1838 | + await atomicWriteFileAsync(destination, "second"); |
| 1839 | + |
| 1840 | + expect(lstatSync(destination).isSymbolicLink()).toBe(false); |
| 1841 | + expect(readFileSync(destination, "utf8")).toBe("second"); |
| 1842 | + }); |
| 1843 | + |
| 1844 | + test("a dangling symlink is preserved and the write is refused", async () => { |
| 1845 | + const link = join(testDir, "dangling-async.toml"); |
| 1846 | + symlinkSync(join(testDir, "gone-async", "config.toml"), link); |
| 1847 | + |
| 1848 | + await expect(atomicWriteFileAsync(link, "recovered")).rejects.toThrow(/unresolvable symlinked write target/); |
| 1849 | + expect(lstatSync(link).isSymbolicLink()).toBe(true); |
| 1850 | + expect(existsSync(join(testDir, "gone-async"))).toBe(false); |
1741 | 1851 | }); |
1742 | 1852 | }); |
0 commit comments