Skip to content

feat: add JSON coercion support for non-JSON plist types#309

Closed
calilkhalil wants to merge 1 commit into
libimobiledevice:masterfrom
calilkhalil:feat/json-coercion-support
Closed

feat: add JSON coercion support for non-JSON plist types#309
calilkhalil wants to merge 1 commit into
libimobiledevice:masterfrom
calilkhalil:feat/json-coercion-support

Conversation

@calilkhalil

@calilkhalil calilkhalil commented Feb 21, 2026

Copy link
Copy Markdown
Contributor

Add --coerce option and plist2json symlink

Problem

Converting plist files that contain date, data, or UID nodes to JSON
currently fails with PLIST_ERR_FORMAT because these types have no native
JSON representation. This is by design, the JSON writer enforces strict type
compatibility, but in practice it makes JSON output unusable for the majority
of real-world Apple plist files, which almost always contain at least one
date or data value.

A common example is Safari's Downloads.plist:

$ plistutil -i Downloads.plist -f json
ERROR: Input plist data is not compatible with output format.

Users currently work around this with Python one-liners:

python3 -c "import plistlib,json,datetime,base64,sys;print(json.dumps(
  plistlib.load(open(sys.argv[1],'rb')),
  default=lambda o:o.isoformat() if isinstance(o,datetime.datetime)
    else base64.b64encode(o).decode() if isinstance(o,bytes) else None))" \
  Downloads.plist

This defeats the purpose of having a dedicated plist conversion tool.

Solution

This patch adds opt-in coercion of non-JSON plist types to JSON-compatible
representations, following the same conventions used by Apple's own
plutil -convert json and by Python's plistlib:

Plist type JSON representation Rationale
PLIST_DATE ISO 8601 string ("2025-03-08T14:03:43Z") Same format already used by the XML writer in xplist.c
PLIST_DATA Base64-encoded string Uses the existing base64encode() from base64.c
PLIST_UID Integer Natural numeric representation, consistent with xplist.c

The coercion is strictly opt-in and does not change existing behavior in
any way. Without the flag, the JSON writer continues to reject these types
exactly as before.

Changes

Library (src/jplist.c, include/plist/plist.h)

  • Add PLIST_OPT_COERCE = 1 << 4 to plist_write_options_t
  • Add plist_to_json_ex() with an additional int coerce parameter
  • plist_to_json() remains unchanged (delegates to plist_to_json_ex with
    coerce=0)
  • Both node_to_json and _node_estimate_size are updated to handle the
    three new types when coerce is enabled
  • plist_write_to_string and plist_write_to_stream in plist.c route
    PLIST_OPT_COERCE to plist_to_json_ex

The date formatting reuses the exact same gmtime64_r + strftime +
Time64_T code path from xplist.c, ensuring consistent ISO 8601 output
across formats. The base64 encoding uses the existing base64encode() from
base64.c. No new dependencies are introduced.

Tool (tools/plistutil.c, tools/Makefile.am)

  • Add -C / --coerce command-line option
  • Add argv[0] detection: when invoked as plist2json, the tool
    automatically implies -f json --coerce
  • Makefile.am installs a plist2json → plistutil symlink via
    install-exec-hook (same pattern used by gzip/gunzip, vim/vi, busybox)

Usage

# Explicit option
plistutil -i Downloads.plist -f json --coerce

# Short flag, compact output
plistutil -i Downloads.plist -f json -C -c

# Via symlink (implies -f json --coerce)
plist2json -i Downloads.plist

# Pipe-friendly
plist2json -i Downloads.plist | jq '.DownloadHistory[] | .DownloadEntryURL'

# Backward compatible, without --coerce, behavior is unchanged
plistutil -i Downloads.plist -f json
# ERROR: Input plist data is not compatible with output format.

Testing

Tested with a real-world Safari Downloads.plist containing date and
data nodes:

$ plist2json -i Downloads.plist | jq -cr '
  [.DownloadHistory[]
   | select(.DownloadEntryPath | test("Scientific"))
   | {date_added: .DownloadEntryDateAddedKey,
      url: (.DownloadEntryURL | split("?")[0])}]
  | sort_by(.date_added)'

[{"date_added":"2025-03-07T21:11:56Z","url":"https://drive.usercontent.google.com/download"},
 {"date_added":"2025-03-07T21:13:50Z","url":"https://drive.usercontent.google.com/download"}]

Verified:

  • plistutil -f json without --coerce still returns PLIST_ERR_FORMAT
    on plist files with date/data nodes (backward compatibility preserved)
  • plistutil -f json -C produces valid JSON (validated with jq empty)
  • -c (compact) and -C (coerce) compose correctly
  • argv[0] detection works via symlink
  • Plist files without date/data/UID nodes work identically with or
    without --coerce
  • All existing plist types (string, integer, real, boolean, null, array,
    dict) are unaffected by the change

- Add PLIST_OPT_COERCE option to coerce PLIST_DATE, PLIST_DATA, and PLIST_UID to JSON-compatible types (ISO 8601 strings, Base64 strings, and integers)

- Add plist_to_json_ex() function with coercion parameter

- Update plist_write_to_string() and plist_write_to_stream() to support coercion option

- Add --coerce flag to plistutil for JSON output

- Create plist2json symlink that automatically enables coercion when invoked
@calilkhalil calilkhalil force-pushed the feat/json-coercion-support branch from 325e2c8 to 2d75089 Compare February 21, 2026 13:39
@calilkhalil

Copy link
Copy Markdown
Contributor Author

jerry_camel_chewing_hg_clr

@nikias

nikias commented Mar 20, 2026

Copy link
Copy Markdown
Member

Ok so I am almost done with the changes, and I definitely like the idea about PLIST_OPT_COERCE option. But I feel like rather having a plist_to_json_ex function with an additional parameter, I would do a

plist_err_t plist_to_json_with_options(plist_t plist, char **plist_json, uint32_t* length, plist_write_options_t options);

That essentially only has one parameter for options. But, we already have

plist_err_t plist_write_to_string(plist_t plist, char **output, uint32_t* length, plist_format_t format, plist_write_options_t options);

Which is essentially the same except for the additional format parameter. So this could then easily be declared with a #define like this:

#define plist_to_json_with_options(plist, out, len, opt) plist_write_to_string(plist, out, len, PLIST_FORMAT_JSON, opt)

Would this satisfy your needs?

@calilkhalil

Copy link
Copy Markdown
Contributor Author

Yes, that works perfectly for my use case. I should have noticed that plist_write_to_string already covers this.

Thanks for the refinement!

@nikias

nikias commented Mar 20, 2026

Copy link
Copy Markdown
Member

Forget what I said, I need to add a plist_to_json_with_options to be able to actually pass the options from plist_write_to_string etc :D

@calilkhalil

Copy link
Copy Markdown
Contributor Author

lol, fair enough the abstraction layers have a way of pulling you back in.

Let me know if there's anything I can help with on my end!

@nikias

nikias commented Mar 20, 2026

Copy link
Copy Markdown
Member

See 3edac28

@nikias nikias closed this Mar 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants