// SchFS object-layer test: files, directories and space accounting. // // schfs_test covers the B-tree. This covers everything built on it — create, // write at an offset, truncate, unlink, rename, hard links — and, most // importantly, that space comes back. A filesystem that loses a block on every // delete passes every functional test ever written and is still useless. // // Everything runs against a memory-backed volume, so a full create/write/ // delete/remount cycle takes milliseconds. The kernel gets this code only once // it passes here, because the same failure inside a VM costs a rebuild and a // boot to see once. // // Build and run: make schfs-objtest #define SCHFS_HOSTED 1 #include #include #include #include /* the crash in section 10 is a longjmp out of a write */ #include "../kernel/fs/schfs/btree.c" #include "../kernel/fs/schfs/space.c" #include "../kernel/fs/schfs/vol.c" #include "../kernel/fs/schfs/obj.c" #include "../kernel/fs/schfs/objw.c" #include "../kernel/fs/schfs/attr.c" /* ------------------------------------------------------------------ */ /* Memory-backed device */ /* ------------------------------------------------------------------ */ #define NBLOCKS 4096 static unsigned char *DISK; static struct schfs_vol VOL; static char CTX_A[SCHFS_BLKSIZE]; static char CTX_B[SCHFS_BLKSIZE]; static char CTX_C[SCHFS_BLKSIZE]; static struct schfs_ctx CTX = { CTX_A, CTX_B, CTX_C }; static char CURBLK[SCHFS_BLKSIZE]; static char DATABLK[SCHFS_BLKSIZE]; static int md_read(void *ctx, uint64_t b, void *buf) { (void)ctx; if (b >= NBLOCKS) return -1; memcpy(buf, DISK + b * SCHFS_BLKSIZE, SCHFS_BLKSIZE); return 0; } // Simulated power loss. // // md_fail_after counts writes down; when it reaches zero the write does not // happen and control leaves the filesystem entirely, through the longjmp. That // is what a power cut is: the disk keeps whatever reached it and *nothing else // runs*. The obvious cheaper models are both wrong in ways that test the wrong // thing -- returning an error tests error handling, and silently swallowing the // write while letting execution continue leaves the code reading back blocks it // believes it just wrote, which no real failure does. static long md_fail_after = -1; static int md_crash_armed; static jmp_buf md_crash_jmp; static long md_writes; static int md_write(void *ctx, uint64_t b, const void *buf) { (void)ctx; if (b >= NBLOCKS) return -1; md_writes++; if (md_crash_armed && md_fail_after >= 0 && md_fail_after-- == 0) longjmp(md_crash_jmp, 1); memcpy(DISK + b * SCHFS_BLKSIZE, buf, SCHFS_BLKSIZE); return 0; } static uint64_t md_alloc(void *ctx) { (void)ctx; return schfs_vol_alloc(&VOL); } static uint64_t md_run(void *ctx, uint64_t n) { (void)ctx; return schfs_vol_alloc_run(&VOL, n); } static void md_free(void *ctx, uint64_t b, uint64_t g) { (void)ctx; schfs_vol_free(&VOL, b, g); } static long md_pages_taken, md_pages_given; static void *md_palloc(void) { md_pages_taken++; return calloc(1, SCHFS_BLKSIZE); } // The matching half. Without it every volume this test opened leaked its whole // allocation map -- 2121728 bytes across 518 allocations by the end, which made // the target exit non-zero and drowned any real leak the filesystem might have // had in expected noise. static void md_pfree(void *p) { md_pages_given++; free(p); } static void mkbdev(struct schfs_bdev *bd) { // Zero first. Every field has to be set even when the answer is "no such // hook", because callers declare these on the stack: leaving the optional // bflush uninitialised handed schfs_vol_commit a garbage function pointer // to test and call, which is a segfault at the first commit. memset(bd, 0, sizeof(*bd)); bd->ctx = 0; bd->bread = md_read; bd->bwrite = md_write; bd->balloc = md_alloc; bd->bfree = md_free; bd->balloc_run = md_run; } /* ------------------------------------------------------------------ */ static int failures; static void ok(int cond, const char *what) { if (cond) { printf(" [ok] %s\n", what); } else { printf(" FAIL: %s\n", what); failures++; } } static uint32_t rng = 987654321; static uint32_t rnd(void) { rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5; return rng; } // Read a whole file into buf, returning its length or -1. static long readall(uint64_t ino, char *buf, unsigned cap) { struct schfs_cursor c; schfs_cursor_init(&c, &VOL.tree, CURBLK); struct schfs_inode in; if (schfs_inode_get(&VOL, &c, ino, &in) != 0) return -1; if (in.size > cap) return -1; unsigned off = 0; while (off < in.size) { long got = schfs_file_read(&VOL, &c, ino, off, buf + off, (unsigned)(in.size - off), DATABLK); if (got <= 0) return -1; off += (unsigned)got; } return (long)off; } static uint64_t lookup(const char *path) { struct schfs_cursor c; schfs_cursor_init(&c, &VOL.tree, CURBLK); return schfs_path_lookup(&VOL, &c, path); } static uint32_t nlink_of(uint64_t ino) { struct schfs_cursor c; schfs_cursor_init(&c, &VOL.tree, CURBLK); struct schfs_inode in; if (schfs_inode_get(&VOL, &c, ino, &in) != 0) return 0; return in.nlink; } static uint64_t size_of(uint64_t ino) { struct schfs_cursor c; schfs_cursor_init(&c, &VOL.tree, CURBLK); struct schfs_inode in; if (schfs_inode_get(&VOL, &c, ino, &in) != 0) return ~0ULL; return in.size; } static int count_extents_of(uint64_t ino) { struct schfs_cursor c; schfs_cursor_init(&c, &VOL.tree, CURBLK); struct schfs_key start; schfs_mkkey(&start, ino, SCHFS_KEY_EXTENT, 0); if (schfs_cursor_seek(&c, &start) != 0) return 0; int n = 0; for (;;) { const struct schfs_key *k = schfs_cursor_key(&c); if (!k || k->objectid != ino || k->type != SCHFS_KEY_EXTENT) break; n++; if (schfs_cursor_next(&c) != 0) break; } return n; } // Publish, then reopen the volume from its uberblock — which rebuilds the // allocation map by walking the tree. Returns the free count the writer had, so // a caller can hold it against the one the mount derived: a bitmap that has // drifted from its own counter shows up here and nowhere else. // // Reopening also ages every block into a previous transaction group, which is // what makes the copy-on-write paths run at all. Within one group the tree and // the data both rewrite in place, so a test that never commits exercises the // cheap half of the filesystem and none of the careful half. static uint64_t remount(void) { if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit before remount\n"); failures++; return ~0ULL; } uint64_t before = VOL.free_blocks; struct schfs_bdev bd2; mkbdev(&bd2); // Straight back into VOL, not into a local that is then copied over it: a // schfs_vol points at its own dev field, so a copy of one leaves the tree // talking to whichever struct it was built in. schfs_space_release(&VOL.space); /* before the memset strands it */ memset(&VOL, 0, sizeof(VOL)); if (schfs_vol_open(&VOL, &bd2, &CTX, md_palloc, md_pfree) != 0) { printf(" FAIL: remount\n"); failures++; return ~0ULL; } return before; } /* ------------------------------------------------------------------ */ #define ROOT (VOL.root_objectid) static uint64_t mkfile(uint64_t dir, const char *name) { uint64_t ino = 0; int r = schfs_create(&VOL, dir, name, (unsigned)strlen(name), SCHFS_T_FILE, 0644, 0, 0, 1000, &ino); if (r != 0) { printf(" FAIL: create %s -> %d\n", name, r); failures++; return 0; } return ino; } static uint64_t mkdirent(uint64_t dir, const char *name) { uint64_t ino = 0; int r = schfs_create(&VOL, dir, name, (unsigned)strlen(name), SCHFS_T_DIR, 0755, 0, 0, 1000, &ino); if (r != 0) { printf(" FAIL: mkdir %s -> %d\n", name, r); failures++; return 0; } return ino; } int main(void) { struct schfs_bdev bd; DISK = calloc(NBLOCKS, SCHFS_BLKSIZE); if (!DISK) { perror("calloc"); return 1; } mkbdev(&bd); printf("SchFS object-layer test\n\n"); if (schfs_vol_format(&VOL, &bd, NBLOCKS, "objtest", &CTX, md_palloc, md_pfree) != 0) { fprintf(stderr, "FAIL: format\n"); return 1; } // The root directory is an object like any other; nothing points at it // except the uberblock's root_objectid. { struct schfs_inode root; memset(&root, 0, sizeof(root)); root.type = SCHFS_T_DIR; root.mode = 0755; root.nlink = 2; if (schfs_inode_put(&VOL, ROOT, &root) != 0) { fprintf(stderr, "FAIL: root inode\n"); return 1; } } if (schfs_vol_commit(&VOL) != 0) { fprintf(stderr, "FAIL: commit\n"); return 1; } uint64_t baseline_free = VOL.free_blocks; /* ---- 1. create, write, read back ---- */ { uint64_t d = mkdirent(ROOT, "dir"); uint64_t f = mkfile(d, "hello"); const char *msg = "the quick brown fox"; long w = schfs_file_write(&VOL, f, 0, msg, (unsigned)strlen(msg), DATABLK); ok(w == (long)strlen(msg), "write returns the byte count"); char buf[128]; long got = readall(f, buf, sizeof(buf)); ok(got == (long)strlen(msg) && memcmp(buf, msg, (size_t)got) == 0, "a short file reads back exactly"); ok(lookup("/dir/hello") == f, "path lookup finds it"); ok(nlink_of(d) == 2, "a new directory has nlink 2"); ok(nlink_of(ROOT) == 3, "creating a subdirectory bumps the parent"); } /* ---- 2. a large file becomes few extents, not one per block ---- */ { uint64_t f = mkfile(ROOT, "big"); static char src[64 * 1024]; for (unsigned i = 0; i < sizeof(src); i++) src[i] = (char)(i * 7 + (i >> 8)); // Written in small pieces on purpose: this is the shape that used to // produce one extent record per block, and one copy-on-write per call. unsigned off = 0; while (off < sizeof(src)) { unsigned n = 100 + rnd() % 900; if (off + n > sizeof(src)) n = (unsigned)sizeof(src) - off; long w = schfs_file_write(&VOL, f, off, src + off, n, DATABLK); if (w != (long)n) { printf(" FAIL: partial write %ld\n", w); failures++; break; } off += n; } static char back[64 * 1024]; long got = readall(f, back, sizeof(back)); ok(got == (long)sizeof(src) && memcmp(back, src, sizeof(src)) == 0, "64 KB written in small pieces reads back identical"); int ne = count_extents_of(f); ok(ne <= 4, "sequential writes merge into a handful of extents"); if (ne > 4) printf(" (%d extents for 16 blocks)\n", ne); } /* ---- 3. overwriting the middle, and holes ---- */ { uint64_t f = mkfile(ROOT, "patch"); static char src[16 * 1024]; memset(src, 'a', sizeof(src)); schfs_file_write(&VOL, f, 0, src, sizeof(src), DATABLK); // Straddles a block boundary, so it exercises the split path on both // sides of the extent it lands in. char patch[600]; memset(patch, 'Z', sizeof(patch)); schfs_file_write(&VOL, f, 4000, patch, sizeof(patch), DATABLK); memcpy(src + 4000, patch, sizeof(patch)); static char back[16 * 1024]; long got = readall(f, back, sizeof(back)); ok(got == (long)sizeof(src) && memcmp(back, src, sizeof(src)) == 0, "an overwrite across a block boundary lands exactly"); // Now the same thing across a commit. Everything above happened inside // one transaction group, where a data block may be rewritten in place; // once it is committed it belongs to a tree a crash can still land on, // so an overwrite has to copy the block and split the extent record // around it. That is the path that actually risks losing data, and only // a commit makes it run. if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; } char patch2[3000]; memset(patch2, 'K', sizeof(patch2)); long w = schfs_file_write(&VOL, f, 9000, patch2, sizeof(patch2), DATABLK); ok(w == (long)sizeof(patch2), "an overwrite after a commit succeeds"); memcpy(src + 9000, patch2, sizeof(patch2)); got = readall(f, back, sizeof(back)); ok(got == (long)sizeof(src) && memcmp(back, src, sizeof(src)) == 0, "copy-on-write of a committed block keeps the rest of the run intact"); // And the block it replaced must really be gone, not still in the map. uint64_t was = remount(); ok(was == VOL.free_blocks, "free space after a copy-on-write overwrite survives a remount"); got = readall(lookup("/patch"), back, sizeof(back)); ok(got == (long)sizeof(src) && memcmp(back, src, sizeof(src)) == 0, "and the patched file still reads correctly after remounting"); uint64_t h = mkfile(ROOT, "sparse"); schfs_file_write(&VOL, h, 40000, "end", 3, DATABLK); ok(size_of(h) == 40003, "writing past the end extends the file"); static char hb[64 * 1024]; got = readall(h, hb, sizeof(hb)); int zeros = (got == 40003); for (long i = 0; i < 40000 && zeros; i++) if (hb[i] != 0) zeros = 0; ok(zeros && memcmp(hb + 40000, "end", 3) == 0, "the gap left behind is a hole and reads as zeros"); } /* ---- 4. truncate ---- */ { uint64_t f = mkfile(ROOT, "trunc"); static char src[20 * 1024]; memset(src, 'q', sizeof(src)); schfs_file_write(&VOL, f, 0, src, sizeof(src), DATABLK); ok(schfs_file_truncate(&VOL, f, 100, DATABLK) == 0, "truncate succeeds"); ok(size_of(f) == 100, "truncate sets the size"); char back[256]; long got = readall(f, back, sizeof(back)); int good = (got == 100); for (int i = 0; i < 100 && good; i++) if (back[i] != 'q') good = 0; ok(good, "what survives a truncate is the original head"); // Growing again must read as zeros, not as whatever the released // blocks used to hold. ok(schfs_file_truncate(&VOL, f, 8192, DATABLK) == 0, "truncate can grow"); static char big[8192]; got = readall(f, big, sizeof(big)); int zeroed = (got == 8192); for (int i = 100; i < 8192 && zeroed; i++) if (big[i] != 0) zeroed = 0; ok(zeroed, "growing past the old end reads as zeros, not stale data"); } /* ---- 5. links, unlink, rename, rmdir ---- */ { uint64_t f = mkfile(ROOT, "orig"); schfs_file_write(&VOL, f, 0, "content", 7, DATABLK); ok(schfs_link(&VOL, ROOT, "alias", 5, f) == 0, "hard link succeeds"); ok(nlink_of(f) == 2, "hard link bumps nlink"); ok(lookup("/alias") == f, "both names reach the same object"); ok(schfs_unlink(&VOL, ROOT, "orig", 4) == 0, "unlink one name"); ok(nlink_of(f) == 1, "nlink drops but the object survives"); char buf[32]; ok(readall(f, buf, sizeof(buf)) == 7, "the surviving name still reads"); ok(schfs_rename(&VOL, ROOT, "alias", 5, ROOT, "renamed", 7) == 0, "rename within a directory"); ok(lookup("/alias") == 0 && lookup("/renamed") == f, "the old name is gone and the new one resolves"); uint64_t d = lookup("/dir"); ok(schfs_unlink(&VOL, ROOT, "dir", 3) == -5, "rmdir refuses a directory that still has entries"); ok(schfs_unlink(&VOL, d, "hello", 5) == 0, "emptying it first"); ok(schfs_unlink(&VOL, ROOT, "dir", 3) == 0, "then rmdir succeeds"); ok(nlink_of(ROOT) == 2, "removing a subdirectory drops the parent's count"); // Moving a directory under itself would detach the whole subtree. uint64_t a = mkdirent(ROOT, "a"); uint64_t b = mkdirent(a, "b"); (void)b; ok(schfs_rename(&VOL, ROOT, "a", 1, lookup("/a/b"), "loop", 4) != 0, "renaming a directory into its own subtree is refused"); } /* ---- 6. it all survives a commit and a remount ---- */ { if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; } static char back_before[64 * 1024]; long before = readall(lookup("/big"), back_before, sizeof(back_before)); uint64_t was_free = remount(); ok(was_free != ~0ULL, "the volume reopens from its uberblock"); static char back_after[64 * 1024]; long after = readall(lookup("/big"), back_after, sizeof(back_after)); ok(before == after && before > 0 && memcmp(back_before, back_after, (size_t)before) == 0, "file contents are identical after a remount"); ok(lookup("/renamed") != 0 && lookup("/dir") == 0, "the namespace is exactly as it was left"); ok(VOL.free_blocks == was_free, "free space derived at mount matches what the writer had"); if (VOL.free_blocks != was_free) printf(" (mount says %llu, writer said %llu)\n", (unsigned long long)VOL.free_blocks, (unsigned long long)was_free); } /* ---- 7. deleting everything gives the space back ---- * * The one test that catches a leak. Every earlier check would still pass on * a filesystem that never reused a block; this one does not. */ { const char *names[] = { "big", "patch", "sparse", "trunc", "renamed" }; for (unsigned i = 0; i < sizeof(names) / sizeof(names[0]); i++) { int r = schfs_unlink(&VOL, ROOT, names[i], (unsigned)strlen(names[i])); if (r != 0) { printf(" FAIL: unlink %s -> %d\n", names[i], r); failures++; } } int r = schfs_unlink(&VOL, lookup("/a"), "b", 1); if (r == 0) r = schfs_unlink(&VOL, ROOT, "a", 1); if (r != 0) { printf(" FAIL: rmdir a/b -> %d\n", r); failures++; } if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; } ok(VOL.free_blocks == baseline_free, "an emptied filesystem is back to its starting free-space count"); if (VOL.free_blocks != baseline_free) printf(" (%llu free now, %llu when empty: %lld blocks lost)\n", (unsigned long long)VOL.free_blocks, (unsigned long long)baseline_free, (long long)baseline_free - (long long)VOL.free_blocks); ok(schfs_btree_check(&VOL.tree) == 0, "the tree is still structurally sound after all of that"); } /* ---- 8. fill, empty, repeat: the space has to be genuinely reusable ---- * * The free *count* coming back is not the same as the free *map* coming * back, and a remount hides the difference -- it throws the map away and * derives a correct one from the tree. Only spending the space again, on * the same mount, holds the two against each other. A map that leaks a * little on every delete stores less on each round until nothing fits. */ { static char chunk[32 * 1024]; memset(chunk, 'x', sizeof(chunk)); uint64_t first_round = 0; int good = 1; for (int round = 0; round < 3 && good; round++) { uint64_t f = mkfile(ROOT, "hog"); uint64_t off = 0; int saw_enospc = 0; for (int i = 0; i < 10000; i++) { long w = schfs_file_write(&VOL, f, off, chunk, sizeof(chunk), DATABLK); if (w == -3) { saw_enospc = 1; break; } // Running out part way is a short write of what fitted, as // write(2) is, and the next one is ENOSPC. if (w >= 0 && w < (long)sizeof(chunk)) { off += (uint64_t)w; saw_enospc = schfs_file_write(&VOL, f, off, chunk, sizeof(chunk), DATABLK) == -3; break; } if (w != (long)sizeof(chunk)) { printf(" FAIL: write returned %ld\n", w); good = 0; break; } off += sizeof(chunk); } if (round == 0) { ok(saw_enospc, "filling the volume reports out of space rather than corrupting"); first_round = off; } else if (off + 128 * 1024 < first_round) { printf(" FAIL: round %d stored %llu KB, round 0 stored %llu KB\n", round, (unsigned long long)(off / 1024), (unsigned long long)(first_round / 1024)); good = 0; } int r = schfs_unlink(&VOL, ROOT, "hog", 3); if (r != 0) { printf(" FAIL: could not delete on a full volume (%d)\n", r); good = 0; } if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); good = 0; } } ok(good, "filling and emptying three times reuses the same space"); ok(schfs_btree_check(&VOL.tree) == 0, "the tree survives being filled to the brim"); } /* ---- 8b. snapshots ---- */ { // A snapshot is only worth anything if the data it names survives the // live tree changing underneath it. So: write, snapshot, overwrite, // and then read *through the snapshot's root* and expect the old bytes. int r; uint64_t ino = mkfile(VOL.root_objectid, "snapfile"); ok(ino != 0, "created a file to snapshot"); const char before[] = "BEFORE-THE-SNAPSHOT"; schfs_file_write(&VOL, ino, 0, before, sizeof before, DATABLK); if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; } // The snapshot names the committed root, which is the state just // written -- not the root this call produces. r = schfs_snap_create(&VOL, 1, "first", 5); ok(r == 0, "took a snapshot"); if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; } struct schfs_cursor c; schfs_cursor_init(&c, &VOL.tree, CURBLK); struct schfs_snap sn; ok(schfs_snap_get(&VOL, &c, 1, &sn) == 0, "the snapshot record reads back"); // Change the live tree: overwrite the file completely. const char after[] = "AFTER-THE-SNAPSHOT!"; schfs_file_write(&VOL, ino, 0, after, sizeof after, DATABLK); if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; } char buf[64]; memset(buf, 0, sizeof buf); schfs_cursor_init(&c, &VOL.tree, CURBLK); schfs_file_read(&VOL, &c, ino, 0, buf, sizeof after, DATABLK); ok(memcmp(buf, after, sizeof after) == 0, "the live tree shows the new contents"); // Read through the retained root. Same volume, same block device, a // tree pointed at the old root -- which is all a snapshot is. struct schfs_vol snapvol = VOL; snapvol.tree.root = sn.root; snapvol.tree.root_crc = sn.root_crc; snapvol.tree.root_gen = sn.root_generation; memset(buf, 0, sizeof buf); schfs_cursor_init(&c, &snapvol.tree, CURBLK); long got = schfs_file_read(&snapvol, &c, ino, 0, buf, sizeof before, DATABLK); ok(got == (long)sizeof before && memcmp(buf, before, sizeof before) == 0, "the snapshot still shows the OLD contents"); if (memcmp(buf, before, sizeof before) != 0) printf(" (snapshot read back \"%s\")\n", buf); // And the blocks it holds must not be considered free, or they get // handed out and the snapshot rots silently. // Exact agreement is not the invariant once a snapshot exists, and the // number is the only thing that drifts. Allocation works from the // in-use bitmap, which the derivation filled by walking the snapshot // too, so a snapshot's blocks are never handed out. The running // counter is not corrected for them, so it and the derivation disagree // until the next mount. Cosmetic, and reported rather than asserted // away. uint64_t claimed = remount(); if (claimed != VOL.free_blocks) printf(" (note: counter %llu, derived %llu -- the counter " "drifts while a snapshot exists)\n", (unsigned long long)claimed, (unsigned long long)VOL.free_blocks); // Deleting the file from the live tree must not disturb the snapshot. schfs_dir_del(&VOL, VOL.root_objectid, "snapfile", 8); schfs_object_free(&VOL, ino); if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; } memset(buf, 0, sizeof buf); snapvol = VOL; snapvol.tree.root = sn.root; snapvol.tree.root_crc = sn.root_crc; snapvol.tree.root_gen = sn.root_generation; schfs_cursor_init(&c, &snapvol.tree, CURBLK); got = schfs_file_read(&snapvol, &c, ino, 0, buf, sizeof before, DATABLK); ok(got == (long)sizeof before && memcmp(buf, before, sizeof before) == 0, "a file deleted from the live tree is still in the snapshot"); // Data written after the snapshot is in no snapshot, so deleting it // gives its blocks back now rather than at the next mount. The old // tree nodes the churn copies are kept -- the snapshot may share them // -- so the check is that the 64 KB of data came back, not that every // block did. { static char fresh[64 * 1024]; memset(fresh, 'f', sizeof fresh); uint64_t before_free = VOL.space.free_blocks; uint64_t f = mkfile(VOL.root_objectid, "fresh"); schfs_file_write(&VOL, f, 0, fresh, sizeof fresh, DATABLK); if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; } schfs_dir_del(&VOL, VOL.root_objectid, "fresh", 5); schfs_object_free(&VOL, f); if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; } uint64_t lost = before_free > VOL.space.free_blocks ? before_free - VOL.space.free_blocks : 0; ok(lost < sizeof fresh / SCHFS_BLKSIZE, "data written after a snapshot is freed while the snapshot exists"); if (lost >= sizeof fresh / SCHFS_BLKSIZE) printf(" (%llu blocks not given back)\n", (unsigned long long)lost); memset(buf, 0, sizeof buf); snapvol = VOL; snapvol.tree.root = sn.root; snapvol.tree.root_crc = sn.root_crc; snapvol.tree.root_gen = sn.root_generation; schfs_cursor_init(&c, &snapvol.tree, CURBLK); got = schfs_file_read(&snapvol, &c, ino, 0, buf, sizeof before, DATABLK); ok(got == (long)sizeof before && memcmp(buf, before, sizeof before) == 0, "and the snapshot still reads back after that churn"); } // Drop it again, so what it was holding becomes reclaimable and the // volume goes back to exact accounting for the checks that follow. ok(schfs_snap_delete(&VOL, 1) == 0, "the snapshot can be deleted"); if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; } schfs_cursor_init(&c, &VOL.tree, CURBLK); ok(schfs_snap_get(&VOL, &c, 1, &sn) != 0, "a deleted snapshot is gone"); // Deleting it makes what it held unreachable, and the derivation at // the next mount is what reclaims it -- there is no refcount to // decrement. So the derived figure must be at least what the stale // counter believed; check 9 below then proves exact agreement is back. claimed = remount(); ok(VOL.free_blocks >= claimed, "space the snapshot held is reclaimed at the next mount"); } /* ---- 9. the map and its counter still agree ---- */ { uint64_t claimed = remount(); ok(claimed == VOL.free_blocks, "free space counted in memory matches free space derived from the tree"); if (claimed != VOL.free_blocks) printf(" (counter said %llu, the tree says %llu)\n", (unsigned long long)claimed, (unsigned long long)VOL.free_blocks); } /* ---- 9b. reflink ---- */ // // The thing that makes a reflink worth having is that it costs no space, // and the thing that makes it dangerous is that both files are writable // afterwards. So both are checked: the clone must be free, and a write to // either side must not be visible on the other. printf("\n reflink\n"); { uint64_t before = VOL.free_blocks; uint64_t a = mkfile(ROOT, "clone-src"); char big[16 * 1024]; for (unsigned i = 0; i < sizeof(big); i++) big[i] = (char)(i * 7 + 1); schfs_file_write(&VOL, a, 0, big, sizeof(big), DATABLK); remount(); uint64_t used_by_a = before - VOL.free_blocks; ok(used_by_a > 0, "the source file occupies space"); uint64_t b = mkfile(ROOT, "clone-dst"); uint64_t pre = VOL.free_blocks; ok(schfs_file_reflink(&VOL, a, b) == 0, "reflink succeeds"); remount(); // A handful of metadata blocks is expected; a full copy is not. uint64_t cost = pre - VOL.free_blocks; ok(cost * 4 < used_by_a, "the clone costs far less than the data it shares"); if (cost * 4 >= used_by_a) printf(" (clone cost %llu blocks, data is %llu)\n", (unsigned long long)cost, (unsigned long long)used_by_a); ok(size_of(b) == sizeof(big), "the clone has the source's size"); char rb[sizeof(big)]; ok(readall(b, rb, sizeof(rb)) == (long)sizeof(big) && memcmp(rb, big, sizeof(big)) == 0, "the clone reads back exactly what the source held"); // Writing to the clone must not disturb the source. This is the check // that fails if the write path rewrites a shared run in place. const char *poke = "CLONE-WAS-WRITTEN"; schfs_file_write(&VOL, b, 0, poke, 17, DATABLK); remount(); char ra[sizeof(big)]; ok(readall(a, ra, sizeof(ra)) == (long)sizeof(big) && memcmp(ra, big, sizeof(big)) == 0, "writing the clone leaves the source untouched"); ok(readall(b, rb, sizeof(rb)) == (long)sizeof(big) && memcmp(rb, poke, 17) == 0 && memcmp(rb + 17, big + 17, sizeof(big) - 17) == 0, "and the clone holds the new bytes with the rest unchanged"); // And the other direction, on a fresh pair: writing the *source*. uint64_t c2 = mkfile(ROOT, "clone-dst2"); ok(schfs_file_reflink(&VOL, a, c2) == 0, "a second clone of the same file"); remount(); schfs_file_write(&VOL, a, 0, "SOURCE-WAS-WRITTEN", 18, DATABLK); remount(); ok(readall(c2, rb, sizeof(rb)) == (long)sizeof(big) && memcmp(rb, big, sizeof(big)) == 0, "writing the source leaves the clone untouched"); // Deleting every holder must give all the space back. A refcount that // is decremented once too often frees blocks another file still uses; // one decremented too rarely leaks them. Only this catches either. schfs_unlink(&VOL, ROOT, "clone-src", 9); schfs_unlink(&VOL, ROOT, "clone-dst", 9); schfs_unlink(&VOL, ROOT, "clone-dst2", 10); remount(); ok(VOL.free_blocks == before, "deleting every holder returns exactly the space they shared"); if (VOL.free_blocks != before) printf(" (started %llu, ended %llu)\n", (unsigned long long)before, (unsigned long long)VOL.free_blocks); ok(schfs_btree_check(&VOL.tree) == 0, "the tree is sound after all that"); } /* ---- 9c. attributes, and the index over them ---- */ // // Two things are being checked, and only the second is interesting. That // attributes store and reload is table stakes. That a *query* returns // exactly the right objects -- including after a value changes, which has // to remove the old index entry as well as add the new one -- is the part // that makes the feature worth having and the part that quietly breaks. printf("\n attributes and query\n"); { uint64_t d = mkdirent(ROOT, "photos"); uint64_t f1 = mkfile(d, "beach.jpg"); uint64_t f2 = mkfile(d, "hills.jpg"); uint64_t f3 = mkfile(d, "notes.txt"); ok(schfs_attr_set(&VOL, f1, "tag", 3, "holiday", 7, SCHFS_ATTR_STRING) == 0, "set an attribute"); char buf[64]; unsigned vl = 0; uint16_t ty = 0; ok(schfs_attr_get(&VOL, f1, "tag", 3, buf, sizeof(buf), &vl, &ty) == 1 && vl == 7 && memcmp(buf, "holiday", 7) == 0 && ty == SCHFS_ATTR_STRING, "it reads back with its value and type"); ok(schfs_attr_get(&VOL, f2, "tag", 3, buf, sizeof(buf), &vl, &ty) == 0, "an object without it says so"); remount(); ok(schfs_attr_get(&VOL, f1, "tag", 3, buf, sizeof(buf), &vl, &ty) == 1 && memcmp(buf, "holiday", 7) == 0, "and it survives a remount"); // Two attributes on one object, and one replaced. schfs_attr_set(&VOL, f1, "rating", 6, "5", 1, SCHFS_ATTR_INT); schfs_attr_set(&VOL, f1, "tag", 3, "work", 4, SCHFS_ATTR_STRING); remount(); ok(schfs_attr_get(&VOL, f1, "tag", 3, buf, sizeof(buf), &vl, &ty) == 1 && vl == 4 && memcmp(buf, "work", 4) == 0, "replacing a value replaces it rather than appending"); ok(schfs_attr_get(&VOL, f1, "rating", 6, buf, sizeof(buf), &vl, &ty) == 1 && vl == 1 && buf[0] == '5', "and the other attribute is untouched"); ok(schfs_attr_del(&VOL, f1, "rating", 6) == 0 && schfs_attr_get(&VOL, f1, "rating", 6, buf, sizeof(buf), &vl, &ty) == 0, "removing one leaves the rest"); ok(schfs_attr_get(&VOL, f1, "tag", 3, buf, sizeof(buf), &vl, &ty) == 1, "specifically, the one not removed"); /* --- the index --- */ uint64_t hits[8]; ok(schfs_query_equal(&VOL, "tag", 3, "holiday", 7, hits, 8) < 0, "querying an attribute with no index is refused, not answered slowly"); ok(schfs_index_create(&VOL, "tag", 3) == 0, "declare an index on `tag`"); // Written *after* the index exists, which is the only thing it covers. schfs_attr_set(&VOL, f1, "tag", 3, "holiday", 7, SCHFS_ATTR_STRING); schfs_attr_set(&VOL, f2, "tag", 3, "holiday", 7, SCHFS_ATTR_STRING); schfs_attr_set(&VOL, f3, "tag", 3, "work", 4, SCHFS_ATTR_STRING); remount(); int n = schfs_query_equal(&VOL, "tag", 3, "holiday", 7, hits, 8); ok(n == 2, "a query returns exactly the objects carrying that value"); if (n != 2) printf(" (got %d)\n", n); ok(n == 2 && ((hits[0] == f1 && hits[1] == f2) || (hits[0] == f2 && hits[1] == f1)), "and they are the right two"); n = schfs_query_equal(&VOL, "tag", 3, "work", 4, hits, 8); ok(n == 1 && hits[0] == f3, "a different value returns a different set"); n = schfs_query_equal(&VOL, "tag", 3, "nonesuch", 8, hits, 8); ok(n == 0, "a value nothing carries returns nothing"); // Changing a value must take the object *out* of the old set. An index // that only ever gains entries answers confidently and wrongly. schfs_attr_set(&VOL, f2, "tag", 3, "work", 4, SCHFS_ATTR_STRING); remount(); n = schfs_query_equal(&VOL, "tag", 3, "holiday", 7, hits, 8); ok(n == 1 && hits[0] == f1, "changing a value removes the object from the old value's set"); n = schfs_query_equal(&VOL, "tag", 3, "work", 4, hits, 8); ok(n == 2, "and adds it to the new one"); // And removing the attribute entirely. schfs_attr_del(&VOL, f1, "tag", 3); remount(); n = schfs_query_equal(&VOL, "tag", 3, "holiday", 7, hits, 8); ok(n == 0, "removing the attribute removes it from the index too"); ok(schfs_btree_check(&VOL.tree) == 0, "the tree is sound after all that"); } /* ---- 10. crash recovery ---- */ // // The whole consistency argument for this filesystem is one sentence: // mount takes the newest uberblock that validates, so a crash lands on // either the old tree or the new one and never between them. Every test // above this point runs on a volume that was never interrupted, so not one // of them touches that sentence -- schfs_test proves it for the B-tree in // isolation, and nothing proved it for a whole volume until here. printf("\n crash recovery\n"); { // A known-good starting point, and a note of where it is. { uint64_t d = mkdirent(ROOT, "survivor"); uint64_t f = mkfile(d, "keep"); schfs_file_write(&VOL, f, 0, "before the crash", 16, DATABLK); } remount(); uint64_t good_gen = VOL.generation; unsigned char *saved = malloc(NBLOCKS * SCHFS_BLKSIZE); memcpy(saved, DISK, NBLOCKS * SCHFS_BLKSIZE); /* --- 10a. cut the power in the middle of a transaction --- */ // // Enough writes to be well into the tree and nowhere near the commit, // which is the case that matters: the uberblock never lands, so the // previous one is still newest and every block this group touched is // one no committed root points at. md_writes = 0; md_writes = 0; if (setjmp(md_crash_jmp) == 0) { md_crash_armed = 1; md_fail_after = 12; uint64_t d = mkdirent(ROOT, "doomed"); for (int i = 0; i < 40; i++) { char nm[16]; snprintf(nm, sizeof(nm), "f%d", i); uint64_t f = mkfile(d, nm); if (f) schfs_file_write(&VOL, f, 0, "junk", 4, DATABLK); } schfs_vol_commit(&VOL); printf(" FAIL: the simulated crash never fired\n"); failures++; } md_crash_armed = 0; md_fail_after = -1; struct schfs_bdev bd3; mkbdev(&bd3); schfs_space_release(&VOL.space); /* before the memset strands it */ memset(&VOL, 0, sizeof(VOL)); int r = schfs_vol_open(&VOL, &bd3, &CTX, md_palloc, md_pfree); ok(r == 0, "the volume mounts after a crash mid-transaction"); ok(VOL.generation == good_gen, "it comes back at the last committed generation, not a partial one"); ok(VOL.recovered == 0, "and needs no fallback to do it: the newest uberblock is the good one"); /* --- 10b. the pre-crash filesystem is exactly as it was --- */ { struct schfs_cursor c; schfs_cursor_init(&c, &VOL.tree, CURBLK); uint64_t d = 0, f = 0; uint16_t ty = 0; ok(schfs_dir_lookup(&VOL, &c, ROOT, "survivor", 8, &d, &ty) == 0, "what was committed before the crash is still there"); schfs_cursor_init(&c, &VOL.tree, CURBLK); ok(d && schfs_dir_lookup(&VOL, &c, d, "keep", 4, &f, &ty) == 0, "and so is its content"); char buf[32]; memset(buf, 0, sizeof(buf)); schfs_cursor_init(&c, &VOL.tree, CURBLK); long got = f ? schfs_file_read(&VOL, &c, f, 0, buf, 16, DATABLK) : -1; ok(got == 16 && memcmp(buf, "before the crash", 16) == 0, "the file reads back byte for byte"); schfs_cursor_init(&c, &VOL.tree, CURBLK); uint64_t gone = 0; ok(schfs_dir_lookup(&VOL, &c, ROOT, "doomed", 6, &gone, &ty) != 0, "nothing the interrupted transaction did is visible"); } ok(schfs_btree_check(&VOL.tree) == 0, "the tree is structurally sound after the crash"); /* --- 10b'. crash at every write point, not one --- */ // // One crash point proves the mechanism exists. It does not prove there // is no window -- and a window is exactly what this kind of bug is: a // handful of writes somewhere in the middle after which the volume // comes back subtly wrong rather than obviously broken. So: run the // same transaction again and again, cutting the power one write later // each time, and check the whole story every time. { int points = 0, bad = 0; for (long cut = 1; cut < 120; cut++) { memcpy(DISK, saved, NBLOCKS * SCHFS_BLKSIZE); schfs_space_release(&VOL.space); /* before the memset strands it */ memset(&VOL, 0, sizeof(VOL)); mkbdev(&bd3); if (schfs_vol_open(&VOL, &bd3, &CTX, md_palloc, md_pfree) != 0) { bad++; break; } int fired = 0; if (setjmp(md_crash_jmp) == 0) { md_crash_armed = 1; md_fail_after = cut; uint64_t d = mkdirent(ROOT, "doomed"); for (int i = 0; i < 40; i++) { char nm[16]; snprintf(nm, sizeof(nm), "f%d", i); uint64_t f = mkfile(d, nm); if (f) schfs_file_write(&VOL, f, 0, "junk", 4, DATABLK); } schfs_vol_commit(&VOL); } else { fired = 1; } md_crash_armed = 0; md_fail_after = -1; if (!fired) break; /* ran out of writes to cut at */ points++; // Whatever the disk looks like now, all three of these must // hold: it mounts, it is a whole tree, and the data committed // before the crash is exactly as it was. schfs_space_release(&VOL.space); /* before the memset strands it */ memset(&VOL, 0, sizeof(VOL)); mkbdev(&bd3); if (schfs_vol_open(&VOL, &bd3, &CTX, md_palloc, md_pfree) != 0) { printf(" cut %ld: does not mount\n", cut); bad++; continue; } if (schfs_btree_check(&VOL.tree) != 0) { printf(" cut %ld: tree is not sound\n", cut); bad++; continue; } struct schfs_cursor c; schfs_cursor_init(&c, &VOL.tree, CURBLK); uint64_t d = 0, f = 0; uint16_t ty = 0; if (schfs_dir_lookup(&VOL, &c, ROOT, "survivor", 8, &d, &ty) != 0) { printf(" cut %ld: lost a committed directory\n", cut); bad++; continue; } schfs_cursor_init(&c, &VOL.tree, CURBLK); if (schfs_dir_lookup(&VOL, &c, d, "keep", 4, &f, &ty) != 0) { printf(" cut %ld: lost a committed file\n", cut); bad++; continue; } char buf[32]; memset(buf, 0, sizeof(buf)); schfs_cursor_init(&c, &VOL.tree, CURBLK); if (schfs_file_read(&VOL, &c, f, 0, buf, 16, DATABLK) != 16 || memcmp(buf, "before the crash", 16) != 0) { printf(" cut %ld: committed file came back changed\n", cut); bad++; continue; } } char msg[96]; snprintf(msg, sizeof(msg), "%d crash points, every one mounts intact with its " "committed data", points); ok(bad == 0 && points > 20, msg); } /* --- 10c. a newest uberblock naming a tree that never landed --- */ // // This is the failure the commit barrier exists to prevent, and it is // still reachable through a misdirected write or a bad sector under // the root. Before the fallback existed the mount simply refused, with // 127 intact older roots sitting on the disk untouched. memcpy(DISK, saved, NBLOCKS * SCHFS_BLKSIZE); schfs_space_release(&VOL.space); /* before the memset strands it */ memset(&VOL, 0, sizeof(VOL)); mkbdev(&bd3); if (schfs_vol_open(&VOL, &bd3, &CTX, md_palloc, md_pfree) != 0) { printf(" FAIL: reopen for the misdirected-write case\n"); failures++; } else { uint64_t base_gen = VOL.generation; // Two more commits, then destroy the block each of their roots // points at -- so the two newest uberblocks are both intact and // both name rubbish. uint64_t roots[2]; for (int i = 0; i < 2; i++) { char nm[16]; snprintf(nm, sizeof(nm), "later%d", i); mkfile(ROOT, nm); schfs_vol_commit(&VOL); roots[i] = VOL.tree.root; } for (int i = 0; i < 2; i++) memset(DISK + roots[i] * SCHFS_BLKSIZE, 0xA5, SCHFS_BLKSIZE); schfs_space_release(&VOL.space); /* before the memset strands it */ memset(&VOL, 0, sizeof(VOL)); mkbdev(&bd3); int rr = schfs_vol_open(&VOL, &bd3, &CTX, md_palloc, md_pfree); ok(rr == 0, "a volume whose two newest roots are rubbish still mounts"); // Strictly older, not "exactly two older". Copy-on-write shares // every unchanged node between generations, so a block that is part // of the newest tree is very often part of several older ones too -- // destroying it takes them all with it. How far back the ring has to // reach is therefore not something a test can pin down, and an // earlier version of this asserted exactly two and was simply wrong // once the filesystem had enough history to share nodes. // // What must hold is that it landed on neither corrupted generation // and said so. ok(VOL.generation < base_gen, "it falls back past both of them to an older readable root"); ok(VOL.recovered >= 2, "and says that at least those two groups were lost"); ok(schfs_btree_check(&VOL.tree) == 0, "what it recovered is structurally sound"); } /* --- 10d. every uberblock destroyed is a failure, not a guess --- */ memcpy(DISK, saved, NBLOCKS * SCHFS_BLKSIZE); memset(DISK + SCHFS_UBER_START * SCHFS_BLKSIZE, 0, SCHFS_UBER_COUNT * SCHFS_BLKSIZE); schfs_space_release(&VOL.space); /* before the memset strands it */ memset(&VOL, 0, sizeof(VOL)); mkbdev(&bd3); ok(schfs_vol_open(&VOL, &bd3, &CTX, md_palloc, md_pfree) != 0, "a volume with no valid uberblock at all is refused, not invented"); // Put the good image back so anything after this runs on a sane volume. memcpy(DISK, saved, NBLOCKS * SCHFS_BLKSIZE); schfs_space_release(&VOL.space); /* before the memset strands it */ memset(&VOL, 0, sizeof(VOL)); mkbdev(&bd3); schfs_vol_open(&VOL, &bd3, &CTX, md_palloc, md_pfree); free(saved); } // The last volume opened is still holding its map. Nothing else will take // it, and a test that leaks cannot detect a leak: the filesystem's own // allocations would arrive as noise inside this one. schfs_space_release(&VOL.space); if (md_pages_taken != md_pages_given) { printf(" [FAIL] %ld allocation-map pages taken, %ld given back\n", md_pages_taken, md_pages_given); failures++; } else { printf(" [ok] every allocation-map page was given back (%ld)\n", md_pages_taken); } printf("\n%s\n", failures ? "FAILED" : "all checks passed"); return failures ? 1 : 0; }