The Pedigree Project 0.1
fs-mutation-contracts.c
1/*
2 * Copyright (c) 2026, Pedigree Developers
3 *
4 * Permission to use, copy, modify, and distribute this software for any
5 * purpose with or without fee is hereby granted.
6 */
7
8#define _GNU_SOURCE
9
10#include <errno.h>
11#include <fcntl.h>
12#include <stdio.h>
13#include <stdlib.h>
14#include <string.h>
15#include <unistd.h>
16#include <utime.h>
17
18#include <sys/mman.h>
19#include <sys/stat.h>
20#include <sys/syscall.h>
21#include <sys/time.h>
22#include <sys/uio.h>
23
24extern void fail(void) __attribute__((noreturn));
25
26static const char* filesystem;
27
28static void require(int condition, const char* operation) {
29 if (!condition) {
30 printf("FS-MUTATION-CONTRACT: FAIL %s %s errno=%d\n", filesystem, operation, errno);
31 fail();
32 }
33}
34
35static int create_file(const char* name, const char* contents) {
36 int file = open(name, O_CREAT | O_EXCL | O_RDWR, 0600);
37 require(file >= 0, "create file");
38 const size_t length = strlen(contents);
39 require(write(file, contents, length) == (ssize_t)length, "initial file contents");
40 return file;
41}
42
43static void require_contents(int file, const char* expected, const char* operation) {
44 char buffer[64] = {0};
45 const size_t length = strlen(expected);
46 require(length < sizeof(buffer) && pread(file, buffer, sizeof(buffer), 0) == (ssize_t)length &&
47 !memcmp(buffer, expected, length),
48 operation);
49}
50
51static void replacement_contracts(int hardlinks) {
52 int source = create_file("source", "source-data");
53 int replaced = create_file("destination", "old-data");
54 struct stat original;
55 struct stat current;
56 require(fstat(source, &original) == 0, "source inode");
57 require(rename("source", "destination") == 0, "replace destination");
58 require(stat("destination", &current) == 0 && current.st_ino == original.st_ino,
59 "rename preserves inode");
60 errno = 0;
61 require(stat("source", &current) == -1 && errno == ENOENT, "old name removed");
62 require_contents(source, "source-data", "open source after rename");
63 require_contents(replaced, "old-data", "open replaced file survives");
64 require(pwrite(replaced, "OLD", 3, 0) == 3, "write replaced open file");
65 require_contents(replaced, "OLD-data", "replaced inode remains writable");
66 int destination = open("destination", O_RDONLY);
67 require(destination >= 0, "open renamed destination");
68 require_contents(destination, "source-data", "destination isolation");
69 require(rename("destination", "destination") == 0, "same-name rename");
70
71 if (hardlinks) {
72 require(link("destination", "alias") == 0, "create hardlink");
73 require(rename("destination", "alias") == 0, "same-inode rename");
74 require(stat("destination", &current) == 0 && current.st_ino == original.st_ino,
75 "same-inode rename keeps source name");
76 require(stat("alias", &current) == 0 && current.st_ino == original.st_ino,
77 "same-inode rename keeps destination name");
78 int alias = open("alias", O_RDWR);
79 require(alias >= 0, "open hardlink alias");
80 require(fstat(source, &current) == 0 && current.st_nlink == 2,
81 "hardlinks report inode link count");
82 require(fchmod(alias, 0640) == 0 && fstat(source, &current) == 0 &&
83 (current.st_mode & 0777) == 0640,
84 "hardlinks share permission changes");
85 require(ftruncate(alias, 6) == 0, "truncate hardlink alias");
86 require(fstat(source, &current) == 0 && current.st_size == 6, "hardlinks share size");
87 require_contents(source, "source", "hardlinks share truncated contents");
88 require(pwrite(alias, "S", 1, 0) == 1, "write hardlink alias");
89 require_contents(source, "Source", "hardlinks share writes");
90 require(unlink("alias") == 0, "unlink alias with open descriptor");
91 require(fstat(alias, &current) == 0 && current.st_nlink == 1,
92 "open alias observes decremented link count");
93 require_contents(alias, "Source", "unlinked alias retains shared inode");
94 close(alias);
95 }
96
97 close(destination);
98 close(source);
99 close(replaced);
100 require(unlink("destination") == 0, "remove renamed file");
101 printf("FS-MUTATION-CONTRACT: PASS %s replacement-and-open-inodes\n", filesystem);
102}
103
104static void hardlink_mapping_contracts(void) {
105 const size_t page_size = (size_t)sysconf(_SC_PAGESIZE);
106 int first = create_file("mapped-first", "");
107 require(ftruncate(first, 2 * page_size) == 0 && link("mapped-first", "mapped-second") == 0,
108 "create mapped hardlink layout");
109 int second = open("mapped-second", O_RDWR);
110 require(second >= 0, "open mapped alias");
111 unsigned char* left = mmap(NULL, 2 * page_size, PROT_READ | PROT_WRITE, MAP_SHARED, first, 0);
112 unsigned char* right = mmap(NULL, 2 * page_size, PROT_READ | PROT_WRITE, MAP_SHARED, second, 0);
113 require(left != MAP_FAILED && right != MAP_FAILED, "map hardlink aliases");
114 require(left[17] == 0 && right[17] == 0, "fault initial alias pages");
115 left[17] = 0x6b;
116 require(right[17] == 0x6b, "mapped aliases share writes immediately");
117 const unsigned char value = 0x9e;
118 require(pwrite(second, &value, 1, 29) == 1 && left[29] == value && right[29] == value,
119 "descriptor writes update both mapped aliases");
120 require(msync(left, 2 * page_size, MS_SYNC) == 0, "sync shared alias changes");
121 require(unlink("mapped-first") == 0 && close(first) == 0 && munmap(left, 2 * page_size) == 0,
122 "retire first mapped alias");
123 struct stat status;
124 require(fstat(second, &status) == 0 && status.st_nlink == 1, "surviving mapped alias link count");
125 right[31] = 0x4a;
126 require(fsync(second) == 0 && munmap(right, 2 * page_size) == 0 && close(second) == 0,
127 "fsync surviving alias after first alias retirement");
128 second = open("mapped-second", O_RDONLY);
129 unsigned char buffer[32] = {0};
130 require(second >= 0 && pread(second, buffer, sizeof(buffer), 0) == sizeof(buffer) &&
131 buffer[17] == 0x6b && buffer[29] == 0x9e && buffer[31] == 0x4a,
132 "reopen retains all alias writes");
133 close(second);
134 require(unlink("mapped-second") == 0, "mapped alias cleanup");
135 printf("FS-MUTATION-CONTRACT: PASS %s hardlink-metadata-and-mapped-cache\n", filesystem);
136}
137
138static void directory_contracts(void) {
139 require(mkdir("first", 0700) == 0 && mkdir("second", 0700) == 0 &&
140 mkdir("first/child", 0700) == 0 && mkdir("second/child", 0700) == 0,
141 "create directory move layout");
142 int child = open("first/child", O_RDONLY | O_DIRECTORY);
143 int old_destination = open("second/child", O_RDONLY | O_DIRECTORY);
144 int parent = open("second", O_RDONLY | O_DIRECTORY);
145 require(child >= 0 && old_destination >= 0 && parent >= 0, "open directory move layout");
146 int leaf = create_file("first/child/leaf", "leaf");
147 close(leaf);
148 struct stat original;
149 struct stat current;
150 struct stat parent_status;
151 require(fstat(child, &original) == 0 && fstat(parent, &parent_status) == 0,
152 "directory identities");
153 require(rename("first/child", "second/child") == 0, "cross-parent directory replacement");
154 require(stat("second/child", &current) == 0 && current.st_ino == original.st_ino,
155 "moved directory retains inode");
156 int moved_parent = openat(child, "..", O_RDONLY | O_DIRECTORY);
157 require(moved_parent >= 0 && fstat(moved_parent, &current) == 0 &&
158 current.st_ino == parent_status.st_ino,
159 "open moved directory follows new parent");
160 require(fstat(old_destination, &current) == 0 && S_ISDIR(current.st_mode),
161 "replaced open directory survives");
162 leaf = openat(child, "leaf", O_RDONLY);
163 require(leaf >= 0, "open child through retained directory");
164 require_contents(leaf, "leaf", "moved directory retains children");
165 close(leaf);
166 close(moved_parent);
167 close(old_destination);
168 close(child);
169 close(parent);
170
171 require(mkdir("empty", 0700) == 0, "create empty directory");
172 errno = 0;
173 require(rename("empty", "second/child") == -1 && errno == ENOTEMPTY,
174 "nonempty replacement fails");
175 require(stat("empty", &current) == 0 && stat("second/child/leaf", &current) == 0,
176 "failed replacement preserves both directories");
177 errno = 0;
178 require(rename("second", "second/child/descendant") == -1 && errno == EINVAL,
179 "directory descendant cycle rejected");
180 require(stat("second/child/leaf", &current) == 0, "cycle failure preserves namespace");
181 errno = 0;
182 require(rename("missing", "second/child/leaf") == -1 && errno == ENOENT,
183 "missing source leaves destination");
184 leaf = open("second/child/leaf", O_RDONLY);
185 require(leaf >= 0, "destination after missing-source failure");
186 require_contents(leaf, "leaf", "failed rename retains destination contents");
187 close(leaf);
188 errno = 0;
189 require(rename("second/child/leaf", "empty") == -1 && errno == EISDIR,
190 "file cannot replace directory");
191 errno = 0;
192 require(rename("empty", "second/child/leaf") == -1 && errno == ENOTDIR,
193 "directory cannot replace file");
194
195 require(unlink("second/child/leaf") == 0 && rmdir("second/child") == 0 && rmdir("second") == 0 &&
196 rmdir("first") == 0 && rmdir("empty") == 0,
197 "directory cleanup");
198 printf("FS-MUTATION-CONTRACT: PASS %s directory-moves-and-failure-preservation\n", filesystem);
199}
200
201static void symlink_contracts(void) {
202 int target = create_file("target", "unchanged");
203 require(symlink("target", "source-link") == 0 && symlink("absent", "destination-link") == 0,
204 "create symlink layout");
205 require(rename("source-link", "destination-link") == 0, "rename terminal symlink");
206 char value[32] = {0};
207 require(readlink("destination-link", value, sizeof(value)) == 6 && !memcmp(value, "target", 6),
208 "rename moves symlink itself");
209 require_contents(target, "unchanged", "rename does not alter symlink target");
210 struct stat status;
211 errno = 0;
212 require(lstat("source-link", &status) == -1 && errno == ENOENT, "old symlink name removed");
213 close(target);
214 require(unlink("destination-link") == 0 && unlink("target") == 0, "symlink cleanup");
215 printf("FS-MUTATION-CONTRACT: PASS %s terminal-symlinks\n", filesystem);
216}
217
218static void truncate_contracts(void) {
219 const off_t extent = 80 * 1024 + 17;
220 const off_t prefix = 4099;
221 int file = create_file("resize", "");
222 unsigned char buffer[4096];
223 memset(buffer, 0x5a, sizeof(buffer));
224 for (off_t offset = 0; offset < extent;) {
225 size_t amount = extent - offset < (off_t)sizeof(buffer) ? extent - offset : sizeof(buffer);
226 require(write(file, buffer, amount) == (ssize_t)amount, "write multi-block file");
227 offset += amount;
228 }
229 require(lseek(file, 55, SEEK_SET) == 55, "set offset before truncate");
230 require(ftruncate(file, prefix) == 0, "shrink through indirect blocks");
231 require(lseek(file, 0, SEEK_CUR) == 55, "truncate preserves file offset");
232 struct stat status;
233 require(fstat(file, &status) == 0 && status.st_size == prefix, "shrunk size");
234 require(ftruncate(file, extent) == 0, "grow truncated file");
235 for (off_t offset = 0; offset < extent;) {
236 size_t amount = extent - offset < (off_t)sizeof(buffer) ? extent - offset : sizeof(buffer);
237 require(pread(file, buffer, amount, offset) == (ssize_t)amount, "read regrown extent");
238 for (size_t i = 0; i < amount; ++i) {
239 require(buffer[i] == (offset + (off_t)i < prefix ? 0x5a : 0),
240 "truncate preserves prefix and zeroes regrowth");
241 }
242 offset += amount;
243 }
244 errno = 0;
245 require(ftruncate(file, -1) == -1 && errno == EINVAL, "negative truncate length");
246 require(fstat(file, &status) == 0 && status.st_size == extent, "failed truncate preserves size");
247 int readonly = open("resize", O_RDONLY);
248 require(readonly >= 0, "open read-only truncate descriptor");
249 errno = 0;
250 require(ftruncate(readonly, 0) == -1 && errno == EINVAL, "read-only truncate rejected");
251 close(readonly);
252 int truncated = open("resize", O_WRONLY | O_TRUNC);
253 require(truncated >= 0 && fstat(file, &status) == 0 && status.st_size == 0,
254 "O_TRUNC changes existing open inode");
255 close(truncated);
256 require(ftruncate(file, 8193) == 0, "grow empty file");
257 memset(buffer, 0xff, sizeof(buffer));
258 require(pread(file, buffer, sizeof(buffer), 4096) == sizeof(buffer), "read empty-file growth");
259 for (size_t i = 0; i < sizeof(buffer); ++i) {
260 require(buffer[i] == 0, "empty-file growth is zero filled");
261 }
262 require(fsync(file) == 0, "sync resized file");
263 close(file);
264 file = open("resize", O_RDONLY);
265 require(file >= 0 && fstat(file, &status) == 0 && status.st_size == 8193, "reopen resized inode");
266 close(file);
267 require(unlink("resize") == 0, "resize cleanup");
268 printf("FS-MUTATION-CONTRACT: PASS %s truncation-and-zero-filling\n", filesystem);
269}
270
271static void metadata_contracts(int hardlinks) {
272 int file = create_file("metadata", "data");
273 int writer = file;
274 if (hardlinks) {
275 require(link("metadata", "metadata-alias") == 0, "create metadata alias");
276 writer = open("metadata-alias", O_RDWR);
277 require(writer >= 0, "open metadata alias");
278 }
279 const struct utimbuf historical = {.actime = 11, .modtime = 22};
280 const struct timeval fractional[2] = {{33, 999999}, {44, 999999}};
281 struct stat before;
282 struct stat after;
283 require(syscall(SYS_utime, "metadata", &historical) == 0 && fstat(file, &after) == 0 &&
284 after.st_atime == 11 && after.st_mtime == 22,
285 "utime stores seconds");
286 require(syscall(SYS_utimes, "metadata", fractional) == 0 && fstat(file, &after) == 0 &&
287 after.st_atime == 33 && after.st_mtime == 44 && !after.st_atim.tv_nsec &&
288 !after.st_mtim.tv_nsec,
289 "utimes rounds down to filesystem seconds");
290 require(syscall(SYS_futimesat, AT_FDCWD, "metadata", fractional) == 0 &&
291 fstat(file, &before) == 0 && before.st_atime == 33 && before.st_mtime == 44,
292 "futimesat stores seconds");
293 const struct utimbuf invalid_seconds[] = {{-1, 22}, {11, 0x100000000LL}};
294 for (size_t i = 0; i < sizeof(invalid_seconds) / sizeof(invalid_seconds[0]); ++i) {
295 errno = 0;
296 require(syscall(SYS_utime, "metadata", &invalid_seconds[i]) == -1 && errno == EINVAL,
297 "utime rejects unrepresentable seconds");
298 }
299 const struct timeval invalid_fractional[][2] = {{{33, -1}, {44, 0}},
300 {{33, 0}, {44, 1000000}},
301 {{-1, 0}, {44, 0}},
302 {{33, 0}, {0x100000000LL, 0}}};
303 for (size_t i = 0; i < sizeof(invalid_fractional) / sizeof(invalid_fractional[0]); ++i) {
304 errno = 0;
305 require(syscall(SYS_futimesat, AT_FDCWD, "metadata", invalid_fractional[i]) == -1 &&
306 errno == EINVAL,
307 "futimesat rejects invalid timestamps");
308 }
309 require(fstat(file, &after) == 0 && after.st_atime == before.st_atime &&
310 after.st_mtime == before.st_mtime && after.st_ctime == before.st_ctime,
311 "invalid timestamps preserve metadata");
312 require(syscall(SYS_futimesat, AT_FDCWD, "metadata", NULL) == 0 && fstat(file, &after) == 0 &&
313 after.st_atime > 44 && after.st_atime == after.st_mtime &&
314 after.st_mtime <= after.st_ctime,
315 "implicit timestamp uses current seconds");
316
317 require(syscall(SYS_utime, "metadata", &historical) == 0 && fstat(file, &before) == 0,
318 "seed write timestamps");
319 require(
320 write(writer, "", 0) == 0 && pwrite(writer, "", 0, 0) == 0 && writev(writer, NULL, 0) == 0,
321 "empty writes succeed");
322 errno = 0;
323 require(pwrite(writer, "x", 1, -1) == -1 && errno == EINVAL, "negative write offset rejected");
324 require(fstat(file, &after) == 0 && after.st_atime == before.st_atime &&
325 after.st_mtime == before.st_mtime && after.st_ctime == before.st_ctime &&
326 after.st_size == before.st_size && after.st_blocks == before.st_blocks,
327 "zero-progress writes preserve metadata");
328
329 char bytes[] = "XY";
330 struct iovec vector[2] = {{bytes, 1}, {bytes + 1, 1}};
331 for (int operation = 0; operation < 4; ++operation) {
332 require(syscall(SYS_utime, "metadata", &historical) == 0 && lseek(writer, 0, SEEK_SET) == 0,
333 "reset overwrite timestamp");
334 ssize_t written;
335 if (operation == 1) {
336 written = pwrite(writer, bytes, 2, 0);
337 } else if (operation == 2) {
338 written = writev(writer, vector, 2);
339 } else {
340 if (operation == 3) {
341 require(fcntl(writer, F_SETFL, O_APPEND) == 0, "enable metadata append");
342 }
343 written = write(writer, bytes, 2);
344 }
345 require(written == 2 && fstat(file, &after) == 0 && after.st_atime == 11 &&
346 after.st_mtime > 22 && after.st_ctime == after.st_mtime,
347 "successful writes update modification and change times");
348 require(fstat(writer, &before) == 0 && before.st_mtime == after.st_mtime &&
349 before.st_ctime == after.st_ctime && before.st_blocks == after.st_blocks &&
350 before.st_size == after.st_size,
351 "write metadata agrees through both descriptors");
352 }
353 if (hardlinks) {
354 require(fcntl(writer, F_SETFL, 0) == 0 && pwrite(writer, bytes, 1, 3 * after.st_blksize) == 1 &&
355 fstat(writer, &before) == 0 && fstat(file, &after) == 0 &&
356 before.st_blocks == after.st_blocks &&
357 after.st_blocks == 4 * (after.st_blksize / 512),
358 "hardlinks share extended allocation counts");
359 close(writer);
360 require(unlink("metadata-alias") == 0, "remove metadata alias");
361 }
362 close(file);
363 require(unlink("metadata") == 0, "remove metadata file");
364 printf("FS-MUTATION-CONTRACT: PASS %s ordinary-write-metadata\n", filesystem);
365}
366
367static void ramfs_allocation_contracts(void) {
368 const size_t page_size = (size_t)sysconf(_SC_PAGESIZE);
369 const blkcnt_t sectors_per_page = page_size / 512;
370 int file = create_file("allocation", "");
371 struct stat status;
372 require(fstat(file, &status) == 0 && status.st_blocks == 0 &&
373 ftruncate(file, 3 * page_size) == 0 && fstat(file, &status) == 0 &&
374 status.st_blocks == 0,
375 "untouched ramfs growth allocates no pages");
376 require(pwrite(file, "x", 1, 2 * page_size + 17) == 1 && fstat(file, &status) == 0 &&
377 status.st_blocks == sectors_per_page,
378 "ramfs accounts one touched page");
379 require(pwrite(file, "x", 1, 0) == 1 && fstat(file, &status) == 0 &&
380 status.st_blocks == 2 * sectors_per_page,
381 "ramfs excludes untouched middle page");
382 require(ftruncate(file, page_size) == 0 && fstat(file, &status) == 0 &&
383 status.st_blocks == sectors_per_page && ftruncate(file, 0) == 0 &&
384 fstat(file, &status) == 0 && status.st_blocks == 0,
385 "ramfs truncation retires allocated pages");
386 close(file);
387 require(unlink("allocation") == 0, "remove allocation file");
388 printf("FS-MUTATION-CONTRACT: PASS %s allocated-page-accounting\n", filesystem);
389}
390
391static void filesystem_contracts(const char* base, const char* label, int hardlinks) {
392 filesystem = label;
393 char path[96];
394 snprintf(path, sizeof(path), "%s/fs-mutation-contract-%d", base, getpid());
395 int previous = open(".", O_RDONLY | O_DIRECTORY);
396 require(previous >= 0 && mkdir(path, 0700) == 0 && chdir(path) == 0,
397 "create isolated contract directory");
398 replacement_contracts(hardlinks);
399 directory_contracts();
400 if (hardlinks) {
401 symlink_contracts();
402 hardlink_mapping_contracts();
403 } else {
404 printf("FS-MUTATION-CONTRACT: SKIP %s terminal-symlinks unsupported-backend\n", filesystem);
405 }
406 truncate_contracts();
407 metadata_contracts(hardlinks);
408 if (!hardlinks) {
409 ramfs_allocation_contracts();
410 }
411 require(fchdir(previous) == 0, "restore working directory");
412 close(previous);
413 require(rmdir(path) == 0, "remove isolated contract directory");
414}
415
416void test_fs_mutation_contracts(void) {
417 filesystem_contracts("/tmp", "ramfs", 0);
418 filesystem_contracts("", "root", 1);
419 int device = open("/dev/null", O_WRONLY | O_TRUNC);
420 require(device >= 0 && write(device, "x", 1) == 1, "O_TRUNC ignores character device");
421 errno = 0;
422 require(ftruncate(device, 0) == -1 && errno == EINVAL, "device truncate rejected");
423 close(device);
424 puts("FS-MUTATION-CONTRACT: PASS all");
425}