Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,17 @@ because it turns other people's test suites red.

### Changed

- **The `encryption` setting now says that a locked archive is a fixture rather
than protection.** Nothing about the files changes - the sentence `tfg formats
zip` prints, and the one the window shows beside the field, gained the two
facts that were missing: the key is worked out from the run seed, so the same
recipe gives the same archive on every machine, and the password is written
into the manifest beside the file.

Both are deliberate and both are what makes these archives useful for testing
a reader. `aes-256` means something else everywhere else it is written, which
is why it is now said out loud. `README.md` says it too.

- **Files are written over several threads, so a run of many files is several
times faster.** They used to be written one after another.

Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,12 @@ tfg generate --format jpg --size 500kb --set width=1920 --set height=1080 --set
wrong size.** The message names the format, the smallest it can be, the reason
for that floor and what to do instead. `tfg formats` lists every floor.

**A locked archive is a fixture, not protection.** `password` and `encryption`
produce an archive that real readers open with a password, which is what makes it
useful for testing one. It offers no confidentiality: the key is worked out from
the run seed so that the same recipe gives the same archive on every machine, and
the password is written into the manifest beside the file so a test can open it.

## 🧾 The manifest

Written next to the files at the end of every run, including a run that was
Expand Down
19 changes: 12 additions & 7 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,15 @@ the files a manifest lists and nothing else.

**It writes only inside the output directory.** A file name is a name rather than a
path, and the manifest name too. Paths that would leave the directory, including
through a symbolic link, are refused.
through a symbolic link, are refused. Every file is written under a temporary name
first, and those names are claimed rather than created - so a link left at one of
them by somebody else is a refusal rather than a way out of the directory.

**The released binaries are not signed.** Signing is not set up, the release notes
say so, and your operating system will warn you. Verify a download against
`verify-SHA256SUMS.txt` from the same release.
**The Windows and macOS downloads are signed**, and the macOS ones are notarised by
Apple, so they start without a warning about an unknown developer. **The Linux ones
are not signed**, because desktop Linux has no equivalent to sign them with. Verify
any download against `verify-SHA256SUMS.txt` from the same release, and the build
provenance attestation published with it.

### In scope

Expand All @@ -74,9 +78,10 @@ say so, and your operating system will warn you. Verify a download against

## Secrets and permissions in this repository

**There are no repository secrets.** Measured on 2026-08-27: zero. Every workflow
runs on the per-job token GitHub issues for the run, and nothing else is stored
here.
**There are no repository secrets.** Measured again on 2026-09-06: zero. Every
workflow runs on the per-job token GitHub issues for the run, and nothing else is
stored here. Signing happens on the owner's machine, against a key on a card, so
the release workflow never holds one either.

**Access is scoped per workflow.** The main suite, the dependency review and the
release workflow all declare `contents: read` at the top, and the single job that
Expand Down
5 changes: 4 additions & 1 deletion internal/format/archive/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,10 @@ var axes = map[string]format.Property{
Default: NoEncryption,
Detail: "How the archive is locked. AES is the WinZip scheme, and some readers cannot open it " +
"at all. ZipCrypto is the old one every reader opens and nothing modern trusts, and some " +
"of them hand back the encrypted bytes without saying so.",
"of them hand back the encrypted bytes without saying so. " +
"A locked archive from this tool is a fixture rather than protection - the key is worked out " +
"from the run seed, so the same recipe gives the same archive on any machine, and the password " +
"is written into the manifest beside it.",
},
}

Expand Down
126 changes: 126 additions & 0 deletions internal/guard/documentedclaims_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package guard

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/donislawdev/TestingFilesGenerator/internal/format"
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/all"
"github.com/donislawdev/TestingFilesGenerator/internal/format/archive"
)

