NAME

File::SOPS::Metadata - SOPS metadata section handling

VERSION

version 0.003

SYNOPSIS

use File::SOPS::Metadata;

# Create new metadata
my $meta = File::SOPS::Metadata->new(
    unencrypted_suffix => '_unencrypted',
);

# Add age recipient
$meta->add_age_recipient(
    recipient => 'age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p',
    enc       => '-----BEGIN AGE ENCRYPTED FILE-----...',
);

# Update timestamp
$meta->update_lastmodified;

# Set MAC
$meta->mac($mac_string);

# Convert to hash for serialization
my $hash = $meta->to_hash;

# Parse from existing hash
my $meta = File::SOPS::Metadata->from_hash($sops_section);

DESCRIPTION

File::SOPS::Metadata manages the sops metadata section of encrypted files. This section contains:

  • Encrypted data keys for each recipient/backend

  • MAC for tamper detection

  • Timestamp of last modification

  • Rules for which keys should be encrypted

  • SOPS version information

age

ArrayRef of age-encrypted data keys. Each entry is a HashRef with:

{
    recipient => 'age1...',
    enc       => '-----BEGIN AGE ENCRYPTED FILE-----...'
}

Defaults to [].

pgp

ArrayRef of PGP-encrypted data keys. Not yet implemented. Defaults to [].

kms

ArrayRef of AWS KMS-encrypted data keys. Not yet implemented. Defaults to [].

gcp_kms

ArrayRef of Google Cloud KMS-encrypted data keys. Not yet implemented. Defaults to [].

azure_kv

ArrayRef of Azure Key Vault-encrypted data keys. Not yet implemented. Defaults to [].

hc_vault

ArrayRef of HashiCorp Vault-encrypted data keys. Not yet implemented. Defaults to [].

mac

Message Authentication Code over the entire encrypted data structure.

Stored as an encrypted value string: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]

lastmodified

RFC 3339 timestamp of last modification, and the AAD the metadata MAC is authenticated with. Example: 2026-01-10T12:00:00Z.

Read out of a document by "from_hash", this holds Go's own rendering of the instant the document spells and not the document's text -- because that rendering is what sops feeds the MAC. A document saying 2026-01-10T12:00:00+00:00, 2026-01-10T12:00:00.123Z or 2026-01-10T2:00:00Z arrives here as the value above, the one that authenticates. See "from_hash" for the rule and docs/adr/0044 for the measurement.

"update_lastmodified" writes that rendering directly, and it is the only thing every document this library produces ever carries.

version

SOPS version string. Defaults to 3.7.3 for compatibility with the Go implementation.

sops semver-parses this field and refuses a document it cannot parse, on every read path -- 3, 3.13, true, "" and an absent version are each exit 1. "from_hash" accepts all of them and defaults an absent one to 3.7.3, which is a deliberate divergence in the permissive direction: nothing here reads the field, and no value read out of a document is ever written back into one, because "policy_args" does not carry it across a re-encryption -- the divergence from sops here is that sops preserves the document's own version verbatim on a rotate while we stamp 3.7.3 instead. See "policy_args", docs/adr/0058 (k151) and "from_hash" for the measured table and why a partial check would be worse than none.

unencrypted_suffix

Keys ending with this suffix are not encrypted (but are included in MAC).

Example: api_key_unencrypted would not be encrypted.

Defaults to _unencrypted, but only when no other encryption rule is set -- see "Encryption rules are mutually exclusive". Constructed with any of "encrypted_suffix", "unencrypted_regex" or "encrypted_regex", it defaults to undef instead.

Passing unencrypted_suffix => undef explicitly means no rule at all, which is a different thing from leaving it out: with no rule every leaf is encrypted, including one whose key ends in _unencrypted. That is what "from_hash" does for a document that carries no rule field, and it is what the reference implementation does with such a document.

encrypted_suffix

If set, a value is encrypted only when some component of its path ends with this suffix -- see "should_encrypt_path".

Defaults to undef. Mutually exclusive with the other rules, see "Encryption rules are mutually exclusive".

unencrypted_regex

Regular expression: a value is not encrypted when some component of its path matches it -- see "should_encrypt_path".

