Skip to content

docs(java): document the 17.4.0 breaking changes, and three review follow-ups - #1120

Merged
stas-schaller merged 4 commits into
release/sdk/java/core/v17.4.0from
fix/java-core-17.4.0-followups
Aug 19, 2026
Merged

stas-schaller merged 4 commits into
release/sdk/java/core/v17.4.0from
fix/java-core-17.4.0-followups

Conversation

@mgallego-keeper

Copy link
Copy Markdown
Contributor

Follow-ups from the review pass over the v17.4.0 release branch, picking up what #1117 did not cover. Four fixes, one commit each; every commit builds and tests green on its own (70 tests, 0 failures).

Nothing here changes runtime behaviour except the exception message in item 2.

1. The changelog never documented the 17.4.0 breaking changes

The 17.4.0 section listed only features and fixes. #1110's description calls out the deleteFolder() return type change under a Breaking Changes heading, but that never reached README.md, which is what users actually read on upgrade. The 17.3.0 section right below it already uses a Breaking Changes block, so the convention exists.

To make the list defensible rather than a judgment call, I enumerated every class in the compiled jar and diffed javap output against master. Exactly three public members change, plus two synthetic constructors:

Member Change
deleteFolder() return type SecretsManagerDeleteResponse to SecretsManagerDeleteFolderResponse
KeeperRecord.copy() arity 9 to 10
SecretsManagerOptions.copy() arity 7 to 9

Nothing else is removed anywhere in the SDK.

The second entry is easy to state too narrowly, and my first draft did. Adding a constructor parameter changes copy(), but it also changes the synthetic $default constructor, which is the real call target whenever Kotlin source omits a defaulted argument. So Kotlin code that merely builds a KeeperRecord breaks on a jar swap too, not just copy() callers. Verified by compiling a caller against 17.3.0 and running it against 17.4.0:

run against 17.3.0 : OK uid=uid rev=1
run against 17.4.0 : java.lang.NoSuchMethodError: 'void KeeperRecord.<init>(
                       byte[], String, String, byte[], String, KeeperRecordData,
                       long, List, List, int, DefaultConstructorMarker)'

Recompiling resolves it, and Java call sites are unaffected either way thanks to the @JvmOverloads added in #1117. The changelog entry now says exactly that.

2. A failed config write threw away the underlying IOException (KSM-1262)

Both temp-file write paths in LocalConfigStorage caught IOException and threw a SecretsManagerException constructed from a message alone, so the stack trace and the errno were gone. The message also asserted a cause it could not know:

Cannot write config <path>: directory <parent> is not writable.

Files.createTempFile also fails on a full or read-only volume, a missing parent directory, and an fd limit. In each of those cases the operator was told, confidently, that it was a permission problem.

SecretsManagerException gains an optional cause. @JvmOverloads keeps the single-argument constructor, so Java callers and the two existing subclasses that call super(message) are untouched; javap confirms no member was removed from the class.

3. KeeperRecord.isEditable had no KDoc

The field carries a backend permission decision and its only description lived in the changelog. The doc records the three things a caller needs: what each value means, that the SDK does not enforce it before an update (per KSM-1173), and that the false default is reachable only through direct construction, because the response envelope requires the field.

Worth knowing for the last point: SecretsManagerResponseRecord.isEditable has no default, so a server that omitted the field would fail the whole getSecrets() call rather than yielding false:

field absent -> MissingFieldException: Field 'isEditable' is required ...
field null   -> JsonDecodingException: Expected valid boolean literal ...

That is pre-existing and not changed here, but it is why the default is documented as construction-only.

4. The isEditable test could fail as IndexOutOfBounds

testIsEditableForwardedToKeeperRecord indexed records[0] directly. fetchAndDecryptSecrets swallows per-record failures to stderr, so a decrypt regression would have surfaced as IndexOutOfBoundsException instead of a readable assertion. Both neighbouring tests already guard on the record count first.

Also folds the two cases into a loop. The assertion still discriminates: hardcoding isEditable = true in decryptRecord fails the test, reverting passes.

