NAME

File::SOPS::Format::JSON - JSON format handler for SOPS

VERSION

version 0.003

SYNOPSIS

use File::SOPS::Format::JSON;

# Parse JSON with SOPS metadata
my ($data, $metadata) = File::SOPS::Format::JSON->parse($json_content);

# Serialize data with SOPS metadata
my $json = File::SOPS::Format::JSON->serialize(
    data     => $encrypted_data,
    metadata => $metadata_obj,
);

# Check if filename is JSON
if (File::SOPS::Format::JSON->detect('secrets.json')) {
    # It's a JSON file
}

DESCRIPTION

JSON format handler for File::SOPS. Handles parsing and serialization of SOPS-encrypted JSON files.

Uses Cpanel::JSON::XS for JSON processing, named rather than chosen at runtime. It used to go through JSON::MaybeXS, which binds to a backend once per process depending on what was loaded first, so the same data was written differently depending on the calling program -- and the alternatives are not merely differently formatted: JSON::XS reads 0.3 back as a different double, and both it and JSON::PP write an -0.0 in a form that makes the document fail its own MAC. See docs/adr/0005.

Output is always pretty-printed and canonically ordered for consistent diffs.

parse

my ($data, $metadata) = File::SOPS::Format::JSON->parse($json_string);

Class method to parse a JSON string.

Returns a two-element list:

1. $data - HashRef of the data (without the sops section)
2. $metadata - File::SOPS::Metadata object, or undef if no sops section

Dies if the JSON is invalid or doesn't parse to a HashRef.

Dies on a document with duplicate keys (Duplicate keys not allowed). Such a document cannot have a well-defined MAC -- it carries two values under one key and the digest covers one of them -- so it is refused rather than silently resolved. Until 0.003 that depended on which JSON backend the process had loaded: JSON::XS accepted such a document and kept the last value.

Dies too if the document has a top-level sops entry that is not an object -- "sops": "mine", an array, or null. Until 0.003 that entry was deleted from the tree and reported as no metadata at all, so "encrypt_file" in File::SOPS wrote the document back without it. See "from_hash" in File::SOPS::Metadata, which is where the refusal lives.

A bare JSON number past Go's int64 comes back as a float, carrying its source spelling -- a "dualvar" in Scalar::Util. The boundary named here is Go's, and naming Perl's instead would name the wrong one: Perl's integers are a magnitude wider, so two windows sit above 2**63-1, they reach this walk as different scalars, and only the upper one is too wide for a Perl integer at all.

a bare JSON literal in    the decoder returns    until 0.003   since 0.003
2**63 .. 2**64-1          a Perl integer, IOK    an int        a float
past 2**64-1              a plain string, POK    a str         a float

Each window had a defect of its own. In the upper one Cpanel::JSON::XS returns the literal as a plain string SV, indistinguishable from the same digits quoted, so 100000000000000000000 was typed str and "rotate" in File::SOPS wrote it back into the document as a JSON string: the schema of a file the reference implementation had written changed, silently, and nothing failed because a string's digest is its own text either way. In the lower one the decoder holds the digits exactly, so the leaf was an int and "assert_representable" in File::SOPS::Encrypted's int64 guard refused it -- while sops writes that same literal as a type:float and normalises 9223372036854775808 to 9223372036854776000, which is itself inside the window. "rotate" in File::SOPS therefore exited on a JSON document sops -e had just written and sops -d reads.

There is no big integer in the SOPS data model -- past int64 a JSON number is a float64 to Go, and sops writes such a leaf as type:float in an encrypted slot and an unencrypted one alike -- so both windows are floats here too, and the document keeps the bare number it came with.

The two windows are told apart from a quoted string by different means, which is why they are two branches here and not one. The lower window needs no oracle: a JSON string never reaches this walk carrying the public SVf_IOK, so that flag alone separates 9223372036854775808 from "9223372036854775808", and the boundary itself is asked of "integer_fits_int64" in File::SOPS::Encrypted rather than spelled a second time here. The upper window has nothing to read off the scalar -- the bare literal and the quoted one arrive as bit-identical plain PVs -- so it consults the decoder's own type map, never a pattern match on the text (ADR 0002).