Defaults to undef. Mutually exclusive with the other rules, see "Encryption rules are mutually exclusive". Matched in Go RE2's dialect and not in Perl's; a pattern RE2 cannot compile matches nothing, as it does at sops, and one the two dialects read differently is refused rather than matched -- see "The regex rules are matched in RE2's dialect".

encrypted_regex

Regular expression: a value is encrypted only when some component of its path matches it -- see "should_encrypt_path".

Defaults to undef. Mutually exclusive with the other rules, see "Encryption rules are mutually exclusive". Matched in Go RE2's dialect and not in Perl's; a pattern RE2 cannot compile matches nothing, as it does at sops, and one the two dialects read differently is refused rather than matched -- see "The regex rules are matched in RE2's dialect".

The regex rules are matched in RE2's dialect

New in 0.003, and it changes which keys get encrypted. "unencrypted_regex" and "encrypted_regex" are matched here with Perl and in sops with Go's RE2, and those are not the same dialect. Two things follow.

The character classes are ASCII-only. RE2's \w, \W, \d, \D, \s, \S, \b, \B and its POSIX classes reach ASCII and nothing else, for every subject. Perl's are Unicode-aware for any string carrying the UTF-8 flag -- which is every non-ASCII key our parsers produce. Measured against sops 3.13.3 with one ordinary .sops.yaml, one sops -e and no hand editing anywhere:

unencrypted_regex: '^\w+$'      key: cafE<eacute>

sops                 encrypts the value
File::SOPS (0.002)   left it READABLE

Twenty-nine such disagreements were measured over the class escapes, the thirteen POSIX classes and both rule fields; twenty-two of them left a secret readable that sops encrypts. The patterns are therefore compiled /a, which is RE2's answer for these classes -- and, not incidentally, it takes the UTF-8 flag out of the answer, which ADR 0003 forbids reading everywhere else in this distribution. It is /a and not /aa because RE2's (?i) is Unicode-aware: Go folds k to U+212A KELVIN SIGN and s to U+017F LATIN SMALL LETTER LONG S, which /a keeps and /aa would break.

An ASCII rule over ASCII keys does not move. Measured over the 15_960 decisions this distribution's own test suite makes -- every rule pattern in it against every key in it -- eighteen changed, all of them a non-ASCII key under ^\w+$, and none of them an ASCII one.

