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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ The release run heads these entries with the version and opens a fresh
spreadsheets alike.
- Text copied out of a pdf laid out glyph by glyph reads as words, not as
`L a g e`. A word break also survives a run with nothing extractable in it.
- An encrypted `.doc`, `.ppt` or `.xls` reports itself encrypted and raises
`FileEncrypted` instead of a parse error, so a reader can prompt for the
password. Decrypting them is still out of reach.

## v6.9.0 - 2026-08-18

Expand Down
40 changes: 39 additions & 1 deletion src/odr/internal/oldms/oldms_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,37 @@

#include <odr/internal/common/path.hpp>
#include <odr/internal/oldms/presentation/ppt_document.hpp>
#include <odr/internal/oldms/presentation/ppt_parser.hpp>
#include <odr/internal/oldms/spreadsheet/xls_document.hpp>
#include <odr/internal/oldms/spreadsheet/xls_parser.hpp>
#include <odr/internal/oldms/text/doc_document.hpp>
#include <odr/internal/oldms/text/doc_parser.hpp>

#include <memory>
#include <optional>
#include <unordered_map>

namespace odr::internal::oldms {

namespace {
/// Each format keeps the bytes that say so in the clear, so this is readable
/// without the password. Detection only — odrcore cannot decrypt any of them.
/// Nothing where the format's own probe could not read the signal.
std::optional<bool>
parse_password_encrypted(const FileType type,
const abstract::ReadableFilesystem &files) {
switch (type) {
case FileType::legacy_word_document:
return text::password_encrypted(files);
case FileType::legacy_powerpoint_presentation:
return presentation::password_encrypted(files);
case FileType::legacy_excel_worksheets:
return spreadsheet::password_encrypted(files);
default:
return {};
}
}

FileMeta parse_meta(const abstract::ReadableFilesystem &files) {
struct Variant {
FileType type{FileType::unknown};
Expand Down Expand Up @@ -61,6 +83,16 @@ LegacyMicrosoftFile::LegacyMicrosoftFile(
std::shared_ptr<abstract::ReadableFilesystem> files)
: m_files{std::move(files)} {
m_file_meta = parse_meta(*m_files);

// `EncryptionState::unknown` where the probe could not read the signal: a
// stream that cannot be inspected is not a document that said it is in the
// clear, and `FileMeta` has only the boolean to carry it.
const std::optional<bool> encrypted =
parse_password_encrypted(m_file_meta.type, *m_files);
m_file_meta.password_encrypted = encrypted.value_or(false);
m_encryption_state = !encrypted.has_value() ? EncryptionState::unknown
: *encrypted ? EncryptionState::encrypted
: EncryptionState::not_encrypted;
}

std::shared_ptr<abstract::File> LegacyMicrosoftFile::file() const noexcept {
Expand All @@ -86,7 +118,7 @@ bool LegacyMicrosoftFile::password_encrypted() const noexcept {
}

EncryptionState LegacyMicrosoftFile::encryption_state() const noexcept {
return EncryptionState::unknown;
return m_encryption_state;
}

std::shared_ptr<abstract::DecodedFile> LegacyMicrosoftFile::decrypt(
Expand All @@ -98,6 +130,12 @@ std::shared_ptr<abstract::DecodedFile> LegacyMicrosoftFile::decrypt(
bool LegacyMicrosoftFile::is_decodable() const noexcept { return false; }

std::shared_ptr<abstract::Document> LegacyMicrosoftFile::document() const {
// otherwise the encrypted bytes get read as structure, and the caller sees a
// parse error where a password prompt belongs
if (m_encryption_state == EncryptionState::encrypted) {
throw FileEncryptedError();
}

switch (file_type()) {
case FileType::legacy_word_document:
return std::make_shared<text::Document>(m_files);
Expand Down
1 change: 1 addition & 0 deletions src/odr/internal/oldms/oldms_file.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class LegacyMicrosoftFile final : public abstract::DocumentFile {
private:
std::shared_ptr<abstract::ReadableFilesystem> m_files;
FileMeta m_file_meta;
EncryptionState m_encryption_state{EncryptionState::unknown};
};

} // namespace odr::internal::oldms
19 changes: 19 additions & 0 deletions src/odr/internal/oldms/presentation/ppt_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -919,4 +919,23 @@ presentation::parse_tree(ElementRegistry &registry,
return root_id;
}

std::optional<bool>
presentation::password_encrypted(const abstract::ReadableFilesystem &files) {
const std::shared_ptr<abstract::File> file =
files.open(AbsPath("/Current User"));
if (file == nullptr) {
return {};
}

const std::unique_ptr<std::istream> stream = file->stream();
CurrentUserAtomHead head{};
stream->read(reinterpret_cast<char *>(&head), sizeof(head));
if (stream->gcount() != sizeof(head) ||
head.rh.recType != RT_CurrentUserAtom) {
return {};
}

return head.headerToken == current_user_token_encrypted;
}

} // namespace odr::internal::oldms
8 changes: 8 additions & 0 deletions src/odr/internal/oldms/presentation/ppt_parser.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

#include <odr/definitions.hpp>

#include <optional>

namespace odr::internal::abstract {
class ReadableFilesystem;
}
Expand All @@ -16,4 +18,10 @@ ElementIdentifier parse_tree(ElementRegistry &registry,
StyleRegistry &style_registry,
const abstract::ReadableFilesystem &files);

/// Whether the presentation is encrypted, from `CurrentUserAtom.headerToken`
/// ([MS-PPT] 2.3.2). Nothing where the `/Current User` stream is missing, too
/// short, or does not hold a CurrentUserAtom: that is not an answer.
[[nodiscard]] std::optional<bool>
password_encrypted(const abstract::ReadableFilesystem &files);

} // namespace odr::internal::oldms::presentation
5 changes: 5 additions & 0 deletions src/odr/internal/oldms/presentation/ppt_structs.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ namespace odr::internal::oldms::presentation {
// LSB-first hosts only — see oldms/AGENTS.md.

/// Record types relevant to text extraction. See [MS-PPT] 2.13.24 RecordType.
/// CurrentUserAtom.headerToken ([MS-PPT] 2.3.2); the file is encrypted when it
/// carries the second one.
constexpr std::uint32_t current_user_token_plain = 0xE391C05F;
constexpr std::uint32_t current_user_token_encrypted = 0xF3D1C4DF;

enum RecordType : std::uint16_t {
RT_DocumentContainer = 0x03E8, //< top-level document
RT_DocumentAtom = 0x03E9, //< slide size etc. [MS-PPT] 2.4.2
Expand Down
7 changes: 4 additions & 3 deletions src/odr/internal/oldms/spreadsheet/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,10 @@ ignore their format codes. Fix by following the format chain:
- **Hidden rows/columns** (`Row.fDyZero`, `ColInfo.fHidden`).
- **Typed cell values**: expose numeric/bool/date `ValueType`s instead of
pre-rendered strings.
- **Encrypted workbooks**: a `FilePass` (0x002F) in globals means the rest is
encrypted ([MS-OFFCRYPTO]); currently parses as garbage or throws — should report
password-protected.
- **Encrypted workbooks**: a `FilePass` (0x002F) in globals is what
`password_encrypted()` reports, so the file surfaces as encrypted rather than
parsing as garbage; reading one still needs a `decrypt` that throws
([MS-OFFCRYPTO]).
- **BIFF5/BIFF7** (`vers != 0x0600`): currently throws; older files exist in the
wild (no SST — `Label` records carry strings inline).
- **Drawings/charts/images** — likely never worth it for text extraction.
Expand Down
38 changes: 38 additions & 0 deletions src/odr/internal/oldms/spreadsheet/xls_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -243,4 +243,42 @@ spreadsheet::parse_tree(ElementRegistry &registry,
return root_id;
}

std::optional<bool>
spreadsheet::password_encrypted(const abstract::ReadableFilesystem &files) {
const std::shared_ptr<abstract::File> file = files.open(AbsPath("/Workbook"));
if (file == nullptr) {
return {};
}

const std::unique_ptr<std::istream> stream = file->stream();
BiffReader reader{*stream};

// FilePass sits at the head of the globals substream, right after BOF and an
// optional WriteProtect ([MS-XLS] 2.1.7.20.1). Record headers are not
// encrypted, so they can be walked either way; the substream's own EOF, or
// the BOF of the first sheet, ends the search.
try {
if (!reader.next_record() || reader.record_type() != biff_bof) {
return {};
}
while (reader.next_record()) {
switch (reader.record_type()) {
case biff_filepass:
return true;
case biff_bof:
case biff_eof:
return false;
default:
break;
}
}
} catch (const std::exception &) {
// a stream that cannot be walked cannot answer either way
return {};
}

// the records ran out before the globals substream ended
return {};
}

} // namespace odr::internal::oldms
10 changes: 10 additions & 0 deletions src/odr/internal/oldms/spreadsheet/xls_parser.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

#include <odr/definitions.hpp>

#include <optional>

namespace odr::internal::abstract {
class ReadableFilesystem;
} // namespace odr::internal::abstract
Expand All @@ -18,4 +20,12 @@ ElementIdentifier parse_tree(ElementRegistry &registry,
StyleRegistry &style_registry,
const abstract::ReadableFilesystem &files);

/// Whether the workbook is encrypted, i.e. whether the globals substream
/// carries a FilePass record ([MS-XLS] 2.4.117). Record headers stay in the
/// clear, which is what makes this readable at all. Nothing where the
/// `/Workbook` stream is missing, or where the record walk does not reach the
/// end of the globals substream: that is not an answer.
[[nodiscard]] std::optional<bool>
password_encrypted(const abstract::ReadableFilesystem &files);

} // namespace odr::internal::oldms::spreadsheet
1 change: 1 addition & 0 deletions src/odr/internal/oldms/spreadsheet/xls_structs.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ namespace odr::internal::oldms::spreadsheet {
enum BiffRecordType : std::uint16_t {
biff_formula = 0x0006, //< [MS-XLS] 2.4.127
biff_eof = 0x000A, //< [MS-XLS] 2.4.103
biff_filepass = 0x002F, //< [MS-XLS] 2.4.117
biff_font = 0x0031, //< [MS-XLS] 2.4.122
biff_continue = 0x003C, //< [MS-XLS] 2.4.58
biff_boundsheet = 0x0085, //< [MS-XLS] 2.4.28 BoundSheet8
Expand Down
5 changes: 3 additions & 2 deletions src/odr/internal/oldms/text/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,9 @@ WordDocument stream
margins, §2.6.4) are unparsed.
- **Images / OLE / drawn objects** — anchor chars dropped; would need `PlcfSpa` /
Office Art (`dggInfo`).
- **Encrypted / obfuscated** — `fEncrypted`/`fObfuscated` parsed but not acted on;
`decrypt` throws.
- **Encrypted / obfuscated** — `fEncrypted` is what `password_encrypted()`
reports, so the file surfaces as encrypted rather than throwing a parse error;
reading one still needs a `decrypt` that throws ([MS-OFFCRYPTO]).

## 3. Smaller shortcomings

Expand Down
18 changes: 18 additions & 0 deletions src/odr/internal/oldms/text/doc_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -253,4 +253,22 @@ ElementIdentifier text::parse_tree(ElementRegistry &registry,
return root_id;
}

std::optional<bool>
text::password_encrypted(const abstract::ReadableFilesystem &files) {
const std::shared_ptr<abstract::File> file =
files.open(AbsPath("/WordDocument"));
if (file == nullptr) {
return {};
}

const std::unique_ptr<std::istream> stream = file->stream();
FibBase base{};
stream->read(reinterpret_cast<char *>(&base), sizeof(base));
if (stream->gcount() != sizeof(base) || base.wIdent != fib_wIdent) {
return {};
}

return base.fEncrypted != 0;
}

} // namespace odr::internal::oldms
10 changes: 10 additions & 0 deletions src/odr/internal/oldms/text/doc_parser.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

#include <odr/definitions.hpp>

#include <optional>

namespace odr::internal::abstract {
class ReadableFilesystem;
}
Expand All @@ -17,4 +19,12 @@ ElementIdentifier parse_tree(ElementRegistry &registry,
StyleRegistry &style_registry,
const abstract::ReadableFilesystem &files);

/// Whether the document is encrypted or obfuscated, from `FibBase.fEncrypted`
/// ([MS-DOC] 2.5.2). The FIB itself stays in the clear, which is what makes
/// this readable at all. Nothing where the `/WordDocument` stream is missing or
/// too short to carry a FIB: that is not an answer, and saying "not encrypted"
/// would be claiming one.
[[nodiscard]] std::optional<bool>
password_encrypted(const abstract::ReadableFilesystem &files);

} // namespace odr::internal::oldms::text
3 changes: 3 additions & 0 deletions src/odr/internal/oldms/text/doc_structs.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ namespace odr::internal::oldms::text {
// Filled by copying file bytes straight in (see doc_io): little-endian,
// LSB-first hosts only — see oldms/AGENTS.md.

/// FibBase.wIdent of every word binary document ([MS-DOC] 2.5.2).
constexpr std::uint16_t fib_wIdent = 0xA5EC;

enum NFibValues : std::uint16_t {
nFib97 = 0x00C1,
nFib2000 = 0x00D9,
Expand Down
1 change: 1 addition & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ add_executable(odr_test
"src/internal/odf/odf_table_test.cpp"

"src/internal/oldms/doc_test.cpp"
"src/internal/oldms/encryption_test.cpp"
"src/internal/oldms/ppt_test.cpp"
"src/internal/oldms/xls_test.cpp"

Expand Down
6 changes: 3 additions & 3 deletions test/src/html_output_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,14 @@ TEST_P(HtmlOutputTests, html_meta) {
GTEST_SKIP();
}

// TODO oldms decryption
EXPECT_EQ(test_file.password.has_value(), file.password_encrypted());

// TODO oldms decryption — detected, but odrcore cannot open it
if (test_file.password.has_value() &&
test_file.type == FileType::legacy_word_document) {
GTEST_SKIP();
}

EXPECT_EQ(test_file.password.has_value(), file.password_encrypted());

if (test_file.password.has_value()) {
file = file.decrypt(test_file.password.value());

Expand Down
Loading
Loading