A quoted literal is unaffected in either window and stays a str. So is every value the decoder could hold, undef, every boolean and every reference -- and every float, which is why the 0.3 handling above is untouched. So is -9223372036854775808: an IV cannot reach below int64's floor, so both windows are positive-only, and -9223372036854775809 is already a plain string and already the upper window's leaf. A document this library has already written carries an upper-window value quoted and could not carry a lower-window one at all, so it keeps verifying and keeps reading back exactly as before.

Only the upper window's leaf is the one YAML::XS has always returned for the same digits, so there this parser is merely catching up with the other one. The lower window is where the two formats now disagree, deliberately: YAML::XS returns those digits as a Perl integer and "encrypt" in File::SOPS still dies on them, because sops -e cannot write such a YAML document either -- it stops at Error walking tree: Cannot walk value, unknown type: uint64, exit 23, measured against sops 3.13.3. Only JSON has a window Go reads as a number and Perl reads as an integer.

Three consequences worth naming. What this method hands back is a dualvar in both windows, numeric as well as printable. What a File::SOPS caller then sees is slot-dependent and is not this method's to promise -- an encrypted leaf is no number here at all, only the ENC[...] string it is in the document, and what comes back out of the decryption is the bare NV every decrypted float is. See "A number past Go's int64 is a float" in File::SOPS.

Second, value_to_bytes re-derives the digits from the double, so a literal that is not its own double's canonical decimal is written -- and digested -- rounded. In the upper window that loses a digit: 99999999999999999999 becomes 100000000000000000000, which no sops -e writes either, because sops rounds it the same way. In the lower window the same re-derivation settles a refusal instead -- 9223372036854776832 and 9223372036854775808 name one double and both digest as 9223372036854776000, which is what Go digests, so a document sops -d accepts and this library used to report MAC verification failed on now verifies.

Third, a literal that overflows a double, 1 followed by 400 zeros, dies in "assert_representable" in File::SOPS::Encrypted where it used to be written as a string; sops refuses that document itself, at unmarshal time.

See k63 and docs/adr/0020 for the upper window, k101 and docs/adr/0021 for the lower.

parse_in_document_order

my $ordered = File::SOPS::Format::JSON->parse_in_document_order($content);

Reparses $content for its key order only and returns the document as a HashRef whose mappings iterate in the order the file writes them, with the sops section removed. Returns nothing when the text cannot be read that way.

The contract is "parse_in_document_order" in File::SOPS::Format::YAML's, and so is the implementation: a JSON document is one YAML 1.2 document, and this distribution keeps a single order-preserving reader for both formats (docs/adr/0001, docs/adr/0036). Only order and shape are taken from it, never a value.

serialize

my $json = File::SOPS::Format::JSON->serialize(
    data     => \%data,
    metadata => $metadata_obj,
);

Class method to serialize data and metadata to JSON.

The data parameter must be a HashRef. The metadata parameter must be a File::SOPS::Metadata object.

Dies if data has a top-level sops key: that is where the metadata section is written, so the value would be overwritten. Until 0.003 it was, silently, and the resulting document failed its own MAC because the digest had already covered the discarded value.

Returns a pretty-printed, canonically-ordered JSON string with the sops section included.

emit

my $json = File::SOPS::Format::JSON->emit(\%data);

Class method to emit a data structure as JSON, with no sops section and no metadata of any kind -- a plain document. This is what "decrypt_file" in File::SOPS writes and what "edit" in File::SOPS hands to the editor.

