Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/sdks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,15 @@ jobs:
name: devenv
- name: Install devenv.sh
run: nix profile install nixpkgs#devenv
- name: Install Terraform for provider acceptance tests
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
# Keep testing the minimum supported ephemeral-resource release series.
terraform_version: "1.10.5"
terraform_wrapper: false
- name: Build cdylib and run all SDK suites
env:
TF_ACC: "1"
run: devenv shell -- bash scripts/ci-sdks.sh

swift:
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Read existing manifests from Terraform using `secretspec_secrets` ephemeral
resources or data sources. Both refuse generated-value writeback and
`as_path` declarations. Go SDK callers can select these restrictions with
`WithReadOnly` and `WithNoFiles`; older native libraries fail without
resolving the request. Read-only resolution rejects generation that requires
storage before running the generator, including in `serve --read-only`.

- Pin static binary installations using a version in the installer URL, such
as `https://install.secretspec.dev/v0.21.1`. The URL without a version still
installs the latest release.
Expand Down
190 changes: 190 additions & 0 deletions libsecretspec/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,196 @@ fn call_rejects_unknown_versions_operations_and_source_combinations() {
}
}

#[test]
fn call_rejects_unknown_options_before_loading_the_manifest() {
let request = serde_json::json!({
"request_version": 1, "operation": "resolve",
"source": { "kind": "path", "path": "nonexistent.toml" },
"options": { "unknown_restriction": true }
});
let result = call(&request.to_string());
assert_eq!(result["ok"], false);
assert_eq!(result["error"]["kind"], "invalid_request");
}

#[test]
fn call_read_only_reads_stored_values_but_refuses_generated_writes() {
let dir = TempDir::new().unwrap();
let (path, provider) = write_project(
&dir,
r#"
[project]
name = "read-only-ffi"
revision = "1.0"

[profiles.default]
TOKEN = { description = "token", type = "password", generate = true }
"#,
"TOKEN=already-stored\n",
);
let request = serde_json::json!({
"request_version": 1, "operation": "resolve",
"source": { "kind": "path", "path": path },
"options": { "provider": provider, "read_only": true, "reason": "read-only test" }
});
let stored = call(&request.to_string());
assert_eq!(stored["ok"], true, "{stored}");
assert_eq!(
stored["response"]["secrets"]["TOKEN"]["value"],
"already-stored"
);
let env_path = dir.path().join(".env");
assert_eq!(
fs::read_to_string(&env_path).unwrap(),
"TOKEN=already-stored\n"
);

fs::write(&env_path, "# preserve this file\n").unwrap();
let missing = call(&request.to_string());
assert_eq!(missing["ok"], false, "{missing}");
assert_eq!(missing["error"]["kind"], "produced_value_write_refused");
assert_eq!(
fs::read_to_string(&env_path).unwrap(),
"# preserve this file\n"
);

// The same declaration can generate and save when the restriction is off.
let mut writable = request;
writable["options"]["read_only"] = false.into();
let generated = call(&writable.to_string());
assert_eq!(generated["ok"], true, "{generated}");
assert!(fs::read_to_string(&env_path).unwrap().contains("TOKEN="));
}

#[cfg(unix)]
#[test]
fn call_read_only_rejects_persisted_command_before_generation() {
let dir = TempDir::new().unwrap();
let marker = dir.path().join("generated");
let command = format!(
"printf ran > '{}'; printf generated-value",
marker.display().to_string().replace('\'', "'\\''")
);
let (path, provider) = write_project(
&dir,
&format!(
r#"
[project]
name = "read-only-command-ffi"
revision = "1.0"

[profiles.default.TOKEN]
description = "token"
type = "command"
[profiles.default.TOKEN.generate]
command = {}

[profiles.default.COMPOSED]
description = "composed"
composed = "prefix:${{TOKEN}}"

[scopes.composed]
secrets = ["COMPOSED"]
"#,
serde_json::to_string(&command).unwrap()
),
"# unchanged\n",
);
let mut request = serde_json::json!({
"request_version": 1, "operation": "resolve",
"source": { "kind": "path", "path": path },
"options": { "provider": provider, "read_only": true, "reason": "read-only command test" }
});
for scope in [None, Some("composed")] {
request["options"]["scope"] = scope.into();
let refused = call(&request.to_string());
assert_eq!(refused["ok"], false, "{refused}");
assert_eq!(
refused["error"]["kind"], "produced_value_write_refused",
"{refused}"
);
assert_eq!(
refused["error"]["message"]
.as_str()
.unwrap()
.contains("TOKEN"),
scope.is_none(),
"{refused}"
);
assert!(!marker.exists(), "the refused generator ran");
}
assert_eq!(
fs::read_to_string(dir.path().join(".env")).unwrap(),
"# unchanged\n"
);

// The same generator remains allowed when its value is never stored.
request["options"]["provider"] = "null://".into();
request["options"]["scope"] = serde_json::Value::Null;
let generated = call(&request.to_string());
assert_eq!(generated["ok"], true, "{generated}");
assert_eq!(
generated["response"]["secrets"]["TOKEN"]["value"],
"generated-value"
);
assert_eq!(fs::read_to_string(marker).unwrap(), "ran");
}

