NAME
File::SOPS::Format::ENV - dotenv (.env) format handler for SOPS
VERSION
version 0.003
SYNOPSIS
use File::SOPS::Format::ENV;
# Parse a dotenv document with its flat SOPS metadata
my ($data, $metadata) = File::SOPS::Format::ENV->parse($env_content);
# Serialize data with SOPS metadata
my $env = File::SOPS::Format::ENV->serialize(
data => $encrypted_data,
metadata => $metadata_obj,
);
# Check if a filename is a dotenv file
if (File::SOPS::Format::ENV->detect('secrets.env')) {
# It is
}
DESCRIPTION
The dotenv (.env) format handler for File::SOPS, which sops calls dotenv. The document is a flat list of lines:
#ENC[AES256_GCM,data:...,type:comment]
FOO=ENC[AES256_GCM,data:...,type:str]
EMPTY=
plain_unencrypted=visible
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdl...\n
sops_lastmodified=2026-08-21T08:47:47Z
sops_mac=ENC[AES256_GCM,data:...,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.13.3
The whole format is measured, not read out of the Go source. Everything below was taken from sops 3.13.3 on one document at a time.
The line
KEY=VALUE, no spaces around the =, the key being everything up to the first =. A key may hold spaces, a #, an =-free prefix of anything at all, and may be empty; a value may hold =, #, quotes and leading or trailing whitespace, all of which are part of it -- quotes are not stripped, so QUOTED="hello world" is a value five characters longer than hello world.
A line whose first byte is # is a comment, whatever follows. An empty line is skipped; a line of blanks is not -- sops refuses it with invalid dotenv input line, and so does "parse". So is a line that is neither.
What sops does to a document it writes
Blank lines are dropped. Comments keep their place among the data lines. Values are encrypted one per line, with type:str for everything an env source holds (the store has no syntax for a type, so every value it parses is a string -- ADR 0002 reproduces that for free) and with whatever type the value had when the tree came from somewhere else: a YAML source written out with --output-type dotenv carries type:int, type:float, type:bool and type:time, and sops reads every one of them back. An empty value is not encrypted and stays KEY=. The flat sops_* metadata goes last.
Where this handler differs from sops, and why
Keys are written sorted. sops writes them in document order. The MAC's encrypt side hashes in
sort keysorder, so a document this library writes has to be in that order or it fails its own digest -- the same property File::SOPS::Format::YAML and File::SOPS::Format::JSON get from their emitters, and the reasont/05-format-key-order.texists. Measured: sorting the data lines of a file sops wrote makessops -dfail at exit 51, because the digest covers the values in the order the file lists them.Comments are written first, as a block, because the empty key sorts first. Their position among the data lines is not preserved -- it cannot be: the tree is a Perl hash, its order is gone before any emitter sees it, and the data keys are being reordered anyway. Measured: moving every comment line of a file sops wrote to the top leaves
sops -dat exit 0, because no comment is in the digest.A value the newline escape cannot carry is refused rather than written. See "The escape, and the one value that is refused".
A boolean, a null and an integral float are written as the bytes the digest covers, where sops writes a display form and then cannot read its own file. See "An unencrypted leaf is written as its digest bytes".
The escape, and the one value that is refused
The env store writes a real newline in a data value as the two characters backslash and n, and unescapes the same on the way in. Nothing else is touched -- not a tab, not a carriage return, and not a backslash, so the transform is not injective: a value that already holds backslash-n is written identically to one holding a newline, and comes back as the newline.
The digest covers the value before the escape (measured: two documents with byte-identical data lines and different sops_mac plaintexts), so sops writes such a file at exit 0 and then refuses to read it, MAC mismatch, exit 51.
This handler refuses that value when it writes, and refuses nothing on the way in -- reading unescapes unconditionally, exactly as sops does. The rule asks the escape rather than testing for a character, so that it cannot drift away from the escape it guards:
my $bytes = File::SOPS::Encrypted->value_to_bytes($leaf);
my $escaped = $flat->escape_value($bytes);
croak ... unless $flat->unescape_value($escaped) eq $bytes;
Only leaves that reach the document verbatim can trigger it: an encrypted leaf is an ENC[...] string whose alphabet holds neither a backslash nor a newline, so the escape is the identity on it. See docs/adr/0030.
A comment's text is not escaped and not unescaped -- measured, a comment holding a\nb comes back as a\nb where a data value holding the same bytes comes back as a newline. A comment text with a real newline in it therefore has no spelling in this format and is refused as well.
An unencrypted leaf is written as its digest bytes
A leaf the encryption rules exclude reaches the document as plain text, and the env store has no type label for it, so the reader hands the digest the literal text of the line. The condition for such a document to verify is exactly
the text written == File::SOPS::Encrypted->value_to_bytes($leaf)
and that is what this emitter writes -- it asks the single source of truth rather than re-deriving anything. sops breaks the condition in three places, writing a display form where its own digest covers the wire form: a boolean (true written, True digested), a null (<nil> written, the empty string digested) and a float whose Go display form is not its canonical positional decimal (1.0, 1E+20, -0.0). Each is a file sops wrote at exit 0 and refused to read in the same run.
So this handler writes True, False, the empty string, 1, 100000000000000000000 and -0 where sops writes true, false, <nil>, 1.0, 1E+20 and -0.0. The digest does not move: those are the bytes sops's own sops_mac already covers, every one of them is a line sops itself writes for the corresponding string, and each replaces a line that makes the whole file unreadable. Nothing is refused for its type. See docs/adr/0035.
One consequence is worth stating plainly: an unencrypted value's type is erased by the round trip. 42 comes back as the string "42", a boolean as "True", an undef as ''. sops does the same to every unencrypted value it reads. A caller who needs the type preserved puts the value in an encrypted slot, where the type: label carries it.
Comments
For YAML and JSON a comment leaf is the exception. For an env document it is the ordinary case, and this handler reads and writes it rather than refusing it: a .env with comments goes through decrypt_file and edit without losing them.
A comment lives in the tree under the empty key, as a sequence:
{
'' => [ File::SOPS::Comment->new(text => ' a comment'), ... ],
FOO => 'bar',
}
That is not a convention this handler is free to choose -- see the note on $COMMENT_KEY in the source. The empty key is reserved for it: "parse" refuses a document with an empty data key rather than losing one of the two, and "emit" refuses anything in that slot that is not a comment.
Comments are not in the MAC, in any format and in both MAC modes; the exclusion is File::SOPS::_digested_leaves's and this handler neither has to do anything about it nor may. See docs/adr/0041.
What this handler cannot carry
Nesting. The format is one level deep. sops refuses a nested value itself --
cannot use complex value in dotenv file; offending key db, exit 4 -- and so does "emit".A duplicate key. sops keeps both (its tree is an ordered list of items, not a map) and reads them both back. A Perl hash cannot hold two, so "parse" refuses rather than silently keeping the last one.
A top-level key starting with
sops_. That is where the metadata goes; sops refuses such a document too, at exit 203, with the same message it uses for a top-levelsopsentry in YAML.Bytes that are not valid UTF-8. This distribution's boundary is character strings and its emitters encode unconditionally (ADR 0003), so a data key or value that will not decode is refused at the line rather than written back double-encoded. Both sibling parsers refuse the same document (
invalid trailing UTF-8 octetfrom YAML::XS,malformed UTF-8 characterfrom Cpanel::JSON::XS); sops, whose strings are byte slices, reads it.
parse
my ($data, $metadata) = File::SOPS::Format::ENV->parse($env_string);
Class method. Parses a dotenv document and returns the data tree and, if the document carries a sops_* section, a File::SOPS::Metadata object built from it; otherwise undef for the metadata, which is what a plaintext .env gives.
The tree is flat: one mapping of scalars, plus the comment sequence under the empty key described in "Comments". Values are unescaped ("unescape_value" in File::SOPS::Metadata::Flat) and decoded from UTF-8; comments are decoded and not unescaped.
Dies on a line that is neither a comment nor KEY=VALUE, on a duplicate key, on an empty key, on a repeated flat metadata key, on a metadata section "unflatten" in File::SOPS::Metadata::Flat cannot rebuild, and on a key or value that is not valid UTF-8. Each message names the line.
parse_in_document_order
my $ordered = File::SOPS::Format::ENV->parse_in_document_order($content);
Reparses $content for its key order only and returns the document as a HashRef whose keys iterate in the order the file writes them, with the sops_* metadata removed. Returns nothing when the text cannot be read that way.
This is the half of MAC verification that only a format handler can supply: an env document sops wrote is in document order, and without this the digest would be taken in sorted key order and every such file would fail to verify. The values are undef -- only the shape is read, never a value -- and the comment sequence is present with the right length so that the shape matches "parse"'s. The order comes from a tied hash, which is the whole of what "order preserving" means for a format whose parser has none. See docs/adr/0036.
serialize
my $env = File::SOPS::Format::ENV->serialize(
data => \%data,
metadata => $metadata_obj,
);
Class method. "emit" plus the flat sops_* metadata section, appended at the end as sops writes it.
The data parameter must be a HashRef and metadata a File::SOPS::Metadata object. Dies through "emit" if the data carries a top-level key starting with sops_, which is where the section goes.
emit
my $env = File::SOPS::Format::ENV->emit(\%data);
Class method. Turns a tree into a dotenv document without a metadata section: the comment block first, then KEY=VALUE lines in sorted key order, each terminated by a newline.
This is the emitter "serialize" uses and the one File::SOPS::decrypt_file and File::SOPS::edit write plaintext with, so it is on the wire path. In particular the sorted key order is not a formatting preference -- see the note in "Where this handler differs from sops, and why".
An encrypted leaf is written verbatim. Everything else is written as File::SOPS::Encrypted->value_to_bytes (see "An unencrypted leaf is written as its digest bytes"), escaped for the format, and refused where that escape does not round-trip (see "The escape, and the one value that is refused").
Dies on a nested value, on an unblessed reference, on a comment in a value slot, on a top-level key starting with sops_, and on anything in the empty key that is not a comment. A blessed leaf is written as its stringification, which is what the digest covers for it -- where File::SOPS::Format::YAML and File::SOPS::Format::JSON refuse one, because their emitters would write it as something else (docs/adr/0008). Here document and digest cannot disagree: they are the same call.
detect_content
if (File::SOPS::Format::ENV->detect_content($encrypted)) { ... }
Class method. True when $content is an encrypted dotenv document: every non-empty line is a comment or a KEY=VALUE pair, and at least one of the keys belongs to the flat metadata section.
Both halves are needed. A YAML block scalar can hold a line that spells sops_version=3.13.3, and a plaintext .env carries no metadata at all -- which is why this answers for encrypted content only, and why File::SOPS asks it only where the content is known to be encrypted. Plaintext is recognised by its file name.
format_name
Returns 'env'.
file_extensions
Returns a list of file extensions: ('env').
detect
if (File::SOPS::Format::ENV->detect($filename)) {
# File is a dotenv file based on extension
}
Class method to detect if a filename is a dotenv file based on extension.
Returns true if the filename ends with .env (case-insensitive), which covers a bare .env as well as secrets.env -- the same two shapes Go's filepath.Ext gives sops.
SEE ALSO
File::SOPS - Main SOPS interface
File::SOPS::Metadata::Flat - the flat
sops_*metadata encoding this format carries, and the escape it shares with the data valuesFile::SOPS::Comment - the comment leaf
File::SOPS::Format::ENV::Ordered - the tied hash that carries the document order this handler's
parse_in_document_orderreturnsdocs/adr/0030 - the value that is refused rather than written
docs/adr/0035 - what an unencrypted leaf is written as
docs/adr/0036 - the order-preserving parse, and the contract above
docs/adr/0041 - the comment leaf, and why it is not in the digest
docs/adr/0045 - this handler: the whole document measured, the comment key, the sorted emitter and the nine refusals
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.