Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add command unset #1475

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
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
81 changes: 81 additions & 0 deletions cmd/sops/main.go
Original file line number Diff line number Diff line change
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`,
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
68 changes: 68 additions & 0 deletions cmd/sops/unset.go
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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.yaml", 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
48 changes: 48 additions & 0 deletions sops.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import (
"fmt"
"reflect"
"regexp"
"slices"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -189,6 +190,53 @@ func (branch TreeBranch) Set(path []interface{}, value interface{}) TreeBranch {
return set(branch, path, value).(TreeBranch)
}

func unset(branch interface{}, path []interface{}) (interface{}, error) {
switch branch := branch.(type) {
case TreeBranch:
for i, item := range branch {
if item.Key == path[0] {
if len(path) == 1 {
branch = slices.Delete(branch, i, i+1)
} else {
v, err := unset(item.Value, path[1:])
if err != nil {
return nil, err
}
branch[i].Value = v
}
return branch, nil
}
}
return nil, fmt.Errorf("Key not found: %s", path[0])
duthils marked this conversation as resolved.
Show resolved Hide resolved
case []interface{}:
position := path[0].(int)
duthils marked this conversation as resolved.
Show resolved Hide resolved
if position >= len(branch) {
return nil, fmt.Errorf("Index %d out of bounds (maximum: %d)", position, len(branch))
duthils marked this conversation as resolved.
Show resolved Hide resolved
}
if len(path) == 1 {
branch = slices.Delete(branch, position, position+1)
} else {
v, err := unset(branch[position], path[1:])
if err != nil {
return nil, err
}
branch[position] = v
}
return branch, nil
default:
panic(fmt.Sprintf("Unsupported type: %T", branch))
duthils marked this conversation as resolved.
Show resolved Hide resolved
}
}

// Unset removes a value on a given tree from the specified path
func (branch TreeBranch) Unset(path []interface{}) (TreeBranch, error) {
v, err := unset(branch, path)
if err != nil {
return nil, err
}
return v.(TreeBranch), nil
}

// Tree is the data structure used by sops to represent documents internally
type Tree struct {
Metadata Metadata
Expand Down