#[test]
fn call_no_files_checks_the_selected_dependency_closure_before_resolution() {
let dir = TempDir::new().unwrap();
let (path, provider) = write_project(
&dir,
r#"
[project]
name = "no-files-ffi"
revision = "1.0"

[profiles.default]
FILE = { description = "file", type = "password", generate = true, as_path = true }
PLAIN = { description = "plain", default = "inline-value" }
COMPOSED = { description = "composed", composed = "prefix:${FILE}" }

[scopes.plain]
secrets = ["PLAIN"]
[scopes.composed]
secrets = ["COMPOSED"]
"#,
"# unchanged\n",
);
let mut request = serde_json::json!({
"request_version": 1, "operation": "resolve",
"source": { "kind": "path", "path": path },
"options": { "provider": provider, "no_files": true, "reason": "no files test" }
});
for scope in [None, Some("composed")] {
request["options"]["scope"] = scope.into();
let result = call(&request.to_string());
assert_eq!(result["ok"], false, "{result}");
assert_eq!(result["error"]["kind"], "file_value_refused", "{result}");
assert_eq!(
result["error"]["message"]
.as_str()
.unwrap()
.contains("FILE"),
scope.is_none(),
"{result}"
);
// Even generation preceding file materialization must not have run.
assert_eq!(
fs::read_to_string(dir.path().join(".env")).unwrap(),
"# unchanged\n"
);
}
request["options"]["scope"] = "plain".into();
let plain = call(&request.to_string());
assert_eq!(plain["ok"], true, "{plain}");
assert_eq!(
plain["response"]["secrets"]["PLAIN"]["value"],
"inline-value"
);
}

#[test]
fn call_rejects_unknown_inline_declaration_fields() {
let request = serde_json::json!({
Expand Down
3 changes: 3 additions & 0 deletions scripts/ci-sdks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ run_go() {
echo "==> Go (default purego/dlopen path)"
( cd secretspec-go && go test ./... )

echo "==> Terraform provider"
( cd secretspec-terraform && go test ./... )

echo "==> Go (-tags static: cgo links the archive in)"
# Stage the debug archive + header + generated cgo LDFLAGS, then exercise the
# static binding. This is the glibc self-contained build; the fully-static musl
Expand Down
18 changes: 18 additions & 0 deletions secretspec-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,24 @@ resolved, err := secretspec.New().WithScope("api").Load()
`resolved.Close()` (e.g. `defer resolved.Close()`) when done so the secret files
do not accumulate in the temp dir.

## Resolution restrictions (unreleased)

Use `WithReadOnly(true)` to refuse resolution that needs to save a generated
or prompted value back to a provider. Stored values remain readable even when
their declarations enable generation. This uses the same restriction as
`secretspec serve --read-only`. Generation that requires storage is refused
before the generator runs. Local caches, auditing, temporary files and provider
commands remain enabled; generators that do not persist values may still run.

Use `WithNoFiles(true)` when your integration cannot own temporary files.
It rejects `as_path` declarations in the selected profile/scope, including
hidden dependencies, before fetching values or creating files. Unselected
declarations do not cause a refusal.

The target release for these options has not been assigned. Use a native
library built from the same checkout as the SDK. Older libraries reject
restricted requests before resolution. Both options default to false.

## Value-free report

`Report()` returns the inventory/preflight view: per-secret status and
Expand Down
8 changes: 4 additions & 4 deletions secretspec-go/binding_purego.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,9 @@ func ensureLoaded() error {
return loadErr
}

// ensureCallLoaded binds the capability-gated call symbol only for SDKs that
// request an inline declaration. Keeping it out of ensureLoaded preserves the
// legacy path/search API for an older runtime that predates secretspec_call.
// ensureCallLoaded binds the capability-gated call symbol for versioned requests.
// Keeping it out of ensureLoaded preserves the legacy path/search API for an
// older runtime that predates secretspec_call.
func ensureCallLoaded() error {
if err := ensureLoaded(); err != nil {
return err
Expand All @@ -129,7 +129,7 @@ func ensureCallLoaded() error {
defer func() {
if r := recover(); r != nil {
callErr = &Error{Kind: "capability", Message: fmt.Sprintf(
"loaded libsecretspec does not support inline specs (missing secretspec_call): %v", r)}
"loaded libsecretspec does not support this request (missing secretspec_call): %v", r)}
}
}()
purego.RegisterLibFunc(&cCall, libHandle, "secretspec_call")
Expand Down
28 changes: 28 additions & 0 deletions secretspec-go/binding_purego_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,38 @@
package secretspec

import (
"errors"
"runtime"
"testing"
)

func TestRestrictionsDoNotFallBackToLegacyResolver(t *testing.T) {
if err := ensureCallLoaded(); err != nil {
t.Fatal(err)
}
previousErr, previousResolve := callErr, cResolve
t.Cleanup(func() { callErr, cResolve = previousErr, previousResolve })
callErr = &Error{Kind: "capability", Message: "missing secretspec_call"}
cResolve = func(string) uintptr {
t.Fatal("restricted request used the legacy resolver")
return 0
}
for _, builder := range []*Builder{New().WithReadOnly(true), New().WithNoFiles(true)} {
_, err := builder.Load()
if !errors.Is(err, callErr) {
t.Fatalf("expected capability error, got %v", err)
}
}
cResolve = previousResolve
manifestPath, provider := writeProject(t, "DATABASE_URL=postgres://db\n")
resolved, err := New().WithPath(manifestPath).WithProvider(provider).
WithReadOnly(false).WithNoFiles(false).WithReason("compatibility test").Load()
if err != nil {
t.Fatalf("disabled restrictions must still work without secretspec_call: %v", err)
}
defer resolved.Close()
}

func TestLibraryNamesPreferLibsecretspecAndRetainPre020Fallback(t *testing.T) {
names := libNames()
want := []string{"libsecretspec.so", "libsecretspec_ffi.so"}
Expand Down
Loading
Loading