// The security policy says what the release actually does about signing.
//
// It said the opposite until 2026-09-06: "The released binaries are not signed.
// Signing is not set up, the release notes say so, and your operating system
// will warn you." Every part of that had stopped being true. The release
// workflow writes notes saying the Windows binaries are signed and the macOS
// ones are signed and notarised by Apple, internal/legal/codesign.go pins the
// certificate by the SHA-256 of its DER bytes, and there is a guard family
// around the wording of those notes.
//
// The effect of a stale sentence there is not cosmetic, and that is why this
// exists. SECURITY.md is the one document a cautious person reads before
// deciding whether to trust a download. Telling them the signature they can see
// is not one the project makes is the exact reasoning that leads somebody to
// ignore a mismatch, or to skip a check they have been told there is nothing to
// do.
//
// Tied to the release notes rather than to a list written here, so the two
// cannot drift apart again. Found by an outside review on 2026-09-05.
func TestTheSecurityPolicySaysWhatTheReleaseDoesAboutSigning(t *testing.T) {
policy := readRepoFile(t, "SECURITY.md")
notes := workflowText(t, "release.yml")

if strings.Contains(policy, "binaries are not signed") ||
strings.Contains(policy, "Signing is not set up") {
t.Error("SECURITY.md still says the released binaries are not signed.\n" +
"They are: the Windows ones are signed and the macOS ones are signed and notarised. " +
"A person who reads that sentence has been told the signature they can see is not ours.")
}

// What the release notes claim, and therefore what the policy has to agree
// with. Asking the workflow rather than repeating its words keeps one
// source of truth for the day a platform is added or dropped.
claims := map[string]string{
"Windows binaries are signed": "Windows",
"macOS binaries are signed and notarised": "macOS",
"Linux binaries are not signed": "Linux",
}
checked := 0
for inNotes, platform := range claims {
if !strings.Contains(notes, inNotes) {
continue
}
checked++
if !strings.Contains(policy, platform) {
t.Errorf("the release notes say %q and SECURITY.md never mentions %s.\n"+
"That document is where somebody decides whether to trust a download, so it has "+
"to name which platforms carry a signature and which do not.", inNotes, platform)
}
}
if checked == 0 {
t.Fatal("no sentence about signing was found in release.yml, so this guard compared " +
"SECURITY.md against nothing. If the wording of the notes changed, change the claims " +
"above with it rather than leaving a check that asks nothing.")
}
}

// The setting that locks an archive says what locking it does not give.
//
// This is not a defect in the design and the review that raised it said so. The
// salt comes from the run seed rather than from crypto/rand on purpose: a
// random one would give two runs of one recipe different bytes, which
// untouchable rule 3 forbids. The password is in the manifest on purpose too.
// Both are right for a tool that produces fixtures.
//
// What was missing is the sentence where somebody meets it. "aes-256" carries a
// meaning everywhere else it is written, and a person choosing it here gets an
// archive that offers no confidentiality at all: same recipe, same seed, same
// password gives the same key on any machine, and the password is written down
// beside the file.
//
// Asked of the declaration rather than of the README, because the declaration
// is what both surfaces show - tfg formats prints it and the window puts it
// beside the field.
func TestTheLockingSettingSaysItIsAFixtureRatherThanProtection(t *testing.T) {
zip, err := format.Get("zip")
if err != nil {
t.Fatalf("zip is not registered, so this guard has nothing to read: %v", err)
}

var detail string
for _, p := range zip.Properties {
if p.Name == archive.Encryption {
detail = p.Detail
}
}
if detail == "" {
t.Fatalf("zip declares no %q setting with a description, so this guard asks nothing",
archive.Encryption)
}

// The two concrete reasons it is not protection. A rewrite that drops both
// has dropped the meaning, whatever else it says.
for _, word := range []string{"seed", "manifest"} {
if !strings.Contains(detail, word) {
t.Errorf("the description of %q never mentions the %s:\n %s\n"+
"The two reasons a locked archive from this tool is not protection are that the "+
"key comes from the run seed and that the password is written into the manifest. "+
"Somebody choosing aes-256 here has to be told that, because that name means "+
"something else everywhere they have met it before.", archive.Encryption, word, detail)
}
}
}

// readRepoFile reads a file from the root of the repository.
func readRepoFile(t *testing.T, name string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join(repoRoot(t), name))
if err != nil {
t.Fatalf("reading %s: %v", name, err)
}
return string(raw)
}
Loading