Verification

  • 70 tests, 0 failures. Each of the four commits builds and tests green on its own.
  • javap sweep over every class in the jar: the three members above are the only public removals between master and this branch.
  • javap on SecretsManagerException: two additions, nothing removed.
  • Jar-swap probe reproduces and then confirms the NoSuchMethodError described in item 1.

Not addressed here

Found during the same pass, left out as pre-existing and outside the release-blocker scope. Happy to open tickets:

  • The atomic write has no fsync before the rename, so a power cut can leave a truncated config. Outside the permissions threat model KSM-1262 states, but "atomic write" invites the expectation.
  • keyId at SecretsManager.kt:1738 is a public mutable top-level var, mutated as a side effect of the keeperPublicKeys initializer and exported as getKeyId()/setKeyId().
  • testConfigFileAtomicWriteIsOwnerOnly asserts permissions but not atomicity despite the name, and does not cover saveCachedValue, the other half of KSM-1262.
  • LocalConfigStorage switched from FileReader to readText(Charsets.UTF_8) in fix(java): validate server key_id range and eliminate config write race window #1109, which pairs correctly with the UTF-8 write side and fixes a latent platform-charset mismatch. Harmless for ASCII configs, but it went unremarked in the changelog.

The 17.4.0 section listed only features and fixes. Enumerating every class
in the compiled jar and diffing javap output against master shows exactly
three public members change relative to 17.3.0, plus the two synthetic
constructors Kotlin emits for default arguments:

  deleteFolder()                  return type changed
  KeeperRecord.copy()             arity changed
  SecretsManagerOptions.copy()    arity changed

deleteFolder() was already called out in the release PR description but
never reached the changelog, which is what users actually read on upgrade.

The second entry covers a case easy to state too narrowly. Adding a
constructor parameter changes copy(), but it also changes the synthetic
$default constructor, so Kotlin code that merely builds a KeeperRecord
while omitting a defaulted argument breaks on a jar swap too. Verified:
a caller compiled against 17.3.0 and run against 17.4.0 throws

  NoSuchMethodError: KeeperRecord.<init>(..., int, DefaultConstructorMarker)

Recompiling resolves it. Java call sites are unaffected either way, since
both types carry @jvmoverloads.

Matches the Breaking Changes block the 17.3.0 section already uses.
…(KSM-1262)

Both temp-file write paths caught IOException and threw a
SecretsManagerException that discarded the original exception, so the
stack trace and errno were lost. The message also asserted a cause it
could not know: createTempFile fails on a full or read-only volume, a
missing parent directory, or an fd limit, none of which are a permission
problem, and the operator was told the directory was not writable.

SecretsManagerException gains an optional cause. @jvmoverloads keeps the
single-argument constructor, so Java callers and the existing subclasses
that call super(message) are unaffected; javap confirms no member was
removed from the class.
The field carries a backend permission decision, and the only description
of it lived in the changelog. Records the three things a caller needs: the
meaning of each value, that the SDK does not enforce it before an update,
and that the `false` default is reachable only through direct construction
because the response envelope requires the field.
…-1176)

The test indexed records[0] directly. fetchAndDecryptSecrets swallows
per-record failures to stderr, so a decrypt regression would have surfaced
as IndexOutOfBoundsException rather than a readable assertion; both
neighbouring tests already guard on the record count first.

Folds the two cases into a loop over true/false. Verified the assertion
still discriminates: hardcoding isEditable = true in decryptRecord fails
the test, and reverting passes.

@stas-schaller stas-schaller left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@stas-schaller
stas-schaller merged commit 5a075b0 into release/sdk/java/core/v17.4.0 Aug 19, 2026
2 checks passed
mgallego-keeper added a commit that referenced this pull request Aug 19, 2026
PR #1120 changed both write paths to report what the file system returned
and to retain the original IOException as the cause, but shipped no test
for it, so a later refactor can drop the cause without anything failing.

Points LocalConfigStorage at a path whose parent does not exist and
asserts the cause survives, that a missing directory is not reported as
a permissions problem, and that the message names the directory it could
not use.

