diff --git a/cmd/commands/license_grace.go b/cmd/commands/license_grace.go index eeac627d2..a9d166bf7 100644 --- a/cmd/commands/license_grace.go +++ b/cmd/commands/license_grace.go @@ -151,10 +151,16 @@ 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 } + 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..46097bffc 100644 --- a/cmd/commands/license_grace_test.go +++ b/cmd/commands/license_grace_test.go @@ -191,6 +191,51 @@ 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) + } +} + +// 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/autorefresh.go b/internal/license/entitlement/autorefresh.go index cc3c78299..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,7 +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 + 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) // a stale rejection only fails closed entry, _ := license.ReadCache() token, terr := cachedToken() switch { @@ -270,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 b425658a4..8c79c2b74 100644 --- a/internal/license/entitlement/installed.go +++ b/internal/license/entitlement/installed.go @@ -105,18 +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) // a stale rejection only fails closed return nil } // FromCache alone cannot distinguish an absent cache from an auth rejection. if !rejected { return ErrValidationUnavailable } + 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 309874963..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,14 +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 + 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) // 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 new file mode 100644 index 000000000..f18963a9e --- /dev/null +++ b/internal/license/entitlement/rejected.go @@ -0,0 +1,205 @@ +package entitlement + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "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" + rejectedLockFile = "entitlement-rejected.lock" + markerPrefix = "entitlement-rejected-" + markerSuffix = ".mark" +) + +// 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. +// 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: 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 readRejected(path string) (map[string]bool, error) { + set := make(map[string]bool) + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return set, nil + } + if err != nil { + return nil, fmt.Errorf("read rejection record: %w", err) + } + var hashes []string + 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, 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 + } + 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. + // 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 +} + +// 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 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() + if err != nil { + return err + } + 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: 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: 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. +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) + 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) + for _, key := range license.CollectLicenseKeys() { + set[license.HashKey(key)] = true + } + } else if set[hash] == rejected { + return nil + } + if rejected { + set[hash] = true + } else { + delete(set, hash) + } + hashes := make([]string, 0, len(set)) + for h := range set { + hashes = append(hashes, h) + } + data, err := json.Marshal(hashes) + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".entitlement-rejected-*") + if err != nil { + return err + } + defer func() { _ = os.Remove(tmp.Name()) }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(0600); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + 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..0794e5d90 --- /dev/null +++ b/internal/license/entitlement/rejected_test.go @@ -0,0 +1,181 @@ +package entitlement + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + + "github.com/nself-org/cli/internal/license" +) + +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) + } + // 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) + } +} + +// 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") + } +} diff --git a/internal/license/entitlement/rejection_persistence_test.go b/internal/license/entitlement/rejection_persistence_test.go new file mode 100644 index 000000000..ffe716f97 --- /dev/null +++ b/internal/license/entitlement/rejection_persistence_test.go @@ -0,0 +1,186 @@ +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") + } +} + +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") + } +} + +// 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") + } +} diff --git a/internal/license/entitlement/selection.go b/internal/license/entitlement/selection.go index 0484c1100..064d55c60 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,14 @@ func selectToken(candidates []string, names []string, rejected map[string]bool) for _, key := range license.CollectLicenseKeys() { configured[license.HashKey(key)] = true } + 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] { + 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 { 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. 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)