Skip to content

Fix Windows attachment corruption: add O_BINARY flag to os.open - #495

Merged
taylorwilsdon merged 2 commits into
taylorwilsdon:mainfrom
mickey-mikey:fix/windows-attachment-corruption
Feb 24, 2026
Merged

Fix Windows attachment corruption: add O_BINARY flag to os.open#495
taylorwilsdon merged 2 commits into
taylorwilsdon:mainfrom
mickey-mikey:fix/windows-attachment-corruption

Conversation

@mickey-mikey

@mickey-mikey mickey-mikey commented Feb 20, 2026

Copy link
Copy Markdown
Contributor

Fix Windows attachment corruption from text-mode file writes

Fixes #494

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Root Cause

On Windows, os.open() defaults to text mode unless os.O_BINARY is explicitly set. In text mode, os.write() translates every \n (0x0a) byte into \r\n (0x0d 0x0a). This corrupts any binary attachment containing 0x0a bytes — which includes PNG headers (89 50 4e 47 0d 0a 1a 0a), PDFs, and most binary formats.

The previous commit incorrectly attributed the corruption to base64 decoding and attempted to strip \r/\n from the base64 string before decoding. This was a no-op — Python's base64.urlsafe_b64decode() already silently ignores whitespace characters.

Proof (tested on Windows against 100 real files — PDF, JPG, DOCX, EML, ICS, XLS — all 100 corrupted without fix, 0 with fix):

Write method Output hex Size Correct?
os.open without O_BINARY 89504e470d**0d0a**1a**0d0a**... 110 bytes Corrupted
os.open with O_BINARY 89504e470d0a1a0a... 108 bytes Correct
Path.write_bytes() 89504e470d0a1a0a... 108 bytes Correct

The 0d0d0a pattern reported in #494 is exactly what text-mode write translation produces: the existing \r\n (0d 0a) in the PNG header has its \n doubled to \r\n, yielding \r\r\n (0d 0d 0a).

The Fix

Location: core/attachment_storage.py line 105 in save_attachment()

Before:

fd = os.open(file_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)

After:

fd = os.open(file_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, 'O_BINARY', 0), 0o600)

Uses the standard getattr(os, 'O_BINARY', 0) idiom (same pattern used in CPython's zipfile and tarfile). Returns the O_BINARY flag on Windows, 0 (no-op) on platforms where it doesn't exist.

Also reverted the no-op base64 stripping in gmail/gmail_tools.py since urlsafe_b64decode already handles whitespace.

Testing

  • Proved urlsafe_b64decode already ignores \r/\n (no stripping needed)
  • Proved os.open without O_BINARY corrupts binary writes on Windows
  • Proved os.open with O_BINARY preserves binary data correctly
  • End-to-end test through AttachmentStorage.save_attachment with PNG payloads
  • Parametrized tests covering PNG, PDF, and all-byte-values payloads
  • Tested against 100 real files (PDF, JPG, DOCX, EML, ICS, XLS): 100/100 corrupted without fix, 0/100 with fix
  • All 7 tests passing on Windows

Impact

  • Fixes: Attachment corruption on Windows for all binary files containing 0x0a bytes (virtually every file)
  • Unix/Linux/Mac: getattr(os, 'O_BINARY', 0) evaluates to 0, making it a no-op on these platforms
  • Reverts the unnecessary base64 stripping from the prior commit

  • Allow edits from maintainers

Summary by CodeRabbit

  • Bug Fixes

    • Improved attachment binary handling so saved files preserve exact bytes across platforms (notably Windows), avoiding cross-OS corruption.
  • Tests

    • Added comprehensive tests covering base64 decoding, cross-platform file write behavior, and preservation of various binary attachment formats.

@coderabbitai

coderabbitai Bot commented Feb 20, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉


📝 Walkthrough

Walkthrough

Adds a Windows-safe open flag to save_attachment() by ORing in os.O_BINARY when present, and adds a new pytest module validating base64 handling and binary I/O behavior across platforms and attachment saving.

Changes

Cohort / File(s) Summary
Attachment storage change
core/attachment_storage.py
Modify save_attachment() to include getattr(os, 'O_BINARY', 0) in the os.open() flags so files are opened in binary mode on Windows (preserves mode 0o600).
Tests for binary integrity
tests/gmail/test_attachment_fix.py
Add tests and an isolated_storage fixture covering: urlsafe_b64decode CR/LF handling, os.open behavior with/without O_BINARY, save_attachment() using binary mode, and parametrized payload preservation (PNG, PDF, full byte range).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hopped through bytes both big and small,

Sniffed out stray CRs that made data fall.
One tiny flag, snug at the door,
Keeps every 0x0a intact once more. 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix Windows attachment corruption: add O_BINARY flag to os.open' directly and specifically summarizes the main change—adding the O_BINARY flag to fix Windows attachment corruption.
Description check ✅ Passed The PR description fully addresses the template requirements: clearly explains the bug fix, provides root cause analysis, documents testing performed, and includes the required 'Allow edits from maintainers' confirmation.
Linked Issues check ✅ Passed The PR implementation directly addresses all coding requirements from issue #494: adds O_BINARY flag to os.open() in save_attachment() using the getattr idiom, includes comprehensive test coverage for the fix, and validates the solution across multiple scenarios.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the linked issue #494: the production code fix in attachment_storage.py and comprehensive test coverage in test_attachment_fix.py with no unrelated modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

@mickey-mikey
mickey-mikey marked this pull request as ready for review February 20, 2026 03:55
@mickey-mikey
mickey-mikey force-pushed the fix/windows-attachment-corruption branch from ee6d083 to 57f5049 Compare February 20, 2026 04:00

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
tests/gmail/test_attachment_fix.py (2)

59-75: Test doesn't actually exercise newline stripping for "large" input.

The PDF header b"%PDF-1.7" encodes to only 12 base64 characters ("JVBERi0xLjc="). Since the chunking uses 76-character segments, no newlines are actually inserted—the list comprehension produces a single element, and "\n".join() returns the string unchanged.

Consider using a larger payload to actually test multi-line base64 stripping.

♻️ Proposed fix: Use a payload large enough to span multiple lines
 def test_large_base64_with_line_breaks():
     """Test with a large PDF-like base64 string."""
-    # Create a PDF-like binary (starts with %PDF-1.7)
-    pdf_header = b"%PDF-1.7"
+    # Create a larger PDF-like binary to ensure multiple base64 lines
+    pdf_header = b"%PDF-1.7" + b"\x00" * 100  # 108 bytes -> ~144 base64 chars
     pdf_base64 = base64.b64encode(pdf_header).decode()
     
     # Simulate embedded newlines (how large base64 is formatted)
     pdf_base64_with_newlines = "\n".join(
         [pdf_base64[i:i+76] for i in range(0, len(pdf_base64), 76)]
     )
     
+    # Verify newlines were actually inserted
+    assert "\n" in pdf_base64_with_newlines
+    
     # Apply the fix
     cleaned = pdf_base64_with_newlines.replace("\r", "").replace("\n", "")
     
     # Should decode to original PDF header
     result = base64.b64decode(cleaned)
     assert result == pdf_header
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/gmail/test_attachment_fix.py` around lines 59 - 75, The
test_large_base64_with_line_breaks currently uses pdf_header = b"%PDF-1.7" which
base64-encodes to only ~12 chars so pdf_base64_with_newlines never gets split;
change the payload to be large enough (e.g. repeat pdf_header or use a larger
binary) so pdf_base64 becomes longer than 76 chars, then regenerate
pdf_base64_with_newlines, strip newlines into cleaned, decode and assert the
decoded bytes equal the original repeated payload; update references in the test
to pdf_header, pdf_base64, pdf_base64_with_newlines and cleaned accordingly.

1-84: Tests duplicate the stripping logic instead of calling the production function.

These tests re-implement the fix logic inline rather than importing and testing get_gmail_attachment_content (or a helper extracted from it). If the production code diverges, these tests won't catch the regression.

Consider adding at least one integration-style test that exercises the actual production code path, or extract the stripping logic into a testable helper function.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/gmail/test_attachment_fix.py` around lines 1 - 84, Tests in
test_base64_* currently re-implement the stripping logic instead of exercising
the production code; update the tests to call the real function
(get_gmail_attachment_content) or extract the normalization into a small helper
(e.g., normalize_base64 or strip_base64_line_endings) in production and import
that into tests, then replace the inline .replace("\r","").replace("\n","")
calls with calls to that function in the test cases (including the parametrized
test and the large PDF-like test) so any future changes to
get_gmail_attachment_content are covered by tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/gmail/test_attachment_fix.py`:
- Around line 7-17: The tests use the wrong base64 payloads: the string
"VGVz\ndA==" decodes to b"Test", not b"Testdata", causing failures in
test_base64_stripping_with_unix_newlines and the other listed tests; update each
failing test (test_base64_stripping_with_unix_newlines,
test_base64_stripping_with_windows_crlf,
test_base64_stripping_with_mixed_line_endings, test_various_line_endings) so the
embedded-newline base64 encodes the intended "Testdata" (replace the base64
literals with the correct base64 for "Testdata") or alternatively adjust the
expected bytes to b"Test" to match the current literal—ensure the cleaned = ...
replace("\r", "").replace("\n", "") step remains and the assertion compares to
the matching decoded bytes.

---

Nitpick comments:
In `@tests/gmail/test_attachment_fix.py`:
- Around line 59-75: The test_large_base64_with_line_breaks currently uses
pdf_header = b"%PDF-1.7" which base64-encodes to only ~12 chars so
pdf_base64_with_newlines never gets split; change the payload to be large enough
(e.g. repeat pdf_header or use a larger binary) so pdf_base64 becomes longer
than 76 chars, then regenerate pdf_base64_with_newlines, strip newlines into
cleaned, decode and assert the decoded bytes equal the original repeated
payload; update references in the test to pdf_header, pdf_base64,
pdf_base64_with_newlines and cleaned accordingly.
- Around line 1-84: Tests in test_base64_* currently re-implement the stripping
logic instead of exercising the production code; update the tests to call the
real function (get_gmail_attachment_content) or extract the normalization into a
small helper (e.g., normalize_base64 or strip_base64_line_endings) in production
and import that into tests, then replace the inline
.replace("\r","").replace("\n","") calls with calls to that function in the test
cases (including the parametrized test and the large PDF-like test) so any
future changes to get_gmail_attachment_content are covered by tests.

Comment thread tests/gmail/test_attachment_fix.py Outdated
@mickey-mikey
mickey-mikey force-pushed the fix/windows-attachment-corruption branch 2 times, most recently from f23af94 to 1ca75d2 Compare February 20, 2026 04:06

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
gmail/gmail_tools.py (1)

902-910: ⚠️ Potential issue | 🟡 Minor

Deduplicate stateless-mode warning text.

Line 903 and Line 907 both emit the same warning, and Line 903 starts with a leading newline. This produces duplicate output and a stray blank line in the response.

🧹 Suggested fix
-        result_lines = [
-            "\n⚠️ Stateless mode: File storage disabled.",
+        result_lines = [
+            "⚠️ Stateless mode: File storage disabled.",
             f"Message ID: {message_id}",
             f"Size: {size_kb:.1f} KB ({size_bytes} bytes)",
-            "\n⚠️ Stateless mode: File storage disabled.",
             "\nBase64-encoded content (first 100 characters shown):",
             f"{base64_data[:100]}...",
             "\nNote: Attachment IDs are ephemeral. Always use IDs from the most recent message fetch.",
         ]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gmail/gmail_tools.py` around lines 902 - 910, The result_lines list in
gmail_tools.py currently includes the stateless-mode warning twice (elements
around the f"Message ID: {message_id}" and the later "\n⚠️ Stateless mode: File
storage disabled.") and one occurrence has a leading newline causing a blank
line; update the result_lines construction to include the stateless-mode warning
only once (remove the duplicate entry) and ensure the remaining warning string
has no leading newline so it doesn't emit an extra blank line—modify the list
where result_lines is defined to keep a single "⚠️ Stateless mode: File storage
disabled." entry and remove the "\n" prefix from any preserved warning text.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@gmail/gmail_tools.py`:
- Around line 890-896: The assignment to base64_data contains unescaped literal
newlines in the string literals causing a SyntaxError; update the sanitization
to use escaped sequences instead of actual line breaks, e.g. call
attachment.get("data", "") and then .replace("\r", "").replace("\n", "") (or a
single .replace("\r\n", "") plus .replace("\n", "") to be safe) so the
base64_data variable is built from properly escaped "\r" and "\n" characters and
the code parses correctly.

---

Outside diff comments:
In `@gmail/gmail_tools.py`:
- Around line 902-910: The result_lines list in gmail_tools.py currently
includes the stateless-mode warning twice (elements around the f"Message ID:
{message_id}" and the later "\n⚠️ Stateless mode: File storage disabled.") and
one occurrence has a leading newline causing a blank line; update the
result_lines construction to include the stateless-mode warning only once
(remove the duplicate entry) and ensure the remaining warning string has no
leading newline so it doesn't emit an extra blank line—modify the list where
result_lines is defined to keep a single "⚠️ Stateless mode: File storage
disabled." entry and remove the "\n" prefix from any preserved warning text.

Comment thread gmail/gmail_tools.py Outdated
@mickey-mikey
mickey-mikey force-pushed the fix/windows-attachment-corruption branch 3 times, most recently from d1ea154 to 9740b96 Compare February 20, 2026 04:24

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@gmail/gmail_tools.py`:
- Around line 894-895: Call to is_stateless_mode() in gmail_tools.py is missing
an import and will raise NameError; add an import for is_stateless_mode from its
defining module at the top of the file (alongside other imports) so the symbol
is available where it's used, e.g., import is_stateless_mode into gmail_tools.py
and run tests/lint to confirm no unresolved references remain.

Comment thread gmail/gmail_tools.py
@mickey-mikey
mickey-mikey force-pushed the fix/windows-attachment-corruption branch 4 times, most recently from fdb7248 to 464197a Compare February 20, 2026 04:28
@mickey-mikey

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Feb 20, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@mickey-mikey

Copy link
Copy Markdown
Contributor Author

@coderabbitai resume

@coderabbitai

coderabbitai Bot commented Feb 20, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Reviews resumed.

@mickey-mikey

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Feb 20, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@mickey-mikey
mickey-mikey force-pushed the fix/windows-attachment-corruption branch from 8d36f62 to 464197a Compare February 20, 2026 04:51
@mickey-mikey mickey-mikey changed the title Fix Windows attachment corruption from base64 line-ending conversion Fix Windows attachment corruption: add O_BINARY flag to os.open Feb 22, 2026
@mickey-mikey
mickey-mikey force-pushed the fix/windows-attachment-corruption branch from 95b6dd3 to 72e95f0 Compare February 22, 2026 00:05

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
tests/gmail/test_attachment_fix.py (2)

70-113: Consider using tmp_path fixture for better test isolation.

The integration tests write to the default STORAGE_DIR (~/.workspace-mcp/attachments), which modifies the user's home directory during test runs. Using pytest's tmp_path fixture and monkeypatching STORAGE_DIR would improve test isolation and avoid side effects.

♻️ Example approach using tmp_path
`@pytest.fixture`
def isolated_storage(tmp_path, monkeypatch):
    """Create an isolated AttachmentStorage with temp directory."""
    import core.attachment_storage as storage_module
    monkeypatch.setattr(storage_module, "STORAGE_DIR", tmp_path)
    return storage_module.AttachmentStorage()


def test_save_attachment_uses_binary_mode(isolated_storage):
    # Use isolated_storage instead of creating new AttachmentStorage()
    payload = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
    b64_data = base64.urlsafe_b64encode(payload).decode()
    result = isolated_storage.save_attachment(b64_data, filename="test.png")
    # No manual cleanup needed - tmp_path handles it
    with open(result.path, "rb") as f:
        assert f.read() == payload
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/gmail/test_attachment_fix.py` around lines 70 - 113, Update the tests
to avoid writing to the user's home STORAGE_DIR by using pytest's tmp_path
fixture and monkeypatch to override STORAGE_DIR in the module under test;
specifically, create a fixture (e.g., isolated_storage) that monkeypatches
core.attachment_storage.STORAGE_DIR to tmp_path and returns AttachmentStorage(),
then replace direct instantiation in test_save_attachment_uses_binary_mode and
test_save_attachment_preserves_various_binary_formats with that fixture (use
isolated_storage) so files are written into the temporary directory and no
manual cleanup is required.

52-54: Consider aligning the platform check style with production code.

The test uses hasattr(os, "O_BINARY") while production code uses sys.platform == "win32". Both approaches work correctly, but using consistent patterns aids maintainability. The hasattr approach is slightly more defensive.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/gmail/test_attachment_fix.py` around lines 52 - 54, Change the platform
check in the test so it matches production style: replace the hasattr(os,
"O_BINARY") condition around setting flags with a sys.platform == "win32" check;
ensure sys is imported in tests/gmail/test_attachment_fix.py and keep the rest
of the flags logic (flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC and flags |=
os.O_BINARY) unchanged, referencing the flags variable and os.O_BINARY symbol.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tests/gmail/test_attachment_fix.py`:
- Around line 70-113: Update the tests to avoid writing to the user's home
STORAGE_DIR by using pytest's tmp_path fixture and monkeypatch to override
STORAGE_DIR in the module under test; specifically, create a fixture (e.g.,
isolated_storage) that monkeypatches core.attachment_storage.STORAGE_DIR to
tmp_path and returns AttachmentStorage(), then replace direct instantiation in
test_save_attachment_uses_binary_mode and
test_save_attachment_preserves_various_binary_formats with that fixture (use
isolated_storage) so files are written into the temporary directory and no
manual cleanup is required.
- Around line 52-54: Change the platform check in the test so it matches
production style: replace the hasattr(os, "O_BINARY") condition around setting
flags with a sys.platform == "win32" check; ensure sys is imported in
tests/gmail/test_attachment_fix.py and keep the rest of the flags logic (flags =
os.O_WRONLY | os.O_CREAT | os.O_TRUNC and flags |= os.O_BINARY) unchanged,
referencing the flags variable and os.O_BINARY symbol.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/gmail/test_attachment_fix.py (1)

24-43: Consider using pytest's tmp_path fixture for cleaner cleanup.

The manual temp file management works, but if os.open fails before file creation, os.unlink(tmp) in the finally block will raise FileNotFoundError. Using pytest's tmp_path fixture handles cleanup automatically.

♻️ Optional: Use tmp_path fixture
-def test_os_open_without_o_binary_corrupts_on_windows():
+def test_os_open_without_o_binary_corrupts_on_windows(tmp_path):
     """On Windows, os.open without O_BINARY translates LF to CRLF in written bytes."""
     # PNG header contains 0x0a (LF) bytes that trigger text-mode translation
     payload = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50

-    tmp = os.path.join(tempfile.gettempdir(), "test_no_binary.bin")
-    try:
-        fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
-        try:
-            os.write(fd, payload)
-        finally:
-            os.close(fd)
+    tmp = tmp_path / "test_no_binary.bin"
+    fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+    try:
+        os.write(fd, payload)
+    finally:
+        os.close(fd)

-        with open(tmp, "rb") as f:
-            written = f.read()
+    with open(tmp, "rb") as f:
+        written = f.read()

-        if sys.platform == "win32":
-            # Without O_BINARY, Windows inserts extra \r before each \n
-            assert written != payload, "Expected corruption without O_BINARY on Windows"
-            assert len(written) > len(payload)
-        else:
-            # Unix doesn't have this problem
-            assert written == payload
-    finally:
-        os.unlink(tmp)
+    if sys.platform == "win32":
+        # Without O_BINARY, Windows inserts extra \r before each \n
+        assert written != payload, "Expected corruption without O_BINARY on Windows"
+        assert len(written) > len(payload)
+    else:
+        # Unix doesn't have this problem
+        assert written == payload
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/gmail/test_attachment_fix.py` around lines 24 - 43, The test manually
creates tmp and calls os.unlink(tmp) in a finally block which can raise
FileNotFoundError if os.open fails; switch to using pytest's tmp_path fixture to
create the temporary file (e.g., tmp_path / "test_no_binary.bin") and use Path
or open(..., "wb") to write/read payload so pytest handles cleanup
automatically; replace references to tmp, os.open/os.unlink with the tmp_path
Path object and regular file open calls to avoid the manual unlink and race
conditions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tests/gmail/test_attachment_fix.py`:
- Around line 24-43: The test manually creates tmp and calls os.unlink(tmp) in a
finally block which can raise FileNotFoundError if os.open fails; switch to
using pytest's tmp_path fixture to create the temporary file (e.g., tmp_path /
"test_no_binary.bin") and use Path or open(..., "wb") to write/read payload so
pytest handles cleanup automatically; replace references to tmp,
os.open/os.unlink with the tmp_path Path object and regular file open calls to
avoid the manual unlink and race conditions.

@mickey-mikey
mickey-mikey force-pushed the fix/windows-attachment-corruption branch from 72e95f0 to 093b8f8 Compare February 22, 2026 00:24
Fixes #494 - Windows attachment corruption for PNG/PDF files.

On Windows, os.open() defaults to text mode, which translates LF (0x0a)
bytes to CRLF (0x0d 0x0a) during os.write(). This corrupts any binary
attachment containing 0x0a bytes (PNG headers, PDFs, etc.).

The fix adds os.O_BINARY to the os.open() flags using the standard
getattr(os, 'O_BINARY', 0) idiom, which returns the flag on Windows
and 0 (no-op) on platforms where it doesn't exist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@mickey-mikey
mickey-mikey force-pushed the fix/windows-attachment-corruption branch from 093b8f8 to ffeba1f Compare February 22, 2026 00:45
@mickey-mikey

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Feb 22, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@mickey-mikey

Copy link
Copy Markdown
Contributor Author

@coderabbitai resume

@coderabbitai

coderabbitai Bot commented Feb 22, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Reviews resumed.

@taylorwilsdon taylorwilsdon self-assigned this Feb 24, 2026
@taylorwilsdon taylorwilsdon added the bug Something isn't working label Feb 24, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a critical bug where binary file attachments were being corrupted on Windows due to text-mode file writes. The fix adds the O_BINARY flag to the os.open() call in the attachment storage module to ensure binary data is written correctly on Windows while maintaining compatibility with Unix-like systems.

Changes:

  • Added O_BINARY flag to os.open() call in core/attachment_storage.py using the standard getattr(os, 'O_BINARY', 0) pattern
  • Added comprehensive test suite in tests/gmail/test_attachment_fix.py covering base64 decoding, binary file writes, and attachment storage

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
core/attachment_storage.py Fixed Windows attachment corruption by adding O_BINARY flag to os.open() call
tests/gmail/test_attachment_fix.py Added comprehensive tests verifying binary file handling across platforms

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

import base64
import os
import sys

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test file is missing the sys.path.insert pattern used by other test files in the codebase. Other test files (e.g., tests/core/test_comments.py, tests/gdrive/test_drive_tools.py) include sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) before importing modules. This ensures the test can find the source modules regardless of how pytest is invoked. Consider adding this import pattern at the beginning of the file for consistency with the rest of the test suite.

Suggested change
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))

Copilot uses AI. Check for mistakes.
@taylorwilsdon
taylorwilsdon merged commit 285406e into taylorwilsdon:main Feb 24, 2026
9 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Feb 24, 2026
@mickey-mikey
mickey-mikey deleted the fix/windows-attachment-corruption branch February 26, 2026 22:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows attachment corruption: missing O_BINARY flag in os.open

3 participants