[GEMINI] Write tests for forensics and stage baseline implementation

This commit is contained in:
venus
2026-07-18 03:28:52 -05:00
parent be58885e4f
commit 7bb0f87808
11 changed files with 574 additions and 34 deletions

View File

@@ -4,7 +4,7 @@ competition = "picoctf"
catagory = "textCat" catagory = "textCat"
challenge = "testChal" challenge = "testChal"
flag_format = "picoCTF\\{.*\\}" flag_format = "picoCTF\\{.*\\}"
last_flag = "picoCTF{flag}" last_flag = "flag{my_flag_here}"
[Enviroment] [Enviroment]
ctf_dir = "/home/venus/ctf" ctf_dir = "/home/venus/ctf"

View File

@@ -22,8 +22,13 @@ def tf():
# {{{ inspect # {{{ inspect
@forensics_group.command() @forensics_group.command()
@click.argument('path', type=click.Path(exists=True)) @click.argument('path', type=click.Path(exists=True))
def inspect(path): @click.option('-i', '--inode', is_flag=True, help="Print only POSIX inode metadata (without EXIF data).")
"""Lists all basic inode metadata about a file""" @click.option('-e', '--exif', is_flag=True, help="Print only EXIF metadata (without POSIX inode metadata).")
def inspect(path, inode, exif):
"""Lists all basic inode metadata and EXIF data about a file"""
if inode and exif:
raise click.UsageError("Cannot specify both -i/--inode and -e/--exif.")
meta = get_metadata(Path(path)) meta = get_metadata(Path(path))
# Present using Rich Table # Present using Rich Table
@@ -31,6 +36,8 @@ def inspect(path):
from rich.table import Table from rich.table import Table
console = Console() console = Console()
if not exif:
table = Table(title=f"Metadata: {meta.filename}", show_header=False) table = Table(title=f"Metadata: {meta.filename}", show_header=False)
table.add_column("Key", style="bold cyan") table.add_column("Key", style="bold cyan")
table.add_column("Value") table.add_column("Value")
@@ -51,8 +58,39 @@ def inspect(path):
if meta.extended_attributes: if meta.extended_attributes:
xattr_str = ", ".join(f"{k}={v}" for k, v in meta.extended_attributes.items()) xattr_str = ", ".join(f"{k}={v}" for k, v in meta.extended_attributes.items())
table.add_row("Extended Attributes", xattr_str) table.add_row("Extended Attributes", xattr_str)
console.print(table) console.print(table)
exif_displayed = False
if not inode:
if meta.exif_data:
exif_table = Table(title=f"EXIF Metadata: {meta.filename}", show_header=True)
exif_table.add_column("Tag", style="bold green")
exif_table.add_column("Value")
for tag, val in sorted(meta.exif_data.items()):
if isinstance(val, bytes):
val_str = val.hex().upper()
if len(val_str) > 60:
val_str = val_str[:57] + "..."
else:
val_str = str(val)
exif_table.add_row(tag, val_str)
console.print(exif_table)
exif_displayed = True
if meta.comment:
from rich.panel import Panel
console.print(Panel(
meta.comment,
title=f"[bold green]Comment: {meta.filename}[/bold green]",
border_style="green"
))
exif_displayed = True
if exif and not exif_displayed:
console.print("[bold yellow]No EXIF or comment metadata found.[/bold yellow]")
return meta return meta
# }}} # }}}

View File

@@ -7,7 +7,9 @@ from pathlib import Path
import stat import stat
import os import os
import sys import sys
from typing import List, Dict import struct
import xml.etree.ElementTree as ET
from typing import List, Dict, Any
try: try:
import pwd import pwd
@@ -16,6 +18,7 @@ except ImportError:
pwd = None pwd = None
grp = None grp = None
# {{{ Common Signatures
COMMON_SIGNATURES = { COMMON_SIGNATURES = {
b"\x89PNG\r\n\x1a\n": ("PNG Image", [".png"]), b"\x89PNG\r\n\x1a\n": ("PNG Image", [".png"]),
b"\xff\xd8\xff": ("JPEG Image", [".jpg", ".jpeg"]), b"\xff\xd8\xff": ("JPEG Image", [".jpg", ".jpeg"]),
@@ -33,6 +36,7 @@ COMMON_SIGNATURES = {
b"BM": ("BMP Image", [".bmp"]), b"BM": ("BMP Image", [".bmp"]),
b"ID3": ("MP3 Audio", [".mp3"]), b"ID3": ("MP3 Audio", [".mp3"]),
} }
# }}}
# {{{ FileMetadata # {{{ FileMetadata
@dataclass @dataclass
@@ -65,6 +69,12 @@ class FileMetadata:
# Task 6: Extended Attributes # Task 6: Extended Attributes
extended_attributes: Dict[str, str] = field(default_factory=dict) extended_attributes: Dict[str, str] = field(default_factory=dict)
# EXIF Data
exif_data: Dict[str, Any] = field(default_factory=dict)
# JPEG/PNG Comments
comment: str = ""
# }}} # }}}
# {{{ get_metadata # {{{ get_metadata
@@ -146,6 +156,9 @@ def get_metadata(path: Path) -> FileMetadata:
except OSError: except OSError:
pass pass
exif_data = get_exif(p)
comment = get_comment(p)
return FileMetadata( return FileMetadata(
filename=p.name, filename=p.name,
size=size, size=size,
@@ -162,7 +175,344 @@ def get_metadata(path: Path) -> FileMetadata:
hard_links=hard_links, hard_links=hard_links,
inode=inode, inode=inode,
device=device, device=device,
extended_attributes=extended_attributes extended_attributes=extended_attributes,
exif_data=exif_data,
comment=comment
) )
# }}} # }}}
EXIF_TAGS = {
0x010e: "ImageDescription",
0x010f: "Make",
0x0110: "Model",
0x0112: "Orientation",
0x011a: "XResolution",
0x011b: "YResolution",
0x0128: "ResolutionUnit",
0x0131: "Software",
0x0132: "DateTime",
0x013b: "Artist",
0x8298: "Copyright",
0x829a: "ExposureTime",
0x829d: "FNumber",
0x8822: "ExposureProgram",
0x8825: "GPSInfo",
0x8827: "ISOSpeedRatings",
0x9000: "ExifVersion",
0x9003: "DateTimeOriginal",
0x9004: "DateTimeDigitized",
0x9201: "ShutterSpeedValue",
0x9202: "ApertureValue",
0x9204: "ExposureBiasValue",
0x9205: "MaxApertureValue",
0x9207: "MeteringMode",
0x9208: "LightSource",
0x9209: "Flash",
0x920a: "FocalLength",
0x9286: "UserComment",
0xa000: "FlashpixVersion",
0xa001: "ColorSpace",
0xa002: "ExifImageWidth",
0xa003: "ExifImageHeight",
0xa402: "ExposureMode",
0xa403: "WhiteBalance",
0xa405: "FocalLengthIn35mmFilm",
0xa406: "SceneCaptureType",
}
# {{{ parse_tiff
def parse_tiff(data: bytes) -> Dict[str, Any]:
"""Parses raw TIFF data block into EXIF tag dictionary."""
tags = {}
if len(data) < 8:
return tags
byte_order = data[0:2]
if byte_order == b"II":
endian = "<"
elif byte_order == b"MM":
endian = ">"
else:
return tags
magic = struct.unpack(f"{endian}H", data[2:4])[0]
if magic != 42:
return tags
ifd_offset = struct.unpack(f"{endian}I", data[4:8])[0]
try:
while ifd_offset != 0 and ifd_offset < len(data):
if ifd_offset + 2 > len(data):
break
num_entries = struct.unpack(f"{endian}H", data[ifd_offset:ifd_offset+2])[0]
entry_offset = ifd_offset + 2
for _ in range(num_entries):
if entry_offset + 12 > len(data):
break
tag, field_type, count, val_offset = struct.unpack(
f"{endian}HHII", data[entry_offset:entry_offset+12]
)
entry_offset += 12
# Resolve value based on type and count
val = None
type_sizes = {1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8}
size = type_sizes.get(field_type, 1) * count
if size <= 4:
raw_val = struct.pack(f"{endian}I", val_offset)[:size]
else:
if val_offset + size <= len(data):
raw_val = data[val_offset:val_offset+size]
else:
raw_val = b""
if field_type == 2: # ASCII
val = raw_val.split(b"\x00")[0].decode("utf-8", errors="ignore")
elif field_type == 3: # SHORT
fmt = f"{endian}" + "H" * count
if len(raw_val) >= 2 * count:
val = struct.unpack(fmt, raw_val[:2*count])
if count == 1:
val = val[0]
elif field_type == 4: # LONG
fmt = f"{endian}" + "I" * count
if len(raw_val) >= 4 * count:
val = struct.unpack(fmt, raw_val[:4*count])
if count == 1:
val = val[0]
elif field_type == 5: # RATIONAL
fmt = f"{endian}" + "II" * count
if len(raw_val) >= 8 * count:
unpacked = struct.unpack(fmt, raw_val[:8*count])
rationals = [f"{unpacked[i*2]}/{unpacked[i*2+1]}" for i in range(count)]
val = rationals[0] if count == 1 else rationals
elif field_type == 7: # UNDEFINED
val = raw_val
elif field_type == 9: # SLONG
fmt = f"{endian}" + "i" * count
if len(raw_val) >= 4 * count:
val = struct.unpack(fmt, raw_val[:4*count])
if count == 1:
val = val[0]
elif field_type == 10: # SRATIONAL
fmt = f"{endian}" + "ii" * count
if len(raw_val) >= 8 * count:
unpacked = struct.unpack(fmt, raw_val[:8*count])
rationals = [f"{unpacked[i*2]}/{unpacked[i*2+1]}" for i in range(count)]
val = rationals[0] if count == 1 else rationals
else:
val = raw_val
tag_name = EXIF_TAGS.get(tag, f"Tag_{hex(tag)}")
tags[tag_name] = val
if entry_offset + 4 > len(data):
break
ifd_offset = struct.unpack(f"{endian}I", data[entry_offset:entry_offset+4])[0]
except Exception:
pass
return tags
# }}}
# {{{ extract_jpeg_exif
def extract_jpeg_exif(data: bytes) -> bytes:
"""Extracts raw Exif/TIFF payload from JPEG APP1 segments."""
if not data.startswith(b"\xff\xd8\xff"):
return b""
idx = 2
while idx < len(data) - 4:
if data[idx] == 0xff:
marker = data[idx+1]
if marker == 0xd9: # EOI
break
# Markers without length parameters
if marker in (0xd8, 0xd9, 0x00) or 0xd0 <= marker <= 0xd7:
idx += 2
continue
length = struct.unpack(">H", data[idx+2:idx+4])[0]
if marker == 0xe1: # APP1
app1_data = data[idx+4:idx+4+length-2]
if app1_data.startswith(b"Exif\x00\x00"):
return app1_data[6:]
idx += 2 + length
else:
idx += 1
return b""
# }}}
# {{{ extract_png_exif
def extract_png_exif(data: bytes) -> bytes:
"""Extracts raw Exif/TIFF payload from PNG eXIf chunks."""
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
return b""
idx = 8
while idx < len(data) - 8:
length = struct.unpack(">I", data[idx:idx+4])[0]
chunk_type = data[idx+4:idx+8]
if chunk_type == b"eXIf":
return data[idx+8:idx+8+length]
elif chunk_type == b"IEND":
break
idx += 12 + length
return b""
# }}}
# {{{ get_exif
def get_exif(path: Path) -> Dict[str, Any]:
"""Reads file, extracts Exif and XMP segments, and parses them to a tag dictionary."""
try:
with open(path, "rb") as f:
data = f.read()
except Exception:
return {}
tags = {}
# 1. Parse standard EXIF
exif_data = b""
if data.startswith(b"\xff\xd8\xff"):
exif_data = extract_jpeg_exif(data)
elif data.startswith(b"\x89PNG\r\n\x1a\n"):
exif_data = extract_png_exif(data)
if exif_data:
try:
tags.update(parse_tiff(exif_data))
except Exception:
pass
# 2. Parse XMP (Adobe)
xmp_str = ""
if data.startswith(b"\xff\xd8\xff"):
xmp_str = extract_jpeg_xmp(data)
if xmp_str:
try:
tags.update(parse_xmp(xmp_str))
except Exception:
pass
return tags
# }}}
# {{{ extract_jpeg_comment
def extract_jpeg_comment(data: bytes) -> str:
"""Extracts raw comment string from JPEG COM (0xfe) segments."""
if not data.startswith(b"\xff\xd8\xff"):
return ""
idx = 2
while idx < len(data) - 4:
if data[idx] == 0xff:
marker = data[idx+1]
if marker == 0xd9: # EOI
break
if marker in (0xd8, 0xd9, 0x00) or 0xd0 <= marker <= 0xd7:
idx += 2
continue
length = struct.unpack(">H", data[idx+2:idx+4])[0]
if marker == 0xfe: # COM
comment_data = data[idx+4:idx+4+length-2]
return comment_data.decode("utf-8", errors="ignore")
idx += 2 + length
else:
idx += 1
return ""
# }}}
# {{{ get_comment
def get_comment(path: Path) -> str:
"""Reads file, checks headers, and extracts JPEG COM comments."""
try:
with open(path, "rb") as f:
data = f.read()
except Exception:
return ""
if data.startswith(b"\xff\xd8\xff"):
return extract_jpeg_comment(data)
return ""
# }}}
NS_MAP = {
"http://creativecommons.org/ns#": "cc",
"http://purl.org/dc/elements/1.1/": "dc",
"http://ns.adobe.com/xap/1.0/": "xmp",
"http://ns.adobe.com/exif/1.0/": "exif",
"http://ns.adobe.com/tiff/1.0/": "tiff",
}
# {{{ parse_xmp
def parse_xmp(xmp_str: str) -> Dict[str, Any]:
"""Parses Adobe XMP XML string and returns namespace-prefixed tags."""
metadata = {}
try:
start_idx = xmp_str.find("<x:xmpmeta")
if start_idx == -1:
return metadata
end_idx = xmp_str.find("</x:xmpmeta>")
if end_idx == -1:
return metadata
xml_data = xmp_str[start_idx:end_idx + len("</x:xmpmeta>")]
root = ET.fromstring(xml_data)
def get_clean_name(tag: str) -> str:
if "}" in tag:
ns, name = tag.split("}")
ns = ns.lstrip("{")
prefix = NS_MAP.get(ns, ns.split("/")[-1].split("#")[0])
return f"{prefix}:{name}"
return tag
for elem in root.iter():
clean_tag = get_clean_name(elem.tag)
if clean_tag in ("rdf:RDF", "rdf:Description", "x:xmpmeta"):
continue
val = None
if elem.text and elem.text.strip():
val = elem.text.strip()
else:
attribs = {get_clean_name(k): v for k, v in elem.attrib.items()}
attribs.pop("rdf:about", None)
if "rdf:resource" in attribs:
val = attribs["rdf:resource"]
elif attribs:
val = ", ".join(f"{k}={v}" for k, v in attribs.items())
if val:
metadata[clean_tag] = val
except Exception:
pass
return metadata
# }}}
# {{{ extract_jpeg_xmp
def extract_jpeg_xmp(data: bytes) -> str:
"""Extracts XMP XML string from JPEG APP1 segments."""
if not data.startswith(b"\xff\xd8\xff"):
return ""
idx = 2
while idx < len(data) - 4:
if data[idx] == 0xff:
marker = data[idx+1]
if marker == 0xd9: # EOI
break
if marker in (0xd8, 0xd9, 0x00) or 0xd0 <= marker <= 0xd7:
idx += 2
continue
length = struct.unpack(">H", data[idx+2:idx+4])[0]
if marker == 0xe1: # APP1
app1_data = data[idx+4:idx+4+length-2]
if app1_data.startswith(b"http://ns.adobe.com/xap/1.0/\x00"):
return app1_data[29:].decode("utf-8", errors="ignore")
idx += 2 + length
else:
idx += 1
return ""
# }}}

View File

@@ -2,7 +2,7 @@
# Parses and calls commands # Parses and calls commands
from ctf.commands import basic_group from ctf.commands import basic_group
from ctf.cli_forensics import forensics_group from ctf.cli_forensics import forensics_group, inspect
import click import click
import sys import sys
import re import re
@@ -64,6 +64,7 @@ class FlagDetectorStream:
@click.option("-p", "--plain", is_flag=True, help="Print raw flag without flavor text.") @click.option("-p", "--plain", is_flag=True, help="Print raw flag without flavor text.")
def flag_cmd(plain): def flag_cmd(plain):
"""Retrieve the last detected flag from config.""" """Retrieve the last detected flag from config."""
# TODO add support for multiple flags and flag selection
global _active_detector global _active_detector
if _active_detector is not None: if _active_detector is not None:
_active_detector.detecting = False _active_detector.detecting = False
@@ -76,7 +77,7 @@ def flag_cmd(plain):
click.echo("No flag has been detected yet.") click.echo("No flag has been detected yet.")
return return
if plain: if plain:
click.echo(last_flag, nl=False) click.echo(last_flag)
else: else:
click.echo(f"Last detected flag: {last_flag}") click.echo(f"Last detected flag: {last_flag}")
finally: finally:
@@ -84,15 +85,18 @@ def flag_cmd(plain):
_active_detector.detecting = True _active_detector.detecting = True
# }}} # }}}
# {{{ cli
@click.group()
def cli(): pass
cli.add_command(forensics_group)
cli.add_command(basic_group)
cli.add_command(flag_cmd)
cli.add_command(inspect)
# }}}
# {{{ main # {{{ main
def main(): def main():
@click.group()
def cli(): pass
cli.add_command(forensics_group)
cli.add_command(basic_group)
cli.add_command(flag_cmd)
from ctf.utils import load_config from ctf.utils import load_config
config = load_config("/home/venus/code/ctf/config.toml") config = load_config("/home/venus/code/ctf/config.toml")
flag_format = config.get("Competition", {}).get("flag_format", "") flag_format = config.get("Competition", {}).get("flag_format", "")

2
tests/env/clean_no_exif.png vendored Normal file
View File

@@ -0,0 +1,2 @@
<EFBFBD>PNG


After

Width:  |  Height:  |  Size: 8 B

BIN
tests/env/comment_only.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 B

BIN
tests/env/mock_comment.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 B

BIN
tests/env/mock_exif_image.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

2
tests/env/test_file_nested.png vendored Normal file
View File

@@ -0,0 +1,2 @@
<EFBFBD>PNG


After

Width:  |  Height:  |  Size: 8 B

2
tests/env/test_file_root.png vendored Normal file
View File

@@ -0,0 +1,2 @@
<EFBFBD>PNG


After

Width:  |  Height:  |  Size: 8 B

View File

@@ -461,7 +461,149 @@ def test_adobe_xmp_cli():
assert "cc:license" in result.output assert "cc:license" in result.output
assert "cGljb0NURnt0ZXN0X3htcF9mbGFnfQ==" in result.output assert "cGljb0NURnt0ZXN0X3htcF9mbGFnfQ==" in result.output
def test_jpeg_physical_parsing():
"""
Verifies that get_metadata successfully parses JPEG physical parameters (JFIF & SOF).
"""
import struct
app0_payload = b"JFIF\x00\x01\x02\x01\x00\x48\x00\x48\x00\x00"
app0_block = b"\xff\xe0" + struct.pack(">H", len(app0_payload) + 2) + app0_payload
sof_payload = b"\x08\x03\xe8\x05\xdc\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01"
sof_block = b"\xff\xc0" + struct.pack(">H", len(sof_payload) + 2) + sof_payload
mock_jpeg = b"\xff\xd8" + app0_block + sof_block + b"\xff\xd9"
test_file = TEST_ENV / "mock_physical.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
meta = get_metadata(test_file)
assert meta.physical_data.get("JFIF Version") == "1.02"
assert meta.physical_data.get("Image Size") == "1500x1000"
assert meta.physical_data.get("Megapixels") == "1.5"
assert meta.physical_data.get("Encoding Process") == "Baseline DCT, Huffman coding"
def test_jpeg_iptc_parsing():
"""
Verifies that get_metadata successfully parses JPEG IPTC metadata from APP13.
"""
import struct
iptc_ds = b"\x1c\x02\x74\x00\x0ePicoCTF Rights"
irb_id = b"\x04\x04"
irb_name = b"\x00\x00"
irb_size = struct.pack(">I", len(iptc_ds))
irb_block = b"8BIM" + irb_id + irb_name + irb_size + iptc_ds
app13_payload = b"Photoshop 3.0\x00" + irb_block
app13_block = b"\xff\xed" + struct.pack(">H", len(app13_payload) + 2) + app13_payload
mock_jpeg = b"\xff\xd8" + app13_block + b"\xff\xd9"
test_file = TEST_ENV / "mock_iptc.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
meta = get_metadata(test_file)
assert meta.exif_data.get("CopyrightNotice") == "PicoCTF Rights"
def test_cli_physical_option():
"""
Verifies the ctf inspect CLI supports -p/--physical and mutual exclusion rules.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "mock_physical.jpg"
# Mutual exclusion check
result_err = runner.invoke(cli, ["inspect", "-p", "-e", str(test_file)])
assert result_err.exit_code != 0
assert "Cannot specify more than one" in result_err.output
# Physical view check
result_phys = runner.invoke(cli, ["inspect", "-p", str(test_file)])
assert result_phys.exit_code == 0
assert "Physical Metadata" in result_phys.output
assert "Image Size" in result_phys.output
assert "1500x1000" in result_phys.output
assert "Metadata: mock_physical.jpg" not in result_phys.output
assert "EXIF Metadata" not in result_phys.output
def test_png_text_chunks_decompression():
"""
Verifies that get_metadata successfully parses tEXt and compressed zTXt chunks.
"""
import zlib
import struct
# 1. tEXt chunk: Keyword (Copyright) + NUL + Text (PicoCTF)
text_data = b"Copyright\x00PicoCTF"
text_chunk = struct.pack(">I", len(text_data)) + b"tEXt" + text_data + b"\x00\x00\x00\x00"
# 2. zTXt chunk: Keyword (Author) + NUL + CompMethod(0) + Deflated Text (John Doe)
deflated = zlib.compress(b"John Doe")
ztxt_data = b"Author\x00\x00" + deflated
ztxt_chunk = struct.pack(">I", len(ztxt_data)) + b"zTXt" + ztxt_data + b"\x00\x00\x00\x00"
png_data = b"\x89PNG\r\n\x1a\n" + text_chunk + ztxt_chunk + b"\x00\x00\x00\x00IEND\xaeB`\x82"
test_file = TEST_ENV / "mock_text_chunks.png"
with open(test_file, "wb") as f:
f.write(png_data)
meta = get_metadata(test_file)
assert meta.exif_data.get("Copyright") == "PicoCTF"
assert meta.exif_data.get("Author") == "John Doe"
def test_gif_comment_parsing():
"""
Verifies that get_metadata successfully parses GIF comment blocks.
"""
gif_data = (
b"GIF89a"
b"\x01\x00\x01\x00\x00\x00\x00"
b"\x21\xfe\x0aGIFComment\x00"
b"\x3b"
)
test_file = TEST_ENV / "mock_comment.gif"
with open(test_file, "wb") as f:
f.write(gif_data)
meta = get_metadata(test_file)
assert meta.comment == "GIFComment"
def test_adobe_xmp_formatting():
"""
Verifies that get_metadata parses rdf:Description attributes and formats other
attributes with ' | ' instead of '='.
"""
import struct
xmp_xml = (
b"<x:xmpmeta xmlns:x='adobe:ns:meta/'>\n"
b"<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>\n"
b" <rdf:Description rdf:about='' xmlns:xmp='http://ns.adobe.com/xap/1.0/' xmlns:exif='http://ns.adobe.com/exif/1.0/'>\n"
b" <xmp:CreatorTool>Photoshop</xmp:CreatorTool>\n"
b" <exif:Flash exif:Fired='True' exif:Mode='1'/>\n"
b" </rdf:Description>\n"
b"</rdf:RDF>\n"
b"</x:xmpmeta>"
)
xmp_prefix = b"http://ns.adobe.com/xap/1.0/\x00"
app1_payload = xmp_prefix + xmp_xml
app1_len = len(app1_payload) + 2
mock_jpeg = (
b"\xff\xd8"
b"\xff\xe1"
+ struct.pack(">H", app1_len)
+ app1_payload
+ b"\xff\xd9"
)
test_file = TEST_ENV / "mock_xmp_formatting.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
meta = get_metadata(test_file)
assert meta.exif_data.get("xmp:CreatorTool") == "Photoshop"
assert meta.exif_data.get("exif:Flash") == "exif:Fired | True, exif:Mode | 1"