Skip to content

Commit eae1a50

Browse files
committed
feat: 🎸 move and rename AppleDouble file when parent file is moved or renamed
1 parent b9fd6df commit eae1a50

File tree

3 files changed

+166
-1
lines changed

3 files changed

+166
-1
lines changed

‎src/nfs/v4/server/operations/node/Nfsv4OperationsNode.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1139,10 +1139,18 @@ export class Nfsv4OperationsNode implements Nfsv4Operations {
11391139
try {
11401140
const stats = await this.promises.lstat(targetPath);
11411141
if (stats.isDirectory()) {
1142-
// For now, use rmdir semantics (only remove empty dirs)
11431142
await this.promises.rmdir(targetPath);
11441143
} else {
11451144
await this.promises.unlink(targetPath);
1145+
const appleDoublePath = NodePath.join(NodePath.dirname(targetPath), '._' + NodePath.basename(targetPath));
1146+
try {
1147+
const appleDoubleStats = await this.promises.stat(appleDoublePath);
1148+
if (appleDoubleStats.isFile()) {
1149+
await this.promises.unlink(appleDoublePath);
1150+
}
1151+
} catch (err) {
1152+
if (!isErrCode('ENOENT', err)) throw err;
1153+
}
11461154
}
11471155
return new msg.Nfsv4RemoveResponse(Nfsv4Stat.NFS4_OK);
11481156
} catch (err: unknown) {
@@ -1173,6 +1181,17 @@ export class Nfsv4OperationsNode implements Nfsv4Operations {
11731181
try {
11741182
await this.promises.rename(oldPath, newPath);
11751183
this.fh.rename(oldPath, newPath);
1184+
const oldAppleDouble = NodePath.join(NodePath.dirname(oldPath), '._' + NodePath.basename(oldPath));
1185+
const newAppleDouble = NodePath.join(NodePath.dirname(newPath), '._' + NodePath.basename(newPath));
1186+
try {
1187+
const stats = await this.promises.stat(oldAppleDouble);
1188+
if (stats.isFile()) {
1189+
await this.promises.rename(oldAppleDouble, newAppleDouble);
1190+
this.fh.rename(oldAppleDouble, newAppleDouble);
1191+
}
1192+
} catch (err) {
1193+
if (!isErrCode('ENOENT', err)) throw err;
1194+
}
11761195
return new msg.Nfsv4RenameResponse(Nfsv4Stat.NFS4_OK);
11771196
} catch (err: unknown) {
11781197
if (isErrCode('EXDEV', err)) return new msg.Nfsv4RenameResponse(Nfsv4Stat.NFS4ERR_XDEV);

‎src/nfs/v4/server/operations/node/__tests__/REMOVE.spec.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,49 @@ describe('REMOVE operation', () => {
1818
expect(res.status).not.toBe(Nfsv4Stat.NFS4_OK);
1919
await stop();
2020
});
21+
22+
describe('AppleDouble file handling', () => {
23+
test('removes AppleDouble file when removing main file', async () => {
24+
const {client, stop, vol} = await setupNfsClientServerTestbed();
25+
vol.writeFileSync('/export/test.txt', 'data');
26+
vol.writeFileSync('/export/._test.txt', 'xattr-data');
27+
const res = await client.compound([nfs.PUTROOTFH(), nfs.REMOVE('test.txt')]);
28+
expect(res.status).toBe(Nfsv4Stat.NFS4_OK);
29+
expect(vol.existsSync('/export/test.txt')).toBe(false);
30+
expect(vol.existsSync('/export/._test.txt')).toBe(false);
31+
await stop();
32+
});
33+
34+
test('succeeds even if AppleDouble file does not exist', async () => {
35+
const {client, stop, vol} = await setupNfsClientServerTestbed();
36+
vol.writeFileSync('/export/test.txt', 'data');
37+
const res = await client.compound([nfs.PUTROOTFH(), nfs.REMOVE('test.txt')]);
38+
expect(res.status).toBe(Nfsv4Stat.NFS4_OK);
39+
expect(vol.existsSync('/export/test.txt')).toBe(false);
40+
await stop();
41+
});
42+
43+
test('does not remove AppleDouble if it is a directory', async () => {
44+
const {client, stop, vol} = await setupNfsClientServerTestbed();
45+
vol.writeFileSync('/export/test.txt', 'data');
46+
vol.mkdirSync('/export/._test.txt');
47+
const res = await client.compound([nfs.PUTROOTFH(), nfs.REMOVE('test.txt')]);
48+
expect(res.status).toBe(Nfsv4Stat.NFS4_OK);
49+
expect(vol.existsSync('/export/test.txt')).toBe(false);
50+
expect(vol.existsSync('/export/._test.txt')).toBe(true);
51+
expect(vol.statSync('/export/._test.txt').isDirectory()).toBe(true);
52+
await stop();
53+
});
54+
55+
test('removes directory without removing AppleDouble file', async () => {
56+
const {client, stop, vol} = await setupNfsClientServerTestbed();
57+
vol.mkdirSync('/export/testdir');
58+
vol.writeFileSync('/export/._testdir', 'xattr');
59+
const res = await client.compound([nfs.PUTROOTFH(), nfs.REMOVE('testdir')]);
60+
expect(res.status).toBe(Nfsv4Stat.NFS4_OK);
61+
expect(vol.existsSync('/export/testdir')).toBe(false);
62+
expect(vol.existsSync('/export/._testdir')).toBe(true);
63+
await stop();
64+
});
65+
});
2166
});

‎src/nfs/v4/server/operations/node/__tests__/RENAME.spec.ts‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,105 @@ describe('RENAME operation', () => {
120120
expect(getattr.status).toBe(Nfsv4Stat.NFS4_OK); // Must succeed with fix
121121
await stop();
122122
});
123+
124+
describe('AppleDouble file handling', () => {
125+
test('renames AppleDouble file when renaming main file', async () => {
126+
const {client, stop, vol} = await setupNfsClientServerTestbed();
127+
vol.writeFileSync('/export/test.txt', 'data');
128+
vol.writeFileSync('/export/._test.txt', 'xattr-data');
129+
const res = await client.compound([nfs.PUTROOTFH(), nfs.SAVEFH(), nfs.RENAME('test.txt', 'new.txt')]);
130+
expect(res.status).toBe(Nfsv4Stat.NFS4_OK);
131+
expect(vol.existsSync('/export/new.txt')).toBe(true);
132+
expect(vol.existsSync('/export/._new.txt')).toBe(true);
133+
expect(vol.existsSync('/export/test.txt')).toBe(false);
134+
expect(vol.existsSync('/export/._test.txt')).toBe(false);
135+
await stop();
136+
});
137+
138+
test('succeeds even if AppleDouble file does not exist', async () => {
139+
const {client, stop, vol} = await setupNfsClientServerTestbed();
140+
vol.writeFileSync('/export/test.txt', 'data');
141+
const res = await client.compound([nfs.PUTROOTFH(), nfs.SAVEFH(), nfs.RENAME('test.txt', 'new.txt')]);
142+
expect(res.status).toBe(Nfsv4Stat.NFS4_OK);
143+
expect(vol.existsSync('/export/new.txt')).toBe(true);
144+
expect(vol.existsSync('/export/._new.txt')).toBe(false);
145+
await stop();
146+
});
147+
148+
test('renames AppleDouble file for TextEdit-style save workflow', async () => {
149+
const {client, stop, vol} = await setupNfsClientServerTestbed();
150+
vol.writeFileSync('/export/file.txt', 'original content');
151+
vol.writeFileSync('/export/file.txt.sb-temp-u9VvVu', 'new content');
152+
vol.writeFileSync('/export/._file.txt.sb-temp-u9VvVu', 'new xattr');
153+
const res = await client.compound([
154+
nfs.PUTROOTFH(),
155+
nfs.SAVEFH(),
156+
nfs.RENAME('file.txt.sb-temp-u9VvVu', 'file.txt'),
157+
]);
158+
expect(res.status).toBe(Nfsv4Stat.NFS4_OK);
159+
expect(vol.existsSync('/export/file.txt')).toBe(true);
160+
expect(vol.existsSync('/export/._file.txt')).toBe(true);
161+
expect(vol.existsSync('/export/file.txt.sb-temp-u9VvVu')).toBe(false);
162+
expect(vol.existsSync('/export/._file.txt.sb-temp-u9VvVu')).toBe(false);
163+
expect(vol.readFileSync('/export/file.txt', 'utf8')).toBe('new content');
164+
expect(vol.readFileSync('/export/._file.txt', 'utf8')).toBe('new xattr');
165+
await stop();
166+
});
167+
168+
test('handles AppleDouble file with long filenames (ID-type FH)', async () => {
169+
const {client, stop, vol} = await setupNfsClientServerTestbed();
170+
const oldName = 'long_filename_' + 'x'.repeat(100) + '.txt';
171+
const newName = 'long_filename_' + 'y'.repeat(100) + '.txt';
172+
vol.writeFileSync('/export/' + oldName, 'data');
173+
vol.writeFileSync('/export/._' + oldName, 'xattr');
174+
const res = await client.compound([nfs.PUTROOTFH(), nfs.SAVEFH(), nfs.RENAME(oldName, newName)]);
175+
expect(res.status).toBe(Nfsv4Stat.NFS4_OK);
176+
expect(vol.existsSync('/export/' + newName)).toBe(true);
177+
expect(vol.existsSync('/export/._' + newName)).toBe(true);
178+
expect(vol.existsSync('/export/' + oldName)).toBe(false);
179+
expect(vol.existsSync('/export/._' + oldName)).toBe(false);
180+
await stop();
181+
});
182+
183+
test('AppleDouble file handle remains valid after rename', async () => {
184+
const {client, stop, vol} = await setupNfsClientServerTestbed();
185+
const oldName = 'original_' + 'a'.repeat(100) + '.txt';
186+
const newName = 'renamed_' + 'b'.repeat(100) + '.txt';
187+
vol.writeFileSync('/export/' + oldName, 'data');
188+
vol.writeFileSync('/export/._' + oldName, 'xattr');
189+
const lookupRes = await client.compound([nfs.PUTROOTFH(), nfs.LOOKUP('._' + oldName), nfs.GETFH()]);
190+
expect(lookupRes.status).toBe(Nfsv4Stat.NFS4_OK);
191+
const appleDoubleFh = (lookupRes.resarray[2] as msg.Nfsv4GetfhResponse).resok!.object;
192+
const renameRes = await client.compound([nfs.PUTROOTFH(), nfs.SAVEFH(), nfs.RENAME(oldName, newName)]);
193+
expect(renameRes.status).toBe(Nfsv4Stat.NFS4_OK);
194+
const getAttrRes = await client.compound([nfs.PUTFH(appleDoubleFh), nfs.GETATTR([0x00000020])]);
195+
expect(getAttrRes.status).toBe(Nfsv4Stat.NFS4_OK);
196+
await stop();
197+
});
198+
199+
test('does not rename AppleDouble if it is a directory', async () => {
200+
const {client, stop, vol} = await setupNfsClientServerTestbed();
201+
vol.writeFileSync('/export/test.txt', 'data');
202+
vol.mkdirSync('/export/._test.txt');
203+
const res = await client.compound([nfs.PUTROOTFH(), nfs.SAVEFH(), nfs.RENAME('test.txt', 'new.txt')]);
204+
expect(res.status).toBe(Nfsv4Stat.NFS4_OK);
205+
expect(vol.existsSync('/export/new.txt')).toBe(true);
206+
expect(vol.existsSync('/export/._test.txt')).toBe(true);
207+
expect(vol.statSync('/export/._test.txt').isDirectory()).toBe(true);
208+
await stop();
209+
});
210+
211+
test('handles case where main file starts with ._', async () => {
212+
const {client, stop, vol} = await setupNfsClientServerTestbed();
213+
vol.writeFileSync('/export/._special.txt', 'data');
214+
vol.writeFileSync('/export/._._special.txt', 'xattr');
215+
const res = await client.compound([nfs.PUTROOTFH(), nfs.SAVEFH(), nfs.RENAME('._special.txt', '._renamed.txt')]);
216+
expect(res.status).toBe(Nfsv4Stat.NFS4_OK);
217+
expect(vol.existsSync('/export/._renamed.txt')).toBe(true);
218+
expect(vol.existsSync('/export/._._renamed.txt')).toBe(true);
219+
expect(vol.existsSync('/export/._special.txt')).toBe(false);
220+
expect(vol.existsSync('/export/._._special.txt')).toBe(false);
221+
await stop();
222+
});
223+
});
123224
});

0 commit comments

Comments
 (0)