Skip to content

Commit

Permalink
add command unset
Browse files Browse the repository at this point in the history
Signed-off-by: Sebastien Duthil <[email protected]>
  • Loading branch information
duthils committed Mar 29, 2024
1 parent d8e8809 commit 2c1668c
Show file tree
Hide file tree
Showing 5 changed files with 370 additions and 1 deletion.
19 changes: 19 additions & 0 deletions README.rst
Expand Up @@ -1470,6 +1470,25 @@ The value must be formatted as json.
$ sops set ~/git/svc/sops/example.yaml '["an_array"][1]' '{"uid1":null,"uid2":1000,"uid3":["bob"]}'
Unset a sub-part in a document tree
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Symmetrically, SOPS can unset a specific part of a YAML or JSON document, by providing
the path in the ``unset`` command. This is useful to unset specific values, like keys, without
needing an editor.
.. code:: sh
$ sops unset ~/git/svc/sops/example.yaml '["app2"]["key"]'
The tree path syntax uses regular python dictionary syntax, without the
variable name. Set to keys by naming them, and array elements by
numbering them.
.. code:: sh
$ sops unset ~/git/svc/sops/example.yaml '["an_array"][1]'
Showing diffs in cleartext in git
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
102 changes: 101 additions & 1 deletion cmd/sops/main.go
Expand Up @@ -1283,6 +1283,87 @@ func main() {
return toExitError(err)
}

// We open the file *after* the operations on the tree have been
// executed to avoid truncating it when there's errors
file, err := os.Create(fileName)
if err != nil {
return common.NewExitError(fmt.Sprintf("Could not open in-place file for writing: %s", err), codes.CouldNotWriteOutputFile)
}
defer file.Close()
_, err = file.Write(output)
if err != nil {
return toExitError(err)
}
log.Info("File written successfully")
return nil
},
},
{
Name: "unset",
Usage: `unset a specific key or branch in the input document.`,
ArgsUsage: `file index value`,
Flags: append([]cli.Flag{
cli.StringFlag{
Name: "input-type",
Usage: "currently json, yaml, dotenv and binary are supported. If not set, sops will use the file's extension to determine the type",
},
cli.StringFlag{
Name: "output-type",
Usage: "currently json, yaml, dotenv and binary are supported. If not set, sops will use the input file's extension to determine the output format",
},
cli.IntFlag{
Name: "shamir-secret-sharing-threshold",
Usage: "the number of master keys required to retrieve the data key with shamir",
},
cli.BoolFlag{
Name: "ignore-mac",
Usage: "ignore Message Authentication Code during decryption",
},
cli.StringFlag{
Name: "decryption-order",
Usage: "comma separated list of decryption key types",
EnvVar: "SOPS_DECRYPTION_ORDER",
},
}, keyserviceFlags...),
Action: func(c *cli.Context) error {
if c.Bool("verbose") {
logging.SetLevel(logrus.DebugLevel)
}
if c.NArg() != 2 {
return common.NewExitError("Error: no file specified, or index is missing", codes.NoFileSpecified)
}
fileName, err := filepath.Abs(c.Args()[0])
if err != nil {
return toExitError(err)
}

inputStore := inputStore(c, fileName)
outputStore := outputStore(c, fileName)
svcs := keyservices(c)

path, err := parseTreePath(c.Args()[1])
if err != nil {
return common.NewExitError("Invalid unset index format", codes.ErrorInvalidSetFormat)
}

order, err := decryptionOrder(c.String("decryption-order"))
if err != nil {
return toExitError(err)
}
output, err := unset(unsetOpts{
OutputStore: outputStore,
InputStore: inputStore,
InputPath: fileName,
Cipher: aes.NewCipher(),
KeyServices: svcs,
DecryptionOrder: order,
IgnoreMAC: c.Bool("ignore-mac"),
TreePath: path,
})
if err != nil {
return toExitError(err)
}

// We open the file *after* the operations on the tree have been
// executed to avoid truncating it when there's errors
file, err := os.Create(fileName)
Expand Down Expand Up @@ -1558,7 +1639,7 @@ func main() {
// While this check is also done below, the `err` in this scope shadows
// the `err` in the outer scope. **Only** do this in case --decrypt,
// --rotate-, and --set are not specified, though, to keep old behavior.
if err != nil && !c.Bool("decrypt") && !c.Bool("rotate") && c.String("set") == "" {
if err != nil && !c.Bool("decrypt") && !c.Bool("rotate") && c.String("set") == "" && c.String("unset") == "" {
return toExitError(err)
}
}
Expand Down Expand Up @@ -1614,6 +1695,25 @@ func main() {
})
}

if c.String("unset") != "" {
var path []interface{}

path, err = parseTreePath(c.String("unset"))
if err != nil {
return toExitError(err)
}
output, err = unset(unsetOpts{
OutputStore: outputStore,
InputStore: inputStore,
InputPath: fileName,
Cipher: aes.NewCipher(),
KeyServices: svcs,
DecryptionOrder: order,
IgnoreMAC: c.Bool("ignore-mac"),
TreePath: path,
})
}

isEditMode := !c.Bool("encrypt") && !c.Bool("decrypt") && !c.Bool("rotate") && c.String("set") == ""
if isEditMode {
_, statErr := os.Stat(fileName)
Expand Down
68 changes: 68 additions & 0 deletions cmd/sops/unset.go
@@ -0,0 +1,68 @@
package main

import (
"fmt"

"github.com/getsops/sops/v3"
"github.com/getsops/sops/v3/cmd/sops/codes"
"github.com/getsops/sops/v3/cmd/sops/common"
"github.com/getsops/sops/v3/keyservice"
)

type unsetOpts struct {
Cipher sops.Cipher
InputStore sops.Store
OutputStore sops.Store
InputPath string
IgnoreMAC bool
TreePath []interface{}
KeyServices []keyservice.KeyServiceClient
DecryptionOrder []string
}

func unset(opts unsetOpts) ([]byte, error) {
// Load the file
// TODO: Issue #173: if the file does not exist, create it with the contents passed in as opts.Value
tree, err := common.LoadEncryptedFileWithBugFixes(common.GenericDecryptOpts{
Cipher: opts.Cipher,
InputStore: opts.InputStore,
InputPath: opts.InputPath,
IgnoreMAC: opts.IgnoreMAC,
KeyServices: opts.KeyServices,
})
if err != nil {
return nil, err
}

// Decrypt the file
dataKey, err := common.DecryptTree(common.DecryptTreeOpts{
Cipher: opts.Cipher,
IgnoreMac: opts.IgnoreMAC,
Tree: tree,
KeyServices: opts.KeyServices,
DecryptionOrder: opts.DecryptionOrder,
})
if err != nil {
return nil, err
}

// Unset the value
newBranch, err := tree.Branches[0].Unset(opts.TreePath)
if err != nil {
return nil, err
}
tree.Branches[0] = newBranch

err = common.EncryptTree(common.EncryptTreeOpts{
DataKey: dataKey, Tree: tree, Cipher: opts.Cipher,
})
if err != nil {
return nil, err
}

encryptedFile, err := opts.OutputStore.EmitEncryptedFile(*tree)
if err != nil {
return nil, common.NewExitError(fmt.Sprintf("Could not marshal tree: %s", err), codes.ErrorDumpingTree)
}
return encryptedFile, err
}
134 changes: 134 additions & 0 deletions functional-tests/src/lib.rs
Expand Up @@ -549,6 +549,140 @@ b: ba"#
}
}

