From 30ede9da1b80a5c33c06cb7c7d82e92fdf155fb4 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 25 Oct 2026 10:58:19 -0400 Subject: [PATCH 1/7] fix(license): persist server-rejected keys and honour the revocation list when selecting the entitlement token (D-1110, D-1111) --- internal/license/entitlement/autorefresh.go | 2 + internal/license/entitlement/installed.go | 2 + internal/license/entitlement/lifecycle.go | 2 + internal/license/entitlement/rejected.go | 93 ++++++++++++++++ .../entitlement/rejection_persistence_test.go | 100 ++++++++++++++++++ internal/license/entitlement/selection.go | 7 +- 6 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 internal/license/entitlement/rejected.go create mode 100644 internal/license/entitlement/rejection_persistence_test.go diff --git a/internal/license/entitlement/autorefresh.go b/internal/license/entitlement/autorefresh.go index cc3c78299..379239539 100644 --- a/internal/license/entitlement/autorefresh.go +++ b/internal/license/entitlement/autorefresh.go @@ -253,7 +253,9 @@ func revalidateClassified(ctx context.Context) (renewed []string, rejected map[s switch { case err == nil && explicitRejection(remote, result): rejected[license.HashKey(key)], class = true, ClassRejected + recordVerdict(license.HashKey(key), true) case err == nil && result != nil && result.CanProceed && !result.FromCache && result.Status == license.StatusValid: + recordVerdict(license.HashKey(key), false) entry, _ := license.ReadCache() token, terr := cachedToken() switch { diff --git a/internal/license/entitlement/installed.go b/internal/license/entitlement/installed.go index b425658a4..cd70fe172 100644 --- a/internal/license/entitlement/installed.go +++ b/internal/license/entitlement/installed.go @@ -105,12 +105,14 @@ func validateNewKey(ctx context.Context, projectDir, key string) error { // 403, or a signed rejection revoke this runtime entitlement. rejected := explicitRejection(remote, result) if result.CanProceed && !rejected { + recordVerdict(license.HashKey(key), false) return nil } // FromCache alone cannot distinguish an absent cache from an auth rejection. if !rejected { return ErrValidationUnavailable } + recordVerdict(license.HashKey(key), true) if projectDir != "" { if writeErr := publishAfterKeyRejection(projectDir, key); writeErr != nil { return writeErr diff --git a/internal/license/entitlement/lifecycle.go b/internal/license/entitlement/lifecycle.go index 309874963..5f1880d05 100644 --- a/internal/license/entitlement/lifecycle.go +++ b/internal/license/entitlement/lifecycle.go @@ -32,8 +32,10 @@ func revalidateKeys(ctx context.Context) (map[string]bool, []string) { result, err := license.Validate(ctx, key, &license.ValidatorOptions{HTTPClient: remote}) if err == nil && explicitRejection(remote, result) { rejected[license.HashKey(key)] = true + recordVerdict(license.HashKey(key), true) } if err == nil && result != nil && result.CanProceed && !result.FromCache && result.Status == license.StatusValid { + recordVerdict(license.HashKey(key), false) if token, err := cachedToken(); err == nil { renewed = append(renewed, token) } diff --git a/internal/license/entitlement/rejected.go b/internal/license/entitlement/rejected.go new file mode 100644 index 000000000..b5fba0f7f --- /dev/null +++ b/internal/license/entitlement/rejected.go @@ -0,0 +1,93 @@ +package entitlement + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/nself-org/cli/internal/license" +) + +// rejectedFile sits beside the licence cache so a test or deployment that +// relocates the cache relocates the rejections with it. +const rejectedFile = "entitlement-rejected.json" + +// Purpose: locate the persisted server-rejection record. +// Inputs: none. Outputs: path/error. Constraints: same directory as the licence cache. +// SPORT: durable per-key rejection. +func rejectedPath() (string, error) { + cache, err := license.CachePath() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(cache), rejectedFile), nil +} + +// Purpose: load subjects the licence server refused in an earlier run. +// Inputs: none. Outputs: key-hash set. Constraints: a missing or unreadable file +// is empty (the next online refresh re-establishes it); never errors a refresh. +// SPORT: offline refresh must not republish a server-rejected key. +func loadRejected() map[string]bool { + set := make(map[string]bool) + path, err := rejectedPath() + if err != nil { + return set + } + data, err := os.ReadFile(path) + if err != nil { + return set + } + var hashes []string + if json.Unmarshal(data, &hashes) != nil { + return set + } + for _, h := range hashes { + set[h] = true + } + return set +} + +// Purpose: persist a server verdict for one configured key. +// Inputs: key hash and whether the server refused it. Outputs: none. +// Constraints: only an explicit refusal adds, only a fresh online acceptance +// clears; write failures are non-fatal because the in-run map still applies. +// SPORT: durable per-key rejection. +func recordVerdict(hash string, rejected bool) { + set := loadRejected() + if set[hash] == rejected { + return + } + if rejected { + set[hash] = true + } else { + delete(set, hash) + } + path, err := rejectedPath() + if err != nil { + return + } + hashes := make([]string, 0, len(set)) + for h := range set { + hashes = append(hashes, h) + } + data, err := json.Marshal(hashes) + if err != nil { + return + } + if os.MkdirAll(filepath.Dir(path), 0700) != nil { + return + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".entitlement-rejected-*") + if err != nil { + return + } + defer func() { _ = os.Remove(tmp.Name()) }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return + } + if tmp.Chmod(0600) != nil || tmp.Close() != nil { + return + } + _ = os.Rename(tmp.Name(), path) +} diff --git a/internal/license/entitlement/rejection_persistence_test.go b/internal/license/entitlement/rejection_persistence_test.go new file mode 100644 index 000000000..b9575c647 --- /dev/null +++ b/internal/license/entitlement/rejection_persistence_test.go @@ -0,0 +1,100 @@ +package entitlement_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/nself-org/cli/internal/compat/compattest" + "github.com/nself-org/cli/internal/errs" + "github.com/nself-org/cli/internal/license" + "github.com/nself-org/cli/internal/license/entitlement" +) + +// D-1110: a 401 in one run must still withhold the cached token from a later offline run. +func TestRefreshServerRejectionSurvivesOfflineRefresh(t *testing.T) { + compattest.Set(t, true) + entry, _ := signedFixture(t, "synthetic-license-key", []string{"chat"}, time.Now().Add(time.Hour)) + installLicensed(t, "chat") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + t.Setenv("NSELF_PING_API_URL", server.URL) + dir := t.TempDir() + if err := entitlement.Refresh(context.Background(), dir); errs.ExitCodeFor(err) != 3 { + t.Fatalf("online rejection must fail closed: %v", err) + } + server.Close() + t.Setenv("NSELF_PING_API_URL", "http://127.0.0.1:1") + if err := entitlement.Refresh(context.Background(), dir); errs.ExitCodeFor(err) != 3 { + t.Fatalf("offline refresh must stay fail-closed after a server rejection: %v", err) + } + if data, _ := os.ReadFile(entitlement.Path(dir)); len(data) != 0 || string(data) == entry.JWT { + t.Fatal("offline refresh republished the cached token of a rejected key") + } +} + +// A later online acceptance of the same key lifts the persisted rejection. +func TestRefreshOnlineAcceptanceClearsPersistedRejection(t *testing.T) { + compattest.Set(t, true) + entry, _ := signedFixture(t, "synthetic-license-key", []string{"chat"}, time.Now().Add(time.Hour)) + installLicensed(t, "chat") + reject := true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if reject { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("X-NSelf-License-Sig", entry.BodySig) + _, _ = w.Write([]byte(entry.RawBody)) + })) + defer server.Close() + t.Setenv("NSELF_PING_API_URL", server.URL) + dir := t.TempDir() + if err := entitlement.Refresh(context.Background(), dir); errs.ExitCodeFor(err) != 3 { + t.Fatalf("rejection expected: %v", err) + } + reject = false + if err := entitlement.Refresh(context.Background(), dir); err != nil { + t.Fatalf("accepted key must publish again: %v", err) + } + t.Setenv("NSELF_PING_API_URL", "http://127.0.0.1:1") + if err := entitlement.Refresh(context.Background(), dir); err != nil { + t.Fatalf("offline refresh after acceptance: %v", err) + } + if data, _ := os.ReadFile(entitlement.Path(dir)); string(data) != entry.JWT { + t.Fatal("rejection was not cleared by the online acceptance") + } +} + +// D-1111: a candidate whose subject is on the persistent revocation list is not selected. +func TestRefreshSkipsSubjectOnRevocationList(t *testing.T) { + compattest.Set(t, true) + entry, _ := signedFixture(t, "synthetic-license-key", []string{"chat"}, time.Now().Add(time.Hour)) + installLicensed(t, "chat") + t.Setenv("LICENSE_REVOCATION_CACHE_PATH", filepath.Join(t.TempDir(), "revocation.json")) + dir := t.TempDir() + if err := entitlement.Refresh(context.Background(), dir); err != nil { + t.Fatalf("unrevoked baseline: %v", err) + } + if data, _ := os.ReadFile(entitlement.Path(dir)); string(data) != entry.JWT { + t.Fatal("baseline did not publish the cached token") + } + err := license.WriteRevocationCache(&license.RevocationCache{ + List: license.RevocationList{Revoked: []license.RevocationEntry{{Type: "key_hash", ID: license.HashKey("synthetic-license-key")}}}, + FetchedAt: time.Now().Unix(), + }) + if err != nil { + t.Fatal(err) + } + if err := entitlement.Refresh(context.Background(), dir); errs.ExitCodeFor(err) != 3 { + t.Fatalf("revoked subject must fail closed: %v", err) + } + if data, _ := os.ReadFile(entitlement.Path(dir)); len(data) != 0 { + t.Fatal("revoked subject's token (cache or retained file) was republished") + } +} diff --git a/internal/license/entitlement/selection.go b/internal/license/entitlement/selection.go index 0484c1100..25d82eff9 100644 --- a/internal/license/entitlement/selection.go +++ b/internal/license/entitlement/selection.go @@ -8,7 +8,8 @@ import ( // Purpose: select the newest signed covering token among configured subjects. // Inputs: candidates/slugs/rejected subjects. Outputs: token/policy error. -// Constraints: rejected keys grant no authority; signed exp orders candidates. +// Constraints: rejected keys (this run, persisted, or on the revocation list) grant +// no authority; signed exp orders candidates. // SPORT: single-token multi-key continuity. func selectToken(candidates []string, names []string, rejected map[string]bool) (string, error) { best, fallback := "", "" @@ -17,9 +18,11 @@ func selectToken(candidates []string, names []string, rejected map[string]bool) for _, key := range license.CollectLicenseKeys() { configured[license.HashKey(key)] = true } + persisted := loadRejected() for _, token := range candidates { claims, err := license.ParseLicenseJWT(token) - if err != nil || claims.Exp <= 0 || !configured[claims.Sub] || rejected[claims.Sub] { + if err != nil || claims.Exp <= 0 || !configured[claims.Sub] || rejected[claims.Sub] || persisted[claims.Sub] || + license.IsRecordRevoked(license.LicenseRecord{KeyHash: claims.Sub}) { continue } if claims.Exp > fallbackExp { From 17a9460edca81fc7483ad409bce1e54d7148fdb8 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 25 Oct 2026 11:33:01 -0400 Subject: [PATCH 2/7] fix(license): serialise rejection-record updates across processes and fail closed on unreadable or unwritable state (D-1110) --- internal/license/entitlement/autorefresh.go | 15 ++- internal/license/entitlement/installed.go | 10 +- internal/license/entitlement/lifecycle.go | 13 ++- internal/license/entitlement/rejected.go | 103 ++++++++++++------ .../entitlement/rejected_lock_other.go | 10 ++ .../license/entitlement/rejected_lock_unix.go | 37 +++++++ internal/license/entitlement/rejected_test.go | 92 ++++++++++++++++ .../entitlement/rejection_persistence_test.go | 59 ++++++++++ internal/license/entitlement/selection.go | 5 +- internal/license/entitlement/write.go | 11 +- 10 files changed, 308 insertions(+), 47 deletions(-) create mode 100644 internal/license/entitlement/rejected_lock_other.go create mode 100644 internal/license/entitlement/rejected_lock_unix.go create mode 100644 internal/license/entitlement/rejected_test.go diff --git a/internal/license/entitlement/autorefresh.go b/internal/license/entitlement/autorefresh.go index 379239539..f81f36b42 100644 --- a/internal/license/entitlement/autorefresh.go +++ b/internal/license/entitlement/autorefresh.go @@ -223,7 +223,10 @@ func RunScheduled(ctx context.Context, projectDir string) (Outcome, error) { } return Outcome{Removed: res.Removed, RootRequired: res.RootRequired, Reason: reason}, nil } - renewed, rejected, class := revalidateClassified(ctx) + renewed, rejected, class, recordErr := revalidateClassified(ctx) + if recordErr != nil { // a rejection that cannot be remembered must not publish anything + return Outcome{}, &RefreshError{Class: ClassWriteFailed, Err: recordErr} + } if len(renewed) == 0 { return Outcome{}, &RefreshError{Class: class} } @@ -242,7 +245,7 @@ func RunScheduled(ctx context.Context, projectDir string) (Outcome, error) { } // revalidateClassified renews every configured key once and names why nothing was renewed. -func revalidateClassified(ctx context.Context) (renewed []string, rejected map[string]bool, class string) { +func revalidateClassified(ctx context.Context) (renewed []string, rejected map[string]bool, class string, recordErr error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() rejected, class = map[string]bool{}, ClassNoLicence @@ -253,9 +256,11 @@ func revalidateClassified(ctx context.Context) (renewed []string, rejected map[s switch { case err == nil && explicitRejection(remote, result): rejected[license.HashKey(key)], class = true, ClassRejected - recordVerdict(license.HashKey(key), true) + if err := recordVerdict(license.HashKey(key), true); err != nil { + recordErr = err + } case err == nil && result != nil && result.CanProceed && !result.FromCache && result.Status == license.StatusValid: - recordVerdict(license.HashKey(key), false) + _ = recordVerdict(license.HashKey(key), false) // a stale rejection only fails closed entry, _ := license.ReadCache() token, terr := cachedToken() switch { @@ -272,7 +277,7 @@ func revalidateClassified(ctx context.Context) (renewed []string, rejected map[s class = ClassPingUnavailable } } - return renewed, rejected, class + return renewed, rejected, class, recordErr } // PublishFromCache rewrites the entitlement file from the verified licence cache without diff --git a/internal/license/entitlement/installed.go b/internal/license/entitlement/installed.go index cd70fe172..8c79c2b74 100644 --- a/internal/license/entitlement/installed.go +++ b/internal/license/entitlement/installed.go @@ -105,20 +105,26 @@ func validateNewKey(ctx context.Context, projectDir, key string) error { // 403, or a signed rejection revoke this runtime entitlement. rejected := explicitRejection(remote, result) if result.CanProceed && !rejected { - recordVerdict(license.HashKey(key), false) + _ = recordVerdict(license.HashKey(key), false) // a stale rejection only fails closed return nil } // FromCache alone cannot distinguish an absent cache from an auth rejection. if !rejected { return ErrValidationUnavailable } - recordVerdict(license.HashKey(key), true) + recordErr := recordVerdict(license.HashKey(key), true) if projectDir != "" { if writeErr := publishAfterKeyRejection(projectDir, key); writeErr != nil { return writeErr } + if recordErr != nil { + return errs.Wrap("E125", "the licence rejection could not be recorded and may not hold offline", recordErr) + } return errs.New("E125", "the new licence was rejected and could not obtain a verified entitlement") } + if recordErr != nil { + return fmt.Errorf("the licence rejection could not be recorded and may not hold offline: %w", recordErr) + } return errors.New("the new licence was rejected and could not obtain a verified entitlement") } diff --git a/internal/license/entitlement/lifecycle.go b/internal/license/entitlement/lifecycle.go index 5f1880d05..e93832f59 100644 --- a/internal/license/entitlement/lifecycle.go +++ b/internal/license/entitlement/lifecycle.go @@ -16,14 +16,15 @@ import ( ) // Purpose: renew configured keys once each without blocking offline starts. -// Inputs: context/keys. Outputs: rejected subjects/renewed tokens. Constraints: five seconds +// Inputs: context/keys. Outputs: rejected subjects/renewed tokens/rejection persistence error. Constraints: five seconds // overall, existing HTTP timeouts, no cache changes on transient failures. // SPORT: online entitlement renewal. -func revalidateKeys(ctx context.Context) (map[string]bool, []string) { +func revalidateKeys(ctx context.Context) (map[string]bool, []string, error) { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() rejected := make(map[string]bool) var renewed []string + var recordErr error for _, key := range license.CollectLicenseKeys() { if ctx.Err() != nil { break @@ -32,16 +33,18 @@ func revalidateKeys(ctx context.Context) (map[string]bool, []string) { result, err := license.Validate(ctx, key, &license.ValidatorOptions{HTTPClient: remote}) if err == nil && explicitRejection(remote, result) { rejected[license.HashKey(key)] = true - recordVerdict(license.HashKey(key), true) + if err := recordVerdict(license.HashKey(key), true); err != nil { + recordErr = err + } } if err == nil && result != nil && result.CanProceed && !result.FromCache && result.Status == license.StatusValid { - recordVerdict(license.HashKey(key), false) + _ = recordVerdict(license.HashKey(key), false) // a stale rejection only fails closed if token, err := cachedToken(); err == nil { renewed = append(renewed, token) } } } - return rejected, renewed + return rejected, renewed, recordErr } // Purpose: distinguish revocation from transport and unrelated HTTP failures. diff --git a/internal/license/entitlement/rejected.go b/internal/license/entitlement/rejected.go index b5fba0f7f..b4bd8c982 100644 --- a/internal/license/entitlement/rejected.go +++ b/internal/license/entitlement/rejected.go @@ -2,15 +2,24 @@ package entitlement import ( "encoding/json" + "errors" + "fmt" "os" "path/filepath" + "sync" "github.com/nself-org/cli/internal/license" ) // rejectedFile sits beside the licence cache so a test or deployment that // relocates the cache relocates the rejections with it. -const rejectedFile = "entitlement-rejected.json" +const ( + rejectedFile = "entitlement-rejected.json" + rejectedLockFile = "entitlement-rejected.lock" +) + +// rejectedMu serialises this process; the flock on rejectedLockFile serialises the rest. +var rejectedMu sync.Mutex // Purpose: locate the persisted server-rejection record. // Inputs: none. Outputs: path/error. Constraints: same directory as the licence cache. @@ -24,70 +33,100 @@ func rejectedPath() (string, error) { } // Purpose: load subjects the licence server refused in an earlier run. -// Inputs: none. Outputs: key-hash set. Constraints: a missing or unreadable file -// is empty (the next online refresh re-establishes it); never errors a refresh. +// Inputs: record path. Outputs: key-hash set, or an error when the record exists but +// cannot be read or parsed. Constraints: only an absent file is an empty set; an +// unreadable or corrupt record is unknown state and the caller must fail closed. // SPORT: offline refresh must not republish a server-rejected key. -func loadRejected() map[string]bool { +func readRejected(path string) (map[string]bool, error) { set := make(map[string]bool) - path, err := rejectedPath() - if err != nil { - return set - } data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return set, nil + } if err != nil { - return set + return nil, fmt.Errorf("read rejection record: %w", err) } var hashes []string - if json.Unmarshal(data, &hashes) != nil { - return set + if err := json.Unmarshal(data, &hashes); err != nil { + return nil, fmt.Errorf("rejection record is corrupt: %w", err) } for _, h := range hashes { set[h] = true } - return set + return set, nil +} + +// Purpose: load the persisted rejections for key selection. +// Inputs: none. Outputs: key-hash set or error. Constraints: see readRejected. +// SPORT: offline refresh must not republish a server-rejected key. +func loadRejected() (map[string]bool, error) { + path, err := rejectedPath() + if err != nil { + return nil, err + } + return readRejected(path) } // Purpose: persist a server verdict for one configured key. -// Inputs: key hash and whether the server refused it. Outputs: none. -// Constraints: only an explicit refusal adds, only a fresh online acceptance -// clears; write failures are non-fatal because the in-run map still applies. +// Inputs: key hash and whether the server refused it. Outputs: persistence error. +// Constraints: the whole read-modify-write runs under a process mutex and a file +// lock, so concurrent refreshes never drop each other's rejections. Only an explicit +// refusal adds, only a fresh online acceptance clears. A corrupt record is replaced +// by this verdict (an online refresh re-records every configured key). A failed +// rejection write is returned: the caller must not treat the key as remembered. // SPORT: durable per-key rejection. -func recordVerdict(hash string, rejected bool) { - set := loadRejected() - if set[hash] == rejected { - return +func recordVerdict(hash string, rejected bool) error { + path, err := rejectedPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + rejectedMu.Lock() + defer rejectedMu.Unlock() + lock, err := os.OpenFile(filepath.Join(filepath.Dir(path), rejectedLockFile), os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return err + } + defer func() { _ = lock.Close() }() + if err := lockRejected(lock); err != nil { + return err + } + set, readErr := readRejected(path) + if readErr != nil { + set = make(map[string]bool) + } else if set[hash] == rejected { + return nil } if rejected { set[hash] = true } else { delete(set, hash) } - path, err := rejectedPath() - if err != nil { - return - } hashes := make([]string, 0, len(set)) for h := range set { hashes = append(hashes, h) } data, err := json.Marshal(hashes) if err != nil { - return - } - if os.MkdirAll(filepath.Dir(path), 0700) != nil { - return + return err } tmp, err := os.CreateTemp(filepath.Dir(path), ".entitlement-rejected-*") if err != nil { - return + return err } defer func() { _ = os.Remove(tmp.Name()) }() if _, err := tmp.Write(data); err != nil { _ = tmp.Close() - return + return err + } + if err := tmp.Chmod(0600); err != nil { + _ = tmp.Close() + return err } - if tmp.Chmod(0600) != nil || tmp.Close() != nil { - return + if err := tmp.Close(); err != nil { + return err } - _ = os.Rename(tmp.Name(), path) + return os.Rename(tmp.Name(), path) } diff --git a/internal/license/entitlement/rejected_lock_other.go b/internal/license/entitlement/rejected_lock_other.go new file mode 100644 index 000000000..ad91e6fd2 --- /dev/null +++ b/internal/license/entitlement/rejected_lock_other.go @@ -0,0 +1,10 @@ +//go:build !unix + +package entitlement + +import "os" + +// Purpose: the rejection lock on platforms without flock (Windows). +// Constraints: the process-wide mutex still serialises one process's writers; +// Windows runs the unix build under WSL2 (VMI). +func lockRejected(*os.File) error { return nil } diff --git a/internal/license/entitlement/rejected_lock_unix.go b/internal/license/entitlement/rejected_lock_unix.go new file mode 100644 index 000000000..c5956bf8c --- /dev/null +++ b/internal/license/entitlement/rejected_lock_unix.go @@ -0,0 +1,37 @@ +//go:build unix + +package entitlement + +// Purpose: cross-process exclusion for the rejection file's read-modify-write. +// Inputs: an open lock file. Outputs: the file locked exclusively. +// Constraints: syscall.Flock only (no new module). The kernel drops the lock +// with the descriptor, process death included. The pattern is +// internal/invoke/audit_lock_unix.go. + +import ( + "errors" + "os" + "syscall" + "time" +) + +// rejectedLockWait bounds how long a writer waits for another process. +var rejectedLockWait = 10 * time.Second + +func lockRejected(f *os.File) error { + deadline := time.Now().Add(rejectedLockWait) + for { + err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + switch { + case err == nil: + return nil + case errors.Is(err, syscall.EINTR): + continue + case !errors.Is(err, syscall.EWOULDBLOCK): + return err + case time.Now().After(deadline): + return errors.New("the rejection record stayed locked by another process") + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/internal/license/entitlement/rejected_test.go b/internal/license/entitlement/rejected_test.go new file mode 100644 index 000000000..dba8bbd93 --- /dev/null +++ b/internal/license/entitlement/rejected_test.go @@ -0,0 +1,92 @@ +package entitlement + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" +) + +const rejectedHelperEnv = "NSELF_REJECTED_HELPER" + +// The child records its own 8 hashes; it is the other process the lock must serialise. +func TestRejectedHelperProcess(t *testing.T) { + worker := os.Getenv(rejectedHelperEnv) + if worker == "" { + t.Skip("helper process only") + } + for i := 0; i < 8; i++ { + if err := recordVerdict(fmt.Sprintf("hash-%s-%d", worker, i), true); err != nil { + t.Fatal(err) + } + } +} + +func useRejectedDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("LICENSE_CACHE_PATH", filepath.Join(dir, "license.json")) + return dir +} + +// Concurrent refreshes in separate processes must not drop each other's rejections. +func TestRecordVerdictAcrossProcessesKeepsEveryRejection(t *testing.T) { + useRejectedDir(t) + const workers = 8 + cmds := make([]*exec.Cmd, workers) + for w := range cmds { + cmds[w] = exec.Command(os.Args[0], "-test.run=^TestRejectedHelperProcess$") + cmds[w].Env = append(os.Environ(), rejectedHelperEnv+"="+strconv.Itoa(w+1)) + if err := cmds[w].Start(); err != nil { + t.Fatal(err) + } + } + for _, cmd := range cmds { + if err := cmd.Wait(); err != nil { + t.Fatalf("helper: %v", err) + } + } + set, err := loadRejected() + if err != nil { + t.Fatal(err) + } + if len(set) != workers*8 { + t.Fatalf("kept %d of %d rejections", len(set), workers*8) + } +} + +func TestLoadRejectedSeparatesAbsentFromCorrupt(t *testing.T) { + dir := useRejectedDir(t) + set, err := loadRejected() + if err != nil || len(set) != 0 { + t.Fatalf("absent record = %v, %v; want empty and no error", set, err) + } + if err := os.WriteFile(filepath.Join(dir, rejectedFile), []byte("{not json"), 0600); err != nil { + t.Fatal(err) + } + if _, err := loadRejected(); err == nil { + t.Fatal("a corrupt record read as empty") + } + if _, err := selectToken(nil, nil, nil); err == nil { + t.Fatal("selection proceeded on an unreadable rejection record") + } + if err := recordVerdict("h1", true); err != nil { + t.Fatalf("a fresh verdict must repair a corrupt record: %v", err) + } + if set, err := loadRejected(); err != nil || !set["h1"] { + t.Fatalf("repaired record = %v, %v", set, err) + } +} + +func TestRecordVerdictReportsWriteFailure(t *testing.T) { + dir := useRejectedDir(t) + // A directory at the record path cannot be replaced by the atomic rename, as root or not. + if err := os.Mkdir(filepath.Join(dir, rejectedFile), 0700); err != nil { + t.Fatal(err) + } + if err := recordVerdict("h1", true); err == nil { + t.Fatal("a rejection that was not persisted reported success") + } +} diff --git a/internal/license/entitlement/rejection_persistence_test.go b/internal/license/entitlement/rejection_persistence_test.go index b9575c647..e4990793b 100644 --- a/internal/license/entitlement/rejection_persistence_test.go +++ b/internal/license/entitlement/rejection_persistence_test.go @@ -98,3 +98,62 @@ func TestRefreshSkipsSubjectOnRevocationList(t *testing.T) { t.Fatal("revoked subject's token (cache or retained file) was republished") } } + +func rejectionRecordPath(t *testing.T) string { + t.Helper() + cache, err := license.CachePath() + if err != nil { + t.Fatal(err) + } + return filepath.Join(filepath.Dir(cache), "entitlement-rejected.json") +} + +// An unreadable or corrupt rejection record is unknown state, not "nothing rejected". +func TestRefreshCorruptRejectionRecordFailsClosed(t *testing.T) { + compattest.Set(t, true) + entry, _ := signedFixture(t, "synthetic-license-key", []string{"chat"}, time.Now().Add(time.Hour)) + installLicensed(t, "chat") + dir := t.TempDir() + if err := entitlement.Refresh(context.Background(), dir); err != nil { + t.Fatalf("baseline: %v", err) + } + if data, _ := os.ReadFile(entitlement.Path(dir)); string(data) != entry.JWT { + t.Fatal("baseline did not publish the cached token") + } + if err := os.WriteFile(rejectionRecordPath(t), []byte("{not json"), 0600); err != nil { + t.Fatal(err) + } + if err := entitlement.Refresh(context.Background(), dir); errs.ExitCodeFor(err) != 3 { + t.Fatalf("corrupt rejection record must fail closed: %v", err) + } + if data, _ := os.ReadFile(entitlement.Path(dir)); len(data) != 0 { + t.Fatal("a token was published while the rejection record was corrupt") + } +} + +// A rejection that cannot be written must not let the next offline run republish the key. +func TestRefreshUnwritableRejectionRecordStaysFailClosed(t *testing.T) { + compattest.Set(t, true) + entry, _ := signedFixture(t, "synthetic-license-key", []string{"chat"}, time.Now().Add(time.Hour)) + installLicensed(t, "chat") + // A directory at the record path makes it neither readable nor replaceable, as root or not. + if err := os.MkdirAll(rejectionRecordPath(t), 0700); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + t.Setenv("NSELF_PING_API_URL", server.URL) + dir := t.TempDir() + if err := entitlement.Refresh(context.Background(), dir); errs.ExitCodeFor(err) != 3 { + t.Fatalf("online rejection must fail closed: %v", err) + } + server.Close() + t.Setenv("NSELF_PING_API_URL", "http://127.0.0.1:1") + if err := entitlement.Refresh(context.Background(), dir); errs.ExitCodeFor(err) != 3 { + t.Fatalf("offline refresh must stay fail-closed: %v", err) + } + if data, _ := os.ReadFile(entitlement.Path(dir)); len(data) != 0 || string(data) == entry.JWT { + t.Fatal("offline refresh republished the cached token of a key whose rejection could not be recorded") + } +} diff --git a/internal/license/entitlement/selection.go b/internal/license/entitlement/selection.go index 25d82eff9..064d55c60 100644 --- a/internal/license/entitlement/selection.go +++ b/internal/license/entitlement/selection.go @@ -18,7 +18,10 @@ func selectToken(candidates []string, names []string, rejected map[string]bool) for _, key := range license.CollectLicenseKeys() { configured[license.HashKey(key)] = true } - persisted := loadRejected() + persisted, err := loadRejected() + if err != nil { + return "", fmt.Errorf("the persisted licence rejection record is unreadable: %w", err) + } for _, token := range candidates { claims, err := license.ParseLicenseJWT(token) if err != nil || claims.Exp <= 0 || !configured[claims.Sub] || rejected[claims.Sub] || persisted[claims.Sub] || diff --git a/internal/license/entitlement/write.go b/internal/license/entitlement/write.go index 28caf8dc2..ab5de5f45 100644 --- a/internal/license/entitlement/write.go +++ b/internal/license/entitlement/write.go @@ -52,12 +52,19 @@ func RefreshForPlugins(ctx context.Context, projectDir, pluginDir string) error token, _ := cachedToken() candidates := []string{token, retainedToken(Path(projectDir))} var rejected map[string]bool + var recordErr error if len(names) > 0 { var renewed []string - rejected, renewed = revalidateKeys(ctx) + rejected, renewed, recordErr = revalidateKeys(ctx) candidates = append(candidates, renewed...) } - return publishCandidates(projectDir, names, rejected, candidates) + if err := publishCandidates(projectDir, names, rejected, candidates); err != nil { + return err + } + if recordErr != nil { + return errs.Wrap("E125", "the licence rejection could not be recorded and may not hold offline", recordErr) + } + return nil } // Purpose: select and publish without repeating online validation. From 22080d63b25002a503b1a7aecab7514861599ce8 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 25 Oct 2026 14:54:52 -0400 Subject: [PATCH 3/7] fix(license): keep other keys rejected after corrupt-record recovery and survive rejection-lock failures (D-1110) --- internal/license/entitlement/rejected.go | 53 +++++++++++++++-- internal/license/entitlement/rejected_test.go | 58 ++++++++++++++++++- .../entitlement/rejection_persistence_test.go | 27 +++++++++ 3 files changed, 133 insertions(+), 5 deletions(-) diff --git a/internal/license/entitlement/rejected.go b/internal/license/entitlement/rejected.go index b4bd8c982..56fa230e5 100644 --- a/internal/license/entitlement/rejected.go +++ b/internal/license/entitlement/rejected.go @@ -16,6 +16,8 @@ import ( const ( rejectedFile = "entitlement-rejected.json" rejectedLockFile = "entitlement-rejected.lock" + markerPrefix = "entitlement-rejected-" + markerSuffix = ".mark" ) // rejectedMu serialises this process; the flock on rejectedLockFile serialises the rest. @@ -64,16 +66,40 @@ func loadRejected() (map[string]bool, error) { if err != nil { return nil, err } - return readRejected(path) + set, err := readRejected(path) + if err != nil { + return nil, err + } + // Marker files are the lock-free fallback written when the record could not be updated. + marks, _ := filepath.Glob(filepath.Join(filepath.Dir(path), markerPrefix+"*"+markerSuffix)) + for _, m := range marks { + base := filepath.Base(m) + set[base[len(markerPrefix):len(base)-len(markerSuffix)]] = true + } + return set, nil +} + +// Purpose: remember a rejection when the shared record cannot be updated (lock or +// record unwritable). Inputs: key hash, record path. Outputs: persistence error. +// Constraints: one file per key, no read-modify-write, no lock; loadRejected unions +// the markers, so a failed record update cannot revive the rejected key offline. +// SPORT: durable per-key rejection. +func writeMarker(path, hash string) error { + f, err := os.OpenFile(filepath.Join(filepath.Dir(path), markerPrefix+hash+markerSuffix), os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + return f.Close() } // Purpose: persist a server verdict for one configured key. // Inputs: key hash and whether the server refused it. Outputs: persistence error. // Constraints: the whole read-modify-write runs under a process mutex and a file // lock, so concurrent refreshes never drop each other's rejections. Only an explicit -// refusal adds, only a fresh online acceptance clears. A corrupt record is replaced -// by this verdict (an online refresh re-records every configured key). A failed -// rejection write is returned: the caller must not treat the key as remembered. +// refusal adds, only a fresh online acceptance clears. A corrupt record is unknown +// state: it is rebuilt with every configured key rejected, so only a fresh acceptance +// of a key lifts it. When the record cannot be updated, a rejection falls back to a +// per-key marker; an error is returned only if the key is not remembered at all. // SPORT: durable per-key rejection. func recordVerdict(hash string, rejected bool) error { path, err := rejectedPath() @@ -83,6 +109,22 @@ func recordVerdict(hash string, rejected bool) error { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return err } + if !rejected { + _ = os.Remove(filepath.Join(filepath.Dir(path), markerPrefix+hash+markerSuffix)) + } + err = updateRecord(path, hash, rejected) + if err != nil && rejected { + if merr := writeMarker(path, hash); merr == nil { + return nil + } + } + return err +} + +// Purpose: read-modify-write the shared record under the process mutex and file lock. +// Inputs: record path, key hash, verdict. Outputs: persistence error. +// SPORT: durable per-key rejection. +func updateRecord(path, hash string, rejected bool) error { rejectedMu.Lock() defer rejectedMu.Unlock() lock, err := os.OpenFile(filepath.Join(filepath.Dir(path), rejectedLockFile), os.O_CREATE|os.O_RDWR, 0600) @@ -96,6 +138,9 @@ func recordVerdict(hash string, rejected bool) error { set, readErr := readRejected(path) if readErr != nil { set = make(map[string]bool) + for _, key := range license.CollectLicenseKeys() { + set[license.HashKey(key)] = true + } } else if set[hash] == rejected { return nil } diff --git a/internal/license/entitlement/rejected_test.go b/internal/license/entitlement/rejected_test.go index dba8bbd93..d6aced89c 100644 --- a/internal/license/entitlement/rejected_test.go +++ b/internal/license/entitlement/rejected_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strconv" "testing" + + "github.com/nself-org/cli/internal/license" ) const rejectedHelperEnv = "NSELF_REJECTED_HELPER" @@ -86,7 +88,61 @@ func TestRecordVerdictReportsWriteFailure(t *testing.T) { if err := os.Mkdir(filepath.Join(dir, rejectedFile), 0700); err != nil { t.Fatal(err) } - if err := recordVerdict("h1", true); err == nil { + // The marker fallback holds the rejection, so success is honest. + if err := recordVerdict("h1", true); err != nil { + t.Fatalf("marker fallback should remember the rejection: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, markerPrefix+"h1"+markerSuffix)); err != nil { + t.Fatalf("no marker written: %v", err) + } + // With the marker path blocked too, nothing remembers the key: the error must surface. + if err := os.Mkdir(filepath.Join(dir, markerPrefix+"h2"+markerSuffix), 0700); err != nil { + t.Fatal(err) + } + if err := recordVerdict("h2", true); err == nil { t.Fatal("a rejection that was not persisted reported success") } } + +// A lock that cannot be taken must not lose the rejection: the marker is loaded, and a +// fresh acceptance clears it. +func TestRecordVerdictLockFailureFallsBackToMarker(t *testing.T) { + dir := useRejectedDir(t) + if err := os.Mkdir(filepath.Join(dir, rejectedLockFile), 0700); err != nil { + t.Fatal(err) + } + if err := recordVerdict("h1", true); err != nil { + t.Fatal(err) + } + if set, err := loadRejected(); err != nil || !set["h1"] { + t.Fatalf("rejection lost after lock failure: %v, %v", set, err) + } + if err := recordVerdict("h1", false); err == nil { + t.Log("acceptance recorded despite the lock") // lock failure may surface; marker must still clear + } + if set, _ := loadRejected(); set["h1"] { + t.Fatal("fresh acceptance did not clear the marker") + } +} + +// Recovering from a corrupt record must not forgive the other configured keys. +func TestCorruptRecordKeepsOtherKeysRejectedUntilFreshlyAccepted(t *testing.T) { + dir := useRejectedDir(t) + keyA, keyB := "synthetic-key-a", "synthetic-key-b" + t.Setenv("NSELF_PLUGIN_LICENSE_KEY", keyA) + t.Setenv("NSELF_LICENSE_KEY_1", keyB) + hA, hB := license.HashKey(keyA), license.HashKey(keyB) + if err := recordVerdict(hB, true); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, rejectedFile), []byte("{not json"), 0600); err != nil { + t.Fatal(err) + } + if err := recordVerdict(hA, false); err != nil { + t.Fatal(err) + } + set, err := loadRejected() + if err != nil || set[hA] || !set[hB] { + t.Fatalf("after accepting A: %v, %v; want only B rejected", set, err) + } +} diff --git a/internal/license/entitlement/rejection_persistence_test.go b/internal/license/entitlement/rejection_persistence_test.go index e4990793b..ffe716f97 100644 --- a/internal/license/entitlement/rejection_persistence_test.go +++ b/internal/license/entitlement/rejection_persistence_test.go @@ -157,3 +157,30 @@ func TestRefreshUnwritableRejectionRecordStaysFailClosed(t *testing.T) { t.Fatal("offline refresh republished the cached token of a key whose rejection could not be recorded") } } + +// A lock that cannot be taken is a persistence failure; it must not revive the rejected token offline. +func TestRefreshLockFailureStaysFailClosedOffline(t *testing.T) { + compattest.Set(t, true) + entry, _ := signedFixture(t, "synthetic-license-key", []string{"chat"}, time.Now().Add(time.Hour)) + installLicensed(t, "chat") + // A directory at the lock path makes the lock file unopenable, as root or not. + if err := os.MkdirAll(filepath.Join(filepath.Dir(rejectionRecordPath(t)), "entitlement-rejected.lock"), 0700); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + t.Setenv("NSELF_PING_API_URL", server.URL) + dir := t.TempDir() + if err := entitlement.Refresh(context.Background(), dir); errs.ExitCodeFor(err) != 3 { + t.Fatalf("online rejection must fail closed: %v", err) + } + server.Close() + t.Setenv("NSELF_PING_API_URL", "http://127.0.0.1:1") + if err := entitlement.Refresh(context.Background(), dir); errs.ExitCodeFor(err) != 3 { + t.Fatalf("offline refresh must stay fail-closed after a lock failure: %v", err) + } + if data, _ := os.ReadFile(entitlement.Path(dir)); len(data) != 0 || string(data) == entry.JWT { + t.Fatal("offline refresh republished the cached token of a rejected key") + } +} From b93f467d94886bbef13adb4fd54715faff37d0ea Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 25 Oct 2026 15:09:51 -0400 Subject: [PATCH 4/7] fix(license): enumerate rejection markers literally and fail closed when the cache directory cannot be listed (D-1110) --- internal/license/entitlement/rejected.go | 18 +++++++--- internal/license/entitlement/rejected_test.go | 33 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/internal/license/entitlement/rejected.go b/internal/license/entitlement/rejected.go index 56fa230e5..79bf45ce4 100644 --- a/internal/license/entitlement/rejected.go +++ b/internal/license/entitlement/rejected.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "github.com/nself-org/cli/internal/license" @@ -71,10 +72,19 @@ func loadRejected() (map[string]bool, error) { return nil, err } // Marker files are the lock-free fallback written when the record could not be updated. - marks, _ := filepath.Glob(filepath.Join(filepath.Dir(path), markerPrefix+"*"+markerSuffix)) - for _, m := range marks { - base := filepath.Base(m) - set[base[len(markerPrefix):len(base)-len(markerSuffix)]] = true + // Enumerate literally (no glob: a cache path holding [ or * would match nothing) and + // fail closed when the directory cannot be listed. + entries, err := os.ReadDir(filepath.Dir(path)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("list rejection markers: %w", err) + } + for _, e := range entries { + name := e.Name() + if e.IsDir() || len(name) <= len(markerPrefix)+len(markerSuffix) || + !strings.HasPrefix(name, markerPrefix) || !strings.HasSuffix(name, markerSuffix) { + continue + } + set[name[len(markerPrefix):len(name)-len(markerSuffix)]] = true } return set, nil } diff --git a/internal/license/entitlement/rejected_test.go b/internal/license/entitlement/rejected_test.go index d6aced89c..0794e5d90 100644 --- a/internal/license/entitlement/rejected_test.go +++ b/internal/license/entitlement/rejected_test.go @@ -146,3 +146,36 @@ func TestCorruptRecordKeepsOtherKeysRejectedUntilFreshlyAccepted(t *testing.T) { t.Fatalf("after accepting A: %v, %v; want only B rejected", set, err) } } + +// A cache directory whose name holds glob metacharacters must still yield its markers. +func TestLoadRejectedReadsMarkersInDirectoryWithGlobMetacharacters(t *testing.T) { + dir := filepath.Join(t.TempDir(), "cache[x") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + t.Setenv("LICENSE_CACHE_PATH", filepath.Join(dir, "license.json")) + if err := os.Mkdir(filepath.Join(dir, rejectedLockFile), 0700); err != nil { + t.Fatal(err) + } + if err := recordVerdict("h1", true); err != nil { + t.Fatal(err) + } + if set, err := loadRejected(); err != nil || !set["h1"] { + t.Fatalf("marker in a bracketed directory was not loaded: %v, %v", set, err) + } +} + +// A cache directory that cannot be listed is unknown state: fail closed, never "no markers". +func TestLoadRejectedFailsClosedWhenDirectoryCannotBeListed(t *testing.T) { + dir := useRejectedDir(t) + if err := os.Chmod(dir, 0300); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0700) }) + if _, err := os.ReadDir(dir); err == nil { + t.Skip("directory listing is not denied for this user (root)") + } + if _, err := loadRejected(); err == nil { + t.Fatal("an unlistable cache directory was read as having no rejection markers") + } +} From c87e236069008489e84b80339c888fca5bede7e3 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Mon, 26 Oct 2026 07:06:23 -0400 Subject: [PATCH 5/7] fix(license): lift a persisted rejection when interactive refresh is freshly accepted by the server (D-1110) --- cmd/commands/license_grace.go | 3 +++ cmd/commands/license_grace_test.go | 24 ++++++++++++++++++++++++ internal/license/entitlement/rejected.go | 9 +++++++++ 3 files changed, 36 insertions(+) diff --git a/cmd/commands/license_grace.go b/cmd/commands/license_grace.go index eeac627d2..c2d612eed 100644 --- a/cmd/commands/license_grace.go +++ b/cmd/commands/license_grace.go @@ -155,6 +155,9 @@ every 6 hours. --remove-timer removes that timer by hand (--all: every project o fmt.Fprintf(os.Stderr, "%s: invalid — %s\n", masked, result.Message) continue } + if err := entitlement.RecordAccepted(key); err != nil { // a stale rejection only fails closed + fmt.Fprintf(os.Stderr, "%s: warning: could not clear the recorded licence rejection: %v\n", masked, err) + } row := licenseRefreshKey{Key: masked, Refreshed: true, Tier: licenseScrub(result.Tier)} if !result.ExpiresAt.IsZero() { row.ExpiresAt = result.ExpiresAt.Format("2006-01-02") diff --git a/cmd/commands/license_grace_test.go b/cmd/commands/license_grace_test.go index 0aa50b7d0..8857ed712 100644 --- a/cmd/commands/license_grace_test.go +++ b/cmd/commands/license_grace_test.go @@ -191,6 +191,30 @@ func TestLicenseRefreshRewritesFile(t *testing.T) { } } +// A scheduled refresh that the server rejects persists the rejection; a later interactive +// refresh the server accepts must lift it and rewrite the entitlement file (deterministic +// order: TestLicenseRefreshRewritesFile only hit this when its map iterated timer first). +func TestLicenseRefreshInteractiveLiftsPersistedRejection(t *testing.T) { + f := newGraceFixture(t) + timer := map[string]string{"project": f.project, "quiet": "true"} + f.mode = "revoked" + if _, _, err := runRefresh(t, f.project, timer); err == nil { + t.Fatal("a revoked key must fail the scheduled refresh") + } + f.mode = "ok" + _ = os.Remove(entitlement.Path(f.project)) + if _, _, err := runRefresh(t, f.project, nil); err != nil { + t.Fatal(err) + } + if data, _ := os.ReadFile(entitlement.Path(f.project)); string(data) != f.jwt { + t.Fatalf("interactive refresh accepted by the server did not rewrite the entitlement file (%d bytes)", len(data)) + } + f.mode = "ok" + if _, _, err := runRefresh(t, f.project, timer); err != nil { + t.Fatalf("the lifted rejection still blocks the scheduled refresh: %v", err) + } +} + func TestLicenseImportRewritesJWT(t *testing.T) { f := newGraceFixture(t) if _, _, err := runRefresh(t, f.project, nil); err != nil { diff --git a/internal/license/entitlement/rejected.go b/internal/license/entitlement/rejected.go index 79bf45ce4..f96fc33cf 100644 --- a/internal/license/entitlement/rejected.go +++ b/internal/license/entitlement/rejected.go @@ -131,6 +131,15 @@ func recordVerdict(hash string, rejected bool) error { return err } +// Purpose: lift a persisted rejection after the server freshly accepted the key outside +// the scheduled path (interactive `license refresh`). Inputs: the licence key. +// Outputs: persistence error. Constraints: only call on a live, signed acceptance; a +// failure leaves the rejection in place, which only fails closed. +// SPORT: durable per-key rejection. +func RecordAccepted(key string) error { + return recordVerdict(license.HashKey(key), false) +} + // Purpose: read-modify-write the shared record under the process mutex and file lock. // Inputs: record path, key hash, verdict. Outputs: persistence error. // SPORT: durable per-key rejection. From 818589fa7efdf5e2fc3d6b10336edf7c4a40260a Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Mon, 26 Oct 2026 08:11:33 -0400 Subject: [PATCH 6/7] fix(license): persist a signed server refusal seen by interactive refresh so offline refresh cannot republish the cached token (D-1110) --- cmd/commands/license_grace.go | 3 +++ cmd/commands/license_grace_test.go | 21 +++++++++++++++++++++ internal/license/entitlement/rejected.go | 9 +++++++++ 3 files changed, 33 insertions(+) diff --git a/cmd/commands/license_grace.go b/cmd/commands/license_grace.go index c2d612eed..a9d166bf7 100644 --- a/cmd/commands/license_grace.go +++ b/cmd/commands/license_grace.go @@ -151,6 +151,9 @@ every 6 hours. --remove-timer removes that timer by hand (--all: every project o continue } if !result.Valid { + if err := entitlement.RecordRejected(key); err != nil { // the cached token stays on disk: an unrecorded rejection lets offline refresh republish it + fmt.Fprintf(os.Stderr, "%s: warning: could not record the licence rejection: %v\n", masked, err) + } doc.Keys = append(doc.Keys, licenseRefreshKey{Key: masked, Error: "invalid: " + licenseScrub(result.Message)}) fmt.Fprintf(os.Stderr, "%s: invalid — %s\n", masked, result.Message) continue diff --git a/cmd/commands/license_grace_test.go b/cmd/commands/license_grace_test.go index 8857ed712..46097bffc 100644 --- a/cmd/commands/license_grace_test.go +++ b/cmd/commands/license_grace_test.go @@ -215,6 +215,27 @@ func TestLicenseRefreshInteractiveLiftsPersistedRejection(t *testing.T) { } } +// An interactive refresh the server answers with a signed revocation must persist the +// rejection: the cached token is untouched, so an offline refresh would otherwise republish it. +func TestLicenseRefreshInteractiveRevocationBlocksOfflineRepublish(t *testing.T) { + f := newGraceFixture(t) + if _, _, err := runRefresh(t, f.project, nil); err != nil { + t.Fatal(err) + } + f.mode = "revoked" + if _, _, err := runRefresh(t, f.project, nil); err != nil { + t.Fatal(err) + } + _ = os.Remove(entitlement.Path(f.project)) + f.mode = "5xx" + if err := entitlement.Refresh(t.Context(), f.project); err == nil { + t.Fatal("an offline refresh must fail for a key the server revoked") + } + if data, err := os.ReadFile(entitlement.Path(f.project)); err == nil && len(data) > 0 { // a refusal leaves an empty file + t.Fatalf("offline refresh republished the token of a revoked key (%d bytes)", len(data)) + } +} + func TestLicenseImportRewritesJWT(t *testing.T) { f := newGraceFixture(t) if _, _, err := runRefresh(t, f.project, nil); err != nil { diff --git a/internal/license/entitlement/rejected.go b/internal/license/entitlement/rejected.go index f96fc33cf..f18963a9e 100644 --- a/internal/license/entitlement/rejected.go +++ b/internal/license/entitlement/rejected.go @@ -140,6 +140,15 @@ func RecordAccepted(key string) error { return recordVerdict(license.HashKey(key), false) } +// Purpose: persist a signed server refusal seen outside the scheduled path (interactive +// `license refresh`). Inputs: the licence key. Outputs: persistence error. Constraints: only +// call on an authenticated valid:false reply; the cached token stays on disk, so without this +// a later offline refresh would republish it. +// SPORT: durable per-key rejection. +func RecordRejected(key string) error { + return recordVerdict(license.HashKey(key), true) +} + // Purpose: read-modify-write the shared record under the process mutex and file lock. // Inputs: record path, key hash, verdict. Outputs: persistence error. // SPORT: durable per-key rejection. From 89bc423db6b8b20a3cdef43b028a5d0505f61db5 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Mon, 26 Oct 2026 09:15:06 -0400 Subject: [PATCH 7/7] test(manifestv2migrate): retry the v1.4.12 tag fetch over HTTPS and report git's stderr when origin is unreachable --- tools/manifestv2migrate/readerscan_test.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tools/manifestv2migrate/readerscan_test.go b/tools/manifestv2migrate/readerscan_test.go index 48104dc23..9710b59f2 100644 --- a/tools/manifestv2migrate/readerscan_test.go +++ b/tools/manifestv2migrate/readerscan_test.go @@ -57,9 +57,20 @@ func extractTag(t *testing.T, root string) string { out, err := archive() if err != nil { // A shallow CI checkout carries no tags: fetch just this one, never skip. - fetch := exec.Command("git", "-C", root, "fetch", "-q", "--depth=1", "--no-tags", "origin", "refs/tags/"+readerTag+":refs/tags/"+readerTag) - if ferr := fetch.Run(); ferr != nil { - t.Fatalf("tag %s is not available and fetching it failed (run `git fetch --tags origin`): %v", readerTag, ferr) + // A gate host without an SSH agent cannot use the origin URL, so a + // failed fetch retries the public HTTPS URL before failing. + spec := "refs/tags/" + readerTag + ":refs/tags/" + readerTag + var ferr error + var detail []byte + for _, remote := range []string{"origin", "https://github.com/nself-org/cli.git"} { + fetch := exec.Command("git", "-C", root, "fetch", "-q", "--depth=1", "--no-tags", remote, spec) + fetch.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + if detail, ferr = fetch.CombinedOutput(); ferr == nil { + break + } + } + if ferr != nil { + t.Fatalf("tag %s is not available and fetching it failed (run `git fetch --tags origin`): %v: %s", readerTag, ferr, detail) } if out, err = archive(); err != nil { t.Fatalf("git archive %s failed: %v", readerTag, err)