Verified against the implementation now on the release branch.
mgallego-keeper added a commit that referenced this pull request Aug 19, 2026
PR #1120 changed both write paths to report what the file system returned
and to retain the original IOException as the cause, but shipped no test
for it, so a later refactor can drop the cause without anything failing.

Points LocalConfigStorage at a path whose parent does not exist and
asserts the cause survives, that a missing directory is not reported as
a permissions problem, and that the message names the directory it could
not use.

Verified against the implementation now on the release branch.
mgallego-keeper added a commit that referenced this pull request Aug 19, 2026
…cle guard

Brings in #1117 (Java API compatibility), #1119 (CI on release-branch PRs)
and #1120 (17.4.0 docs). Two files conflicted; SecretsManager.kt merged
cleanly and the cycle guard is unchanged.

README.md: both sides appended a changelog entry at the same position.
Kept both, with KSM-1269 ahead of KSM-1270 in landing order.

SecretsManagerTest.kt: the conflict bundled the new cycle test with
testSecretsManagerOptionsAcceptsCustomTimeouts, whose incoming side was a
deletion. That test was not dropped by #1117, it was relocated to the new
TimeoutTest.kt as timeoutOptions_acceptCustomValues with identical
assertions. Resolving in favour of our side would have left the same
assertions duplicated across two files, so the deletion is accepted here
and only the cycle test is kept.

Verified on Zulu JDK 8: 71 tests, 0 failures. That is 69 from the PR head,
plus 3 from TimeoutTest.kt, minus the 1 relocated test. The net diff
against the base branch is the KSM-1270 change alone.
mgallego-keeper added a commit that referenced this pull request Aug 19, 2026
…-531-java-proxy

Resolves the three conflicts against the base commits that landed after the
rebase (#1117, #1119, #1120):

- SecretsManager.kt: keep the proxy-aware uploadFile call and private helper,
  which are supersets of base's timeout threading (proxyUrl,
  allowUnverifiedCertificate, connectTimeoutMillis, readTimeoutMillis all
  carried). Give proxyUrl a null default in the full postFunction form so
  TimeoutTest's postFunction(url, tk, payload, true, readTimeoutMillis = X)
  call binds; the explicit 4-arg overload is kept for the published Java
  descriptor.
- SecretsManagerExceptions.kt: keep base's @jvmoverloads cause constructor and
  KDoc, and restore the serialVersionUID pin (5401507264959279624) so
  exceptions round-trip with jars built from released 16.6.6/17.2.0/17.3.0.
  Without the pin the computed SUID changes to 1054703023149159532 and
  cross-version deserialization throws InvalidClassException.
- README.md: take base's 17.4.0 Breaking Changes block, corrected for this
  merge: KeeperRecord gained one constructor parameter (isEditable) and
  SecretsManagerOptions gained three (connectTimeoutMillis, readTimeoutMillis,
  proxyUrl). All eight base changelog entries and the KSM-531 entry survive.

Merged tree verified on JDK 8: 95 tests, 0 failures; the released 4-arg
postFunction descriptor and the pinned SUID are present in the built jar.
mgallego-keeper added a commit that referenced this pull request Aug 19, 2026
PR #1120 changed both write paths to report what the file system returned
and to retain the original IOException as the cause, but shipped no test
for it, so a later refactor can drop the cause without anything failing.

Points LocalConfigStorage at a path whose parent does not exist and
asserts the cause survives, that a missing directory is not reported as
a permissions problem, and that the message names the directory it could
not use.