Returns UTF-8 encoded bytes, unconditionally (the encoder is built with utf8 => 1, which encodes regardless of whether the strings carry Perl's UTF-8 flag), pretty-printed and canonically ordered. See "Character encoding" in File::SOPS.

"serialize" is this method plus the metadata section, so both go through the same encoder rather than two copies of its options. Those options are not cosmetic -- canonical is what makes key order sorted, which the MAC's encrypt side relies on -- so a change here moves the encrypted document as well as the plaintext one.

Floats are written in a form that parses back to the same double. Cpanel::JSON::XS renders a float through %.15g, while the MAC digest covers the shortest decimal that round-trips -- up to 17 significant digits. For a value needing 16 or 17 the document stated one number and the digest another, and the file failed its own verification; because this backend keeps no copy of the text it parsed, that applied to floats read out of a sops-written document as much as to computed ones, so rotate could destroy a value the reference implementation had written correctly. This method now reparses its own output and substitutes the canonical decimal from "value_to_bytes" in File::SOPS::Encrypted only where the value does not survive, carried as a Math::BigFloat under allow_bignum -- the only wrapper measured to reach JSON as a bare number instead of a quoted string. A float that already emitted faithfully keeps exactly the bytes it had, -0.0 included. NaN and Inf are unchanged. See docs/adr/0006.

A float leaf carrying its own string form is written as a number. Cpanel::JSON::XS writes a scalar with a public string half as a quoted JSON string whenever that half differs from its own rendering of the number, so such a leaf reached the document as a string where the caller passed a number. It failed nothing: the digest covers the canonical decimal either way, the file verifies, and sops -d exits 0 and reads a string back -- the value's type changed and nothing said so. The round-trip check above could not see it, because the reparsed string re-derives the very text the digest covers. It now also asks whether the reparsed leaf is still a float and, where it is not, sends the leaf to the same Math::BigFloat carrier, which writes the canonical decimal as a bare number.

The usual source is "extract" in File::SOPS, which returns a "dualvar" in Scalar::Util for a float leaf (see "canonical_float_dualvar" in File::SOPS::Encrypted); feeding that return value back into "encrypt" in File::SOPS under an unencrypted key is what reaches this. The other is a float that arrived through a YAML parse, since YAML::XS retains the source text of every scalar it parses. An encrypted slot is unaffected -- the leaf is an ENC[...] string before this method sees it -- and File::SOPS::Format::YAML has always written the string half bare and correctly, which is the document this now produces too. A dualvar whose text is what Cpanel would have written anyway, such as 1.5 or -0.0, still emits as the number it always did, byte for byte. See k78, docs/adr/0011 and docs/adr/0010.

An integer leaf carrying its own, different string form is refused. Cpanel::JSON::XS writes it as a quoted string -- "007" for a 7 a YAML parser kept the source spelling of, "five" for a Scalar::Util/dualvar -- while "detect_type" in File::SOPS::Encrypted calls the leaf an int, so the MAC digest covers the number. The document and its own MAC then state different things and neither sops nor this module can read the file back: measured against sops 3.13.3, sops -d exit 51 for 007, +7, -0, 1e3 and every contradicting dualvar. The refusal names the leaf's key path and neither half of the value. Refused rather than repaired because nothing measurable separates a spelling from a contradiction; an encrypted slot is unaffected, and File::SOPS::Format::YAML refuses only the contradicting ones, because it writes a source spelling back faithfully. See k84 and docs/adr/0012.

A reference as a leaf value is refused, with one exception. Cpanel::JSON::XS under allow_bignum writes a Math::BigFloat / Math::BigInt as a bare number, and an unblessed \1 / \0 as bare true / false -- the documented JSON::XS convention for SCALAR refs -- while "detect_type" in File::SOPS::Encrypted calls the leaf str so the MAC digest covers its stringification. The document and its own MAC then state different things, and the file is unreadable to sops and to this module alike. Until 0.003 it was written anyway, without a word; now it dies naming the class or reference kind (never the value).

The exception is an exact JSON::PP::Boolean, which this emitter writes as bare true / false -- the one reference whose document form and digest agree. A subclass of it is not covered: detect_type calls it bool but Cpanel refuses it (or the carrier pipeline writes it as a tag), so it is refused like any other object.

Only leaves that reach the document verbatim can trigger this -- values excluded by the encryption rules, everything in a plaintext document, and the sops section. A value that gets encrypted is an ENC[...] string by the time this method sees it, so an object in an encrypted slot is unaffected and still stores its stringification as type:str. File::SOPS::Format::YAML has refused the same leaves since 0.003; see docs/adr/0008 and k66 for the unblessed-ref half of the same defect.

format_name

Returns 'json'.

file_extensions

Returns a list of file extensions: ('json').

detect

if (File::SOPS::Format::JSON->detect($filename)) {
    # File is JSON based on extension
}

Class method to detect if a filename is JSON based on extension.

Returns true if filename ends with .json (case-insensitive).

SEE ALSO

SUPPORT

Issues

Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-file-sops/issues.

CONTRIBUTING

Contributions are welcome! Please fork the repository and submit a pull request.

AUTHOR

Torsten Raudssus <getty@cpan.org>

COPYRIGHT AND LICENSE

This software is copyright (c) 2026 by Torsten Raudssus.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.