#[test]
fn unset_json_file() {
// Test removal of tree branch
let file_path =
prepare_temp_file("test_unset.json", r#"{"a": 2, "b": "ba", "c": [1,2]}"#.as_bytes());
assert!(
Command::new(SOPS_BINARY_PATH)
.arg("encrypt")
.arg("-i")
.arg(file_path.clone())
.output()
.expect("Error running sops")
.status
.success(),
"sops didn't exit successfully"
);
let output = Command::new(SOPS_BINARY_PATH)
.arg("unset")
.arg(file_path.clone())
.arg(r#"["a"]"#)
.output()
.expect("Error running sops");
assert!(output.status.success(), "sops didn't exit successfully");
println!(
"stdout: {}, stderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let mut s = String::new();
File::open(file_path.clone())
.unwrap()
.read_to_string(&mut s)
.unwrap();
let data: Value = serde_json::from_str(&s).expect("Error parsing sops's JSON output");
if let Value::Mapping(data) = data {
assert!(!data.contains_key(&Value::String("a".to_owned())));
assert!(data.contains_key(&Value::String("b".to_owned())));
} else {
panic!("Output JSON does not have the expected structure");
}

// Test removal of list item
let output = Command::new(SOPS_BINARY_PATH)
.arg("unset")
.arg(file_path.clone())
.arg(r#"["c"][0]"#)
.output()
.expect("Error running sops");
assert!(output.status.success(), "sops didn't exit successfully");
println!(
"stdout: {}, stderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let mut s = String::new();
File::open(file_path)
.unwrap()
.read_to_string(&mut s)
.unwrap();
let data: Value = serde_json::from_str(&s).expect("Error parsing sops's JSON output");
if let Value::Mapping(data) = data {
assert_eq!(data["c"].as_sequence().unwrap().len(), 1);
} else {
panic!("Output JSON does not have the expected structure");
}
}

#[test]
fn unset_yaml_file() {
// Test removal of tree branch
let file_path =
prepare_temp_file("test_unset.json", r#"{"a": 2, "b": "ba", "c": [1,2]}"#.as_bytes());
assert!(
Command::new(SOPS_BINARY_PATH)
.arg("encrypt")
.arg("-i")
.arg(file_path.clone())
.output()
.expect("Error running sops")
.status
.success(),
"sops didn't exit successfully"
);
let output = Command::new(SOPS_BINARY_PATH)
.arg("unset")
.arg(file_path.clone())
.arg(r#"["a"]"#)
.output()
.expect("Error running sops");
assert!(output.status.success(), "sops didn't exit successfully");
println!(
"stdout: {}, stderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let mut s = String::new();
File::open(file_path.clone())
.unwrap()
.read_to_string(&mut s)
.unwrap();
let data: Value = serde_yaml::from_str(&s).expect("Error parsing sops's YAML output");
if let Value::Mapping(data) = data {
assert!(!data.contains_key(&Value::String("a".to_owned())));
assert!(data.contains_key(&Value::String("b".to_owned())));
} else {
panic!("Output YAML does not have the expected structure");
}

// Test removal of list item
let output = Command::new(SOPS_BINARY_PATH)
.arg("unset")
.arg(file_path.clone())
.arg(r#"["c"][0]"#)
.output()
.expect("Error running sops");
assert!(output.status.success(), "sops didn't exit successfully");
println!(
"stdout: {}, stderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let mut s = String::new();
File::open(file_path)
.unwrap()
.read_to_string(&mut s)
.unwrap();
let data: Value = serde_yaml::from_str(&s).expect("Error parsing sops's YAML output");
if let Value::Mapping(data) = data {
assert_eq!(data["c"].as_sequence().unwrap().len(), 1);
} else {
panic!("Output YAML does not have the expected structure");
}
}

#[test]
fn decrypt_file_no_mac() {
let file_path = prepare_temp_file(
Expand Down

0 comments on commit 2c1668c

Please sign in to comment.