Verified against the implementation now on the release branch.
stas-schaller pushed a commit that referenced this pull request Aug 19, 2026
…d gate stderr diagnostics (#1122)

* test(java): cover the mixed-category password shuffle (KSM-1203)

Every existing testGeneratePassword case requests a single character
category, so all 32 characters come from one charset and the final
shuffle cannot be observed. Those assertions pass whether the shuffle
uses SecureRandom, uses Random.Default, or is deleted outright, which
left the KSM-1203 fix with no coverage at all.

randomSample() emits characters grouped by category, so a mixed request
is the only configuration where the shuffle is visible: without one,
position 0 is always lowercase. The new test asserts that the category
of the first character varies across 200 passwords, and that each
category keeps exactly the requested count.

Verified to fail when the shuffle is removed from generatePassword. It
does not distinguish a CSPRNG from a weak PRNG, since both produce a
uniform permutation; the test comment says so rather than implying
coverage it does not have.

* test(java): cover the config write failure cause (KSM-1262)

PR #1120 changed both write paths to report what the file system returned
and to retain the original IOException as the cause, but shipped no test
for it, so a later refactor can drop the cause without anything failing.

Points LocalConfigStorage at a path whose parent does not exist and
asserts the cause survives, that a missing directory is not reported as
a permissions problem, and that the message names the directory it could
not use.

Verified against the implementation now on the release branch.

* test(java): prove the config write swaps the file instead of rewriting it (KSM-1262)

testConfigFileAtomicWriteIsOwnerOnly asserted only that the finished
config file is 0600. The implementation it replaced also ended at 0600,
by calling chmod after writing the private key through an 0644 handle,
so the test passed against the vulnerable code and could not detect the
fix being reverted.

What KSM-1262 actually changed is that secrets are never written to the
visible path: they go to a 0600 temp file that is renamed into place.
A rename installs a different inode, so BasicFileAttributes.fileKey()
changes across a write while an in-place rewrite keeps the same value.
Comparing fileKey() across two writes makes that deterministic to
assert, with no race to lose.

Also asserts the permissions survive the swap and that no ksm_*.tmp
staging file is left behind on success.

Verified to fail against an in-place write plus chmod-after
implementation, reporting the unchanged inode.

* fix(java): gate delete and folder-skip diagnostics on loggingEnabled

Three stderr writes added for KSM-1081 and KSM-1086 ignored the
loggingEnabled option that the throttle and key-rotation diagnostics in
the same file already respect, so a consumer that had explicitly turned
logging off still got stderr output from deleteSecret, deleteFolder and
the getFolders skip path.

The folder-skip message also diverged from the four equivalent handlers
in fetchAndDecryptSecrets, which all include e.javaClass.simpleName. One
of those logs the identical "Folder <uid> skipped due to error:" prefix,
so the file emitted near-identical lines in two formats. The class name
is not decoration here: the causes this path exists for include a null
data field, whose KotlinNullPointerException carries no message, so the
line read "skipped due to error: null" and told an operator nothing.

Tests drive getFolders through an injected queryFunction with one
undecryptable folder, capturing stderr to assert nothing is written when
loggingEnabled is false and that the exception type appears when it is
true. Both verified to fail against the previous handler.

* docs(java): correct the 17.4.0 changelog

The section had drifted out of the ascending ticket order the file uses,
and it documented no breaking change even though deleteFolder's return
type changed in KSM-1086. Callers read the changelog, not the release PR
description.

Changes:

- Restores ascending ticket order (1081, 1086, 1176, 1203, 1207, 1248,
  1262).
- Adds the missing Breaking Changes block for deleteFolder, noting that
  no working code can be affected: the old return type required a
  "records" field while the backend sends "folders", so every previous
  call threw MissingFieldException rather than returning a value.
- KSM-1203 gains a statement that existing passwords need no rotation.
  Character selection always drew from SecureRandom, so only the
  arrangement was affected: for generatePassword(32, 8, 8, 8, 8) roughly
  137 of the 193 bits were never at risk, and the default
  generatePassword() was unaffected because all of its characters come
  from a single set. Without this, the entry reads as though every
  password the SDK has generated is suspect.
- KSM-1207 notes that downloadFile and downloadThumbnail take only a
  KeeperFile, so they use the built-in defaults rather than the values
  configured on SecretsManagerOptions.
- KSM-1262 documents the config file now being read as UTF-8 explicitly,
  where it previously used the JVM default charset while always being
  written as UTF-8.
- KSM-1081 and KSM-1086 note that the per-item diagnostics are gated on
  loggingEnabled.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants