Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/extract/ubifs.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ struct Ctx {
std::map<uint32_t, InodeInfo> inodes{};
std::map<uint32_t, std::vector<Dent>> dents{}; // keyed by parent inode
std::set<uint32_t> stack{};
std::set<uint32_t> visited{}; // inodes reached by the root walk (orphan detection)
};

std::optional<uint32_t> le32(const Reader& r, uint64_t o) { return r.at<uint32_t>(o, Endian::Little); }
Expand Down Expand Up @@ -284,6 +285,7 @@ void walk_dir(Ctx& c, uint32_t pino, const std::string& rel, size_t depth) {
if (depth > MAX_DEPTH) { c.truncated = true; return; }
if (c.stack.count(pino)) return;
c.stack.insert(pino);
c.visited.insert(pino);
auto it = c.dents.find(pino);
if (it != c.dents.end()) {
std::map<std::string, const Dent*> best;
Expand All @@ -310,6 +312,7 @@ void walk_dir(Ctx& c, uint32_t pino, const std::string& rel, size_t depth) {
void write_inode(Ctx& c, uint32_t ino, const std::string& rel) {
auto it = c.inodes.find(ino);
if (it == c.inodes.end()) { c.truncated = true; return; }
c.visited.insert(ino);
const InodeInfo& in = it->second;
const uint32_t type = in.mode & S_IFMT;
const std::string full = c.subdir + "/" + rel;
Expand All @@ -332,12 +335,91 @@ void write_inode(Ctx& c, uint32_t ino, const std::string& rel) {
}
}

// Recover inodes the root walk never reached. moria is a best-effort recovery
// tool, not a forensic one: a partial capture whose root LEB is missing (common
// in flash dumps) still holds valid inode/dentry/data nodes for real files, and
// dropping them the way a tree-only walker does is the failure mode this exists
// to avoid. Everything unreachable from root inode 1 is re-rooted under a
// synthetic lost+found/ so it lands on disk anyway. Gated so a healthy image
// with no orphans produces no lost+found/.
void recover_orphans(Ctx& c) {
// Every inode named as a child by some dentry. A parent that is itself a
// child is reachable through that other dentry, so it is not a subtree top.
std::set<uint32_t> child_inodes;
for (auto& [pino, dvec] : c.dents)
for (auto& d : dvec)
if (d.inum <= 0xffffffffull) child_inodes.insert(static_cast<uint32_t>(d.inum));

const std::string lf = "lost+found";
const size_t f0 = c.out.files, d0 = c.out.dirs, s0 = c.out.symlinks;
bool started = false;
auto ensure_lf = [&]() {
if (!started) { c.root.make_dir(c.subdir + "/" + lf); started = true; }
};

// 1. Orphan directory subtrees: a parent the root walk never reached and that
// no dentry names as a child — the top of a dangling tree.
for (auto& [pino, dvec] : c.dents) {
(void)dvec;
if (c.visited.count(pino) || child_inodes.count(pino)) continue;
ensure_lf();
walk_dir(c, pino, lf + "/inode_" + std::to_string(pino), 0);
}

// 2. Residual cyclic clusters: parents still unreached after (1) because every
// member is a child of another member, so none qualified as a top. Walk them
// anyway so a parent-link cycle can't make a whole subtree vanish.
for (auto& [pino, dvec] : c.dents) {
(void)dvec;
if (c.visited.count(pino)) continue;
ensure_lf();
walk_dir(c, pino, lf + "/inode_" + std::to_string(pino), 0);
}

// 3. Orphan lone inodes: a file/symlink with content but no dentry anywhere
// (its directory entry was lost). Emit it named by inode so its data isn't
// dropped. Require real content: with no name to carry information, a
// metadata-only inode whose data nodes did not survive would extract as a
// zero-filled shell (build_content fills holes with zero) — nothing dressed
// up as a file. A named orphan (1/2) is always worth emitting; a nameless
// one only when it carries data to recover.
for (auto& [ino, in] : c.inodes) {
if (c.visited.count(ino) || child_inodes.count(ino)) continue;
const uint32_t type = in.mode & S_IFMT;
const bool has_content = (type == S_IFREG && !in.data.empty()) ||
(type == S_IFLNK && !in.inline_data.empty());
if (!has_content) continue;
ensure_lf();
write_inode(c, ino, lf + "/inode_" + std::to_string(ino));
}

if (started) {
std::string parts;
auto add = [&](size_t n, std::string what) {
if (!n) return;
if (n == 1 && !what.empty() && what.back() == 's') what.pop_back(); // singular
if (!parts.empty()) parts += ", ";
parts += std::to_string(n) + " " + what;
};
add(c.out.files - f0, "files");
add(c.out.dirs - d0, "dirs");
add(c.out.symlinks - s0, "symlinks");
c.out.warnings.push_back("recovered " + parts +
" unreachable from root inode into lost+found/");
}
}

// Parse one UBIFS image (Reader over the volume) into `root/subdir`.
void parse_ubifs(const Reader& img, SafeRoot& root, const std::string& subdir, Extracted& out,
bool& truncated) {
Ctx c{img, 0, img.size(), root, subdir, out};
scan_nodes(c);
// A capture missing its root LEB has neither a root inode node nor any dentry
// parented at root; the extraction is then inherently partial (recovery only).
const bool root_present = c.inodes.count(UBIFS_ROOT_INO) || c.dents.count(UBIFS_ROOT_INO);
walk_dir(c, UBIFS_ROOT_INO, "", 0);
recover_orphans(c);
if (!root_present) c.truncated = true;
if (c.truncated) truncated = true;
}

Expand Down
108 changes: 93 additions & 15 deletions src/human.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <algorithm>
#include <array>
#include <cstdio>
#include <cstdlib>
#include <functional>
#include <map>
#include <set>
Expand Down Expand Up @@ -425,6 +426,50 @@ std::vector<DiagRow> gather_diagnostics(const std::vector<Finding>& fs) {
return rows;
}

// Every manifest root begins with the top-level "0x<hex>-<type>" dir; that hex is
// the container's offset in the input file — the useful offset for a nested entry,
// whose own e.offset is relative to its parent payload.
size_t parse_root_offset(const std::string& root) {
if (root.size() > 2 && root[0] == '0' && (root[1] == 'x' || root[1] == 'X'))
return static_cast<size_t>(std::strtoull(root.c_str() + 2, nullptr, 16));
return 0;
}

// Compact identifier for a nested extraction: the deepest "<name>.extracted" path
// segment minus the suffix (e.g. "vol_3.img"). Empty for a top-level entry.
std::string extraction_label(const std::string& root) {
const std::string ext = ".extracted";
std::string best;
for (size_t start = 0; start <= root.size();) {
size_t slash = root.find('/', start);
size_t len = (slash == std::string::npos) ? root.size() - start : slash - start;
std::string seg = root.substr(start, len);
if (seg.size() > ext.size() && seg.compare(seg.size() - ext.size(), ext.size(), ext) == 0)
best = seg.substr(0, seg.size() - ext.size());
if (slash == std::string::npos) break;
start = slash + 1;
}
return best;
}

// Extraction-side trouble for the diagnostics table: every manifest entry's
// warnings (e.g. a lost+found recovery) and hard statuses (error/unsupported).
// This keeps the detail in one scannable place so the NOTES column stays terse.
void gather_extraction_diags(std::vector<DiagRow>& rows, const Manifest& man) {
std::set<std::string> seen; // dedupe identical severity+message
for (const auto& e : man.entries) {
const size_t off = parse_root_offset(e.root);
const std::string label = extraction_label(e.root);
auto add = [&](const std::string& sev, const std::string& msg) {
std::string full = label.empty() ? msg : label + ": " + msg;
if (!seen.insert(sev + "\x1f" + full).second) return;
rows.push_back({sev, "extract", e.type, full, off});
};
for (const auto& w : e.warnings) add("warning", w);
if (e.status != "ok" && e.status != "partial") add("error", e.status);
}
}

// The diagnostics section: a table SEVERITY | OFFSET | TYPE | MESSAGE listing
// every finding's diagnostics. The single place to scan for trouble; the NOTES
// column flags each in situ.
Expand Down Expand Up @@ -526,10 +571,19 @@ void emit_extraction_tree(std::string& o, const Palette& p, const std::vector<Fi
});

auto manifest_node_for = [&](const Finding& f) -> int {
for (size_t i = 0; i < n; ++i)
if (parent[i] < 0 && es[i].offset == f.offset && es[i].type == f.type)
return static_cast<int>(i);
return -1;
// Prefer an exact offset+type match; else fall back to a unique offset
// match, so a container whose top extraction entry is labelled by its inner
// filesystem (a `ubi` finding whose extraction entry is `ubifs`) still links
// to its subtree instead of dropping it from the human view.
int exact = -1, by_off = -1, off_count = 0;
for (size_t i = 0; i < n; ++i) {
if (parent[i] >= 0 || es[i].offset != f.offset) continue;
if (es[i].type == f.type) exact = static_cast<int>(i);
by_off = static_cast<int>(i);
++off_count;
}
if (exact >= 0) return exact;
return off_count == 1 ? by_off : -1;
};

// Extraction-side NOTES: warnings, plus a bare status when it is not clean. A
Expand All @@ -541,7 +595,9 @@ void emit_extraction_tree(std::string& o, const Palette& p, const std::vector<Fi
std::string s;
for (const auto& w : e.warnings) {
if (has_children && w.find("compression not decoded") != std::string::npos) continue;
s += (s.empty() ? "" : " · ") + w;
// Terse flag in NOTES; the full sentence goes to the diagnostics table.
std::string tag = (w.find("lost+found") != std::string::npos) ? "→ lost+found" : w;
s += (s.empty() ? "" : " · ") + tag;
}
if (e.status != "ok" && e.status != "partial")
s += (s.empty() ? "" : " · ") + e.status; // error:<why> / unsupported:<codec>
Expand Down Expand Up @@ -635,8 +691,16 @@ void emit_extraction_tree(std::string& o, const Palette& p, const std::vector<Fi
r.type_str = f.type;
r.tier_str = f.confidence_tier;
r.notes_str = notes_for(f, p, verbose);
rows.push_back(r);
int node = manifest_node_for(f);
// A finding's own extraction status/warning (e.g. a lost+found recovery on
// a rootless UBIFS, or a bare "partial") lives on its manifest node, not on
// a child, so surface it on the finding row itself — emit_kids only annotates
// descendant rows.
if (node >= 0) {
std::string exn = ex_notes(es[node], !kids[node].empty());
if (!exn.empty()) r.notes_str += (r.notes_str.empty() ? "" : " ") + exn;
}
rows.push_back(r);
if (node >= 0) emit_kids(node, "");
}

Expand Down Expand Up @@ -707,19 +771,21 @@ std::string emit_file_human(const std::vector<Finding>& findings,
Palette p{color};
std::string o;

// Errors-only banner at the very top: a "moria could not do this" result
// must not be buried under a long findings table. Warnings/info live only in
// the diagnostics section and the NOTES column.
// Diagnostics drive an errors-only banner emitted lower down (just above the
// footer). Warnings/info live only in the diagnostics section and the NOTES
// column; extraction warnings/statuses are merged in below.
auto diags = gather_diagnostics(findings);
if (extraction) {
gather_extraction_diags(diags, *extraction);
auto rank = [](const std::string& s) { return s == "error" ? 0 : s == "warning" ? 1 : 2; };
std::sort(diags.begin(), diags.end(), [&](const DiagRow& a, const DiagRow& b) {
if (rank(a.severity) != rank(b.severity)) return rank(a.severity) < rank(b.severity);
return a.offset < b.offset;
});
}
size_t nerr = 0;
for (const auto& d : diags)
if (d.severity == "error") ++nerr;
if (nerr > 0) {
o += p.sev("error");
o += (nerr == 1 ? "! 1 error" : "! " + std::to_string(nerr) + " errors");
o += " — see diagnostics below\n\n";
o += p.reset();
}

if (findings.empty()) {
o += p.dim();
Expand Down Expand Up @@ -747,6 +813,18 @@ std::string emit_file_human(const std::vector<Finding>& findings,
}
}

// Errors-only banner, just above the footer: a "moria could not do this"
// result sits right before the "-> extracted to ..." line so it is the last
// thing before the run summary, not buried at the top.
if (nerr > 0) {
o += "\n";
o += p.sev("error");
o += (nerr == 1 ? "! 1 error" : "! " + std::to_string(nerr) + " errors");
o += " — see diagnostics above";
o += p.reset();
o += "\n";
}

if (!footer.empty()) {
o += "\n";
o += p.dim() + footer + p.reset();
Expand Down
Loading
Loading