A pattern the two dialects do not agree on is refused for writing. sops does not report a pattern RE2 cannot compile: it matches with the compile error discarded, so the rule silently matches nothing. Measured -- --encrypted-regex '(?=f)foo' writes every value of the document in plaintext at exit 0, under a sops section that makes it look encrypted, and --unencrypted-regex '(?=f)foo' encrypts every value. Neither is a document this library will produce, so writing under one of these dies:

  • lookahead and lookbehind, (?=), (?!), (?<=) and (?<!)

  • backreferences, \1 and (?P=name)

  • atomic groups (?>...) and possessive quantifiers *+, ++, ?+, {n,m}+

  • the escapes RE2 has no rule for: \Z, \K, \G, \R, \h, \H, \V, \N, \X, \C, \c, \e, \o, \g, \k, \u, \l, \U, \L, and \b inside a character class

  • inline comments (?#...), branch resets (?|...), embedded code, subpattern calls and recursion, the (?'name'...) spelling of a named group, the (?^...) flag reset

  • every regex flag RE2 does not have: x, a, d, l, u, n and p

  • \v, \Q and \E, which both dialects take and read differently: \v is the vertical TAB to Go and the vertical-whitespace class to Perl, and \Q/\E quote a literal run for RE2 while a Perl pattern arriving in a variable keeps them as the letters Q and E (measured: \Qa.b\E selects the key a.b at sops and the key Qa.bE here)

Everything both dialects have is taken, (?i), (?m), (?s), \p{...}, [[:alpha:]], [[:^alpha:]], (?P<name>), (?<name>) and the lazy quantifiers included. (?U) is not among them -- RE2 has that flag and Perl does not, which is the next paragraph but one. The verdicts were read off RE2 itself, not guessed: a .sops.yaml path_regex is the one place sops reports error parsing regexp rather than discarding it.

Reading such a document is a different question, and it gets a different answer. Since 0.003 the rule decides what a leaf is on the way out as well as on the way in, so this predicate is asked on the read path too. Where RE2 cannot compile the pattern, sops's answer -- the rule matches nothing -- is reproducible, and it is reproduced: "decrypt" in File::SOPS, "extract" in File::SOPS and "decrypt_file" in File::SOPS read a document carrying such a rule exactly as sops -d reads it, at exit 0. What stops is writing: "encrypt" in File::SOPS, "encrypt_file" in File::SOPS, "encrypt_in_place" in File::SOPS, "rotate" in File::SOPS and "edit" in File::SOPS all ask "assert_rule_regexes_agree" once, before any leaf is walked. See docs/adr/0051.

A pattern Perl cannot compile is refused everywhere, read path included, with the reason: the dialects disagree in that direction as well, and (?U)fo+ is a pattern sops takes and this side cannot. It is the only such pattern measured -- \C, \g and \k read like company for it and are not, because RE2 rejects all three (measured on 3.13.3 through the path_regex oracle; docs/adr/0048 says otherwise and is wrong there).

Limits

Two measured disagreements survive, both recorded in docs/adr/0048:

  • Full case folding. Perl's (?i) folds U+00DF to ss and RE2's does not, so unencrypted_regex => '(?i)^ss$' leaves a key ß readable here and sops encrypts it. Perl has no flag for simple-only folding.

  • $ before a trailing newline. Perl's $ is (?=\n?\z) and RE2's is \z, so ^foo$ matches a key "foo\n" here and not there. \z is in both dialects and says exactly what RE2's $ says.

A \p{...} naming a property Perl has and Go does not -- \p{Word}, \p{Alpha}, \p{IsAlpha} -- is a third: RE2 rejects it, so sops's rule matches nothing, and this side cannot enumerate Go's table to tell that apart from \p{Greek}, which both accept.

A fourth arrived with the read path: a pattern neither dialect can compile, fo( being the measured one. RE2 rejects it, so sops matches nothing and reads the document at exit 0; here it reaches the Perl compile, which also rejects it, and there is nothing to tell that apart from (?U)fo+ -- a pattern RE2 does compile and this side must not guess at. It is refused, so a document carrying it is one sops reads and this library does not.

Encryption rules are mutually exclusive

"unencrypted_suffix", "encrypted_suffix", "unencrypted_regex" and "encrypted_regex" -- together with unencrypted_comment_regex and encrypted_comment_regex, which this distribution does not implement but does recognise -- select which values get encrypted, and at most one of them may be set. Constructing a Metadata with two of them dies:

Cannot use more than one of unencrypted_suffix, encrypted_suffix, ...
in the same document (got unencrypted_suffix and encrypted_regex);
sops refuses such a file outright

That is not a house rule; it is the reference implementation's, which reports the same conflict and refuses the document before decrypting anything. Constructing the object is the earliest point at which the conflict can be seen, so it is where it is reported.

The consequence worth knowing is the one on "unencrypted_suffix": its _unencrypted default has to stand down as soon as any other rule is set, or every configured document would carry two rules and be unreadable.

mac_only_encrypted

When true, the MAC covers only the values that are actually encrypted; when false (the default) it covers every value in the document, encrypted or not.

Both MAC implementations honour this, and a MAC computed with it on additionally starts from a fixed 32-byte initialization block (MACOnlyEncryptedInitialization in the Go source), so the two settings can never produce the same digest for the same document.

undef or false is emitted as no key at all in the sops section, which is what the Go implementation writes.

The constructor takes a Perl boolean; "from_hash" takes a document's field and decodes it the way sops does. The two are deliberately different. A caller writing mac_only_encrypted => $flag means Perl's truth of $flag, which is the only thing that word can mean in a Perl API. A document saying mac_only_encrypted: "false" means the boolean false -- measured, in a nested YAML section as much as in a flat one -- and Perl's 'false' is true, so reading it as Perl would selects the other digest for a file sops reads at exit 0. See "from_hash" for the accepted set and docs/adr/0042 for the measurement.

What it costs, in YAML: a leaf the MAC no longer covers is one no reader verifies, and this distribution and sops do not resolve every YAML spelling the same way. mode_unencrypted: 0755 is the realistic case -- sops reads the integer 493, this module reads 755, and with mac_only_encrypted set neither the MAC nor sops -d reports anything (measured, sops 3.13.3, exit 0). The same holds for 0o10, 0x1f, 1_000, .inf, Null, TRUE and a date that is not exactly RFC3339. Without this option such a leaf is refused at encrypt time, because the document would fail its own MAC; with it set the document is written and the divergence is warned about instead, naming the leaf's key path. See "serialize" in File::SOPS::Format::YAML for the full rule and docs/adr/0018 for the measurement.

extra

HashRef of the fields in a document's sops section that this class does not model, kept verbatim so that a rewrite does not drop them. Defaults to {}.

The reference implementation knows more metadata fields than this distribution does -- shamir_threshold, key_groups, and the two comment-based encryption rules among them -- and it preserves the ones it knows across a rewrite. Measured against sops 3.13.3: sops rotate on a document carrying shamir_threshold: 2 writes it back out, and drops a field it does not recognise. Modelling each of those fields here would mean modelling key_groups, whose semantics this distribution cannot implement, so instead everything unrecognised is preserved. That is a superset of what Go keeps, and a safe one: Go ignores a field it does not know, so preserving one can never make a document unreadable, whereas dropping shamir_threshold demonstrably changes what sops does with it.

Fields this class does model are never stored here -- "to_hash" lets the attributes win -- so extra cannot be used to shadow mac or age.

One unmodelled field is decoded on the way in rather than kept verbatim: shamir_threshold, the only one of them that is not a string. "from_hash" reads it the way sops does and refuses what sops refuses -- see "Two fields are decoded weakly, because sops decodes them weakly". It stays in extra because this class models what the field is, not what it means: key_groups is what gives a threshold its meaning, and that is the field this distribution cannot implement.

The two comment-based encryption rules pass through here as well, and they are checked rather than decoded: they are strings to sops, so a list or a map in one of them is refused with everything else in "The string fields are checked for their shape, and only for that". A field neither implementation knows keeps whatever it holds, key_groups' list included.

rule_value

my $suffix = $meta->rule_value('unencrypted_suffix');
my $regex  = $meta->rule_value('encrypted_comment_regex');

Returns the value of an encryption rule by name, or undef if the document does not carry it.

The point of going through a name rather than an accessor is the rules this class does not model: unencrypted_comment_regex and encrypted_comment_regex have no attribute, but a caller deciding whether it can honour a document's rule has to be able to ask about them. The names worth asking about are in @File::SOPS::Metadata::ENCRYPTION_RULES and @File::SOPS::Metadata::UNSUPPORTED_ENCRYPTION_RULES.

policy_args

my $fresh = File::SOPS::Metadata->new($meta->policy_args);

Returns the constructor arguments that describe how a document is encrypted, so they can be carried onto a new metadata object: the four encryption rules and "mac_only_encrypted".

Deliberately not included is everything that describes what encrypted this particular document, because none of it survives a re-encryption: the per-backend key material ("age", "pgp", "kms" and friends) wraps a data key that is about to be replaced, "mac" authenticates values that are about to be rewritten, and "lastmodified" is the AAD of that MAC.

"version" is also deliberately not carried, and this is a divergence from the reference implementation: sops preserves the document's own version verbatim across a sops rotate (measured: 3.7.3 stays 3.7.3, 3.13.3 stays 3.13.3, and so do v3.13.3, 1.2.3 and 3.13.3-rc.1); "rotate" in File::SOPS instead constructs a fresh File::SOPS::Metadata object, whose "version" defaults to $SOPS_VERSION (3.7.3), so a document written by sops 3.13.3 reads back here with version: 3.7.3 after a rotation. The stamp is a provenance field that goes backwards rather than forwards, and nothing here reads it -- no MAC, no AAD, no decryption decision depends on "version" -- so the divergence is silent. A document whose version sops itself refuses (which "from_hash" accepts permissively, per "adr/0043" in docs) would be rewritten still refused, but no version sops currently writes is in that set. The decision to keep the stamp rather than carry the document's own value across is recorded in docs/adr/0058 (karr k151).

This is what "rotate" in File::SOPS passes to "encrypt" in File::SOPS so that a rotated file keeps the rules it was written under.

key_material_fields

my @found = $meta->key_material_fields;
# => ('age', 'pgp')

Returns the names of the fields in which this document actually carries a wrapped copy of the data key -- "age", "pgp", "kms", "gcp_kms", "azure_kv", "hc_vault" and key_groups, skipping the ones that are empty or absent. key_groups is included although this class does not model it, because a caller about to replace the data key has to know it is there.

This is what "rotate" in File::SOPS asks before it generates a new data key: age is the only backend implemented here, so a document holding key material for any other one cannot be rotated without either revoking those recipients or leaving them a wrapped copy of a key that no longer encrypts anything.

from_hash

my $meta = File::SOPS::Metadata->from_hash($hash);

Class method to create a Metadata object from a HashRef.

Typically used when parsing the sops section from a YAML/JSON file.

An absent encryption rule stays absent. Every rule field is passed to the constructor whether the document had it or not, so a document with no rule field produces a Metadata with no rule -- not one with the _unencrypted default a freshly constructed object gets. The asymmetry is deliberate and it is the reference implementation's: a default belongs to creating a document, not to reading one. Measured against sops 3.13.3 -- take a file it wrote, delete unencrypted_suffix from the sops section, and it stops treating a _unencrypted key as plaintext, failing with Input string ... does not match sops' data format. Applying the default here would make this library leave a value in plaintext that the document's own producer encrypts.

Dies if the input is not a HashRef, naming the shape it got instead. Until 0.003 it returned undef, and that return was a data-loss path rather than a convenience: both format handlers call this as from_hash(delete $data->{sops}) after seeing the key exist, so undef came back meaning "there was a top-level sops entry, it was not a mapping, and it is now gone from the tree" while every caller reads undef as "this document has no metadata". A plaintext file containing sops: mine therefore lost that key on the way through "encrypt_file" in File::SOPS -- over the original file if output was omitted -- and "decrypt" in File::SOPS reported the generic No SOPS metadata found for a document whose sops section it had in fact just discarded.

sops refuses such a document from both directions and does not care what the entry holds -- a scalar, a list, null or an empty mapping are all the same to it. Measured against sops 3.13.3: sops encrypt stops with exit code 203 and the same reserved-key message it gives an already-encrypted file, sops decrypt and sops rotate stop with Found sops entry that is not a mapping.

undef dies too, rather than being read as "no section". The distinction between an absent sops key and one holding null does not survive the delete at the call site, and sops refuses both, so the only caller that can tell them apart is the one that still has the document -- it asks exists first and does not call this method at all when the answer is no.

Dies if the document carries more than one encryption rule, see "Encryption rules are mutually exclusive".

Two fields are decoded weakly, because sops decodes them weakly

Everything in a sops section is a string except two fields, and sops reads both of them out of text -- it decodes the whole section through mapstructure with WeaklyTypedInput, so mac_only_encrypted: "false" is the boolean false and shamir_threshold: "2" is the integer 2. Anything outside the accepted set is not guessed at: sops stops with cannot parse value as 'bool' or as 'int' and exit 1.

This method does the same, and refuses the same -- naming the field and the value it could not read.

It is not a flat-format concern, which is what decides it lives here

The obvious home looks like "unflatten" in File::SOPS::Metadata::Flat, since the ENV and INI encodings are untyped and hand back every leaf as a string. It is the wrong home: measured against sops 3.13.3, the weak decoding applies to a nested YAML sops: section just as much, so a fix there would leave a quoted mac_only_encrypted: "false" in a YAML document still reading as Perl truth -- the identical bug in the format that has a handler today. unflatten therefore stays the structural inverse of flatten with no schema at all, and the coercion lives here, at the one place every format's parsed section arrives. docs/adr/0042 records the measurement and the decision.

What it costs to get wrong is not cosmetic: "mac_only_encrypted" selects the digest. With it set the MAC covers only the encrypted values, behind a fixed 32-byte initialization block, so the two settings can never produce the same digest for the same document.

mac_only_encrypted: strconv.ParseBool's set, and nothing else

"1"  "t"  "T"  "TRUE"   "true"   "True"     -> true
"0"  "f"  "F"  "FALSE"  "false"  "False"    -> false
""                                          -> false
everything else                             -> dies

yes, no, on and off are not in that set, quoted or bare, and neither is "tRuE", " false" or "2": sops refuses each of them with exit 1. The empty string is mapstructure's own rule rather than ParseBool's -- it maps an empty string to the zero value before ParseBool is reached.

A decoded value is a JSON::PP::Boolean, never 1 or 0, so that the next write emits true or false rather than degrading it to an integer.

shamir_threshold: strconv.ParseInt(s, 0, 64)

Base 0 means the spelling picks the base, and the surprise is real:

"2"       -> 2          "010"     -> 8      (a leading zero is octal)
"0x10"    -> 16         "0b101"   -> 5
"0o17"    -> 15         "1_000"   -> 1000   (underscores separate digits)
"+2"      -> 2          ""        -> 0
everything else, and anything outside Go's int64  -> dies

So " 2", "2 ", "2.0", "1e3", "08", "1__0" and 2**63 are all refused, as sops refuses them. "010" is the row that makes a decimal-only parse unacceptable: it would read 10 where sops reads 8, silently.

Only a string is decoded

A value the parser already typed is passed through untouched -- a JSON::PP::Boolean, or a number, which Go tests as != 0 and Perl tests the same way. The question "is this scalar a string or a number" is asked of "detect_type" in File::SOPS::Encrypted rather than answered a second time here, so this method cannot drift away from the rest of the distribution (ADR 0002).

A reference where sops wants a scalar dies too: sops refuses that document with expected type 'bool', got unconvertible type, exit 1.

Nothing moves for a document that already carried a real boolean or a real number, which is every document sops itself writes: measured before and after, the MAC plaintext of a YAML and a JSON document with mac_only_encrypted absent, explicitly false, and set, is unchanged in all six cases.

The string fields are checked for their shape, and only for that

Every other field the sops section models is a Go string, and the same weak decoding reaches them from the other side: sops stringifies whatever scalar it finds and reads on. Measured against sops 3.13.3 -- unencrypted_suffix: 3 is read as "3", true as "1", false as "0", 1e20 as "100000000000000000000", 0755 as "493" -- and sops rotate writes that text back out as a quoted string.

A list or a map in one of those fields is a different answer: sops refuses the document outright, without opening it, with

'<field>' expected type 'string', got unconvertible type

exit 1. Measured for all nine of them -- "mac", "lastmodified", "version", the four encryption rules and the two comment-based rules this distribution recognises without implementing. This method refuses the same, naming the field and the shape it got.

That refusal is not tidiness. A reference in "encrypted_regex" reached "should_encrypt_key" as the pattern, matched no key, and "rotate" in File::SOPS then wrote every value of the document in plaintext and reported success -- for a document sops -d will not open. One in "lastmodified" becomes the AAD the MAC is authenticated with, whose bytes are then a memory address. A field this class does not model keeps whatever it holds, because sops ignores an unknown field whatever shape it has (measured), and because key_groups is a list by definition.

What is deliberately not done: a non-string scalar is not restringified

unencrypted_suffix => 3 stays the number 3 here rather than becoming "3". For every spelling Perl's own text and Go's agree -- a JSON::PP::Boolean numifies to 1/0, an integer stringifies to its digits -- so the suffix a rule matches with is the same either way. The one spelling where they differ is a float outside positional range: Go's strconv.FormatFloat(v, 'f', -1, 64) writes 1e20 as 100000000000000000000 where Perl writes 1e+20.

It is left alone because it is not reachable from any document either implementation produced. sops writes these fields as quoted strings, always -- including when normalising a hand-written float, measured -- and this library writes back the scalar the parser gave it. So the divergence needs a hand-edited document to exist at all, and the first time sops touches such a document it is gone. docs/adr/0043 records the measurement.

lastmodified is re-formatted, because that is what becomes the AAD

The one string field that is only a string to mapstructure. Everything in sops after that decode holds a Go time.Time and reads LastModified.Format(time.RFC3339) from it -- the MAC's AAD included. So a document is free to spell the instant in any RFC 3339 form: sops reads it and authenticates with its own rendering, and taking the document's text instead fails to decrypt a MAC sops -d accepts.

This method stores that rendering. The round trip is purely lexical, because Go parses into a fixed zone carrying the offset it just read:

"2026-08-21T09:05:08+00:00"    -> 2026-08-21T09:05:08Z
"2026-08-21T09:05:08-00:00"    -> 2026-08-21T09:05:08Z
"2026-08-21T09:05:08.123456Z"  -> 2026-08-21T09:05:08Z
"2026-08-21T09:05:08,5Z"       -> 2026-08-21T09:05:08Z
"2026-08-21T9:05:08Z"          -> 2026-08-21T09:05:08Z
"2026-08-21T11:05:08+02:00"    -> 2026-08-21T11:05:08+02:00   (NOT UTC)
"2026-08-21T09:05:08+00:60"    -> 2026-08-21T09:05:08+01:00

The fraction is dropped -- time.RFC3339 has no fractional field -- the hour is zero-padded, and the zone is re-derived from the total offset and written Z whenever that total is zero, whichever sign the document used. A non-zero offset is kept: +02:00 is not the same AAD as the same instant in UTC, measured.

A spelling Go's layout cannot parse is passed through unchanged rather than refused. sops stops at exit 1 on every one of them -- a lower-case t or z, a one-digit month, +0000 without the colon, a five-digit year, a leading or trailing space -- so nothing is silently mis-read; and this grammar is a reimplementation of Go's parser, where being narrower than Go somewhere unmeasured would refuse a document sops reads. That is the trade k145 recorded for "version", one field over. docs/adr/0044 carries the 45 measured spellings.

Nothing moves for a document either implementation writes: every one of them carries the Z form already, and the decode is the identity on it.

What is deliberately not done: "version" is not parsed

sops does not merely stringify that field, it semver-parses it and stops on a document it cannot parse -- on every read path (sops -d, -d --extract, rotate), exit 1. Measured, sops 3.13.3:

3.13.3  "3.13.3"  v3.13.3  3.13.3-rc.1  3.13.3+build.5  1.a.b     accepted
3       3.13      true     ""  null  (and an ABSENT version)      refused
03.13.3   3.13.03   3.13.3-   3.13.3+   3.13.3-!   3.13.3-01      refused

This method accepts every one of them, and defaults an absent one to 3.7.3. That is permissive -- it reads documents sops refuses, it never writes one -- and it is deliberate rather than forgotten:

  • Nothing here reads the field. It is not in the MAC, not in the AAD, and not in any decryption decision; sops's own use of it is a comparison against its binary version.

  • Every write path stamps a fresh 3.7.3, which sops accepts. "policy_args" does not carry "version" across a re-encryption, so a value read out of a document never reaches a document -- a divergence from sops, which preserves the document's own version verbatim on a rotate; the decision is recorded in docs/adr/0058.

  • A partial check would be worse than none. Refusing what does not look like N.N.N would refuse v3.13.3, 3.13.3-rc.1, 3.13.3+build.5 and 1.a.b -- every one of which sops reads at exit 0 and writes back verbatim on a rotate. Reproducing the refusal faithfully means reproducing blang/semver's strict grammar (leading zeroes, uint64 bounds, prerelease and build components) plus two sops-specific rules on top of it: a leading v is stripped, and a version whose text begins 1. is accepted without being parsed at all.

to_hash

my $hash = $meta->to_hash;

Converts the Metadata object to a HashRef for serialization.

This HashRef is written to the sops section of the encrypted file.

update_lastmodified

$meta->update_lastmodified;

Sets lastmodified to the current time as %Y-%m-%dT%H:%M:%SZ in UTC -- Go's own time.RFC3339 rendering, which is what makes it usable as the MAC's AAD unchanged. "encrypt" in File::SOPS is the only caller, and "policy_args" deliberately does not carry a document's own value across a re-encryption, so this is the only spelling this library writes.

Returns $self for chaining.

add_age_recipient

$meta->add_age_recipient(
    recipient => 'age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p',
    enc       => '-----BEGIN AGE ENCRYPTED FILE-----...',
);

Adds an age recipient with their encrypted data key.

The enc parameter should be the PEM-armored age-encrypted data key.

Returns $self for chaining.

get_age_encrypted_keys

my @keys = $meta->get_age_encrypted_keys;

Returns a list of age-encrypted data key entries (HashRefs).

Each entry has recipient and enc fields.

should_encrypt_key

if ($meta->should_encrypt_key('api_key')) {
    # Encrypt this key
}

Determines if a single hash key, considered on its own, should be encrypted based on suffix/regex rules.

This is not the rule a document is encrypted under -- that is "should_encrypt_path", which applies the same tests to every component of a value's key path, and it is what File::SOPS walks the tree with. The two agree whenever a rule can only exclude (unencrypted_suffix, unencrypted_regex), because an excluded branch stays excluded all the way down. They disagree on encrypted_suffix and encrypted_regex: a leaf whose own key does not match is still encrypted when a key above it does.

This method remains for callers asking about one key in isolation.

Rules are applied in this order:

1. If unencrypted_suffix is set and key ends with it, return false
2. If encrypted_suffix is set, return true if key ends with it, else false
3. If unencrypted_regex is set and key matches, return false
4. If encrypted_regex is set and key matches, return true, else false
5. Default: return true (encrypt everything)

Returns true if the key should be encrypted, false otherwise.

Dies where the rule is a regex this side cannot read the way sops reads it -- a construct both dialects take and disagree about, or one Perl cannot compile. A construct Go RE2 rejects matches nothing instead, because that is what it matches at sops. See "The regex rules are matched in RE2's dialect".

should_encrypt_path

if ($meta->should_encrypt_path(['database', 'password'])) {
    # This leaf is one of the encrypted ones
}

Whole-path counterpart of "should_encrypt_key", mirroring shouldBeEncrypted in the Go implementation: each rule is evaluated against every component of the path, in the same order, with later rules overriding earlier ones.

A leaf is unencrypted if any component carries unencrypted_suffix or matches unencrypted_regex, and (when those are configured) encrypted only if some component carries encrypted_suffix or matches encrypted_regex.

The two regex rules are matched in RE2's dialect, not Perl's. A pattern RE2 cannot compile matches nothing here, which is what it matches at sops; a pattern the two dialects compile and read differently, or one Perl cannot compile at all, is refused rather than matched -- which is why this method can still die. See "The regex rules are matched in RE2's dialect".

This is the predicate File::SOPS encrypts a document with, and the one it decrypts it with: since 0.003 it is asked about every leaf on the way out too, and its answer is what decides whether a leaf is ciphertext at all rather than a value that happens to spell ENC[...]. It is also what decides which values the MAC covers when "mac_only_encrypted" is set. See "The rule decides what a value is, in both directions" in File::SOPS. Measured against sops 3.13.3 with --encrypted-suffix _enc: every value under a top_enc: block is encrypted, a nested_enc: under an ordinary parent is encrypted, and the elements of a list_enc: array are encrypted because an array contributes no path component of its own and its elements carry the parent's path.

Returns true if the value at that path should be encrypted.

assert_rule_regexes_agree

$meta->assert_rule_regexes_agree;   # or dies

Dies unless "unencrypted_regex" and "encrypted_regex" are patterns Go RE2 and Perl read the same way. Returns true otherwise, including when neither is set.

This is the guard for writing, and "should_encrypt_path" is deliberately more permissive than it. A pattern RE2 cannot compile is not an error at sops -- it discards the compile error, so the rule matches nothing -- and that is reproducible, so a document carrying one is still read. Writing under it is not: a caller who asks for encrypted_regex => '(?=foo)' and is given "matches nothing" gets every secret in the document written to disk in plaintext, under a sops section that makes the file look encrypted, at exit 0. See "The rule decides what a value is, in both directions" in File::SOPS and docs/adr/0051.

File::SOPS asks this once per document, when the metadata for a write is built, so the refusal names the rule and arrives before any leaf is walked.

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.