diff --git a/config.toml b/config.toml
index 1a059bf..7dcbb8c 100644
--- a/config.toml
+++ b/config.toml
@@ -4,7 +4,7 @@ competition = "picoctf"
catagory = "textCat"
challenge = "testChal"
flag_format = "picoCTF\\{.*\\}"
-last_flag = "picoCTF{flag}"
+last_flag = "flag{my_flag_here}"
[Enviroment]
ctf_dir = "/home/venus/ctf"
diff --git a/src/ctf/cli_forensics.py b/src/ctf/cli_forensics.py
index eaeacbb..3758e67 100644
--- a/src/ctf/cli_forensics.py
+++ b/src/ctf/cli_forensics.py
@@ -22,8 +22,13 @@ def tf():
# {{{ inspect
@forensics_group.command()
@click.argument('path', type=click.Path(exists=True))
-def inspect(path):
- """Lists all basic inode metadata about a file"""
+@click.option('-i', '--inode', is_flag=True, help="Print only POSIX inode metadata (without EXIF data).")
+@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))
# Present using Rich Table
@@ -31,28 +36,61 @@ def inspect(path):
from rich.table import Table
console = Console()
- table = Table(title=f"Metadata: {meta.filename}", show_header=False)
- table.add_column("Key", style="bold cyan")
- table.add_column("Value")
- table.add_row("Filename", meta.filename)
- table.add_row("Size", f"{meta.size} bytes")
- table.add_row("Magic Bytes (Hex)", meta.magic)
- table.add_row("Detected Type", meta.detected_type)
- table.add_row("Extension", meta.extension)
- table.add_row("Permissions", f"{meta.permissions_symbolic} ({meta.permissions_octal})")
- table.add_row("Owner", f"{meta.owner_username} (UID: {meta.owner_uid})")
- table.add_row("Group", f"{meta.owner_groupname} (GID: {meta.owner_gid})")
- table.add_row("Allocated Space", f"{meta.allocated_size} bytes")
- table.add_row("Hard Links", str(meta.hard_links))
- table.add_row("Inode", str(meta.inode))
- table.add_row("Device", str(meta.device))
-
- if meta.extended_attributes:
- xattr_str = ", ".join(f"{k}={v}" for k, v in meta.extended_attributes.items())
- table.add_row("Extended Attributes", xattr_str)
+ if not exif:
+ table = Table(title=f"Metadata: {meta.filename}", show_header=False)
+ table.add_column("Key", style="bold cyan")
+ table.add_column("Value")
+
+ table.add_row("Filename", meta.filename)
+ table.add_row("Size", f"{meta.size} bytes")
+ table.add_row("Magic Bytes (Hex)", meta.magic)
+ table.add_row("Detected Type", meta.detected_type)
+ table.add_row("Extension", meta.extension)
+ table.add_row("Permissions", f"{meta.permissions_symbolic} ({meta.permissions_octal})")
+ table.add_row("Owner", f"{meta.owner_username} (UID: {meta.owner_uid})")
+ table.add_row("Group", f"{meta.owner_groupname} (GID: {meta.owner_gid})")
+ table.add_row("Allocated Space", f"{meta.allocated_size} bytes")
+ table.add_row("Hard Links", str(meta.hard_links))
+ table.add_row("Inode", str(meta.inode))
+ table.add_row("Device", str(meta.device))
+
+ if meta.extended_attributes:
+ xattr_str = ", ".join(f"{k}={v}" for k, v in meta.extended_attributes.items())
+ table.add_row("Extended Attributes", xattr_str)
+ 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]")
- console.print(table)
return meta
# }}}
diff --git a/src/ctf/forensics.py b/src/ctf/forensics.py
index d011a3e..6c00353 100644
--- a/src/ctf/forensics.py
+++ b/src/ctf/forensics.py
@@ -7,7 +7,9 @@ from pathlib import Path
import stat
import os
import sys
-from typing import List, Dict
+import struct
+import xml.etree.ElementTree as ET
+from typing import List, Dict, Any
try:
import pwd
@@ -16,6 +18,7 @@ except ImportError:
pwd = None
grp = None
+# {{{ Common Signatures
COMMON_SIGNATURES = {
b"\x89PNG\r\n\x1a\n": ("PNG Image", [".png"]),
b"\xff\xd8\xff": ("JPEG Image", [".jpg", ".jpeg"]),
@@ -33,6 +36,7 @@ COMMON_SIGNATURES = {
b"BM": ("BMP Image", [".bmp"]),
b"ID3": ("MP3 Audio", [".mp3"]),
}
+# }}}
# {{{ FileMetadata
@dataclass
@@ -65,6 +69,12 @@ class FileMetadata:
# Task 6: Extended Attributes
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
@@ -146,6 +156,9 @@ def get_metadata(path: Path) -> FileMetadata:
except OSError:
pass
+ exif_data = get_exif(p)
+ comment = get_comment(p)
+
return FileMetadata(
filename=p.name,
size=size,
@@ -162,7 +175,344 @@ def get_metadata(path: Path) -> FileMetadata:
hard_links=hard_links,
inode=inode,
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("")
+ if end_idx == -1:
+ return metadata
+ xml_data = xmp_str[start_idx:end_idx + len("")]
+
+ 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 ""
+# }}}
+
diff --git a/src/ctf/main.py b/src/ctf/main.py
index 56c5cc2..ae3be31 100644
--- a/src/ctf/main.py
+++ b/src/ctf/main.py
@@ -2,7 +2,7 @@
# Parses and calls commands
from ctf.commands import basic_group
-from ctf.cli_forensics import forensics_group
+from ctf.cli_forensics import forensics_group, inspect
import click
import sys
import re
@@ -64,6 +64,7 @@ class FlagDetectorStream:
@click.option("-p", "--plain", is_flag=True, help="Print raw flag without flavor text.")
def flag_cmd(plain):
"""Retrieve the last detected flag from config."""
+ # TODO add support for multiple flags and flag selection
global _active_detector
if _active_detector is not None:
_active_detector.detecting = False
@@ -76,7 +77,7 @@ def flag_cmd(plain):
click.echo("No flag has been detected yet.")
return
if plain:
- click.echo(last_flag, nl=False)
+ click.echo(last_flag)
else:
click.echo(f"Last detected flag: {last_flag}")
finally:
@@ -84,15 +85,18 @@ def flag_cmd(plain):
_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
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
config = load_config("/home/venus/code/ctf/config.toml")
flag_format = config.get("Competition", {}).get("flag_format", "")
diff --git a/tests/env/clean_no_exif.png b/tests/env/clean_no_exif.png
new file mode 100644
index 0000000..0dd1608
--- /dev/null
+++ b/tests/env/clean_no_exif.png
@@ -0,0 +1,2 @@
+‰PNG
+
diff --git a/tests/env/comment_only.jpg b/tests/env/comment_only.jpg
new file mode 100644
index 0000000..3800e73
Binary files /dev/null and b/tests/env/comment_only.jpg differ
diff --git a/tests/env/mock_comment.jpg b/tests/env/mock_comment.jpg
new file mode 100644
index 0000000..84d73eb
Binary files /dev/null and b/tests/env/mock_comment.jpg differ
diff --git a/tests/env/mock_exif_image.png b/tests/env/mock_exif_image.png
new file mode 100644
index 0000000..cbbd19e
Binary files /dev/null and b/tests/env/mock_exif_image.png differ
diff --git a/tests/env/test_file_nested.png b/tests/env/test_file_nested.png
new file mode 100644
index 0000000..0dd1608
--- /dev/null
+++ b/tests/env/test_file_nested.png
@@ -0,0 +1,2 @@
+‰PNG
+
diff --git a/tests/env/test_file_root.png b/tests/env/test_file_root.png
new file mode 100644
index 0000000..0dd1608
--- /dev/null
+++ b/tests/env/test_file_root.png
@@ -0,0 +1,2 @@
+‰PNG
+
diff --git a/tests/test_forensics.py b/tests/test_forensics.py
index dca5a35..024336a 100644
--- a/tests/test_forensics.py
+++ b/tests/test_forensics.py
@@ -461,7 +461,149 @@ def test_adobe_xmp_cli():
assert "cc:license" 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"\n"
+ b"\n"
+ b" \n"
+ b" Photoshop\n"
+ b" \n"
+ b" \n"
+ b"\n"
+ b""
+ )
+ 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"