This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new a49480cda2 TIKA-4814 -- OneNote follow-ups: fall back to legacy string 
dump when… (#3045)
a49480cda2 is described below

commit a49480cda27483c020f4d4ad3bd65903610bc63b
Author: Tim Allison <[email protected]>
AuthorDate: Fri Aug 21 21:24:53 2026 -0400

    TIKA-4814 -- OneNote follow-ups: fall back to legacy string dump when… 
(#3045)
---
 .skills/oss-fuzz/SKILL.md                          |  20 ++
 CHANGES.txt                                        |  17 +-
 .../apache/tika/parser/microsoft/onenote/GUID.java |  18 +-
 .../parser/microsoft/onenote/OneNoteDocument.java  |   3 +
 .../parser/microsoft/onenote/OneNoteParser.java    | 147 +++++++++-----
 .../tika/parser/microsoft/onenote/OneNotePtr.java  |  44 ++++-
 .../microsoft/onenote/OneNoteTreeWalker.java       |  46 ++++-
 .../onenote/fsshttpb/MSOneStorePackage.java        | 161 +++++++++------
 .../property/PrtArrayOfPropertyValues.java         |  13 +-
 .../PrtFourBytesOfLengthFollowedByData.java        |   6 +
 .../onenote/fsshttpb/streamobj/ObjectDataBLOB.java |  19 --
 .../streamobj/ObjectDataBLOBDataElementData.java   |  15 --
 .../onenote/fsshttpb/streamobj/PropertySet.java    |  23 ++-
 .../streamobj/RevisionStoreObjectGroup.java        |  12 +-
 .../space/ObjectSpaceObjectStreamOfContextIDs.java |   8 +-
 .../space/ObjectSpaceObjectStreamOfOIDs.java       |   8 +-
 .../space/ObjectSpaceObjectStreamOfOSIDs.java      |   6 +
 .../tika/parser/microsoft/onenote/GUIDTest.java    |  19 ++
 .../microsoft/onenote/OneNoteParserTest.java       | 219 ++++++++++++++++++++-
 .../onenote/fsshttpb/MSOneStoreBlobTest.java       |  17 +-
 .../onenote/fsshttpb/MSOneStorePackageTest.java    | 112 ++++++++++-
 .../PropertyDeserializationBoundsTest.java         | 184 +++++++++++++++++
 22 files changed, 934 insertions(+), 183 deletions(-)

diff --git a/.skills/oss-fuzz/SKILL.md b/.skills/oss-fuzz/SKILL.md
index ae3d6ea84b..f18098381c 100644
--- a/.skills/oss-fuzz/SKILL.md
+++ b/.skills/oss-fuzz/SKILL.md
@@ -312,6 +312,26 @@ and is safe — it runs a single testcase, not a corpus dir, 
so the
 - **New target:** add `FooParserFuzzer.java` next to the others following the
   `ParserFuzzer.parseOne` + swallow-expected-exceptions pattern.
 
+## Cleanup — the container leaves root-owned files in your working tree
+
+`build_fuzzers` with a local `--mount_path` compiles your working tree *inside
+the container as root*, so `target/` dirs (and other build outputs) in the
+mounted checkout come back **root-owned** — a later host-side `./mvnw clean`
+then fails with permission errors. When you are done fuzzing, run the clean
+**from the container** (root can delete its own files) against the same mount:
+
+```bash
+docker run --rm --platform linux/amd64 \
+  -v /path/to/tika:/src/project-parent/tika \
+  gcr.io/oss-fuzz/apache-tika \
+  bash -c 'cd /src/project-parent/tika && ./mvnw clean -Pfast 
-Dmaven.repo.local=/tmp/m2'
+```
+
+Use a throwaway in-container repo path for `-Dmaven.repo.local` (as above) so
+the clean itself does not write root-owned files into the host 
`.local_m2_repo`.
+Verify nothing is left behind: `find /path/to/tika -user root | head` should
+print nothing.
+
 ## Disclosure caveat (read before touching the public project)
 
 The `apache-tika` project on Google's infra **auto-files bugs and discloses on
diff --git a/CHANGES.txt b/CHANGES.txt
index 1b59350a8c..5c32de6a3d 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -30,6 +30,17 @@ Release 4.1.0 - unreleased
      CONTENT_TYPE_USER_OVERRIDE key is deliberately not carried, so the hint
      cannot force an unrelated type (TIKA-4825).
 
+   * OneNote extraction now follows document order, omits superseded page
+     revisions, sorts author metadata, extracts embedded object BLOBs, and
+     bounds malformed-input recursion and file-derived allocations. Parse
+     warnings and embedded relationship IDs are exposed in metadata. Malformed
+     or truncated files that cannot be fully parsed, and files whose walk
+     yields no content, now fall back to the legacy string dump instead of
+     failing or returning empty output. The legacy MS-ONESTORE walker bounds
+     its recursion (depth caps plus file-node-list and fragment-chain cycle
+     guards) and now honors shouldParseEmbedded for embedded file data
+     (TIKA-4814).
+
    * RawTiffParser extracts the camera-generated JPEG previews embedded in
      TIFF-based raw images (Nikon NEF/NRW, Sony ARW/SRF/SR2, Pentax PEF/PTX,
      Adobe DNG and Canon CR2, including BigTIFF DNG containers) as thumbnail
@@ -510,12 +521,6 @@ Release 4.0.0 - 8/18/2026
 
   OTHER CHANGES
 
-   * OneNote extraction now follows document order, omits superseded page
-     revisions, sorts author metadata, extracts embedded object BLOBs, and
-     bounds malformed-input recursion and per-file-node-list property 
allocation.
-     Malformed truncated property arrays now fail rather than return partial 
data;
-     parse warnings and embedded relationship IDs are exposed in metadata 
(TIKA-4814).
-
    * Dependency upgrades since 4.0.0-beta-1, including Jetty 12.1.12, CXF
      4.2.3 and SolrJ 10.0.0 (TIKA-4327).
 
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/GUID.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/GUID.java
index fe63cc9bf4..55e788bb4a 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/GUID.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/GUID.java
@@ -56,8 +56,8 @@ public class GUID implements Comparable<GUID> {
             throw new TikaException("Invalid GUID string");
         }
         for (int i = 0; i < hex.length(); i += 2) {
-            int high = Character.digit(hex.charAt(i), 16);
-            int low = Character.digit(hex.charAt(i + 1), 16);
+            int high = asciiHexDigit(hex.charAt(i));
+            int low = asciiHexDigit(hex.charAt(i + 1));
             if (high < 0 || low < 0) {
                 throw new TikaException("Invalid GUID string");
             }
@@ -66,6 +66,20 @@ public class GUID implements Comparable<GUID> {
         return new GUID(intGuid);
     }
 
+    // Character.digit accepts non-ASCII Unicode digits; GUIDs are ASCII hex 
only
+    private static int asciiHexDigit(char c) {
+        if (c >= '0' && c <= '9') {
+            return c - '0';
+        }
+        if (c >= 'a' && c <= 'f') {
+            return c - 'a' + 10;
+        }
+        if (c >= 'A' && c <= 'F') {
+            return c - 'A' + 10;
+        }
+        return -1;
+    }
+
     public static int memcmp(int[] b1, int[] b2, int sz) {
         for (int i = 0; i < sz; i++) {
             if (b1[i] != b2[i]) {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteDocument.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteDocument.java
index 936228f25c..cd76a58a55 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteDocument.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteDocument.java
@@ -34,6 +34,9 @@ class OneNoteDocument {
     Map<ExtendedGUID, Pair<Long, ExtendedGUID>> revisionRoleMap = new 
HashMap<>();
     ExtendedGUID currentRevision = ExtendedGUID.nil();
     FileNodeList root = new FileNodeList();
+    // set when the root file node list could not be fully parsed (e.g. a 
truncated file);
+    // the header and any structure parsed so far remain usable
+    Exception structureParseException;
 
     public OneNoteDocument() {
 
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteParser.java
index b7e498cebf..14fd500a50 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteParser.java
@@ -129,39 +129,56 @@ public class OneNoteParser implements Parser {
                 metadata.set(OneNote.RGB_PLACEHOLDER,
                         "0x" + 
Long.toHexString(oneNoteDocument.header.rgbPlaceholder));
 
-                Pair<Long, ExtendedGUID> roleAndContext = Pair.of(1L, 
ExtendedGUID.nil());
-                OneNoteTreeWalker oneNoteTreeWalker =
-                        new OneNoteTreeWalker(options, oneNoteDocument, 
oneNoteDirectFileResource,
-                                xhtml, metadata, context, roleAndContext);
+                Exception structureFailure = 
oneNoteDocument.structureParseException;
+                boolean walked = false;
+                if (structureFailure == null) {
+                    try {
+                        Pair<Long, ExtendedGUID> roleAndContext = Pair.of(1L, 
ExtendedGUID.nil());
+                        OneNoteTreeWalker oneNoteTreeWalker =
+                                new OneNoteTreeWalker(options, oneNoteDocument,
+                                        oneNoteDirectFileResource, xhtml, 
metadata, context,
+                                        roleAndContext);
 
-                oneNoteTreeWalker.walkTree();
+                        oneNoteTreeWalker.walkTree();
 
-                if (!oneNoteTreeWalker.getAuthors().isEmpty()) {
-                    metadata.set(TikaCoreProperties.CREATOR,
-                            sortedValues(oneNoteTreeWalker.getAuthors()));
-                }
-                if (!oneNoteTreeWalker.getMostRecentAuthors().isEmpty()) {
-                    metadata.set(OneNote.MOST_RECENT_AUTHORS,
-                            
sortedValues(oneNoteTreeWalker.getMostRecentAuthors()));
-                }
-                if (!oneNoteTreeWalker.getOriginalAuthors().isEmpty()) {
-                    metadata.set(OneNote.ORIGINAL_AUTHORS,
-                            
sortedValues(oneNoteTreeWalker.getOriginalAuthors()));
-                }
-                if (!Instant.MAX.equals(
-                        
Instant.ofEpochMilli(oneNoteTreeWalker.getCreationTimestamp()))) {
-                    metadata.set(OneNote.CREATION_TIMESTAMP,
-                            
String.valueOf(oneNoteTreeWalker.getCreationTimestamp()));
-                }
-                if 
(!Instant.MIN.equals(oneNoteTreeWalker.getLastModifiedTimestamp())) {
-                    metadata.set(OneNote.LAST_MODIFIED_TIMESTAMP, 
String.valueOf(
-                            
oneNoteTreeWalker.getLastModifiedTimestamp().toEpochMilli()));
+                        if (!oneNoteTreeWalker.getAuthors().isEmpty()) {
+                            metadata.set(TikaCoreProperties.CREATOR,
+                                    
sortedValues(oneNoteTreeWalker.getAuthors()));
+                        }
+                        if 
(!oneNoteTreeWalker.getMostRecentAuthors().isEmpty()) {
+                            metadata.set(OneNote.MOST_RECENT_AUTHORS,
+                                    
sortedValues(oneNoteTreeWalker.getMostRecentAuthors()));
+                        }
+                        if (!oneNoteTreeWalker.getOriginalAuthors().isEmpty()) 
{
+                            metadata.set(OneNote.ORIGINAL_AUTHORS,
+                                    
sortedValues(oneNoteTreeWalker.getOriginalAuthors()));
+                        }
+                        if (!Instant.MAX.equals(
+                                
Instant.ofEpochMilli(oneNoteTreeWalker.getCreationTimestamp()))) {
+                            metadata.set(OneNote.CREATION_TIMESTAMP,
+                                    
String.valueOf(oneNoteTreeWalker.getCreationTimestamp()));
+                        }
+                        if 
(!Instant.MIN.equals(oneNoteTreeWalker.getLastModifiedTimestamp())) {
+                            metadata.set(OneNote.LAST_MODIFIED_TIMESTAMP, 
String.valueOf(
+                                    
oneNoteTreeWalker.getLastModifiedTimestamp().toEpochMilli()));
+                        }
+                        if (oneNoteTreeWalker.getLastModified() > 
Long.MIN_VALUE) {
+                            metadata.set(TikaCoreProperties.MODIFIED,
+                                    
String.valueOf(oneNoteTreeWalker.getLastModified()));
+                        }
+                        walked = true;
+                    } catch (Exception e) {
+                        rethrowIfLimitReached(e);
+                        structureFailure = e;
+                    }
                 }
-                if (oneNoteTreeWalker.getLastModified() > Long.MIN_VALUE) {
-                    metadata.set(TikaCoreProperties.MODIFIED,
-                            
String.valueOf(oneNoteTreeWalker.getLastModified()));
+                if (!walked) {
+                    legacyFallbackDump("OneNote parse failed; falling back to 
legacy text dump: " +
+                                    failureMessage(structureFailure), 
structureFailure, metadata,
+                            xhtml, oneNoteDirectFileResource);
                 }
             } else if (header.isLegacyOrAlternativePackaging()) {
+                MSOneStorePackage pkg = null;
                 try {
                     AlternativePackaging alternatePackageOneStoreFile = new 
AlternativePackaging();
                     byte[] bytes;
@@ -172,27 +189,18 @@ public class OneNoteParser implements Parser {
                     
alternatePackageOneStoreFile.doDeserializeFromByteArray(bytes, 0);
 
                     MSOneStoreParser onenoteParser = new MSOneStoreParser();
-                    MSOneStorePackage pkg =
-                            
onenoteParser.parse(alternatePackageOneStoreFile.dataElementPackage);
+                    pkg = 
onenoteParser.parse(alternatePackageOneStoreFile.dataElementPackage);
 
                     pkg.walkTree(options, metadata, xhtml, context);
                 } catch (Exception e) {
-                    WriteLimitReachedException.throwIfWriteLimitReached(e);
-                    if (e instanceof EmbeddedLimitReachedException) {
-                        throw (EmbeddedLimitReachedException) e;
-                    }
-                    String failure = e.getMessage() == null ? 
e.getClass().getSimpleName() :
-                            e.getMessage();
-                    LOG.warn("OneNote FSSHTTPB parse failed; falling back to 
legacy text dump: {}",
-                            failure);
-                    LOG.debug("OneNote FSSHTTPB parse failure", e);
-                    
metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                    rethrowIfLimitReached(e);
+                    legacyFallbackDump(
                             "OneNote FSSHTTPB parse failed; falling back to 
legacy text dump: " +
-                                    failure);
-                    OneNoteLegacyDumpStrings dumpStrings =
-                            new 
OneNoteLegacyDumpStrings(oneNoteDirectFileResource, xhtml);
-                    dumpStrings.dump();
+                                    failureMessage(e), e, metadata, xhtml,
+                            oneNoteDirectFileResource);
+                    pkg = null;
                 }
+                legacyFallbackIfNoContent(pkg, metadata, xhtml, 
oneNoteDirectFileResource);
             } else {
                 throw new TikaException("Invalid OneStore document - could not 
parse headers");
             }
@@ -208,6 +216,42 @@ public class OneNoteParser implements Parser {
         return sorted;
     }
 
+    private static void rethrowIfLimitReached(Exception e) throws 
TikaException, SAXException {
+        WriteLimitReachedException.throwIfWriteLimitReached(e);
+        if (e instanceof EmbeddedLimitReachedException) {
+            throw (EmbeddedLimitReachedException) e;
+        }
+    }
+
+    private static String failureMessage(Exception e) {
+        return e.getMessage() == null ? e.getClass().getSimpleName() : 
e.getMessage();
+    }
+
+    // the walk completed but every page dangled - without this a degraded
+    // file would yield empty output where the dump still finds its text
+    static void legacyFallbackIfNoContent(MSOneStorePackage pkg, Metadata 
metadata,
+                                          XHTMLContentHandler xhtml,
+                                          OneNoteDirectFileResource 
oneNoteDirectFileResource)
+            throws TikaException, SAXException {
+        if (pkg != null && !pkg.hasEmittedContent()) {
+            legacyFallbackDump("OneNote FSSHTTPB parse produced no content; " +
+                            "falling back to legacy text dump", null, 
metadata, xhtml,
+                    oneNoteDirectFileResource);
+        }
+    }
+
+    private static void legacyFallbackDump(String warning, Exception cause, 
Metadata metadata,
+                                           XHTMLContentHandler xhtml,
+                                           OneNoteDirectFileResource 
oneNoteDirectFileResource)
+            throws TikaException, SAXException {
+        LOG.warn(warning);
+        if (cause != null) {
+            LOG.debug("OneNote parse failure", cause);
+        }
+        metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, warning);
+        new OneNoteLegacyDumpStrings(oneNoteDirectFileResource, xhtml).dump();
+    }
+
     /**
      * Create a OneNoteDocument object.
      * <p>
@@ -241,7 +285,8 @@ public class OneNoteParser implements Parser {
      *                                  content.
      * @return A parsed one note document. This document does not contain any 
of the binary data,
      * rather it just contains
-     * the data pointers and metadata.
+     * the data pointers and metadata. A failure while parsing the root file 
node list is not
+     * thrown; it is recorded in the returned document's {@code 
structureParseException}.
      * @throws IOException Will throw IOException in typical IO issue 
situations.
      */
     public OneNoteDocument createOneNoteDocumentFromDirectFileResource(
@@ -253,9 +298,15 @@ public class OneNoteParser implements Parser {
 
         if (oneNoteDocument.header.isMsOneStoreFormat()) {
             // Now that we parsed the header, the "root file node list"
-            oneNotePtr.reposition(oneNoteDocument.header.fcrFileNodeListRoot);
-            FileNodePtr curPath = new FileNodePtr();
-            oneNotePtr.deserializeFileNodeList(oneNoteDocument.root, curPath);
+            try {
+                
oneNotePtr.reposition(oneNoteDocument.header.fcrFileNodeListRoot);
+                FileNodePtr curPath = new FileNodePtr();
+                oneNotePtr.deserializeFileNodeList(oneNoteDocument.root, 
curPath);
+            } catch (TikaException | IOException | RuntimeException e) {
+                // a truncated or malformed root list is recorded, not thrown, 
so the
+                // caller can fall back to the legacy string dump
+                oneNoteDocument.structureParseException = e;
+            }
         }
         return oneNoteDocument;
     }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNotePtr.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNotePtr.java
index 30ec931658..916b9bbb5a 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNotePtr.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNotePtr.java
@@ -21,8 +21,10 @@ import java.nio.ByteBuffer;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import org.apache.commons.codec.binary.Hex;
 import org.apache.commons.io.EndianUtils;
@@ -55,13 +57,26 @@ class OneNotePtr {
             "{638DE92F-A6D4-4BC1-9A36-B3FC2511A5B7}";
     private static final int MAX_PROPERTY_VALUES = 100_000;
     private static final int MAX_PROPERTY_SET_DEPTH = 1000;
+    // the format nests file node lists only a handful of levels deep; 100 
leaves wide
+    // margin while keeping the recursion far from any stack limit
+    private static final int MAX_FILE_NODE_LIST_DEPTH = 100;
 
     private static final class PropertyValueBudget {
         private long remaining = MAX_PROPERTY_VALUES;
     }
 
+    /**
+     * Recursion state for the baseType-2 file-node-list nesting, shared by 
every pointer
+     * copied while parsing one document.
+     */
+    private static final class FileNodeListRecursion {
+        private int depth;
+        private final Set<Long> activeListOffsets = new HashSet<>();
+    }
+
     int indentLevel = 0;
     private PropertyValueBudget propertyValueBudget = new 
PropertyValueBudget();
+    private FileNodeListRecursion fileNodeListRecursion = new 
FileNodeListRecursion();
 
     long offset;
     long end;
@@ -84,6 +99,7 @@ class OneNotePtr {
         this.end = oneNotePtr.end;
         this.indentLevel = oneNotePtr.indentLevel;
         this.propertyValueBudget = oneNotePtr.propertyValueBudget;
+        this.fileNodeListRecursion = oneNotePtr.fileNodeListRecursion;
     }
 
     public OneNoteHeader deserializeHeader() throws IOException, TikaException 
{
@@ -269,6 +285,9 @@ class OneNotePtr {
             throws IOException, TikaException {
         OneNotePtr localPtr = new OneNotePtr(ptr);
         FileNodePtrBackPush bp = new FileNodePtrBackPush(curPath);
+        // a next-fragment reference pointing at an already-seen fragment 
would loop forever
+        Set<Long> seenFragmentOffsets = new HashSet<>();
+        seenFragmentOffsets.add(ptr.offset);
         try {
             while (true) {
                 FileChunkReference next = FileChunkReference.nil();
@@ -276,6 +295,11 @@ class OneNotePtr {
                 if (FileChunkReference.nil().equals(next)) {
                     break;
                 }
+                if (!seenFragmentOffsets.add(next.stp)) {
+                    throw new TikaException(
+                            "OneNote file node list fragment cycle detected at 
offset " +
+                                    next.stp);
+                }
                 localPtr.reposition(next);
                 ptr = localPtr;
             }
@@ -292,8 +316,24 @@ class OneNotePtr {
      */
     public OneNotePtr deserializeFileNodeList(FileNodeList fileNodeList, 
FileNodePtr curPath)
             throws IOException, TikaException {
-        propertyValueBudget = new PropertyValueBudget();
-        return internalDeserializeFileNodeList(this, fileNodeList, curPath);
+        if (fileNodeListRecursion.depth >= MAX_FILE_NODE_LIST_DEPTH) {
+            throw new TikaMemoryLimitException(
+                    "OneNote file node list nesting exceeds depth limit " +
+                            MAX_FILE_NODE_LIST_DEPTH);
+        }
+        long listOffset = offset;
+        if (!fileNodeListRecursion.activeListOffsets.add(listOffset)) {
+            throw new TikaException(
+                    "OneNote file node list cycle detected at offset " + 
listOffset);
+        }
+        fileNodeListRecursion.depth++;
+        try {
+            propertyValueBudget = new PropertyValueBudget();
+            return internalDeserializeFileNodeList(this, fileNodeList, 
curPath);
+        } finally {
+            fileNodeListRecursion.depth--;
+            fileNodeListRecursion.activeListOffsets.remove(listOffset);
+        }
     }
 
     /**
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteTreeWalker.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteTreeWalker.java
index c98c41dbd1..943520e37f 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteTreeWalker.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/OneNoteTreeWalker.java
@@ -60,6 +60,8 @@ class OneNoteTreeWalker {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(OneNoteTreeWalker.class);
     private static final int MAX_CYCLE_WARNINGS = 100;
+    // low enough that the ~3 stack frames per level cannot overflow a default 
thread stack
+    private static final int MAX_WALK_DEPTH = 500;
     private static final String P = "p";
     /**
      * See spec MS-ONE - 2.3.1 - TIME32 - epoch of jan 1 1980 UTC.
@@ -115,6 +117,8 @@ class OneNoteTreeWalker {
             Collections.newSetFromMap(new IdentityHashMap<>());
     private int cycleWarningCount;
     private boolean cycleWarningsSuppressed;
+    private int walkDepth;
+    private boolean depthWarningRecorded;
 
     /**
      * Create a one tree walker.
@@ -307,17 +311,37 @@ class OneNoteTreeWalker {
         if (fileNode == null) {
             return Collections.emptyMap();
         }
+        if (walkDepth >= MAX_WALK_DEPTH) {
+            // the cycle guard cannot catch a long acyclic chain - cap the 
recursion depth
+            // so a crafted file cannot overflow the stack
+            recordDepthWarning();
+            return Collections.emptyMap();
+        }
         if (!activeFileNodes.add(fileNode)) {
             recordCycleWarning(fileNode);
             return Collections.emptyMap();
         }
+        walkDepth++;
         try {
             return walkFileNodeInternal(fileNode, parentPropertyId);
         } finally {
+            walkDepth--;
             activeFileNodes.remove(fileNode);
         }
     }
 
+    private void recordDepthWarning() {
+        if (depthWarningRecorded) {
+            return;
+        }
+        depthWarningRecorded = true;
+        String warning = "OneNote file-node traversal exceeded depth limit " + 
MAX_WALK_DEPTH;
+        LOG.warn(warning);
+        if (parentMetadata != null) {
+            parentMetadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, 
warning);
+        }
+    }
+
     private void recordCycleWarning(FileNode fileNode) {
         if (reportedCycleNodes.contains(fileNode)) {
             return;
@@ -368,7 +392,7 @@ class OneNoteTreeWalker {
         if (fileNode.subType.fileDataStoreObjectReference.ref != null && 
!FileChunkReference.nil()
                 
.equals(fileNode.subType.fileDataStoreObjectReference.ref.fileData)) {
             structure.put("fileDataStoreObjectReference", 
walkFileDataStoreObjectReference(
-                    fileNode.subType.fileDataStoreObjectReference));
+                    fileNode.subType.fileDataStoreObjectReference, 
fileNode.gosid));
         }
         return structure;
     }
@@ -381,7 +405,7 @@ class OneNoteTreeWalker {
      * @throws IOException Can throw these when manipulating the seekable byte 
channel.
      */
     private Map<String, Object> walkFileDataStoreObjectReference(
-            FileDataStoreObjectReference fileDataStoreObjectReference)
+            FileDataStoreObjectReference fileDataStoreObjectReference, 
ExtendedGUID gosid)
             throws IOException, SAXException, TikaException {
         Map<String, Object> structure = new HashMap<>();
         OneNotePtr content = new OneNotePtr(oneNoteDocument, dif);
@@ -391,12 +415,13 @@ class OneNoteTreeWalker {
                     "File data store cb " + 
fileDataStoreObjectReference.ref.fileData.cb +
                             " exceeds document size: " + dif.size());
         }
-        handleEmbedded((int) fileDataStoreObjectReference.ref.fileData.cb);
+        handleEmbedded((int) fileDataStoreObjectReference.ref.fileData.cb, 
gosid);
         structure.put("fileDataStoreObjectMetadata", 
fileDataStoreObjectReference);
         return structure;
     }
 
-    private void handleEmbedded(int length) throws TikaException, IOException, 
SAXException {
+    private void handleEmbedded(int length, ExtendedGUID gosid)
+            throws TikaException, IOException, SAXException {
         TikaInputStream tis = null;
         ByteBuffer buf;
         try {
@@ -408,14 +433,21 @@ class OneNoteTreeWalker {
             return;
         }
         Metadata embeddedMetadata = Metadata.newInstance(this.parseContext);
+        embeddedMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
+                TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.toString());
+        if (gosid != null && !ExtendedGUID.nil().equals(gosid)) {
+            embeddedMetadata.set(TikaCoreProperties.EMBEDDED_RELATIONSHIP_ID, 
gosid.toString());
+        }
         try {
             AttributesImpl attributes = new AttributesImpl();
             attributes.addAttribute("", "class", "class", "CDATA", "embedded");
             xhtml.startElement("div", attributes);
             xhtml.endElement("div");
-            tis = TikaInputStream.get(buf.array());
-            embeddedDocumentExtractor.parseEmbedded(tis, new 
EmbeddedContentHandler(xhtml),
-                    embeddedMetadata, this.parseContext, false);
+            if 
(embeddedDocumentExtractor.shouldParseEmbedded(embeddedMetadata, parseContext)) 
{
+                tis = TikaInputStream.get(buf.array());
+                embeddedDocumentExtractor.parseEmbedded(tis, new 
EmbeddedContentHandler(xhtml),
+                        embeddedMetadata, this.parseContext, false);
+            }
         } finally {
             IOUtils.closeQuietly(tis);
         }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStorePackage.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStorePackage.java
index b8c9bd0b31..0ad1d277dd 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStorePackage.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStorePackage.java
@@ -28,6 +28,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.IdentityHashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -142,6 +143,11 @@ public class MSOneStorePackage {
     private final Set<String> recordedParseWarningKeys = new HashSet<>();
     private boolean parseWarningsSuppressed;
     private boolean storageMappingsIndexed;
+    private boolean contentEmitted;
+    // flattened property actions per object; objects can be re-flattened many 
times during
+    // picture/resource-name resolution, which is quadratic without this cache
+    private final Map<RevisionStoreObject, List<PropertyAction>> 
objectActionsCache =
+            new IdentityHashMap<>();
     private final Map<CellID, StorageIndexCellMapping> 
storageIndexCellMappingsById =
             new HashMap<>();
     private final Map<ExGuid, StorageIndexRevisionMapping> 
storageIndexRevisionMappingsById =
@@ -305,8 +311,7 @@ public class MSOneStorePackage {
             }
         }
         for (RevisionStoreCell cell : remainingCells.values()) {
-            if (cell.cellID == null || cell.cellID.extendGUID2 == null ||
-                    !coveredObjectSpaces.contains(cell.cellID.extendGUID2)) {
+            if (!coveredObjectSpaces.contains(cell.cellID.extendGUID2)) {
                 // not an older version of one of the pages - keep it so no 
content is lost
                 otherCells.add(cell);
             }
@@ -350,7 +355,7 @@ public class MSOneStorePackage {
         if (object.objectID != null && !visited.add(object.objectID)) {
             return;
         }
-        List<PropertyAction> actions = collectObjectActions(object, depth);
+        List<PropertyAction> actions = collectObjectActions(object);
         for (PropertyAction action : actions) {
             if (action.spaceReference != null) {
                 out.add(action.spaceReference);
@@ -373,19 +378,26 @@ public class MSOneStorePackage {
         // Only objects reachable from the root objects of the current 
revision are part of
         // the current content. The object groups may also contain older, 
superseded versions
         // of objects (under a different object ID); those are intentionally 
not walked.
-        boolean resolvedRoot = false;
+        // A blob-only root (no property set) cannot reach the page body, so 
it does not
+        // count as a resolved content root: if the content root declare 
dangles next to it,
+        // the walk-everything fallback must still fire or the page body is 
silently lost.
+        boolean resolvedContentRoot = false;
+        boolean unresolvedRoot = false;
         for (RevisionManifestRootDeclare rootDeclare : cell.rootDeclares) {
             RevisionStoreObject rootObject = 
objectsById.get(rootDeclare.objectExGuid);
             if (rootObject == null) {
                 recordParseWarning("OneNote cell root object " + 
rootDeclare.objectExGuid +
                         " could not be resolved");
+                unresolvedRoot = true;
             } else {
-                resolvedRoot = true;
+                if (rootObject.propertySet != null) {
+                    resolvedContentRoot = true;
+                }
                 walkObject(rootObject, objectsById, visited, AuthorRole.NONE, 
options,
                         metadata, xhtml, 0);
             }
         }
-        if (!resolvedRoot) {
+        if (cell.rootDeclares.isEmpty() || (unresolvedRoot && 
!resolvedContentRoot)) {
             if (cell.rootDeclares.isEmpty()) {
                 recordParseWarning("OneNote cell has no declared root objects; 
walking all objects");
             } else {
@@ -481,11 +493,15 @@ public class MSOneStorePackage {
             return;
         }
         if (object.objectID != null && !visited.add(object.objectID)) {
+            // one object may be referenced under several author roles (e.g. 
the same author
+            // as both AuthorOriginal and AuthorMostRecent) - record the role 
even though
+            // the subtree is not walked again
+            recordAuthors(object, authorRole);
             return;
         }
         List<PropertyAction> actions = object.propertySet != null &&
                 object.propertySet.objectSpaceObjectPropSet != null ?
-                collectObjectActions(object, depth) : Collections.emptyList();
+                collectObjectActions(object) : Collections.emptyList();
         EmbeddedResourceInfo resourceInfo = embeddedResourceInfo(actions);
         if (resourceInfo == null) {
             resourceInfo = inheritedResourceInfo;
@@ -552,7 +568,7 @@ public class MSOneStorePackage {
         if (object.propertySet == null || 
object.propertySet.objectSpaceObjectPropSet == null) {
             return false;
         }
-        for (PropertyAction action : collectObjectActions(object, depth)) {
+        for (PropertyAction action : collectObjectActions(object)) {
             if (action.isChildReference && action.childReference != null &&
                     hasUsablePicture(action.childReference, objectsById, 
visited, depth + 1)) {
                 return true;
@@ -572,7 +588,7 @@ public class MSOneStorePackage {
                     candidate.propertySet.objectSpaceObjectPropSet == null) {
                 continue;
             }
-            List<PropertyAction> candidateActions = 
collectObjectActions(candidate, depth);
+            List<PropertyAction> candidateActions = 
collectObjectActions(candidate);
             for (PropertyAction action : candidateActions) {
                 if (action.isChildReference && 
target.objectID.equals(action.childReference) &&
                         (action.oneNotePropertyEnum == 
OneNotePropertyEnum.PictureContainer ||
@@ -643,22 +659,24 @@ public class MSOneStorePackage {
     }
 
     /**
-     * Flattens the properties of an object, in order, into a list of actions.
+     * Flattens the properties of an object, in order, into a list of actions. 
The result is
+     * cached per object; callers must not mutate the returned list.
      */
     private List<PropertyAction> collectObjectActions(RevisionStoreObject 
object) {
-        return collectObjectActions(object, 0);
-    }
-
-    private List<PropertyAction> collectObjectActions(RevisionStoreObject 
object, int depth) {
+        List<PropertyAction> actions = objectActionsCache.get(object);
+        if (actions != null) {
+            return actions;
+        }
         List<ExGuid> referencedObjects =
                 object.referencedObjectID == null || 
object.referencedObjectID.content == null ?
                         Collections.emptyList() : 
object.referencedObjectID.content;
         List<CellID> referencedSpaces = object.referencedObjectSpacesID == 
null ||
                 object.referencedObjectSpacesID.content == null ? 
Collections.emptyList() :
                 object.referencedObjectSpacesID.content;
-        List<PropertyAction> actions = new ArrayList<>();
+        actions = new ArrayList<>();
         collectActions(object.propertySet.objectSpaceObjectPropSet.body, 
referencedObjects,
-                new int[]{0}, referencedSpaces, new int[]{0}, actions, depth);
+                new int[]{0}, referencedSpaces, new int[]{0}, actions, 0);
+        objectActionsCache.put(object, actions);
         return actions;
     }
 
@@ -670,13 +688,6 @@ public class MSOneStorePackage {
      * section 2.7.8), so the references must be assigned here, in property 
order, no matter
      * in which order the actions are processed later.
      */
-    private void collectActions(PropertySet propertySet, List<ExGuid> 
referencedObjects,
-                                int[] referenceCursor, List<CellID> 
referencedSpaces,
-                                int[] spaceCursor, List<PropertyAction> 
actions) {
-        collectActions(propertySet, referencedObjects, referenceCursor, 
referencedSpaces,
-                spaceCursor, actions, 0);
-    }
-
     private void collectActions(PropertySet propertySet, List<ExGuid> 
referencedObjects,
                                 int[] referenceCursor, List<CellID> 
referencedSpaces,
                                 int[] spaceCursor, List<PropertyAction> 
actions, int depth) {
@@ -862,17 +873,8 @@ public class MSOneStorePackage {
             }
             metadata.set(TikaCoreProperties.MODIFIED, 
String.valueOf(lastModified));
         } else if (oneNotePropertyEnum == OneNotePropertyEnum.Author) {
-            String author = decodeOneNoteText(
-                    ((PrtFourBytesOfLengthFollowedByData) property).data);
-            if (authorRole == AuthorRole.MOST_RECENT) {
-                mostRecentAuthors.add(author);
-            } else if (authorRole == AuthorRole.ORIGINAL) {
-                originalAuthors.add(author);
-                // the original authors are the creators of the content
-                authors.add(author);
-            } else {
-                authors.add(author);
-            }
+            recordAuthor(decodeOneNoteText(
+                    ((PrtFourBytesOfLengthFollowedByData) property).data), 
authorRole);
         } else if (propertyType == 
PropertyType.FourBytesOfLengthFollowedByData) {
             boolean isBinary = propertyIsBinary(oneNotePropertyEnum);
             PrtFourBytesOfLengthFollowedByData dataProperty =
@@ -880,28 +882,15 @@ public class MSOneStorePackage {
             if ((dataProperty.data.length & 1) == 0 &&
                     oneNotePropertyEnum != 
OneNotePropertyEnum.TextExtendedAscii && !isBinary) {
                 if 
(options.getUtf16PropertiesToPrint().contains(oneNotePropertyEnum)) {
-                    xhtml.startElement(P);
-                    try {
-                        xhtml.characters(new String(dataProperty.data, 
StandardCharsets.UTF_16LE));
-                    } finally {
-                        xhtml.endElement(P);
-                    }
+                    emitParagraph(xhtml, new String(dataProperty.data,
+                            StandardCharsets.UTF_16LE));
                 }
             } else if (oneNotePropertyEnum == 
OneNotePropertyEnum.TextExtendedAscii) {
-                xhtml.startElement(P);
-                try {
-                    xhtml.characters(new String(dataProperty.data, 
StandardCharsets.US_ASCII));
-                } finally {
-                    xhtml.endElement(P);
-                }
+                emitParagraph(xhtml, new String(dataProperty.data, 
StandardCharsets.US_ASCII));
             } else if (!isBinary) {
                 if 
(options.getUtf16PropertiesToPrint().contains(oneNotePropertyEnum)) {
-                    xhtml.startElement(P);
-                    try {
-                        xhtml.characters(new String(dataProperty.data, 
StandardCharsets.UTF_16LE));
-                    } finally {
-                        xhtml.endElement(P);
-                    }
+                    emitParagraph(xhtml, new String(dataProperty.data,
+                            StandardCharsets.UTF_16LE));
                 }
             } else {
                 if (oneNotePropertyEnum == 
OneNotePropertyEnum.RichEditTextUnicode) {
@@ -920,6 +909,36 @@ public class MSOneStorePackage {
         return new String(bytes, StandardCharsets.UTF_16LE).replace("\u0000", 
"");
     }
 
+    private void recordAuthor(String author, AuthorRole role) {
+        if (role == AuthorRole.MOST_RECENT) {
+            mostRecentAuthors.add(author);
+        } else if (role == AuthorRole.ORIGINAL) {
+            originalAuthors.add(author);
+            // the original authors are the creators of the content
+            authors.add(author);
+        } else {
+            authors.add(author);
+        }
+    }
+
+    /**
+     * Records the Author properties of an already-visited object under the 
given role.
+     */
+    private void recordAuthors(RevisionStoreObject object, AuthorRole role) {
+        // NONE also records (into authors) so the result is visit-order 
independent
+        if (object.propertySet == null ||
+                object.propertySet.objectSpaceObjectPropSet == null) {
+            return;
+        }
+        for (PropertyAction action : collectObjectActions(object)) {
+            if (action.oneNotePropertyEnum == OneNotePropertyEnum.Author &&
+                    action.property instanceof 
PrtFourBytesOfLengthFollowedByData) {
+                recordAuthor(decodeOneNoteText(
+                        ((PrtFourBytesOfLengthFollowedByData) 
action.property).data), role);
+            }
+        }
+    }
+
     private EmbeddedResourceInfo embeddedResourceInfo(List<PropertyAction> 
actions) {
         for (PropertyAction action : actions) {
             if (action.property instanceof PrtFourBytesOfLengthFollowedByData 
&&
@@ -939,10 +958,11 @@ public class MSOneStorePackage {
         return null;
     }
 
-    private String sanitizeResourceName(String name) {
+    static String sanitizeResourceName(String name) {
         name = name.replace('\\', '/');
+        // a drive-relative name like C:pic.png has no slash, so strip the 
prefix separately
         if (name.length() > 1 && name.charAt(1) == ':') {
-            return "";
+            name = name.substring(2);
         }
         int slash = name.lastIndexOf('/');
         name = slash >= 0 ? name.substring(slash + 1) : name;
@@ -980,6 +1000,9 @@ public class MSOneStorePackage {
         xhtml.endElement("div");
         try (TikaInputStream tis = TikaInputStream.get(data)) {
             if 
(embeddedDocumentExtractor.shouldParseEmbedded(embeddedMetadata, parseContext)) 
{
+                // only counts as content when the extractor accepts it, so a 
declined
+                // embedded object in an otherwise-empty file still triggers 
the fallback
+                contentEmitted = true;
                 embeddedDocumentExtractor.parseEmbedded(tis, new 
EmbeddedContentHandler(xhtml),
                         embeddedMetadata, this.parseContext, false);
             }
@@ -1011,14 +1034,32 @@ public class MSOneStorePackage {
             } finally {
                 xhtml.endElement("a");
             }
+            contentEmitted = true;
         } else {
-            xhtml.startElement(P);
-            try {
-                xhtml.characters(txt);
-            } finally {
-                xhtml.endElement(P);
-            }
+            emitParagraph(xhtml, txt);
+        }
+    }
+
+    private void emitParagraph(XHTMLContentHandler xhtml, String text) throws 
SAXException {
+        xhtml.startElement(P);
+        try {
+            xhtml.characters(text);
+        } finally {
+            xhtml.endElement(P);
         }
+        // blank text does not count as content, so it cannot suppress the 
legacy fallback
+        if (!text.isBlank()) {
+            contentEmitted = true;
+        }
+    }
+
+    /**
+     * Whether the tree walk emitted any content - text or an embedded object. 
When it did
+     * not, the caller can fall back to the legacy string dump so a degraded 
file still
+     * yields its text.
+     */
+    public boolean hasEmittedContent() {
+        return contentEmitted;
     }
 
     private long getScalar(IProperty property) throws TikaException, 
IOException {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/property/PrtArrayOfPropertyValues.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/property/PrtArrayOfPropertyValues.java
index 68b1dab169..7220f999bf 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/property/PrtArrayOfPropertyValues.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/property/PrtArrayOfPropertyValues.java
@@ -41,16 +41,27 @@ public class PrtArrayOfPropertyValues implements IProperty {
      * @return
      */
     public int doDeserializeFromByteArray(byte[] byteArray, int startIndex) 
throws IOException {
+        return doDeserializeFromByteArray(byteArray, startIndex, 0);
+    }
+
+    public int doDeserializeFromByteArray(byte[] byteArray, int startIndex, 
int depth)
+            throws IOException {
         int index = startIndex;
         this.cProperties = BitConverter.toInt32(byteArray, index);
         index += 4;
         this.propertyID = new PropertyID();
         int len = this.propertyID.doDeserializeFromByteArray(byteArray, index);
         index += len;
+        // each PropertySet consumes at least its 2-byte count, so a valid 
count cannot
+        // exceed the remaining bytes / 2; bounds the allocation on malformed 
counts
+        if (this.cProperties < 0 || this.cProperties > (byteArray.length - 
index) / 2) {
+            throw new IOException("prtArrayOfPropertyValues count " + 
this.cProperties +
+                    " exceeds remaining data " + (byteArray.length - index));
+        }
         this.data = new PropertySet[this.cProperties];
         for (int i = 0; i < this.cProperties; i++) {
             this.data[i] = new PropertySet();
-            int length = this.data[i].doDeserializeFromByteArray(byteArray, 
index);
+            int length = this.data[i].doDeserializeFromByteArray(byteArray, 
index, depth + 1);
             index += length;
         }
 
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/property/PrtFourBytesOfLengthFollowedByData.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/property/PrtFourBytesOfLengthFollowedByData.java
index 8518be09b5..4eb0ca7b83 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/property/PrtFourBytesOfLengthFollowedByData.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/property/PrtFourBytesOfLengthFollowedByData.java
@@ -43,6 +43,12 @@ public class PrtFourBytesOfLengthFollowedByData implements 
IProperty {
         int index = startIndex;
         this.cb = (int) BitConverter.toUInt32(byteArray, startIndex);
         index += 4;
+        // copyOfRange zero-fills past the source end, so cb must be bounded by
+        // the remaining bytes or a malformed length allocates cb bytes of heap
+        if (this.cb < 0 || this.cb > byteArray.length - index) {
+            throw new IOException("prtFourBytesOfLengthFollowedByData length " 
+ this.cb +
+                    " exceeds remaining data " + (byteArray.length - index));
+        }
         this.data = Arrays.copyOfRange(byteArray, index, index + this.cb);
         index += this.cb;
 
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/ObjectDataBLOB.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/ObjectDataBLOB.java
index e92d04f6e9..2873e1d013 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/ObjectDataBLOB.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/ObjectDataBLOB.java
@@ -30,14 +30,8 @@ import 
org.apache.tika.parser.microsoft.onenote.fsshttpb.util.ByteUtil;
  * e.g. an embedded image or file. See MS-FSSHTTPB section 2.2.1.12.8.
  */
 public class ObjectDataBLOB extends StreamObject {
-    /**
-     * A binary item that holds the opaque binary data.
-     */
     public BinaryItem data;
 
-    /**
-     * Initializes a new instance of the ObjectDataBLOB class.
-     */
     public ObjectDataBLOB() {
         super(StreamObjectTypeHeaderStart.ObjectDataBLOB);
         this.data = new BinaryItem();
@@ -53,13 +47,6 @@ public class ObjectDataBLOB extends StreamObject {
         return ByteUtil.toByteArray(this.data.content);
     }
 
-    /**
-     * Used to de-serialize the element.
-     *
-     * @param byteArray     A Byte array
-     * @param currentIndex  Start position
-     * @param lengthOfItems The length of the items
-     */
     @Override
     protected void deserializeItemsFromByteArray(byte[] byteArray, 
AtomicInteger currentIndex,
                                                  int lengthOfItems)
@@ -75,12 +62,6 @@ public class ObjectDataBLOB extends StreamObject {
         currentIndex.set(index.get());
     }
 
-    /**
-     * Used to convert the element into a byte List
-     *
-     * @param byteList A Byte list
-     * @return The number of elements actually contained in the list
-     */
     @Override
     protected int serializeItemsToByteList(List<Byte> byteList) throws 
IOException {
         int startPoint = byteList.size();
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/ObjectDataBLOBDataElementData.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/ObjectDataBLOBDataElementData.java
index 17828cf0d7..ff28e9d096 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/ObjectDataBLOBDataElementData.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/ObjectDataBLOBDataElementData.java
@@ -29,20 +29,10 @@ import org.apache.tika.exception.TikaException;
 public class ObjectDataBLOBDataElementData extends DataElementData {
     public ObjectDataBLOB objectDataBLOB;
 
-    /**
-     * Initializes a new instance of the ObjectDataBLOBDataElementData class.
-     */
     public ObjectDataBLOBDataElementData() {
         this.objectDataBLOB = new ObjectDataBLOB();
     }
 
-    /**
-     * Deserializes this element from the supplied byte array.
-     *
-     * @param byteArray  A byte array
-     * @param startIndex Start position
-     * @return The number of bytes consumed
-     */
     @Override
     public int deserializeDataElementDataFromByteArray(byte[] byteArray, int 
startIndex)
             throws TikaException, IOException {
@@ -51,11 +41,6 @@ public class ObjectDataBLOBDataElementData extends 
DataElementData {
         return index.get() - startIndex;
     }
 
-    /**
-     * Used to convert the element into a byte List.
-     *
-     * @return The Byte list
-     */
     @Override
     public List<Byte> serializeToByteList() throws TikaException, IOException {
         return this.objectDataBLOB.serializeToByteList();
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/PropertySet.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/PropertySet.java
index 45e1278f42..64276e5aa5 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/PropertySet.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/PropertySet.java
@@ -37,6 +37,10 @@ import 
org.apache.tika.parser.microsoft.onenote.fsshttpb.util.BitConverter;
  * This class is used to represent a PropertySet.
  */
 public class PropertySet implements IProperty {
+    // a nesting level costs ~14 file bytes, so unbounded depth lets a small
+    // crafted file drive a StackOverflowError that escapes catch(Exception)
+    public static final int MAX_PROPERTY_NESTING = 100;
+
     public int cProperties;
 
     public PropertyID[] rgPrids;
@@ -72,6 +76,14 @@ public class PropertySet implements IProperty {
      * @return Return the length in byte of the PropertySet.
      */
     public int doDeserializeFromByteArray(byte[] byteArray, int startIndex) 
throws IOException {
+        return doDeserializeFromByteArray(byteArray, startIndex, 0);
+    }
+
+    public int doDeserializeFromByteArray(byte[] byteArray, int startIndex, 
int depth)
+            throws IOException {
+        if (depth > MAX_PROPERTY_NESTING) {
+            throw new IOException("PropertySet nesting exceeds " + 
MAX_PROPERTY_NESTING);
+        }
         int index = startIndex;
 
         this.cProperties = BitConverter.toInt16(byteArray, startIndex);
@@ -124,7 +136,16 @@ public class PropertySet implements IProperty {
                     break;
             }
             if (property != null) {
-                int len = property.doDeserializeFromByteArray(byteArray, 
index);
+                int len;
+                if (property instanceof PropertySet) {
+                    len = ((PropertySet) property)
+                            .doDeserializeFromByteArray(byteArray, index, 
depth + 1);
+                } else if (property instanceof PrtArrayOfPropertyValues) {
+                    len = ((PrtArrayOfPropertyValues) property)
+                            .doDeserializeFromByteArray(byteArray, index, 
depth + 1);
+                } else {
+                    len = property.doDeserializeFromByteArray(byteArray, 
index);
+                }
                 this.rgData.add(property);
                 index += len;
             }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/RevisionStoreObjectGroup.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/RevisionStoreObjectGroup.java
index 1381fdbdbe..fb16a8402a 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/RevisionStoreObjectGroup.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/RevisionStoreObjectGroup.java
@@ -18,7 +18,7 @@ package 
org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj;
 
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -42,11 +42,16 @@ public class RevisionStoreObjectGroup {
                                                           Map<ExGuid, 
DataElement> blobElements)
             throws IOException {
         RevisionStoreObjectGroup objectGroup = new 
RevisionStoreObjectGroup(objectGroupId);
-        Map<ExGuid, RevisionStoreObject> objectDict = new HashMap<>();
+        // insertion-ordered so the object list (and any degraded-file 
fallback walk) is
+        // deterministic
+        Map<ExGuid, RevisionStoreObject> objectDict = new LinkedHashMap<>();
         if (!isEncryption) {
             RevisionStoreObject revisionObject = null;
             for (int i = 0; i < 
dataObject.objectGroupDeclarations.objectDeclarationList.size();
                     i++) {
+                if (i >= 
dataObject.objectGroupData.objectGroupObjectDataList.size()) {
+                    throw new IOException("Missing object data for object 
declaration " + i);
+                }
                 ObjectGroupObjectDeclare objectDeclaration =
                         
dataObject.objectGroupDeclarations.objectDeclarationList.get(i);
                 ObjectGroupObjectData objectData =
@@ -108,6 +113,9 @@ public class RevisionStoreObjectGroup {
         } else {
             for (int i = 0; i < 
dataObject.objectGroupDeclarations.objectDeclarationList.size();
                     i++) {
+                if (i >= 
dataObject.objectGroupData.objectGroupObjectDataList.size()) {
+                    throw new IOException("Missing object data for object 
declaration " + i);
+                }
                 ObjectGroupObjectDeclare objectDeclaration =
                         
dataObject.objectGroupDeclarations.objectDeclarationList.get(i);
                 ObjectGroupObjectData objectData =
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfContextIDs.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfContextIDs.java
index fe6d288330..7e1e348e7d 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfContextIDs.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfContextIDs.java
@@ -57,10 +57,16 @@ public class ObjectSpaceObjectStreamOfContextIDs {
         int headerCount = this.header.doDeserializeFromByteArray(byteArray, 
index);
         index += headerCount;
 
+        // each CompactID consumes 4 bytes, so a valid count cannot exceed the 
remaining
+        // bytes / 4; bounds the allocation on malformed counts
+        if (this.header.count > (byteArray.length - index) / 4L) {
+            throw new IOException("ObjectSpaceObjectStreamOfContextIDs count " 
+
+                    this.header.count + " exceeds remaining data " + 
(byteArray.length - index));
+        }
         this.body = new CompactID[(int) this.header.count];
         for (int i = 0; i < this.header.count; i++) {
             CompactID compactID = new CompactID();
-            int count = compactID.doDeserializeFromByteArray(byteArray, 
startIndex);
+            int count = compactID.doDeserializeFromByteArray(byteArray, index);
             this.body[i] = compactID;
             index += count;
         }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfOIDs.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfOIDs.java
index 7901dd015e..53398663c3 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfOIDs.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfOIDs.java
@@ -58,10 +58,16 @@ public class ObjectSpaceObjectStreamOfOIDs {
         int headerCount = this.header.doDeserializeFromByteArray(byteArray, 
index);
         index += headerCount;
 
+        // each CompactID consumes 4 bytes, so a valid count cannot exceed the 
remaining
+        // bytes / 4; bounds the allocation on malformed counts
+        if (this.header.count > (byteArray.length - index) / 4L) {
+            throw new IOException("ObjectSpaceObjectStreamOfOIDs count " + 
this.header.count +
+                    " exceeds remaining data " + (byteArray.length - index));
+        }
         this.body = new CompactID[(int) this.header.count];
         for (int i = 0; i < this.header.count; i++) {
             CompactID compactID = new CompactID();
-            int count = compactID.doDeserializeFromByteArray(byteArray, 
startIndex);
+            int count = compactID.doDeserializeFromByteArray(byteArray, index);
             this.body[i] = compactID;
             index += count;
         }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfOSIDs.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfOSIDs.java
index 61f7deda90..f37de26f76 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfOSIDs.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/streamobj/space/ObjectSpaceObjectStreamOfOSIDs.java
@@ -57,6 +57,12 @@ public class ObjectSpaceObjectStreamOfOSIDs {
         int headerCount = this.header.doDeserializeFromByteArray(byteArray, 
index);
         index += headerCount;
 
+        // each CompactID consumes 4 bytes, so a valid count cannot exceed the 
remaining
+        // bytes / 4; bounds the allocation on malformed counts
+        if (this.header.count > (byteArray.length - index) / 4L) {
+            throw new IOException("ObjectSpaceObjectStreamOfOSIDs count " + 
this.header.count +
+                    " exceeds remaining data " + (byteArray.length - index));
+        }
         this.body = new CompactID[(int) this.header.count];
         for (int i = 0; i < this.header.count; i++) {
             CompactID compactID = new CompactID();
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/GUIDTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/GUIDTest.java
index 6e7f3b70d1..0f21f861db 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/GUIDTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/GUIDTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.tika.parser.microsoft.onenote;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import java.nio.charset.StandardCharsets;
@@ -26,6 +27,24 @@ import org.apache.tika.exception.TikaException;
 
 public class GUIDTest {
 
+    @Test
+    public void testParsesValidGuidRoundTrip() throws Exception {
+        byte[] valid = "{638DE92F-a6d4-4BC1-9A36-4AFC2511A5B7}"
+                .getBytes(StandardCharsets.UTF_16LE);
+
+        assertEquals("{638DE92F-A6D4-4BC1-9A36-4AFC2511A5B7}",
+                GUID.fromCurlyBraceUTF16Bytes(valid).toString());
+    }
+
+    @Test
+    public void testRejectsNonAsciiUnicodeDigits() {
+        // fullwidth '6' is a Unicode digit that Character.digit(c, 16) would 
accept
+        byte[] malformed = "{638DE92F-A6D4-4BC1-9A36-4AFC2511A5B7}"
+                .getBytes(StandardCharsets.UTF_16LE);
+
+        assertThrows(TikaException.class, () -> 
GUID.fromCurlyBraceUTF16Bytes(malformed));
+    }
+
     @Test
     public void testRejectsMalformedCurlyBraceGuid() {
         byte[] malformed = "{638DE92F-A6D4-4BC1-9IJ36-4AFC2511A5B7}"
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/OneNoteParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/OneNoteParserTest.java
index 90e1ada59e..f064b50c4a 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/OneNoteParserTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/OneNoteParserTest.java
@@ -25,10 +25,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.StringWriter;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.time.Instant;
@@ -49,6 +51,7 @@ import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.MSOneStorePackage;
 import org.apache.tika.sax.ToTextContentHandler;
 import org.apache.tika.sax.XHTMLContentHandler;
 
@@ -57,7 +60,9 @@ public class OneNoteParserTest extends TikaTest {
     //test recursive parser wrapper for image files
 
     @Test
-    public void testFuzzerRegressionInputsFailWithTikaExceptions() throws 
Exception {
+    public void testFuzzerRegressionInputsFallBackToLegacyDump() throws 
Exception {
+        // a structural failure falls back to the legacy string dump; the 
failure is
+        // pinned in the parse-warning metadata
         String[][] resources = {
                 {"testOneNote-fuzz1.one", "Missing dependent revision"},
                 {"testOneNote-fuzz2.one", "unified property count"},
@@ -68,15 +73,73 @@ public class OneNoteParserTest extends TikaTest {
             assertNotNull(input, resource[0]);
             try (InputStream stream = input;
                  TikaInputStream tis = TikaInputStream.get(stream)) {
-                TikaException exception = assertThrows(TikaException.class,
-                        () -> new OneNoteParser().parse(tis, new 
ToTextContentHandler(),
-                                new Metadata(), new ParseContext()), 
resource[0]);
-                assertTrue(exception.getMessage().contains(resource[1]),
-                        exception.getMessage());
+                Metadata metadata = new Metadata();
+                new OneNoteParser().parse(tis, new ToTextContentHandler(), 
metadata,
+                        new ParseContext());
+                assertTrue(Arrays.stream(
+                                
metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING))
+                        .anyMatch(warning -> warning.contains(resource[1])
+                                && warning.contains("falling back to legacy 
text dump")),
+                        () -> resource[0] + ": " + 
Arrays.toString(metadata.getValues(
+                                
TikaCoreProperties.TIKA_META_EXCEPTION_WARNING)));
             }
         }
     }
 
+    @Test
+    public void testNoContentFsshttpbWalkFallsBackToLegacyDump(@TempDir Path 
tempDir)
+            throws Exception {
+        Path file = tempDir.resolve("empty-walk.one");
+        // trailing newline: the dump excludes the file's final byte from 
scanning
+        Files.write(file,
+                "recoverable legacy dump 
text\n".getBytes(StandardCharsets.US_ASCII));
+        Metadata metadata = new Metadata();
+        StringWriter writer = new StringWriter();
+        XHTMLContentHandler xhtml = new XHTMLContentHandler(new 
ToTextContentHandler(writer),
+                metadata, new ParseContext());
+        xhtml.startDocument();
+        try (OneNoteDirectFileResource resource =
+                new OneNoteDirectFileResource(file.toFile())) {
+            // fresh package = the walk completed without emitting anything
+            OneNoteParser.legacyFallbackIfNoContent(new MSOneStorePackage(), 
metadata, xhtml,
+                    resource);
+            assertTrue(writer.toString().contains("recoverable legacy dump 
text"));
+            
assertTrue(metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING)
+                    .contains("produced no content"));
+
+            // pkg == null means the exception path already dumped - no second 
dump
+            StringWriter exceptionPath = new StringWriter();
+            XHTMLContentHandler xhtml2 = new XHTMLContentHandler(
+                    new ToTextContentHandler(exceptionPath), metadata, new 
ParseContext());
+            xhtml2.startDocument();
+            OneNoteParser.legacyFallbackIfNoContent(null, metadata, xhtml2, 
resource);
+            xhtml2.endDocument();
+            assertTrue(exceptionPath.toString().isBlank());
+        }
+        xhtml.endDocument();
+    }
+
+    @Test
+    public void testTruncatedFileFallsBackToLegacyDump(@TempDir Path tempDir) 
throws Exception {
+        byte[] full;
+        try (InputStream is = getClass()
+                .getResourceAsStream("/test-documents/testOneNote1.one")) {
+            full = is.readAllBytes();
+        }
+        // keep the 1024-byte header plus a sliver of content so the root file 
node list
+        // is unreachable
+        Path truncated = tempDir.resolve("truncated.one");
+        Files.write(truncated, Arrays.copyOf(full, 2048));
+
+        Metadata metadata = new Metadata();
+        try (TikaInputStream tis = TikaInputStream.get(truncated)) {
+            new OneNoteParser().parse(tis, new ToTextContentHandler(), 
metadata,
+                    new ParseContext());
+        }
+        
assertTrue(Arrays.stream(metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING))
+                .anyMatch(warning -> warning.contains("falling back to legacy 
text dump")));
+    }
+
     /**
      * This is the sample document that is automatically created from onenote 
2013.
      */
@@ -293,6 +356,16 @@ public class OneNoteParserTest extends TikaTest {
         assertTrue(embeddedTypes.contains("INLINE"));
     }
 
+    @Test
+    public void testOneNoteEmbeddedImageRecursiveMetadata() throws Exception {
+        List<Metadata> metadataList = 
getRecursiveMetadata("testOneNoteEmbeddedImage.one");
+
+        assertEquals(2, metadataList.size());
+        Metadata embedded = metadataList.get(1);
+        assertEquals("INLINE", 
embedded.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        
assertNotNull(embedded.get(TikaCoreProperties.EMBEDDED_RELATIONSHIP_ID));
+    }
+
     @Test
     public void testPropertyValueBudgetIsSharedAcrossCopiesAndResetsPerList(
             @TempDir Path tempDir) throws Exception {
@@ -338,6 +411,135 @@ public class OneNoteParserTest extends TikaTest {
                 .count());
     }
 
+    @Test
+    public void testDeepFileNodeChainIsDepthCapped(@TempDir Path tempDir) 
throws Exception {
+        // a long acyclic chain slips past the cycle guard - the depth cap 
must stop it
+        // before the recursion can overflow the stack
+        FileNode root = new FileNode().setGosid(ExtendedGUID.nil());
+        FileNode current = root;
+        for (int i = 0; i < 600; i++) {
+            FileNode child = new FileNode().setGosid(ExtendedGUID.nil());
+            current.childFileNodeList.setFileNodeListHeader(new 
FileNodeListHeader(0,
+                    FileNodeListHeader.UNIT_MAGIC_CONSTANT, 0x10, 0));
+            current.childFileNodeList.children.add(child);
+            current = child;
+        }
+        Metadata metadata = new Metadata();
+        ParseContext parseContext = new ParseContext();
+        Path emptyFile = Files.createFile(tempDir.resolve("empty"));
+        try (OneNoteDirectFileResource dif = new 
OneNoteDirectFileResource(emptyFile.toFile())) {
+            OneNoteTreeWalker walker = new OneNoteTreeWalker(new 
OneNoteTreeWalkerOptions(),
+                    new OneNoteDocument(), dif,
+                    new XHTMLContentHandler(new ToTextContentHandler(), 
metadata, parseContext),
+                    metadata, parseContext, null);
+            walker.walkFileNode(root, null);
+        }
+        assertEquals(1, Arrays.stream(
+                        
metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING))
+                .filter(warning -> warning.contains("exceeded depth limit"))
+                .count());
+    }
+
+    /**
+     * Writes one minimal 56-byte file-node-list fragment: header, a single 
baseType-2 node
+     * whose child list is at {childStp, childCb}, a terminator, a nil 
next-fragment
+     * reference and the footer.
+     */
+    private static void writeFileNodeListBlock(ByteBuffer buf, long childStp, 
int childCb) {
+        // id=0x10, size=16 (node header + 8-byte stp + 4-byte cb), 
stpFormat=0, cbFormat=0,
+        // baseType=2, reserved=1
+        int fileNodeHeader = 0x10 | (16 << 10) | (2 << 27) | (1 << 31);
+        
buf.putLong(FileNodeListHeader.UNIT_MAGIC_CONSTANT).putInt(0x10).putInt(0)
+                .putInt(fileNodeHeader).putLong(childStp).putInt(childCb)
+                .putInt(0)
+                .putLong(-1).putInt(0)
+                .putLong(OneNotePtr.FOOTER_CONST);
+    }
+
+    @Test
+    public void testFileNodeListCycleFailsCleanly(@TempDir Path tempDir) 
throws Exception {
+        // one fragment holding a baseType-2 node whose child list points back 
at itself
+        ByteBuffer buf = 
ByteBuffer.allocate(56).order(ByteOrder.LITTLE_ENDIAN);
+        writeFileNodeListBlock(buf, 0, 56);
+        Path cyclic = tempDir.resolve("cyclic");
+        Files.write(cyclic, buf.array());
+        try (OneNoteDirectFileResource dif = new 
OneNoteDirectFileResource(cyclic.toFile())) {
+            OneNotePtr ptr = new OneNotePtr(new OneNoteDocument(), dif);
+            TikaException e = assertThrows(TikaException.class,
+                    () -> ptr.deserializeFileNodeList(new FileNodeList(), new 
FileNodePtr()));
+            assertTrue(e.getMessage().contains("cycle"), e.getMessage());
+        }
+    }
+
+    @Test
+    public void testFileNodeListNestingIsDepthCapped(@TempDir Path tempDir) 
throws Exception {
+        // 120 lists, each holding one baseType-2 node pointing at the next 
list - acyclic,
+        // so only the depth cap can stop the recursion
+        int lists = 120;
+        ByteBuffer buf = ByteBuffer.allocate(56 * 
lists).order(ByteOrder.LITTLE_ENDIAN);
+        for (int i = 0; i < lists; i++) {
+            long childStp = (i + 1 < lists ? i + 1 : i) * 56L;
+            writeFileNodeListBlock(buf, childStp, 56);
+        }
+        Path deep = tempDir.resolve("deep");
+        Files.write(deep, buf.array());
+        try (OneNoteDirectFileResource dif = new 
OneNoteDirectFileResource(deep.toFile())) {
+            OneNotePtr ptr = new OneNotePtr(new OneNoteDocument(), dif);
+            assertThrows(TikaMemoryLimitException.class,
+                    () -> ptr.deserializeFileNodeList(new FileNodeList(), new 
FileNodePtr()));
+        }
+    }
+
+    @Test
+    public void testFragmentChainCycleFailsCleanly(@TempDir Path tempDir) 
throws Exception {
+        // an empty fragment whose next-fragment reference points back at 
itself would
+        // previously loop forever
+        ByteBuffer buf = ByteBuffer.allocate(36).order(ByteOrder.LITTLE_ENDIAN)
+                
.putLong(FileNodeListHeader.UNIT_MAGIC_CONSTANT).putInt(0x10).putInt(0)
+                .putLong(0).putInt(36)
+                .putLong(OneNotePtr.FOOTER_CONST);
+        Path cyclic = tempDir.resolve("cyclic-fragment");
+        Files.write(cyclic, buf.array());
+        try (OneNoteDirectFileResource dif = new 
OneNoteDirectFileResource(cyclic.toFile())) {
+            OneNotePtr ptr = new OneNotePtr(new OneNoteDocument(), dif);
+            TikaException e = assertThrows(TikaException.class,
+                    () -> ptr.deserializeFileNodeList(new FileNodeList(), new 
FileNodePtr()));
+            assertTrue(e.getMessage().contains("fragment cycle"), 
e.getMessage());
+        }
+    }
+
+    @Test
+    public void testLegacyEmbeddedExtractionHonorsShouldParseEmbedded() throws 
Exception {
+        List<Metadata> offered = new ArrayList<>();
+        ParseContext context = new ParseContext();
+        context.set(EmbeddedDocumentExtractor.class, new 
EmbeddedDocumentExtractor() {
+            @Override
+            public boolean shouldParseEmbedded(Metadata metadata, ParseContext 
parseContext) {
+                offered.add(metadata);
+                return false;
+            }
+
+            @Override
+            public void parseEmbedded(TikaInputStream stream, ContentHandler 
handler,
+                                      Metadata metadata, ParseContext context,
+                                      boolean outputHtml) {
+                throw new AssertionError("must not parse embedded when 
shouldParseEmbedded" +
+                        " returns false");
+            }
+        });
+        try (TikaInputStream tis =
+                
getResourceAsStream("/test-documents/testOneNoteEmbeddedWordDoc.one")) {
+            new OneNoteParser().parse(tis, new ToTextContentHandler(), new 
Metadata(), context);
+        }
+        assertTrue(offered.size() > 0);
+        // the legacy path offers every embedded object as an ATTACHMENT; the 
relationship
+        // id is set only when the object carries a non-nil gosid
+        for (Metadata offeredMetadata : offered) {
+            
assertEquals(TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.toString(),
+                    
offeredMetadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+        }
+    }
+
     /**
      * Test a document pulled from Office 365 which stores the MS-ONESTORE 
document using the MS-FSSHTTPB
      * protocol.
@@ -351,7 +553,10 @@ public class OneNoteParserTest extends TikaTest {
         // older page version snapshots are not reported
         assertEquals(Arrays.asList("Chang Du", "Du Chang"),
                 Arrays.asList(metadata.getValues(TikaCoreProperties.CREATOR)));
-        assertEquals(1, metadata.getValues(ONE_NOTE_PREFIX + 
"mostRecentAuthors").length);
+        // both authors are referenced as AuthorMostRecent by current content; 
one of them
+        // is first visited under another role, so its most-recent role must 
still register
+        assertEquals(Arrays.asList("Chang Du", "Du Chang"),
+                Arrays.asList(metadata.getValues(ONE_NOTE_PREFIX + 
"mostRecentAuthors")));
 
         assertEquals(Instant.ofEpochSecond(1636621406),
                 
Instant.ofEpochSecond(Long.parseLong(metadata.get(ONE_NOTE_PREFIX + 
"creationTimestamp"))));
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStoreBlobTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStoreBlobTest.java
index 38b5fc9b1b..b7d7508f8b 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStoreBlobTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStoreBlobTest.java
@@ -18,6 +18,7 @@ package org.apache.tika.parser.microsoft.onenote.fsshttpb;
 
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -213,8 +214,7 @@ public class MSOneStoreBlobTest {
         assertNull(blob.getData());
         blob.data = new BinaryItem();
         blob.data.content.add((byte) 1);
-        // Exercise serialization of a non-null BLOB; the round-trip assertion 
is above.
-        blob.serializeToByteList();
+        assertFalse(blob.serializeToByteList().isEmpty());
 
         ObjectDataBLOB validBlob = new ObjectDataBLOB();
         validBlob.data.content.add((byte) 1);
@@ -263,6 +263,19 @@ public class MSOneStoreBlobTest {
         });
     }
 
+    @Test
+    public void testDeclarationWithoutObjectDataFailsCleanly() {
+        ObjectGroupDataElementData mismatched = new 
ObjectGroupDataElementData();
+        ObjectGroupObjectDeclare declaration = new ObjectGroupObjectDeclare();
+        declaration.objectPartitionID.setDecodedValue(1);
+        
mismatched.objectGroupDeclarations.objectDeclarationList.add(declaration);
+
+        IOException e = assertThrows(IOException.class,
+                () -> RevisionStoreObjectGroup.createInstance(new ExGuid(9, 
UUID.randomUUID()),
+                        mismatched, false, Collections.emptyMap()));
+        assertTrue(e.getMessage().contains("Missing object data"));
+    }
+
     @Test
     public void testEncryptedObjectGroupAndMissingBlobReference() throws 
Exception {
         ObjectGroupDataElementData encryptedData = new 
ObjectGroupDataElementData();
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStorePackageTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStorePackageTest.java
index 4de6e92c12..bbeadea253 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStorePackageTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/MSOneStorePackageTest.java
@@ -285,6 +285,91 @@ public class MSOneStorePackageTest {
         assertEquals("Иван Петров", metadata.get(OneNote.ORIGINAL_AUTHORS));
     }
 
+    @Test
+    public void testDualRoleAuthorRecordedForBothRoles() throws Exception {
+        ExGuid authorId = id(710);
+        // the same author object referenced as both AuthorOriginal and 
AuthorMostRecent
+        RevisionStoreObject root = object(id(711), propertySet(
+                        new PropertySpec(PropertyType.ObjectID, 0x20001D78, 
new NoData()),
+                        new PropertySpec(PropertyType.ObjectID, 0x20001D79, 
new NoData())),
+                Arrays.asList(authorId, authorId), Collections.emptyList());
+        RevisionStoreObject author = object(authorId, propertySet(
+                        new 
PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                                0x1C001D75, utf16Text("Single Author"))),
+                Collections.emptyList(), Collections.emptyList());
+        RevisionStoreCell cell = new RevisionStoreCell();
+        cell.objectGroups.add(group(root, author));
+        RevisionManifestRootDeclare rootDeclare = new 
RevisionManifestRootDeclare();
+        rootDeclare.objectExGuid = root.objectID;
+        cell.rootDeclares.add(rootDeclare);
+
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.cells.add(cell);
+        Metadata metadata = new Metadata();
+        walk(pkg, metadata);
+
+        assertEquals("Single Author", 
metadata.get(TikaCoreProperties.CREATOR));
+        assertEquals("Single Author", metadata.get(OneNote.ORIGINAL_AUTHORS));
+        assertEquals("Single Author", 
metadata.get(OneNote.MOST_RECENT_AUTHORS));
+    }
+
+    @Test
+    public void testBlobOnlyRootWithDanglingContentRootWalksAllObjects() 
throws Exception {
+        RevisionStoreObject blobRoot = object(id(620), propertySet(),
+                Collections.emptyList(), Collections.emptyList());
+        blobRoot.propertySet = null;
+        blobRoot.fileDataObject = fileData("blob root");
+        RevisionStoreObject textObject = object(id(621), propertySet(
+                        new 
PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                                0x1C003498, text("page body text"))),
+                Collections.emptyList(), Collections.emptyList());
+        RevisionStoreCell cell = new RevisionStoreCell();
+        cell.objectGroups.add(group(blobRoot, textObject));
+        RevisionManifestRootDeclare blobDeclare = new 
RevisionManifestRootDeclare();
+        blobDeclare.objectExGuid = blobRoot.objectID;
+        cell.rootDeclares.add(blobDeclare);
+        RevisionManifestRootDeclare danglingContentRoot = new 
RevisionManifestRootDeclare();
+        danglingContentRoot.objectExGuid = id(622);
+        cell.rootDeclares.add(danglingContentRoot);
+        MSOneStorePackage pkg = new MSOneStorePackage();
+        pkg.cells.add(cell);
+        Metadata metadata = new Metadata();
+
+        // the blob root alone cannot reach the page body; the dangling 
content root must
+        // trigger the walk-everything fallback so the body is not lost
+        assertTrue(walk(pkg, metadata).contains("page body text"));
+        
assertTrue(Arrays.stream(metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING))
+                .anyMatch(warning -> warning.contains("walking all objects")));
+    }
+
+    @Test
+    public void testHasEmittedContentTracksTextAndEmptyWalks() throws 
Exception {
+        MSOneStorePackage withText = new MSOneStorePackage();
+        withText.cells.add(cellWithText(cell(70, 71), "some text"));
+        walk(withText);
+        assertTrue(withText.hasEmittedContent());
+
+        MSOneStorePackage empty = new MSOneStorePackage();
+        RevisionStoreCell danglingCell = new RevisionStoreCell();
+        RevisionManifestRootDeclare missingRoot = new 
RevisionManifestRootDeclare();
+        missingRoot.objectExGuid = id(72);
+        danglingCell.rootDeclares.add(missingRoot);
+        empty.cells.add(danglingCell);
+        walk(empty);
+        assertFalse(empty.hasEmittedContent());
+    }
+
+    @Test
+    public void testSanitizeResourceNameKeepsBasenameOfPathShapedNames() {
+        assertEquals("pic 1.png",
+                MSOneStorePackage.sanitizeResourceName("D:\\images\\pic 
1.png"));
+        assertEquals("pic.png", 
MSOneStorePackage.sanitizeResourceName("C:pic.png"));
+        assertEquals("a.png", 
MSOneStorePackage.sanitizeResourceName("/tmp/a.png"));
+        assertEquals("plain.png", 
MSOneStorePackage.sanitizeResourceName("plain.png"));
+        assertEquals("", MSOneStorePackage.sanitizeResourceName(".."));
+        assertEquals("", 
MSOneStorePackage.sanitizeResourceName("D:\\images\\.."));
+    }
+
     @Test
     public void testBlobOnlyRootDoesNotFallBackToOtherObjects() throws 
Exception {
         RevisionStoreObject blobRoot = object(id(610), propertySet(),
@@ -417,19 +502,17 @@ public class MSOneStorePackageTest {
                                 new NoData())),
                 Collections.emptyList(), Collections.emptyList());
         Method collectActions = 
MSOneStorePackage.class.getDeclaredMethod("collectActions",
-                PropertySet.class, List.class, int[].class, List.class, 
int[].class, List.class);
+                PropertySet.class, List.class, int[].class, List.class, 
int[].class, List.class,
+                int.class);
         collectActions.setAccessible(true);
         List<Object> actions = new ArrayList<>();
         collectActions.invoke(new MSOneStorePackage(),
                 hugeArrayRoot.propertySet.objectSpaceObjectPropSet.body,
                 Collections.emptyList(), new int[]{0}, 
Collections.emptyList(), new int[]{0},
-                actions);
+                actions, 0);
         assertTrue(actions.isEmpty());
-        Method collectActionsWithDepth = 
MSOneStorePackage.class.getDeclaredMethod(
-                "collectActions", PropertySet.class, List.class, int[].class, 
List.class,
-                int[].class, List.class, int.class);
-        collectActionsWithDepth.setAccessible(true);
-        collectActionsWithDepth.invoke(new MSOneStorePackage(),
+        actions.clear();
+        collectActions.invoke(new MSOneStorePackage(),
                 hugeArrayRoot.propertySet.objectSpaceObjectPropSet.body,
                 Collections.singletonList(id(601)), new int[]{0}, 
Collections.emptyList(),
                 new int[]{0}, actions, 1000);
@@ -454,7 +537,7 @@ public class MSOneStorePackageTest {
         actions.clear();
         collectActions.invoke(new MSOneStorePackage(), alignedSet,
                 Collections.nCopies(100001, id(601)), new int[]{0}, 
Collections.emptyList(),
-                new int[]{0}, actions);
+                new int[]{0}, actions, 0);
         assertEquals(100001, actions.size());
         java.lang.reflect.Field childReference = actions.get(actions.size() - 
1).getClass()
                 .getDeclaredField("childReference");
@@ -467,15 +550,26 @@ public class MSOneStorePackageTest {
                 OneNoteTreeWalkerOptions.class, Metadata.class, 
XHTMLContentHandler.class,
                 int.class);
         walkObject.setAccessible(true);
+        // an object that emits text when walked, so the depth-capped walk's 
blank
+        // output proves the cap fired rather than the setup having nothing to 
emit
+        RevisionStoreObject textRoot = object(id(605), propertySet(
+                        new 
PropertySpec(PropertyType.FourBytesOfLengthFollowedByData,
+                                0x1C003498, text("depth capped text"))),
+                Collections.emptyList(), Collections.emptyList());
         Metadata metadata = new Metadata();
         StringWriter writer = new StringWriter();
         XHTMLContentHandler xhtml = new XHTMLContentHandler(
                 new ToTextContentHandler(writer), metadata, new 
ParseContext());
         xhtml.startDocument();
-        walkObject.invoke(new MSOneStorePackage(), hugeArrayRoot,
+        walkObject.invoke(new MSOneStorePackage(), textRoot,
                 new java.util.HashMap<>(), new java.util.HashSet<>(), null,
                 new OneNoteTreeWalkerOptions(), metadata, xhtml, 1000);
+        assertTrue(writer.toString().isBlank());
+        walkObject.invoke(new MSOneStorePackage(), textRoot,
+                new java.util.HashMap<>(), new java.util.HashSet<>(), null,
+                new OneNoteTreeWalkerOptions(), metadata, xhtml, 0);
         xhtml.endDocument();
+        assertTrue(writer.toString().contains("depth capped text"));
     }
 
     @Test
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/PropertyDeserializationBoundsTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/PropertyDeserializationBoundsTest.java
new file mode 100644
index 0000000000..73d5ecd395
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/onenote/fsshttpb/PropertyDeserializationBoundsTest.java
@@ -0,0 +1,184 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.microsoft.onenote.fsshttpb;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import 
org.apache.tika.parser.microsoft.onenote.fsshttpb.property.PrtArrayOfPropertyValues;
+import 
org.apache.tika.parser.microsoft.onenote.fsshttpb.property.PrtFourBytesOfLengthFollowedByData;
+import org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.PropertySet;
+import 
org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.PropertyID;
+import 
org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.basic.PropertyType;
+import 
org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.space.ObjectSpaceObjectStreamHeader;
+import 
org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.space.ObjectSpaceObjectStreamOfContextIDs;
+import 
org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.space.ObjectSpaceObjectStreamOfOIDs;
+import 
org.apache.tika.parser.microsoft.onenote.fsshttpb.streamobj.space.ObjectSpaceObjectStreamOfOSIDs;
+
+/**
+ * File-derived counts and nesting in the fsshttpb property deserializers must 
be bounded -
+ * a malformed count would otherwise allocate up to gigabytes and a deeply 
nested property
+ * set would overflow the stack, both Errors that escape the parser's 
Exception fallback.
+ */
+public class PropertyDeserializationBoundsTest {
+
+    @Test
+    public void testFourBytesOfLengthRejectsLengthBeyondRemainingData() {
+        byte[] bytes = concat(int32(1000), new byte[2]);
+        IOException e = assertThrows(IOException.class,
+                () -> new PrtFourBytesOfLengthFollowedByData()
+                        .doDeserializeFromByteArray(bytes, 0));
+        assertTrue(e.getMessage().contains("exceeds remaining data"), 
e.getMessage());
+    }
+
+    @Test
+    public void testFourBytesOfLengthRejectsNegativeLength() {
+        byte[] bytes = concat(int32(0xF0000000), new byte[8]);
+        assertThrows(IOException.class,
+                () -> new PrtFourBytesOfLengthFollowedByData()
+                        .doDeserializeFromByteArray(bytes, 0));
+    }
+
+    @Test
+    public void testFourBytesOfLengthAcceptsExactFit() throws IOException {
+        byte[] bytes = concat(int32(3), new byte[]{1, 2, 3});
+        PrtFourBytesOfLengthFollowedByData property = new 
PrtFourBytesOfLengthFollowedByData();
+        assertEquals(7, property.doDeserializeFromByteArray(bytes, 0));
+        assertEquals(3, property.data.length);
+    }
+
+    @Test
+    public void testArrayOfPropertyValuesRejectsCountBeyondRemainingData() 
throws IOException {
+        byte[] bytes = concat(int32(Integer.MAX_VALUE),
+                propertyId(PropertyType.PropertySet.getIntVal()));
+        IOException e = assertThrows(IOException.class,
+                () -> new 
PrtArrayOfPropertyValues().doDeserializeFromByteArray(bytes, 0));
+        assertTrue(e.getMessage().contains("exceeds remaining data"), 
e.getMessage());
+    }
+
+    @Test
+    public void testArrayOfPropertyValuesAcceptsSmallCount() throws 
IOException {
+        // one element: an empty PropertySet (int16 count of 0)
+        byte[] bytes = concat(int32(1), 
propertyId(PropertyType.PropertySet.getIntVal()),
+                new byte[]{0, 0});
+        PrtArrayOfPropertyValues array = new PrtArrayOfPropertyValues();
+        array.doDeserializeFromByteArray(bytes, 0);
+        assertEquals(1, array.data.length);
+    }
+
+    @Test
+    public void testStreamOfIdsRejectCountBeyondRemainingData() throws 
IOException {
+        byte[] bytes = streamHeader(0xFFFFFF);
+        assertThrows(IOException.class,
+                () -> new 
ObjectSpaceObjectStreamOfOIDs().doDeserializeFromByteArray(bytes, 0));
+        assertThrows(IOException.class,
+                () -> new 
ObjectSpaceObjectStreamOfOSIDs().doDeserializeFromByteArray(bytes, 0));
+        assertThrows(IOException.class,
+                () -> new ObjectSpaceObjectStreamOfContextIDs()
+                        .doDeserializeFromByteArray(bytes, 0));
+    }
+
+    @Test
+    public void testStreamOfOidsAcceptsSmallCount() throws IOException {
+        // one CompactID, 4 bytes
+        byte[] bytes = concat(streamHeader(1), new byte[4]);
+        ObjectSpaceObjectStreamOfOIDs stream = new 
ObjectSpaceObjectStreamOfOIDs();
+        stream.doDeserializeFromByteArray(bytes, 0);
+        assertEquals(1, stream.body.length);
+    }
+
+    @Test
+    public void testPropertySetNestingIsDepthCapped() throws IOException {
+        byte[] bytes = nestedPropertySet(PropertySet.MAX_PROPERTY_NESTING + 
50);
+        IOException e = assertThrows(IOException.class,
+                () -> new PropertySet().doDeserializeFromByteArray(bytes, 0));
+        assertTrue(e.getMessage().contains("nesting exceeds"), e.getMessage());
+    }
+
+    @Test
+    public void testPropertySetShallowNestingParses() throws IOException {
+        PropertySet propertySet = new PropertySet();
+        propertySet.doDeserializeFromByteArray(nestedPropertySet(3), 0);
+        assertEquals(1, propertySet.rgData.size());
+    }
+
+    @Test
+    public void testNestingThroughArrayOfPropertyValuesIsDepthCapped() throws 
IOException {
+        // alternate PropertySet -> ArrayOfPropertyValues -> PropertySet -> ...
+        byte[] bytes = new byte[]{0, 0};
+        for (int i = 0; i < PropertySet.MAX_PROPERTY_NESTING + 50; i++) {
+            bytes = concat(new byte[]{1, 0},
+                    
propertyId(PropertyType.ArrayOfPropertyValues.getIntVal()), int32(1),
+                    propertyId(PropertyType.PropertySet.getIntVal()), bytes);
+        }
+        byte[] finalBytes = bytes;
+        IOException e = assertThrows(IOException.class,
+                () -> new PropertySet().doDeserializeFromByteArray(finalBytes, 
0));
+        assertTrue(e.getMessage().contains("nesting exceeds"), e.getMessage());
+    }
+
+    private static byte[] nestedPropertySet(int depth) throws IOException {
+        // innermost: empty PropertySet; each wrapper declares one 
PropertySet-typed property
+        byte[] bytes = new byte[]{0, 0};
+        for (int i = 0; i < depth; i++) {
+            bytes = concat(new byte[]{1, 0},
+                    propertyId(PropertyType.PropertySet.getIntVal()), bytes);
+        }
+        return bytes;
+    }
+
+    private static byte[] propertyId(int type) throws IOException {
+        PropertyID propertyID = new PropertyID();
+        propertyID.id = 1;
+        propertyID.type = type;
+        return toArray(propertyID.serializeToByteList());
+    }
+
+    private static byte[] streamHeader(int count) throws IOException {
+        ObjectSpaceObjectStreamHeader header = new 
ObjectSpaceObjectStreamHeader();
+        header.count = count;
+        return toArray(header.serializeToByteList());
+    }
+
+    private static byte[] int32(int value) {
+        return new byte[]{(byte) value, (byte) (value >> 8), (byte) (value >> 
16),
+                (byte) (value >> 24)};
+    }
+
+    private static byte[] toArray(List<Byte> bytes) {
+        byte[] result = new byte[bytes.size()];
+        for (int i = 0; i < result.length; i++) {
+            result[i] = bytes.get(i);
+        }
+        return result;
+    }
+
+    private static byte[] concat(byte[]... parts) {
+        ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        for (byte[] part : parts) {
+            bos.writeBytes(part);
+        }
+        return bos.toByteArray();
+    }
+}

Reply via email to