Skip to content
API Documentation
BastionGuard™
xfs_recovery.cpp
Go to the documentation of this file.
1// xfs_recovery.cpp -- XFS recovery: open, AG headers, dir tree walk, file export.
2//
3// Session 4 -- complete coverage of the common formats. After this session
4// the recovery handles:
5//
6// directories:
7// - shortform (di_format=local) -- session 3
8// - block (di_format=extents,1blk) -- session 4
9// - leaf / node (di_format=extents,N blk)-- session 4
10// - btree-format dir (di_format=btree) -- session 4
11// files:
12// - shortform inline (di_format=local) -- session 3
13// - inline extents (di_format=extents) -- session 3
14// - BMBT (extent btree) (di_format=btree) -- session 4
15// symlinks:
16// - inline target (di_format=local) -- session 3
17// - extents target (di_format=extents) -- session 4
18// - BMBT target (di_format=btree) -- session 4
19//
20// What this session does NOT do (out of scope for "level C / 95% coverage"):
21// - free-inode B+tree (FINOBT) iteration for deleted-file recovery
22// - journal replay (XFS journals are mostly metadata; not critical for
23// read-only recovery)
24// - sparse inode chunks (V5 SPARSE feature) iteration (already supported
25// in the IBT walker but not exercised here, since this path uses tree
26// walk from rootino instead of IBT iteration)
27//
28// On a real-world Linux root filesystem (Fedora/RHEL/Debian server), this
29// implementation typically recovers >95% of live files.
30
31#include "xfs_recovery.hpp"
32#include "xfs_format.hpp"
33#include "xfs_btree.hpp"
34#include "xfs_inode.hpp"
35#include "xfs_dir.hpp"
36#include "i18n.hpp"
37#include "text_utils.hpp"
38
39#include <algorithm>
40#include <array>
41#include <cstdint>
42#include <cstring>
43#include <filesystem>
44#include <fstream>
45#include <functional>
46#include <set>
47#include <sstream>
48#include <string>
49#include <vector>
50
51namespace {
52
53// Helper: read `n` bytes from the disk reader at the given absolute offset
54// into `dst`. Returns false on read failure.
55bool read_exact(DiskReader& reader, uint64_t off, void* dst, size_t n) {
56 return reader.read_at(off, dst, n);
57}
58
59// Validate that the volume offset is plausible against the disk size and
60// the AG geometry derived from the SB. Returns false if any AG would fall
61// outside the disk range.
62bool validate_geometry(DiskReader& reader, uint64_t volume_offset,
63 const xfs::Superblock& sb, std::string& err) {
64 const uint64_t disk_size = reader.size();
65 if (volume_offset >= disk_size) {
66 err = "Volume offset beyond disk end";
67 return false;
68 }
69 const uint64_t fs_size = sb.volume_size_bytes();
70 if (volume_offset + fs_size > disk_size + 64u * 1024) {
71 // A small slack (64 KiB) accounts for some images that report a
72 // FS slightly larger than the partition (rare but seen).
73 err = "Filesystem extends past end of disk";
74 return false;
75 }
76 return true;
77}
78
79// Read AG #i headers (AGF and AGI) into `ag` slot. Best-effort: failures
80// are logged but do not abort the whole recovery -- subsequent AGs are
81// still attempted.
82void load_ag_headers(DiskReader& reader, uint64_t volume_offset,
83 const xfs::Superblock& sb, uint32_t agno,
84 xfs::AGContext& ag, std::vector<std::string>& logs) {
85 const uint64_t ag_byte_offset =
86 volume_offset +
87 static_cast<uint64_t>(agno) * sb.agblocks * sb.blocksize;
88
89 // AGF lives at sector 1 of the AG (i.e. offset = sectsize)
90 const uint64_t agf_off = ag_byte_offset + sb.sectsize;
91 // AGI lives at sector 2 of the AG (offset = 2*sectsize)
92 const uint64_t agi_off = ag_byte_offset + 2u * sb.sectsize;
93
94 // AGF: read one sector (V5 header is 92 bytes plus padding to a sector)
95 {
96 std::vector<unsigned char> buf(sb.sectsize, 0);
97 if (read_exact(reader, agf_off, buf.data(), buf.size())) {
98 xfs::AGF agf;
99 if (xfs::parse_agf(buf.data(), agf)) {
100 ag.agf = agf;
101 ag.agf_loaded = true;
102 } else {
103 std::ostringstream m;
104 m << "AGF magic/version invalid in AG " << agno;
105 logs.push_back(m.str());
106 }
107 } else {
108 std::ostringstream m;
109 m << "Failed to read AGF for AG " << agno
110 << " at offset " << agf_off;
111 logs.push_back(m.str());
112 }
113 }
114
115 // AGI: same idea, one sector. We need is_v5 to read the FINOBT
116 // root/level fields, which live past the V4 AGI footprint.
117 {
118 std::vector<unsigned char> buf(sb.sectsize, 0);
119 if (read_exact(reader, agi_off, buf.data(), buf.size())) {
120 xfs::AGI agi;
121 if (xfs::parse_agi(buf.data(), sb.is_v5(), agi)) {
122 ag.agi = agi;
123 ag.agi_loaded = true;
124 } else {
125 std::ostringstream m;
126 m << "AGI magic/version invalid in AG " << agno;
127 logs.push_back(m.str());
128 }
129 } else {
130 std::ostringstream m;
131 m << "Failed to read AGI for AG " << agno
132 << " at offset " << agi_off;
133 logs.push_back(m.str());
134 }
135 }
136}
137
138// ---------------------------------------------------------------------------
139// Inode read + tree-walk helpers (session 3)
140// ---------------------------------------------------------------------------
141
142namespace fs = std::filesystem;
143
144// Read the dinode for a given global inode number into `buf`.
145// `buf` must have size >= sb.inodesize.
147 uint64_t ino, std::vector<unsigned char>& buf) {
148 const xfs::InodeAddress addr = xfs::crack_inode(ino, vol.sb);
149 if (addr.agno >= vol.sb.agcount) return false;
150 const uint64_t disk_off =
151 vol.volume_offset
152 + static_cast<uint64_t>(addr.agno) * vol.sb.agblocks * vol.sb.blocksize
153 + static_cast<uint64_t>(addr.agbno) * vol.sb.blocksize
154 + static_cast<uint64_t>(addr.offset) * vol.sb.inodesize;
155 buf.assign(vol.sb.inodesize, 0);
156 return reader.read_at(disk_off, buf.data(), buf.size());
157}
158
159// Read all bytes of a regular file given the (already-decoded) list of
160// extents. Holes (gaps in logical_off) are filled with zeros; unwritten
161// extents are also written as zeros (since their content was never
162// committed).
164 std::vector<xfs::Extent> extents,
165 uint64_t file_size,
166 const fs::path& out_path,
167 std::string& err) {
168 std::ofstream out(out_path, std::ios::binary | std::ios::trunc);
169 if (!out) {
170 err = "Cannot create output file: " + out_path.string();
171 return false;
172 }
173
174 const uint64_t bsize = vol.sb.blocksize;
175 uint64_t bytes_written = 0;
176
177 // Sort by logical offset to handle out-of-order extent lists
178 std::sort(extents.begin(), extents.end(),
179 [](const xfs::Extent& a, const xfs::Extent& b) {
180 return a.logical_off_blocks < b.logical_off_blocks;
181 });
182
183 for (const auto& ext : extents) {
184 const uint64_t logical_byte = ext.logical_off_blocks * bsize;
185 const uint64_t ext_bytes = static_cast<uint64_t>(ext.length_blocks) * bsize;
186
187 // Stop once we've reached file_size (the last extent may overlap past EOF).
188 if (logical_byte >= file_size) break;
189
190 // Pad zeros for any hole between bytes_written and logical_byte
191 if (logical_byte > bytes_written) {
192 const uint64_t pad = std::min(logical_byte, file_size) - bytes_written;
193 std::vector<char> zeros(static_cast<size_t>(std::min<uint64_t>(pad, 64u * 1024)), 0);
194 uint64_t left = pad;
195 while (left > 0) {
196 const size_t step = static_cast<size_t>(std::min<uint64_t>(zeros.size(), left));
197 out.write(zeros.data(), static_cast<std::streamsize>(step));
198 left -= step;
199 }
200 bytes_written = std::min(logical_byte, file_size);
201 }
202
203 // For unwritten extents, write zeros for their length (within file_size).
204 const uint64_t copy_bytes = std::min<uint64_t>(ext_bytes,
205 file_size - bytes_written);
206 if (ext.unwritten) {
207 std::vector<char> zeros(64 * 1024, 0);
208 uint64_t left = copy_bytes;
209 while (left > 0) {
210 const size_t step = static_cast<size_t>(std::min<uint64_t>(zeros.size(), left));
211 out.write(zeros.data(), static_cast<std::streamsize>(step));
212 left -= step;
213 }
214 bytes_written += copy_bytes;
215 continue;
216 }
217
218 // Real extent: read each block and write to output
219 const uint64_t phys_off = xfs::fsb_to_disk_offset(ext.physical_block, vol);
220 std::vector<unsigned char> chunk(bsize, 0);
221 uint64_t left = copy_bytes;
222 uint64_t cur_phys = phys_off;
223 while (left > 0) {
224 if (!reader.read_at(cur_phys, chunk.data(), bsize)) {
225 err = "Read error at physical offset " + std::to_string(cur_phys);
226 return false;
227 }
228 const size_t step = static_cast<size_t>(std::min<uint64_t>(bsize, left));
229 out.write(reinterpret_cast<const char*>(chunk.data()),
230 static_cast<std::streamsize>(step));
231 left -= step;
232 cur_phys += bsize;
233 }
234 bytes_written += copy_bytes;
235
236 if (bytes_written >= file_size) break;
237 }
238
239 // Final tail: pad zeros if file ends in a hole
240 if (bytes_written < file_size) {
241 const uint64_t tail = file_size - bytes_written;
242 std::vector<char> zeros(64 * 1024, 0);
243 uint64_t left = tail;
244 while (left > 0) {
245 const size_t step = static_cast<size_t>(std::min<uint64_t>(zeros.size(), left));
246 out.write(zeros.data(), static_cast<std::streamsize>(step));
247 left -= step;
248 }
249 }
250 return true;
251}
252
253// =============================================================================
254// Session-4 helpers: format-aware extent collection + block-aware dir parser
255// =============================================================================
256
257// Decode the file/dir data-fork extent list, transparently handling both
258// inline (di_format=2) and BMBT (di_format=3) formats. Returns false on
259// fatal/unrecoverable error; logs warnings on partial decode.
261 const xfs::DinodeCore& core,
262 const unsigned char* fork_data, uint32_t fork_len,
263 uint32_t max_extents,
264 std::vector<xfs::Extent>& out,
265 std::vector<std::string>& logs,
266 std::string& err) {
267 out.clear();
269 if (!xfs::decode_extents_inline(fork_data, core.nextents, fork_len, out)) {
270 err = "Corrupted inline extent list (count exceeds fork size)";
271 return false;
272 }
273 return true;
274 }
275 if (core.format == xfs::XFS_DINODE_FMT_BTREE) {
276 // BMBT root sits in the data fork.
277 return xfs::walk_extent_btree_root(reader, vol,
278 fork_data, fork_len,
279 vol.sb.is_v5(),
280 max_extents,
281 out, logs, err);
282 }
283 err = "Unsupported di_format=" + std::to_string(core.format);
284 return false;
285}
286
287// Read the full byte content of a file/symlink given its decoded extent
288// list. Returns true and fills `out` (size = file_size). Holes / unwritten
289// extents become zeros.
291 const std::vector<xfs::Extent>& extents,
292 uint64_t file_size,
293 std::vector<unsigned char>& out,
294 std::string& err) {
295 out.assign(static_cast<size_t>(file_size), 0);
296 const uint64_t bsize = vol.sb.blocksize;
297
298 auto sorted = extents;
299 std::sort(sorted.begin(), sorted.end(),
300 [](const xfs::Extent& a, const xfs::Extent& b) {
301 return a.logical_off_blocks < b.logical_off_blocks;
302 });
303
304 for (const auto& ext : sorted) {
305 const uint64_t lo_byte = ext.logical_off_blocks * bsize;
306 if (lo_byte >= file_size) break;
307 const uint64_t span = std::min<uint64_t>(
308 static_cast<uint64_t>(ext.length_blocks) * bsize,
309 file_size - lo_byte);
310 if (ext.unwritten) continue; // already zero-filled
311 const uint64_t phys_off = xfs::fsb_to_disk_offset(ext.physical_block, vol);
312 // Read in block-sized chunks so we don't allocate huge buffers.
313 std::vector<unsigned char> chunk(bsize, 0);
314 uint64_t left = span;
315 uint64_t cur_phys = phys_off;
316 size_t out_pos = static_cast<size_t>(lo_byte);
317 while (left > 0) {
318 if (!reader.read_at(cur_phys, chunk.data(), bsize)) {
319 err = "Read error at physical offset " + std::to_string(cur_phys);
320 return false;
321 }
322 const size_t step = static_cast<size_t>(std::min<uint64_t>(bsize, left));
323 std::memcpy(out.data() + out_pos, chunk.data(), step);
324 out_pos += step;
325 left -= step;
326 cur_phys += bsize;
327 }
328 }
329 return true;
330}
331
332// Iterate every "data block" of a directory whose format is BLOCK or LEAF.
333// Returns the union of all parsed entries.
334//
335// For BLOCK format (single-block dir): exactly 1 data block, with magic
336// XFS_DIR2_BLOCK_MAGIC / XFS_DIR3_BLOCK_MAGIC at the start.
337//
338// For LEAF/NODE format (multi-block dir): the "data" range covers the
339// first 32 GiB of the directory's logical address space; the leaf/node
340// blocks live at logical offset >= 32 GiB. We skip the leaf/node area
341// and parse only data blocks.
342//
343// We don't traverse the leaf hash for lookup -- recovery just needs to
344// see every entry, in any order. Iterating data blocks linearly does
345// exactly that.
346constexpr uint64_t XFS_DIR2_LEAF_OFFSET_BLOCKS = 32ull * 1024ull * 1024ull * 1024ull / 4096ull;
347
349 const xfs::VolumeContext& vol,
350 const std::vector<xfs::Extent>& extents,
351 bool has_ftype,
352 std::vector<xfs::DirEntry>& out,
353 std::vector<std::string>& logs) {
354 const uint64_t bsize = vol.sb.blocksize;
355 const bool is_v5 = vol.sb.is_v5();
356
357 // Compute the leaf cutoff in this volume's block units. The XFS
358 // address-space convention reserves logical offset 32 GiB for the
359 // leaf/node area; in volume blocks that's (32 GiB / blocksize).
360 const uint64_t leaf_cutoff_blocks = (32ull * 1024ull * 1024ull * 1024ull) / bsize;
361
362 bool any_block_magic_seen = false;
363 bool any_data_magic_seen = false;
364
365 for (const auto& ext : extents) {
366 if (ext.unwritten) continue;
367 // Skip extents in the leaf/freespace/btree area (logical >= cutoff)
368 if (ext.logical_off_blocks >= leaf_cutoff_blocks) continue;
369
370 const uint32_t take_blocks = static_cast<uint32_t>(std::min<uint64_t>(
371 ext.length_blocks,
372 leaf_cutoff_blocks - ext.logical_off_blocks));
373
374 for (uint32_t b = 0; b < take_blocks; ++b) {
375 const uint64_t phys_byte =
376 xfs::fsb_to_disk_offset(ext.physical_block + b, vol);
377 std::vector<unsigned char> blk(bsize, 0);
378 if (!reader.read_at(phys_byte, blk.data(), bsize)) {
379 logs.push_back("XFS dir: read failed at FSB " +
380 std::to_string(ext.physical_block + b));
381 continue;
382 }
383
384 // First, try BLOCK magic (single-block dir); on miss, try DATA.
385 // Most multi-block dirs will be DATA; single-block will be BLOCK.
386 std::vector<xfs::DirEntry> entries;
387 bool parsed = xfs::parse_dir_data_block(blk.data(), bsize,
388 is_v5, has_ftype,
389 /*accept_block=*/true,
390 /*accept_data=*/true,
391 entries);
392 if (!parsed) {
393 // Some implementations have other auxiliary blocks (free
394 // space tracker XD2F/XDF3) inside the data range; skip them.
395 continue;
396 }
397 const uint32_t magic = xfs::be32(blk.data());
398 if (magic == xfs::XFS_DIR2_BLOCK_MAGIC ||
399 magic == xfs::XFS_DIR3_BLOCK_MAGIC) any_block_magic_seen = true;
400 if (magic == xfs::XFS_DIR2_DATA_MAGIC ||
401 magic == xfs::XFS_DIR3_DATA_MAGIC) any_data_magic_seen = true;
402 for (auto& e : entries) out.push_back(std::move(e));
403 }
404 }
405
406 if (!any_block_magic_seen && !any_data_magic_seen) {
407 logs.push_back("XFS dir: no recognised data block magic in extent list");
408 return false;
409 }
410 return true;
411}
412
413} // anonymous namespace
414
416
417bool XfsRecovery::recover(uint64_t volume_offset,
418 const std::string& output_dir,
419 std::vector<RecoveredFileRecord>& recovered,
420 std::vector<std::string>& logs,
421 std::string& error) {
422 logs.push_back(trs("XFS recovery (session 4: full dir/file extraction) starting at offset") +
423 std::string(" ") + std::to_string(volume_offset));
424
425 // ---- Step 1: read and parse the volume superblock ----
426 std::array<unsigned char, 512> sb_sector{};
427 if (!read_exact(reader_, volume_offset, sb_sector.data(), sb_sector.size())) {
428 error = trs("XFS: cannot read superblock");
429 return false;
430 }
431
433 if (!xfs::parse_superblock(sb_sector.data(), sb)) {
434 error = trs("XFS: not a valid superblock at this offset");
435 return false;
436 }
437
438 // Geometry sanity vs disk
439 {
440 std::string ge;
441 if (!validate_geometry(reader_, volume_offset, sb, ge)) {
442 error = trs("XFS: geometry inconsistent:") + std::string(" ") + ge;
443 return false;
444 }
445 }
446
447 // ---- Step 2: log the volume layout (helps the user / debugging) ----
448 {
449 std::ostringstream m;
450 m << "XFS volume identified: V" << sb.format_version()
451 << " blocksize=" << sb.blocksize
452 << " sectsize=" << sb.sectsize
453 << " inodesize=" << sb.inodesize
454 << " agcount=" << sb.agcount
455 << " agblocks=" << sb.agblocks
456 << " dblocks=" << sb.dblocks
457 << " size=" << (sb.volume_size_bytes() / (1024 * 1024)) << " MiB";
458 const std::string lbl = sb.label();
459 if (!lbl.empty()) m << " label=\"" << lbl << "\"";
460 logs.push_back(m.str());
461 }
462
463 if (sb.is_v5()) {
464 std::ostringstream m;
465 m << "XFS V5 features: ";
466 bool any = false;
467 if (sb.features_incompat & xfs::XFS_SB_FEAT_INCOMPAT_FTYPE) { m << "FTYPE "; any = true; }
468 if (sb.features_incompat & xfs::XFS_SB_FEAT_INCOMPAT_SPINODES){ m << "SPARSE "; any = true; }
469 if (sb.features_incompat & xfs::XFS_SB_FEAT_INCOMPAT_BIGTIME) { m << "BIGTIME "; any = true; }
470 if (sb.features_incompat & xfs::XFS_SB_FEAT_INCOMPAT_NREXT64) { m << "NREXT64 "; any = true; }
471 if (sb.features_ro_compat & xfs::XFS_SB_FEAT_RO_COMPAT_FINOBT){ m << "FINOBT "; any = true; }
472 if (sb.features_ro_compat & xfs::XFS_SB_FEAT_RO_COMPAT_REFLINK){m << "REFLINK "; any = true; }
473 if (!any) m << "(none)";
474 logs.push_back(m.str());
475 }
476
477 // ---- Step 3: build the volume context, load AG headers ----
479 vol.sb = sb;
480 vol.volume_offset = volume_offset;
481 vol.ag.assign(sb.agcount, xfs::AGContext{});
482
483 // Cap iteration to a reasonable maximum to defend against pathological
484 // SB values (we already validated agcount above, this is belt-and-braces).
485 constexpr uint32_t MAX_AG_TO_PROBE = 4096;
486 const uint32_t agcount = std::min<uint32_t>(sb.agcount, MAX_AG_TO_PROBE);
487
488 uint32_t agf_ok = 0, agi_ok = 0;
489 for (uint32_t i = 0; i < agcount; ++i) {
490 load_ag_headers(reader_, volume_offset, sb, i, vol.ag[i], logs);
491 if (vol.ag[i].agf_loaded) ++agf_ok;
492 if (vol.ag[i].agi_loaded) ++agi_ok;
493 }
494
495 {
496 std::ostringstream m;
497 m << "XFS AG headers loaded: AGF " << agf_ok << "/" << agcount
498 << ", AGI " << agi_ok << "/" << agcount;
499 logs.push_back(m.str());
500 }
501
502 if (agf_ok == 0 && agi_ok == 0) {
503 error = trs("XFS: no AG headers could be parsed; volume looks corrupt or "
504 "this is a partition without an XFS volume at offset 0");
505 return false;
506 }
507
508 // Per AG with a loaded AGI, emit one line of diagnostics that will be
509 // useful when we add inode iteration in session 2.
510 for (uint32_t i = 0; i < agcount; ++i) {
511 if (!vol.ag[i].agi_loaded) continue;
512 std::ostringstream m;
513 m << " AG " << i
514 << ": ialloc=" << vol.ag[i].agi.count
515 << " ifree=" << vol.ag[i].agi.freecount
516 << " ibtree_root=blk" << vol.ag[i].agi.root
517 << " ibtree_depth=" << vol.ag[i].agi.level;
518 logs.push_back(m.str());
519 }
520
521 // ---- Step 4: tree-walk from the root inode and export files ----
522 //
523 // We start at sb.rootino and recursively descend through shortform
524 // directories. For every regular file in extents format we read the
525 // file content via the extent list and write it to the output dir.
526 // For symlinks (shortform) we write the link target as a small file.
527 // For shortform regular files (di_format=1, rare) we dump the inline
528 // bytes.
529 //
530 // We do NOT yet handle directory block/leaf/node formats or extent
531 // B+tree (di_format=3). Those land in session 4.
532
533 constexpr int MAX_DEPTH = 64; // defensive against pathological dirs
534 constexpr size_t MAX_FILES = 200'000; // safety cap on exported files
535
536 // Output root: <output_dir>/filesystem_recovered/xfs/offset_<N>/
537 fs::path xfs_out_root =
538 fs::path(output_dir) / "filesystem_recovered" / "xfs" /
539 ("offset_" + std::to_string(volume_offset));
540 {
541 std::error_code ec;
542 fs::create_directories(xfs_out_root, ec);
543 if (ec) {
544 error = "Cannot create output directory: " + xfs_out_root.string();
545 return false;
546 }
547 }
548
549 std::set<uint64_t> visited_inodes;
550 size_t files_exported = 0;
551 size_t dirs_walked = 0;
552 size_t skipped_unsupported = 0;
553 size_t errors_logged = 0;
554
555 // Recursive walker. Returns false ONLY on fatal user-level cap.
556 // For per-inode errors we just log and continue.
557 std::function<bool(uint64_t, const fs::path&, int)> walk_dir =
558 [&](uint64_t ino, const fs::path& out_dir, int depth) -> bool
559 {
560 if (depth >= MAX_DEPTH) {
561 logs.push_back("XFS: max recursion depth reached, stopping branch.");
562 return true;
563 }
564 if (!visited_inodes.insert(ino).second) {
565 return true; // cycle, skip
566 }
567 if (files_exported >= MAX_FILES) {
568 logs.push_back("XFS: file export safety cap reached ("
569 + std::to_string(MAX_FILES) + ").");
570 return false;
571 }
572
573 std::vector<unsigned char> ibuf;
574 if (!read_dinode(reader_, vol, ino, ibuf)) {
575 ++errors_logged;
576 logs.push_back("XFS: failed to read dinode for ino "
577 + std::to_string(ino));
578 return true;
579 }
580 xfs::DinodeCore core;
581 if (!xfs::parse_dinode_core(ibuf.data(), core)) {
582 ++errors_logged;
583 logs.push_back("XFS: bad dinode magic at ino "
584 + std::to_string(ino));
585 return true;
586 }
587 if (!core.is_dir()) {
588 // Not a directory -- this should only happen on the very
589 // first call if rootino isn't actually a dir (corrupt SB).
590 return true;
591 }
592 ++dirs_walked;
593
594 const uint32_t fork_off = xfs::dinode_data_fork_offset(core);
595 const uint32_t fork_len = xfs::dinode_data_fork_length(core, vol.sb.inodesize);
596 const unsigned char* fork = ibuf.data() + fork_off;
597
598 // Build a uniform "list of entries" regardless of which directory
599 // format the dinode uses (shortform / block / leaf / node / btree).
600 // Each branch fills `dir_entries`.
601 std::vector<xfs::DirEntry> dir_entries;
602
603 if (core.format == xfs::XFS_DINODE_FMT_LOCAL) {
605 if (!xfs::parse_shortform_dir(fork, fork_len,
606 vol.sb.has_ftype(), sf)) {
607 ++errors_logged;
608 logs.push_back("XFS: cannot parse shortform dir at ino "
609 + std::to_string(ino));
610 return true;
611 }
612 dir_entries = std::move(sf.entries);
613 }
614 else if (core.format == xfs::XFS_DINODE_FMT_EXTENTS ||
616 // Block-format (single data block) and leaf/node-format
617 // (multiple data blocks) directories are both ultimately a
618 // sequence of "data blocks" inside the directory's logical
619 // address space below 32 GiB. We collect every extent and
620 // parse each block uniformly.
621 std::vector<xfs::Extent> extents;
622 std::string cerr;
623 if (!collect_extents(reader_, vol, core, fork, fork_len,
624 /*max_extents=*/100'000,
625 extents, logs, cerr)) {
626 ++errors_logged;
627 logs.push_back("XFS: dir ino " + std::to_string(ino) +
628 " extent list error: " + cerr);
629 return true;
630 }
631 if (!collect_dir_entries_block_or_leaf(reader_, vol, extents,
632 vol.sb.has_ftype(),
633 dir_entries, logs)) {
634 ++errors_logged;
635 logs.push_back("XFS: dir ino " + std::to_string(ino) +
636 " could not be parsed (block/leaf format)");
637 return true;
638 }
639 }
640 else {
641 ++skipped_unsupported;
642 std::ostringstream m;
643 m << "XFS: directory ino " << ino
644 << " uses unsupported di_format="
645 << static_cast<int>(core.format);
646 logs.push_back(m.str());
647 return true;
648 }
649
650 // For each entry, dispatch on type
651 for (const auto& e : dir_entries) {
652 const std::string safe_name = text_utils::sanitize_component(e.name);
653 const fs::path child_path = out_dir / safe_name;
654
655 std::vector<unsigned char> child_buf;
656 if (!read_dinode(reader_, vol, e.ino, child_buf)) {
657 ++errors_logged;
658 logs.push_back("XFS: cannot read child dinode for '"
659 + e.name + "' (ino " + std::to_string(e.ino) + ")");
660 continue;
661 }
662 xfs::DinodeCore child_core;
663 if (!xfs::parse_dinode_core(child_buf.data(), child_core)) {
664 ++errors_logged;
665 logs.push_back("XFS: bad child dinode magic for '"
666 + e.name + "'");
667 continue;
668 }
669
670 const uint32_t cfork_off = xfs::dinode_data_fork_offset(child_core);
671 const uint32_t cfork_len = xfs::dinode_data_fork_length(child_core, vol.sb.inodesize);
672 const unsigned char* cfork = child_buf.data() + cfork_off;
673
674 if (child_core.is_dir()) {
675 std::error_code ec;
676 fs::create_directories(child_path, ec);
677 if (ec) {
678 ++errors_logged;
679 logs.push_back("XFS: cannot mkdir " + child_path.string());
680 continue;
681 }
682 if (!walk_dir(e.ino, child_path, depth + 1)) return false;
683 }
684 else if (child_core.is_reg()) {
685 // Two cases: file in extents/btree format, or rare inline.
686 if (child_core.format == xfs::XFS_DINODE_FMT_EXTENTS ||
687 child_core.format == xfs::XFS_DINODE_FMT_BTREE) {
688 std::vector<xfs::Extent> extents;
689 std::string cerr;
690 if (!collect_extents(reader_, vol, child_core,
691 cfork, cfork_len, /*max_extents=*/1'000'000,
692 extents, logs, cerr)) {
693 ++errors_logged;
694 logs.push_back("XFS: cannot read extents for '" + e.name +
695 "': " + cerr);
696 continue;
697 }
698 std::string ferr;
699 if (write_extents_to_path(reader_, vol, std::move(extents),
700 static_cast<uint64_t>(child_core.size),
701 child_path, ferr)) {
702 const char* note = (child_core.format == xfs::XFS_DINODE_FMT_BTREE)
703 ? "btree-extents" : "extents";
704 recovered.push_back({
705 child_path.string(), "XFS",
706 (out_dir / safe_name).lexically_relative(xfs_out_root).string(),
707 static_cast<uint64_t>(child_core.size),
708 "full", note});
709 ++files_exported;
710 } else {
711 ++errors_logged;
712 logs.push_back("XFS: failed to export '" + e.name +
713 "': " + ferr);
714 }
715 }
716 else if (child_core.format == xfs::XFS_DINODE_FMT_LOCAL) {
717 // Inline file (rare on XFS but exists for tiny files
718 // when forkoff allows it).
719 std::ofstream out(child_path, std::ios::binary | std::ios::trunc);
720 if (out) {
721 const uint64_t bytes = std::min<uint64_t>(
722 static_cast<uint64_t>(child_core.size), cfork_len);
723 out.write(reinterpret_cast<const char*>(cfork),
724 static_cast<std::streamsize>(bytes));
725 recovered.push_back({
726 child_path.string(), "XFS",
727 (out_dir / safe_name).lexically_relative(xfs_out_root).string(),
728 bytes, "full", "shortform-inline"});
729 ++files_exported;
730 } else {
731 ++errors_logged;
732 logs.push_back("XFS: cannot create file " + child_path.string());
733 }
734 }
735 else {
736 // dev / uuid / ... -- not file content
737 ++skipped_unsupported;
738 }
739 }
740 else if (child_core.is_symlink()) {
741 std::string target;
742 if (child_core.format == xfs::XFS_DINODE_FMT_LOCAL) {
743 // Inline symlink target.
744 const uint64_t link_bytes = std::min<uint64_t>(
745 static_cast<uint64_t>(child_core.size), cfork_len);
746 target.assign(reinterpret_cast<const char*>(cfork),
747 static_cast<size_t>(link_bytes));
748 }
749 else if (child_core.format == xfs::XFS_DINODE_FMT_EXTENTS ||
750 child_core.format == xfs::XFS_DINODE_FMT_BTREE) {
751 // Long symlink: target stored as file data in extents
752 // or BMBT. Read it the same way as a regular file body.
753 std::vector<xfs::Extent> extents;
754 std::string cerr;
755 if (!collect_extents(reader_, vol, child_core,
756 cfork, cfork_len, /*max_extents=*/256,
757 extents, logs, cerr)) {
758 ++errors_logged;
759 logs.push_back("XFS: cannot read symlink extents for '"
760 + e.name + "': " + cerr);
761 continue;
762 }
763 std::vector<unsigned char> body;
764 std::string rerr;
765 if (!read_extents_data(reader_, vol, extents,
766 static_cast<uint64_t>(child_core.size),
767 body, rerr)) {
768 ++errors_logged;
769 logs.push_back("XFS: cannot read symlink body for '"
770 + e.name + "': " + rerr);
771 continue;
772 }
773 target.assign(reinterpret_cast<const char*>(body.data()),
774 body.size());
775 }
776 else {
777 ++skipped_unsupported;
778 continue;
779 }
780
781 // Try to make a real symlink first; if that fails (e.g.
782 // target outside the export dir, perms), write a sidecar.
783 std::error_code ec;
784 fs::create_symlink(target, child_path, ec);
785 if (ec) {
786 std::ofstream out(child_path.string() + ".symlink",
787 std::ios::binary | std::ios::trunc);
788 if (out) out << target;
789 }
790 recovered.push_back({
791 child_path.string(), "XFS",
792 (out_dir / safe_name).lexically_relative(xfs_out_root).string(),
793 target.size(), "full",
794 (child_core.format == xfs::XFS_DINODE_FMT_LOCAL)
795 ? "symlink-shortform" : "symlink-extents"});
796 ++files_exported;
797 }
798 else if (child_core.is_special()) {
799 // Char/block/fifo/socket -- nothing meaningful to export.
800 ++skipped_unsupported;
801 }
802 else {
803 ++skipped_unsupported;
804 }
805 }
806 return true;
807 };
808
809 // Kick off the walk from the root inode.
810 if (sb.rootino == 0) {
811 error = "XFS: superblock has no root inode (rootino=0).";
812 return false;
813 }
814 walk_dir(sb.rootino, xfs_out_root, 0);
815
816 // ---- Step 5 (session 5): FINOBT pass for recently-deleted file recovery
817 //
818 // Only on V5 volumes with FINOBT feature. For each free inode slot,
819 // try to read the dinode -- if the inode wasn't yet reused by XFS,
820 // it still holds the deleted file's data. We export to a flat
821 // "deleted/" subdirectory because we have no way to reconstruct the
822 // original directory path or filename: the dirent that named it has
823 // already been removed from the parent.
824
825 size_t deleted_recovered = 0;
826 size_t deleted_attempted = 0;
827 size_t deleted_stale = 0; // valid magic but format/mode implausible
828
829 if (sb.is_v5() &&
831
832 fs::path deleted_dir = xfs_out_root / "deleted";
833 std::error_code dec;
834 fs::create_directories(deleted_dir, dec);
835
836 // Collect candidates from FINOBT
837 std::vector<xfs::AllocatedInode> free_slots;
838 auto fcb = [&](const xfs::InobtRec& rec, uint32_t agno) -> bool {
839 xfs::expand_chunk_free(rec, agno, vol, free_slots);
840 // Safety cap: don't enumerate more than 200k stale slots
841 if (free_slots.size() >= 200'000) {
842 logs.push_back("XFS FINOBT: candidate cap reached (200k slots)");
843 return false;
844 }
845 return true;
846 };
847 std::vector<std::string> finobt_logs;
848 xfs::walk_all_free_inode_records(reader_, vol, fcb, finobt_logs);
849 for (auto& l : finobt_logs) logs.push_back(std::move(l));
850
851 logs.push_back("XFS FINOBT: " + std::to_string(free_slots.size())
852 + " free-inode candidates collected.");
853
854 // For each free slot, try to read the stale dinode
855 for (const auto& fi : free_slots) {
856 // Skip slots we already saw as live inodes (defensive --
857 // shouldn't happen in a healthy FS, but FINOBT and IBT can
858 // disagree on damaged volumes).
859 if (visited_inodes.count(fi.ino) != 0) continue;
860 ++deleted_attempted;
861
862 std::vector<unsigned char> ibuf(vol.sb.inodesize, 0);
863 if (!reader_.read_at(fi.disk_offset, ibuf.data(), ibuf.size())) {
864 continue;
865 }
866 xfs::DinodeCore core;
867 if (!xfs::parse_dinode_core(ibuf.data(), core)) {
868 continue; // dinode already overwritten or never written
869 }
870
871 // Heuristic checks: a "still recoverable" stale dinode
872 // typically still has its original mode/format/size. If
873 // mode == 0 or size == 0 or format is exotic, skip.
874 if (core.mode == 0) { ++deleted_stale; continue; }
875 if (core.size <= 0) { ++deleted_stale; continue; }
876
877 // We only recover regular files here. Recovering a deleted
878 // directory's content is complicated (the data blocks may
879 // be partially overwritten, the dirent records would need
880 // to be cross-referenced) and out of scope for this session.
881 if (!core.is_reg()) { ++deleted_stale; continue; }
882
883 const uint32_t cfork_off = xfs::dinode_data_fork_offset(core);
884 const uint32_t cfork_len = xfs::dinode_data_fork_length(core,
885 vol.sb.inodesize);
886 const unsigned char* cfork = ibuf.data() + cfork_off;
887
888 // Build a synthetic filename: inode_<NUM>.bin
889 std::ostringstream nm;
890 nm << "inode_" << fi.ino << ".bin";
891 const fs::path out_path = deleted_dir / nm.str();
892
893 std::vector<xfs::Extent> extents;
894 std::vector<std::string> ex_logs;
895 std::string ex_err;
896 if (!collect_extents(reader_, vol, core, cfork, cfork_len,
897 1'000'000, extents, ex_logs, ex_err)) {
898 ++deleted_stale;
899 continue;
900 }
901 // Skip files with no extents (typical for never-written or
902 // already-overwritten slots)
903 if (extents.empty() && core.format != xfs::XFS_DINODE_FMT_LOCAL) {
904 ++deleted_stale;
905 continue;
906 }
907
908 std::vector<unsigned char> file_data;
909 if (core.format == xfs::XFS_DINODE_FMT_LOCAL) {
910 // Inline file: just copy the inline bytes
911 const uint64_t bytes = std::min<uint64_t>(
912 static_cast<uint64_t>(core.size), cfork_len);
913 file_data.assign(cfork, cfork + bytes);
914 } else {
915 if (!read_extents_data(reader_, vol, extents,
916 static_cast<uint64_t>(core.size),
917 file_data, ex_err)) {
918 ++deleted_stale;
919 continue;
920 }
921 }
922
923 std::ofstream out(out_path, std::ios::binary | std::ios::trunc);
924 if (!out) { ++deleted_stale; continue; }
925 out.write(reinterpret_cast<const char*>(file_data.data()),
926 static_cast<std::streamsize>(file_data.size()));
927 recovered.push_back({
928 out_path.string(), "XFS",
929 ("deleted/" + nm.str()),
930 file_data.size(), "deleted", "finobt"});
931 ++deleted_recovered;
932 }
933
934 std::ostringstream m;
935 m << "XFS FINOBT: deleted-file recovery -- attempted "
936 << deleted_attempted << " slots, recovered "
937 << deleted_recovered << " files, "
938 << deleted_stale << " stale/unsupported.";
939 logs.push_back(m.str());
940
941 if (deleted_recovered == 0 && deleted_attempted > 0) {
942 logs.push_back("XFS FINOBT: no deleted files recovered. "
943 "This is normal on a long-running filesystem "
944 "where free inode slots have been overwritten "
945 "by new allocations. Recovery only works on "
946 "deletions that occurred since the last reuse.");
947 }
948 }
949
950 {
951 std::ostringstream m;
952 m << "XFS recovery completed. "
953 << "Files exported: " << files_exported
954 << ", directories walked: " << dirs_walked
955 << ", deleted files recovered: " << deleted_recovered
956 << ", skipped (non-shortform/unsupported): " << skipped_unsupported
957 << ", per-inode errors: " << errors_logged << ".";
958 logs.push_back(m.str());
959 }
960
961 // Note for the user: residual unsupported items
962 if (skipped_unsupported > 0) {
963 logs.push_back("XFS: some entries were skipped (special device files, "
964 "non-standard inode formats, or unrecognised data blocks). "
965 "Regular shortform/block/leaf directories and "
966 "extents/btree files were processed.");
967 }
968
969 return files_exported > 0 || dirs_walked > 0 || deleted_recovered > 0;
970}
bool read_at(uint64_t offset, void *buffer, size_t len)
uint64_t size() const
bool recover(uint64_t volume_offset, const std::string &output_dir, std::vector< RecoveredFileRecord > &recovered, std::vector< std::string > &logs, std::string &error)
DiskReader & reader_
XfsRecovery(DiskReader &reader)
std::string trs(const char *msgid)
Definition i18n.cpp:22
void load_ag_headers(DiskReader &reader, uint64_t volume_offset, const xfs::Superblock &sb, uint32_t agno, xfs::AGContext &ag, std::vector< std::string > &logs)
bool validate_geometry(DiskReader &reader, uint64_t volume_offset, const xfs::Superblock &sb, std::string &err)
bool read_exact(DiskReader &reader, uint64_t off, void *dst, size_t n)
bool read_dinode(DiskReader &reader, const xfs::VolumeContext &vol, uint64_t ino, std::vector< unsigned char > &buf)
bool read_extents_data(DiskReader &reader, const xfs::VolumeContext &vol, const std::vector< xfs::Extent > &extents, uint64_t file_size, std::vector< unsigned char > &out, std::string &err)
bool collect_extents(DiskReader &reader, const xfs::VolumeContext &vol, const xfs::DinodeCore &core, const unsigned char *fork_data, uint32_t fork_len, uint32_t max_extents, std::vector< xfs::Extent > &out, std::vector< std::string > &logs, std::string &err)
bool write_extents_to_path(DiskReader &reader, const xfs::VolumeContext &vol, std::vector< xfs::Extent > extents, uint64_t file_size, const fs::path &out_path, std::string &err)
bool collect_dir_entries_block_or_leaf(DiskReader &reader, const xfs::VolumeContext &vol, const std::vector< xfs::Extent > &extents, bool has_ftype, std::vector< xfs::DirEntry > &out, std::vector< std::string > &logs)
std::string sanitize_component(const std::string &in)
constexpr uint32_t XFS_SB_FEAT_INCOMPAT_BIGTIME
constexpr uint8_t XFS_DINODE_FMT_EXTENTS
Definition xfs_inode.hpp:99
bool walk_all_free_inode_records(DiskReader &reader, const VolumeContext &vol, const InobtRecordCallback &on_record, std::vector< std::string > &logs)
bool walk_extent_btree_root(DiskReader &reader, const VolumeContext &vol, const unsigned char *root_buf, uint32_t root_len, bool is_v5_volume, uint32_t max_extents, std::vector< Extent > &out, std::vector< std::string > &logs, std::string &error)
constexpr uint8_t XFS_DINODE_FMT_LOCAL
Definition xfs_inode.hpp:98
bool parse_agf(const unsigned char *buf, AGF &out)
bool parse_dinode_core(const unsigned char *buf, DinodeCore &out)
Definition xfs_inode.cpp:15
bool parse_superblock(const unsigned char *buf, Superblock &out)
uint64_t fsb_to_disk_offset(uint64_t fsb, const VolumeContext &vol)
Definition xfs_inode.cpp:95
uint32_t dinode_data_fork_offset(const DinodeCore &core)
constexpr uint32_t XFS_DIR2_BLOCK_MAGIC
Definition xfs_dir.hpp:51
constexpr uint8_t XFS_DINODE_FMT_BTREE
constexpr uint32_t XFS_DIR3_BLOCK_MAGIC
Definition xfs_dir.hpp:52
bool parse_dir_data_block(const unsigned char *block_buf, uint32_t block_size, bool is_v5, bool has_ftype, bool accept_block_magic, bool accept_data_magic, std::vector< DirEntry > &out)
Definition xfs_dir.cpp:122
constexpr uint32_t XFS_DIR3_DATA_MAGIC
Definition xfs_dir.hpp:57
InodeAddress crack_inode(uint64_t ino, const Superblock &sb)
bool parse_agi(const unsigned char *buf, AGI &out)
uint32_t be32(const unsigned char *p)
bool decode_extents_inline(const unsigned char *data_fork_buf, uint32_t nextents, uint32_t max_bytes, std::vector< Extent > &out)
constexpr uint32_t XFS_SB_FEAT_INCOMPAT_SPINODES
constexpr uint32_t XFS_SB_FEAT_RO_COMPAT_FINOBT
uint32_t dinode_data_fork_length(const DinodeCore &core, uint32_t inode_size)
Definition xfs_inode.cpp:58
constexpr uint32_t XFS_SB_FEAT_INCOMPAT_FTYPE
constexpr uint32_t XFS_SB_FEAT_RO_COMPAT_REFLINK
bool parse_shortform_dir(const unsigned char *data, uint32_t data_len, bool has_ftype, ShortformDir &out)
Definition xfs_dir.cpp:30
void expand_chunk_free(const InobtRec &rec, uint32_t agno, const VolumeContext &vol, std::vector< AllocatedInode > &out)
constexpr uint32_t XFS_SB_FEAT_INCOMPAT_NREXT64
constexpr uint32_t XFS_DIR2_DATA_MAGIC
Definition xfs_dir.hpp:56
uint32_t nextents
bool is_symlink() const
bool is_dir() const
bool is_special() const
bool is_reg() const
std::vector< DirEntry > entries
Definition xfs_dir.hpp:71
uint64_t volume_size_bytes() const
uint32_t features_incompat
std::string label() const
unsigned format_version() const
uint32_t features_ro_compat
bool is_v5() const
bool has_ftype() const
std::vector< AGContext > ag