diff --git a/.pyrit_conf_example b/.pyrit_conf_example
index 51488ee0d8..f31a5feaec 100644
--- a/.pyrit_conf_example
+++ b/.pyrit_conf_example
@@ -161,6 +161,14 @@ allow_custom_initializers: false
# as scenario_presets_source. Defaults to ~/.pyrit/scenario_suites.
# scenario_suites_source: https://account.blob.core.windows.net/container/scenario_suites
+# Optional storage for saved targets, converters, scorers, and techniques, so they
+# come back when the backend restarts. Each kind has its own folder under the source.
+# This supports the same local directory and Azure Blob sources as
+# scenario_presets_source. If a saved entry can't be restored, the backend still
+# starts, lists the failure in its runtime status, and refuses to use that entry's
+# name until the file is fixed or deleted. Defaults to ~/.pyrit/registry_recipes.
+# registry_recipes_source: https://account.blob.core.windows.net/container/registry_recipes
+
# Upload Folder for API-Created Targets
# -------------------------------------
# Folder that targets created through the REST API may upload local files from
diff --git a/doc/getting_started/pyrit_conf.md b/doc/getting_started/pyrit_conf.md
index bba64b45d4..62a3827c43 100644
--- a/doc/getting_started/pyrit_conf.md
+++ b/doc/getting_started/pyrit_conf.md
@@ -194,6 +194,18 @@ Suites store ordered references to scenario presets and resolve each preset's cu
A SAS query string may be included; otherwise, PyRIT uses `DefaultAzureCredential`. The default is `~/.pyrit/scenario_suites`. Local writes use the same hidden lock-file and atomic replacement behavior as scenario presets. Startup scans the configured source so malformed suite documents are reported immediately without preventing the backend from starting.
+### `registry_recipes_source`
+
+Stores saved targets, converters, scorers, and techniques in a local directory or Azure Blob container, so they come back when the backend restarts. An Azure URI may include a blob-name prefix:
+
+```yaml
+registry_recipes_source: https://account.blob.core.windows.net/pyrit-storage/registry-recipes
+```
+
+Each kind has its own folder under the source (`targets/`, `converters/`, `scorers/`, `techniques/`), with one JSON file per saved entry named after the entry. A file holds what's needed to rebuild the entry; restore refuses a file that stores a value for a parameter its class marks as a credential, such as `api_key`. A SAS query string may be included; otherwise, PyRIT uses `DefaultAzureCredential`. The default is `~/.pyrit/registry_recipes`.
+
+The backend restores saved entries at startup and after a live apply, once the configured initializers have registered theirs. If a saved entry can't be restored, the backend still becomes ready and lists each failure with its reason in `GET /api/config/runtime`. Every signed-in user can see the same list in `GET /api/restore-failures` and in the GUI's banner; users who aren't administrators get a short explanation of each reason instead of the full message. The failed entry keeps its name: looking it up, or starting a run that uses it, fails with the reason (409) instead of finding nothing or something else, and nothing else can take the name. While a saved technique is broken, a launch has to name its techniques, because choosing them by `all`, `default`, or a tag could quietly leave it out. If a whole folder can't be listed, nothing is restored and every name of that kind that isn't registered is held the same way. Fix or delete the listed files, then apply or restart. Files whose names aren't valid entry names (up to 64 lowercase letters, digits, and underscores, starting with a letter) are listed as ignored.
+
### `initialization_scripts`
Local paths to custom Python scripts containing `PyRITInitializer` subclasses. Paths can be absolute or relative to the current working directory.
diff --git a/frontend/e2e/fixtures/manual_send_backend.py b/frontend/e2e/fixtures/manual_send_backend.py
index 0c46ed2398..8be9b44941 100644
--- a/frontend/e2e/fixtures/manual_send_backend.py
+++ b/frontend/e2e/fixtures/manual_send_backend.py
@@ -4,6 +4,7 @@
"""Offline backend for manual-send browser tests, with no real configuration or providers."""
import os
+import tempfile
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, patch
@@ -27,19 +28,22 @@ async def _initialize_offline_async(self: ConfigurationLoader, *, raise_on_initi
@asynccontextmanager
async def _offline_lifespan_async(application: FastAPI) -> AsyncGenerator[None, None]:
- config = ConfigurationLoader(memory_db_type="in_memory", env_files=[], env_akv_ref=[])
- with (
- patch.dict(os.environ, {"PYRIT_AUTH_MODE": "local", "PYRIT_DEV_MODE": "true"}, clear=True),
- patch.object(
- ConfigurationFileService,
- "_read_source_async",
- new=AsyncMock(return_value="memory_db_type: in_memory\nenv_files: []\ninitializers: []\n"),
- ),
- patch.object(ConfigurationLoader, "load_with_overrides", return_value=config),
- patch.object(ConfigurationLoader, "initialize_pyrit_async", new=_initialize_offline_async),
- ):
- async with lifespan(application):
- yield
+ with tempfile.TemporaryDirectory() as recipes:
+ config = ConfigurationLoader(
+ memory_db_type="in_memory", env_files=[], env_akv_ref=[], registry_recipes_source=recipes
+ )
+ with (
+ patch.dict(os.environ, {"PYRIT_AUTH_MODE": "local", "PYRIT_DEV_MODE": "true"}, clear=True),
+ patch.object(
+ ConfigurationFileService,
+ "_read_source_async",
+ new=AsyncMock(return_value="memory_db_type: in_memory\nenv_files: []\ninitializers: []\n"),
+ ),
+ patch.object(ConfigurationLoader, "load_with_overrides", return_value=config),
+ patch.object(ConfigurationLoader, "initialize_pyrit_async", new=_initialize_offline_async),
+ ):
+ async with lifespan(application):
+ yield
app.router.lifespan_context = _offline_lifespan_async
diff --git a/frontend/src/hooks/useRuntime.styles.ts b/frontend/src/hooks/useRuntime.styles.ts
new file mode 100644
index 0000000000..b0596a5a62
--- /dev/null
+++ b/frontend/src/hooks/useRuntime.styles.ts
@@ -0,0 +1,9 @@
+import { makeStyles } from '@fluentui/react-components'
+
+export const useRuntimeBannerStyles = makeStyles({
+ // The app doesn't scroll, so a long list scrolls on its own.
+ failureList: {
+ maxHeight: '30vh',
+ overflowY: 'auto',
+ },
+})
diff --git a/frontend/src/hooks/useRuntime.test.tsx b/frontend/src/hooks/useRuntime.test.tsx
index 9d592bbd89..91620a09ee 100644
--- a/frontend/src/hooks/useRuntime.test.tsx
+++ b/frontend/src/hooks/useRuntime.test.tsx
@@ -1,13 +1,22 @@
import { useState } from 'react'
+import type { ReactNode } from 'react'
-import { act, render, screen, waitFor } from '@testing-library/react'
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { runtimeApi } from '@/services/api'
+import type { RestoreFailure } from '@/types'
-import { RuntimeProvider, useRuntime } from './useRuntime'
+import { RuntimeBanner, RuntimeProvider, useRuntime } from './useRuntime'
-jest.mock('@/services/api', () => ({ runtimeApi: { getReadiness: jest.fn() } }))
+jest.mock('@/services/api', () => ({
+ runtimeApi: { getReadiness: jest.fn(), getRestoreFailures: jest.fn() },
+}))
+
+function TestWrapper({ children }: { children: ReactNode }) {
+ return {children}
+}
function Consumer() {
const runtime = useRuntime()
@@ -41,3 +50,173 @@ describe('RuntimeProvider', () => {
expect(screen.getByRole('textbox', { name: 'Draft' })).toHaveValue('keep my draft')
})
})
+
+function brokenTarget(name: string): RestoreFailure {
+ return { kind: 'targets', name, reason: 'unknown_class', message: 'It is broken.', version: 'v1', dependency: null }
+}
+
+function renderBanner() {
+ render()
+}
+
+describe('RuntimeBanner', () => {
+ beforeEach(() => { jest.clearAllMocks() })
+
+ it('lists the saved entries that could not be restored while the runtime is ready', async () => {
+ jest.mocked(runtimeApi.getReadiness).mockResolvedValue({
+ ready: true,
+ state: 'ready',
+ generation: 'first',
+ restore_failures: 2,
+ })
+ jest.mocked(runtimeApi.getRestoreFailures).mockResolvedValue({
+ items: [
+ {
+ kind: 'targets',
+ name: 'pool',
+ reason: 'missing_dependency',
+ message: 'It uses an entry that does not exist.',
+ version: 'v1',
+ dependency: { kind: 'targets', name: 'gone' },
+ },
+ {
+ kind: 'converters',
+ name: null,
+ reason: 'unreadable_storage',
+ message: 'The folder could not be read.',
+ version: null,
+ dependency: null,
+ },
+ ],
+ })
+
+ renderBanner()
+
+ const list = await screen.findByRole('list', { name: "Saved entries that couldn't be restored" })
+ expect(await within(list).findByText('Target pool: It uses an entry that does not exist. (target gone)')).toBeInTheDocument()
+ expect(within(list).getByText('Saved converters: The folder could not be read.')).toBeInTheDocument()
+ expect(screen.getByText(/fixes or\s+deletes their saved files, then applies the configuration/)).toBeInTheDocument()
+ expect(screen.queryByText(/has to name its techniques/)).not.toBeInTheDocument()
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ })
+
+ it('says a scan has to name its techniques while a saved technique is broken', async () => {
+ jest.mocked(runtimeApi.getReadiness).mockResolvedValue({
+ ready: true,
+ state: 'ready',
+ generation: 'first',
+ restore_failures: 1,
+ })
+ jest.mocked(runtimeApi.getRestoreFailures).mockResolvedValue({
+ items: [{ ...brokenTarget('formal'), kind: 'techniques' }],
+ })
+
+ renderBanner()
+
+ expect(await screen.findByText(/a scan has to name its techniques/)).toBeInTheDocument()
+ })
+
+ it('shows nothing and asks for no list when every saved entry was restored', async () => {
+ jest.mocked(runtimeApi.getReadiness).mockResolvedValue({
+ ready: true,
+ state: 'ready',
+ generation: 'first',
+ restore_failures: 0,
+ })
+
+ renderBanner()
+
+ await waitFor(() => expect(runtimeApi.getReadiness).toHaveBeenCalled())
+ expect(screen.queryByRole('list')).not.toBeInTheDocument()
+ expect(runtimeApi.getRestoreFailures).not.toHaveBeenCalled()
+ })
+
+ it('does not ask for restore failures while the runtime is not ready', async () => {
+ jest.mocked(runtimeApi.getReadiness).mockResolvedValue({
+ ready: false,
+ state: 'restart-required',
+ generation: '',
+ restore_failures: 1,
+ })
+
+ renderBanner()
+
+ expect(await screen.findByText(/PyRIT runtime: restart-required/)).toBeInTheDocument()
+ expect(screen.queryByRole('list')).not.toBeInTheDocument()
+ expect(runtimeApi.getRestoreFailures).not.toHaveBeenCalled()
+ })
+
+ it('reads the list again only when the count or the generation changes', async () => {
+ jest.useFakeTimers()
+ const getReadiness = jest.mocked(runtimeApi.getReadiness)
+ getReadiness.mockResolvedValue({ ready: true, state: 'ready', generation: 'first', restore_failures: 1 })
+ jest.mocked(runtimeApi.getRestoreFailures).mockResolvedValue({ items: [brokenTarget('ghost')] })
+
+ renderBanner()
+ expect(await screen.findByText('Target ghost: It is broken.')).toBeInTheDocument()
+ await act(async () => { jest.advanceTimersByTime(4_000) })
+ expect(runtimeApi.getRestoreFailures).toHaveBeenCalledTimes(1)
+
+ jest.mocked(runtimeApi.getRestoreFailures).mockResolvedValue({ items: [brokenTarget('other')] })
+ getReadiness.mockResolvedValue({ ready: true, state: 'ready', generation: 'second', restore_failures: 1 })
+ await act(async () => { jest.advanceTimersByTime(2_000) })
+
+ expect(await screen.findByText('Target other: It is broken.')).toBeInTheDocument()
+ expect(runtimeApi.getRestoreFailures).toHaveBeenCalledTimes(2)
+ jest.useRealTimers()
+ })
+
+ it('hides the list once nothing fails any more', async () => {
+ jest.useFakeTimers()
+ const getReadiness = jest.mocked(runtimeApi.getReadiness)
+ getReadiness.mockResolvedValue({ ready: true, state: 'ready', generation: 'first', restore_failures: 1 })
+ jest.mocked(runtimeApi.getRestoreFailures).mockResolvedValue({ items: [brokenTarget('ghost')] })
+
+ renderBanner()
+ expect(await screen.findByText('Target ghost: It is broken.')).toBeInTheDocument()
+ getReadiness.mockResolvedValue({ ready: true, state: 'ready', generation: 'second', restore_failures: 0 })
+ await act(async () => { jest.advanceTimersByTime(2_000) })
+
+ await waitFor(() => expect(screen.queryByText('Target ghost: It is broken.')).not.toBeInTheDocument())
+ jest.useRealTimers()
+ })
+
+ it('tries again when the list could not be loaded, keeping the last list', async () => {
+ jest.useFakeTimers()
+ jest.mocked(runtimeApi.getReadiness).mockResolvedValue({
+ ready: true,
+ state: 'ready',
+ generation: 'first',
+ restore_failures: 1,
+ })
+ jest.mocked(runtimeApi.getRestoreFailures)
+ .mockRejectedValueOnce(new Error('Network Error'))
+ .mockResolvedValue({ items: [brokenTarget('ghost')] })
+
+ renderBanner()
+ expect(await screen.findByText(/list of saved entries couldn't be loaded/)).toBeInTheDocument()
+ await act(async () => { jest.advanceTimersByTime(2_000) })
+
+ expect(await screen.findByText('Target ghost: It is broken.')).toBeInTheDocument()
+ expect(screen.queryByText(/list of saved entries couldn't be loaded/)).not.toBeInTheDocument()
+ jest.useRealTimers()
+ })
+
+ it('lets a long list scroll on its own', async () => {
+ jest.mocked(runtimeApi.getReadiness).mockResolvedValue({
+ ready: true,
+ state: 'ready',
+ generation: 'first',
+ restore_failures: 40,
+ })
+ jest.mocked(runtimeApi.getRestoreFailures).mockResolvedValue({
+ items: Array.from({ length: 40 }, (_, index) => brokenTarget(`target_${index}`)),
+ })
+
+ renderBanner()
+
+ const list = await screen.findByRole('list', { name: "Saved entries that couldn't be restored" })
+ expect(list).toHaveAttribute('tabindex', '0')
+ expect(within(list).getAllByRole('listitem')).toHaveLength(40)
+ })
+})
diff --git a/frontend/src/hooks/useRuntime.tsx b/frontend/src/hooks/useRuntime.tsx
index 2035358d63..95966600b7 100644
--- a/frontend/src/hooks/useRuntime.tsx
+++ b/frontend/src/hooks/useRuntime.tsx
@@ -4,9 +4,17 @@ import type { ReactNode } from 'react'
import { MessageBar, MessageBarBody } from '@fluentui/react-components'
import { runtimeApi } from '@/services/api'
-import type { RuntimeReadiness } from '@/types'
+import type { RegistryRecipeKind, RestoreFailure, RuntimeReadiness } from '@/types'
+
+import { useRuntimeBannerStyles } from './useRuntime.styles'
const POLL_INTERVAL_MS = 2_000
+const KIND_LABELS: Record = {
+ targets: 'Target',
+ converters: 'Converter',
+ scorers: 'Scorer',
+ techniques: 'Technique',
+}
const RuntimeContext = createContext({ ready: true, state: 'ready', generation: '' })
// eslint-disable-next-line react-refresh/only-export-components
@@ -38,9 +46,84 @@ export function RuntimeProvider({ children }: { children: ReactNode }) {
return {children}
}
+interface RestoreFailures {
+ failures: RestoreFailure[]
+ listFailed: boolean
+}
+
+// The list is read when the count or the runtime generation changes, rather than on every readiness poll.
+function useRestoreFailures(active: boolean, count: number, generation: string): RestoreFailures {
+ const [failures, setFailures] = useState([])
+ const [listFailed, setListFailed] = useState(false)
+ useEffect(() => {
+ if (!active) return
+ let cancelled = false
+ let retryTimer: ReturnType | undefined
+ const load = async (): Promise => {
+ try {
+ const list = await runtimeApi.getRestoreFailures()
+ if (!cancelled) {
+ setFailures(list.items)
+ setListFailed(false)
+ }
+ } catch {
+ // Keep the last list and say it couldn't be refreshed; try again shortly.
+ if (!cancelled) {
+ setListFailed(true)
+ retryTimer = setTimeout(() => { void load() }, POLL_INTERVAL_MS)
+ }
+ }
+ }
+ void load()
+ return () => {
+ cancelled = true
+ clearTimeout(retryTimer)
+ }
+ }, [active, count, generation])
+ return { failures: active ? failures : [], listFailed: active && listFailed }
+}
+
+function describeFailure(failure: RestoreFailure): string {
+ const subject = failure.name === null ? `Saved ${failure.kind}` : `${KIND_LABELS[failure.kind]} ${failure.name}`
+ const dependency = failure.dependency
+ ? ` (${KIND_LABELS[failure.dependency.kind].toLowerCase()} ${failure.dependency.name})`
+ : ''
+ return `${subject}: ${failure.message}${dependency}`
+}
+
export function RuntimeBanner() {
const runtime = useRuntime()
- if (runtime.ready) return null
+ const failureCount = runtime.restore_failures ?? 0
+ const styles = useRuntimeBannerStyles()
+ const { failures, listFailed } = useRestoreFailures(
+ runtime.ready && failureCount > 0,
+ failureCount,
+ runtime.generation,
+ )
+ if (runtime.ready) {
+ if (failureCount === 0) return null
+ const techniqueFailed = failures.some((failure) => failure.kind === 'techniques')
+ return (
+
+
+ Some saved entries couldn't be restored, so they can't be used until an administrator fixes or
+ deletes their saved files, then applies the configuration or restarts the backend.
+ {techniqueFailed && ' Until then, a scan has to name its techniques instead of choosing all, default, or a tag.'}
+
+ {failures.length > 0 && (
+ // Focusable so the list can be scrolled from the keyboard.
+
+ {failures.map((failure) => (
+
{describeFailure(failure)}
+ ))}
+
+ )}
+ {listFailed &&
The list of saved entries couldn't be loaded. Trying again.
}
+
+
+
+ )
+ }
return (
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 10d01b2b9a..116f8808dc 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -377,6 +377,9 @@ export const runtimeApi = {
getReadiness: async (): Promise => {
return (await apiClient.get('/runtime')).data
},
+ getRestoreFailures: async (): Promise => {
+ return (await apiClient.get('/restore-failures')).data
+ },
}
export const targetsApi = {
@@ -994,4 +997,4 @@ export const datasetsApi = {
},
}
-import type { RuntimeReadiness, RuntimeStatus } from '@/types'
+import type { RestoreFailureList, RuntimeReadiness, RuntimeStatus } from '@/types'
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 59054dab85..cb2ebb5585 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -2391,6 +2391,30 @@ export interface RuntimeReadiness {
ready: boolean
state: string
generation: string
+ /** How many saved entries couldn't be restored; the list has the details. */
+ restore_failures?: number
+}
+
+export type RegistryRecipeKind = 'targets' | 'converters' | 'scorers' | 'techniques'
+
+export interface SavedEntryRef {
+ kind: RegistryRecipeKind
+ name: string
+}
+
+/** A saved entry, or a whole folder of them when `name` is null, that couldn't be restored. */
+export interface RestoreFailure {
+ kind: RegistryRecipeKind
+ name: string | null
+ reason: string
+ /** Full details for administrators; a fixed explanation of the reason for everyone else. */
+ message: string
+ version: string | null
+ dependency: SavedEntryRef | null
+}
+
+export interface RestoreFailureList {
+ items: RestoreFailure[]
}
export interface RuntimeStatus {
diff --git a/pyrit/backend/README.md b/pyrit/backend/README.md
index d0b8ea33ab..47a9427ab8 100644
--- a/pyrit/backend/README.md
+++ b/pyrit/backend/README.md
@@ -337,8 +337,9 @@ The chat preserves failed drafts across replacement, but requires converter choi
to be reviewed again rather than restoring outputs tied to the previous registry.
The offline browser fixture `frontend.e2e.fixtures.manual_send_backend:app` uses the real
-backend lifecycle with isolated in-memory SQLite, no environment files, and no default
-providers. The recovery tests register deterministic loopback targets. For example, start
+backend lifecycle with isolated in-memory SQLite, no environment files, no saved registry
+entries, and no default providers. The recovery tests register deterministic loopback
+targets. For example, start
it with `uv run python -m uvicorn frontend.e2e.fixtures.manual_send_backend:app --host 127.0.0.1 --port 18213`
(set `PYTHONUTF8=1` on Windows). In another shell, set `PYRIT_BACKEND_URL=http://127.0.0.1:18213`
and `E2E_FRONTEND_PORT=31213`, then run `npx playwright test chat-recovery --project seeded --workers 1`
diff --git a/pyrit/backend/main.py b/pyrit/backend/main.py
index 15aa89814a..f4f1914e4a 100644
--- a/pyrit/backend/main.py
+++ b/pyrit/backend/main.py
@@ -40,6 +40,7 @@
media,
message_sends,
operations,
+ restore_failures,
scenario_presets,
scenario_suites,
scenarios,
@@ -151,6 +152,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app.include_router(initializers.router, prefix="/api", tags=["initializers"])
app.include_router(labels.router, prefix="/api", tags=["labels"])
app.include_router(health.router, prefix="/api", tags=["health"])
+app.include_router(restore_failures.router, prefix="/api", tags=["restore-failures"])
app.include_router(auth.router, prefix="/api", tags=["auth"])
app.include_router(media.router, prefix="/api", tags=["media"])
app.include_router(scores.router, prefix="/api", tags=["scores"])
diff --git a/pyrit/backend/middleware/error_handlers.py b/pyrit/backend/middleware/error_handlers.py
index 5973d6289e..9186f41531 100644
--- a/pyrit/backend/middleware/error_handlers.py
+++ b/pyrit/backend/middleware/error_handlers.py
@@ -11,9 +11,12 @@
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
+from pyrit.backend.middleware.auth import has_admin_access
from pyrit.backend.middleware.request_size import RequestTooLargeError, request_too_large_response
from pyrit.backend.models import FieldError, ProblemDetail
from pyrit.exceptions.analytics_exception import AnalyticsException
+from pyrit.registry import SavedEntryUnavailableError, TechniqueSelectionUnavailableError
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError, DocumentUnreadableError
logger = logging.getLogger(__name__)
@@ -169,6 +172,68 @@ async def permission_error_handler( # pyrit-async-suffix-exempt
content=problem.model_dump(exclude_none=True),
)
+ @app.exception_handler(DocumentStorageUnavailableError)
+ async def storage_unavailable_handler( # pyrit-async-suffix-exempt
+ request: Request,
+ exc: DocumentStorageUnavailableError,
+ ) -> JSONResponse:
+ """
+ Handle stored documents that can't be read or written as 503 Service Unavailable.
+
+ The log names the storage location, without credentials, and the reason, with the
+ traceback at debug level. The response never carries a path or URL.
+
+ Returns:
+ JSONResponse: RFC 7807 problem detail response with 503 status.
+ """
+ logger.warning(f"{exc} ({request.method} {request.url.path})")
+ logger.debug("Storage error details", exc_info=exc)
+ if isinstance(exc, DocumentUnreadableError):
+ detail = f"{exc.label} '{exc.name}' can't be read: {exc.reason}"
+ else:
+ detail = f"{exc.label} storage is unavailable"
+ problem = ProblemDetail(
+ type="/errors/storage-unavailable",
+ title="Service Unavailable",
+ status=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail=detail,
+ instance=str(request.url.path),
+ )
+
+ return JSONResponse(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ content=problem.model_dump(exclude_none=True),
+ )
+
+ @app.exception_handler(SavedEntryUnavailableError)
+ async def saved_entry_unavailable_handler( # pyrit-async-suffix-exempt
+ request: Request,
+ exc: SavedEntryUnavailableError,
+ ) -> JSONResponse:
+ """
+ Handle a name held for a saved entry that couldn't be restored as 409 Conflict.
+
+ Administrators get the full message; anyone else gets a fixed explanation of the reason,
+ since the message can quote a server setting.
+
+ Returns:
+ JSONResponse: RFC 7807 problem detail response with 409 status.
+ """
+ return _conflict(request=request, detail=exc.describe(with_details=_shows_details(request)))
+
+ @app.exception_handler(TechniqueSelectionUnavailableError)
+ async def technique_selection_unavailable_handler( # pyrit-async-suffix-exempt
+ request: Request,
+ exc: TechniqueSelectionUnavailableError,
+ ) -> JSONResponse:
+ """
+ Handle a broad technique selection made while a saved technique couldn't be restored as 409 Conflict.
+
+ Returns:
+ JSONResponse: RFC 7807 problem detail response with 409 status.
+ """
+ return _conflict(request=request, detail=str(exc))
+
@app.exception_handler(NotImplementedError)
async def not_implemented_handler( # pyrit-async-suffix-exempt
request: Request,
@@ -223,3 +288,33 @@ async def generic_exception_handler( # pyrit-async-suffix-exempt
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content=problem.model_dump(exclude_none=True),
)
+
+
+def _conflict(*, request: Request, detail: str) -> JSONResponse:
+ """
+ Build a 409 Conflict problem response.
+
+ Returns:
+ JSONResponse: RFC 7807 problem detail response with 409 status.
+ """
+ problem = ProblemDetail(
+ type="/errors/conflict",
+ title="Conflict",
+ status=status.HTTP_409_CONFLICT,
+ detail=detail,
+ instance=str(request.url.path),
+ )
+ return JSONResponse(status_code=status.HTTP_409_CONFLICT, content=problem.model_dump(exclude_none=True))
+
+
+def _shows_details(request: Request) -> bool:
+ """
+ Return whether the caller may see a restore failure's full message, which can quote a server setting.
+
+ Returns:
+ bool: True for an administrator; False otherwise, including when no authentication policy is set.
+ """
+ try:
+ return has_admin_access(request)
+ except RuntimeError:
+ return False
diff --git a/pyrit/backend/routes/attacks.py b/pyrit/backend/routes/attacks.py
index 51cc1d4dc0..76bac6db9f 100644
--- a/pyrit/backend/routes/attacks.py
+++ b/pyrit/backend/routes/attacks.py
@@ -45,6 +45,7 @@
from pyrit.backend.services.media_persistence import MediaAccessDeniedError
from pyrit.common import print_deprecation_message
from pyrit.memory.memory_interface import AttackStateConflictError
+from pyrit.registry import SavedEntryUnavailableError
logger = logging.getLogger(__name__)
@@ -666,6 +667,8 @@ async def add_message( # pyrit-async-suffix-exempt
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_msg,
) from e
+ except SavedEntryUnavailableError:
+ raise
except Exception as e:
logger.exception("Failed to add message to attack '%s'", attack_result_id)
raise HTTPException(
diff --git a/pyrit/backend/routes/converters.py b/pyrit/backend/routes/converters.py
index 10d129394e..d97841170e 100644
--- a/pyrit/backend/routes/converters.py
+++ b/pyrit/backend/routes/converters.py
@@ -24,6 +24,7 @@
UnregisteredConverter,
)
from pyrit.backend.services import get_converter_service
+from pyrit.registry import SavedEntryUnavailableError
router = APIRouter(prefix="/converters", tags=["converters"])
@@ -89,6 +90,8 @@ async def create_converter(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
+ except SavedEntryUnavailableError:
+ raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -165,6 +168,8 @@ async def preview_conversion(request: ConverterPreviewRequest) -> ConverterPrevi
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
+ except SavedEntryUnavailableError:
+ raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
diff --git a/pyrit/backend/routes/health.py b/pyrit/backend/routes/health.py
index b6e06c91a4..e0cd361faa 100644
--- a/pyrit/backend/routes/health.py
+++ b/pyrit/backend/routes/health.py
@@ -13,30 +13,31 @@
router = APIRouter()
-def _runtime_readiness(request: Request) -> dict[str, str | bool]:
+def _runtime_readiness(request: Request) -> dict[str, str | bool | int]:
runtime = getattr(request.app.state, "runtime_lifecycle", None)
if runtime is None:
- return {"ready": False, "state": "failed", "generation": ""}
+ return {"ready": False, "state": "failed", "generation": "", "restore_failures": 0}
return {
"ready": runtime.reported_state == "ready",
"state": runtime.reported_state,
"generation": runtime.generation,
+ "restore_failures": len(runtime.restore_failures),
}
@router.get("/runtime")
-async def runtime_readiness_async(request: Request) -> dict[str, str | bool]:
+async def runtime_readiness_async(request: Request) -> dict[str, str | bool | int]:
"""
- Expose readiness and generation without administrative details.
+ Expose readiness, generation, and how many saved entries couldn't be restored, without details.
Returns:
- dict[str, str | bool]: Lightweight runtime readiness.
+ dict[str, str | bool | int]: Lightweight runtime readiness.
"""
return _runtime_readiness(request)
@router.get("/health")
-async def health_check_async(*, request: Request, response: Response) -> dict[str, str | bool | None]:
+async def health_check_async(*, request: Request, response: Response) -> dict[str, str | bool | int | None]:
"""
Check the health status of the backend service.
@@ -48,7 +49,7 @@ async def health_check_async(*, request: Request, response: Response) -> dict[st
deployment checks use the readiness fields instead.
Returns:
- dict[str, str | bool | None]: Health status, timestamp, runtime readiness, and serving ACA revision.
+ dict[str, str | bool | int | None]: Health status, timestamp, runtime readiness, and serving ACA revision.
"""
readiness = _runtime_readiness(request)
response.headers["Cache-Control"] = "no-store"
diff --git a/pyrit/backend/routes/initializers.py b/pyrit/backend/routes/initializers.py
index 50fa79db85..529f92d641 100644
--- a/pyrit/backend/routes/initializers.py
+++ b/pyrit/backend/routes/initializers.py
@@ -14,7 +14,6 @@
DELETE /api/initializers/{name} — unregister an initializer
"""
-from azure.core.exceptions import AzureError
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pyrit.backend.middleware.auth import require_admin
@@ -35,19 +34,6 @@
router = APIRouter(prefix="/initializers", tags=["initializers"])
-def _custom_storage_unavailable() -> HTTPException:
- """
- Create a sanitized response for unavailable custom initializer storage.
-
- Returns:
- HTTPException: A service-unavailable response without SDK details.
- """
- return HTTPException(
- status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
- detail="Custom initializer storage is temporarily unavailable",
- )
-
-
def _configured_initializers(request: Request) -> list[ConfiguredInitializerSetting]:
"""
Read the initializer list captured from ``.pyrit_conf`` at backend startup.
@@ -138,10 +124,7 @@ async def list_custom_initializers(request: Request) -> CustomInitializerListRes
CustomInitializerListResponse: The configured source and stored scripts.
"""
_check_custom_initializers_allowed(request)
- try:
- return await get_initializer_service().list_custom_initializers_async()
- except AzureError as exc:
- raise _custom_storage_unavailable() from exc
+ return await get_initializer_service().list_custom_initializers_async()
@router.get(
@@ -205,8 +188,6 @@ async def register_initializer( # pyrit-async-suffix-exempt
try:
return await service.register_initializer_async(name=body.name, script_content=body.script_content)
- except AzureError as exc:
- raise _custom_storage_unavailable() from exc
except ValueError as e:
detail = str(e)
if "already registered" in detail:
@@ -243,8 +224,6 @@ async def unregister_initializer( # pyrit-async-suffix-exempt
try:
await service.unregister_initializer_async(initializer_name=initializer_name)
- except AzureError as exc:
- raise _custom_storage_unavailable() from exc
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
diff --git a/pyrit/backend/routes/message_sends.py b/pyrit/backend/routes/message_sends.py
index 297783c99e..7441f28af5 100644
--- a/pyrit/backend/routes/message_sends.py
+++ b/pyrit/backend/routes/message_sends.py
@@ -15,6 +15,7 @@
from pyrit.backend.services.manual_send_scheduler import ManualSendConflictError, ManualSendQueueFullError
from pyrit.backend.services.message_send_service import MessageSendNotFoundError, get_message_send_service
from pyrit.memory.memory_interface import AttackStateConflictError
+from pyrit.registry import SavedEntryUnavailableError
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/attacks", tags=["attacks"])
@@ -49,6 +50,8 @@ async def submit_message_send_async(
except ValueError as exc:
is_not_found = "not found" in str(exc).lower() or "does not exist" in str(exc).lower()
raise HTTPException(status_code=404 if is_not_found else 400, detail=str(exc)) from exc
+ except SavedEntryUnavailableError:
+ raise
except Exception as exc:
logger.exception("Failed to accept message send for attack '%s'", attack_result_id)
raise HTTPException(status_code=500, detail="Could not accept the message send. Check server logs.") from exc
diff --git a/pyrit/backend/routes/restore_failures.py b/pyrit/backend/routes/restore_failures.py
new file mode 100644
index 0000000000..a9f25a1805
--- /dev/null
+++ b/pyrit/backend/routes/restore_failures.py
@@ -0,0 +1,38 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Saved registry entries that couldn't be restored, listed for every signed-in user."""
+
+from typing import TYPE_CHECKING, Any
+
+from fastapi import APIRouter, Request
+
+from pyrit.backend.middleware.auth import has_admin_access
+
+if TYPE_CHECKING:
+ from pyrit.models.catalog import RestoreFailure
+
+router = APIRouter(prefix="/restore-failures", tags=["restore-failures"])
+
+
+@router.get("")
+async def list_restore_failures_async(request: Request) -> dict[str, list[dict[str, Any]]]:
+ """
+ List the saved entries that couldn't be restored and why.
+
+ Administrators get the full message. Other users get a fixed explanation of the reason instead,
+ since a constructor's error can quote a server setting.
+
+ Returns:
+ dict[str, list[dict[str, Any]]]: The failures, under ``items``.
+ """
+ runtime = getattr(request.app.state, "runtime_lifecycle", None)
+ failures: list[RestoreFailure] = runtime.restore_failures if runtime is not None else []
+ show_details = has_admin_access(request)
+ items = []
+ for failure in failures:
+ item = failure.model_dump(mode="json")
+ if not show_details:
+ item["message"] = failure.reason.explanation
+ items.append(item)
+ return {"items": items}
diff --git a/pyrit/backend/routes/scenario_presets.py b/pyrit/backend/routes/scenario_presets.py
index 4b3fc1db00..973112df7b 100644
--- a/pyrit/backend/routes/scenario_presets.py
+++ b/pyrit/backend/routes/scenario_presets.py
@@ -19,7 +19,6 @@
from typing import Annotated
-from azure.core.exceptions import AzureError
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pyrit.backend.middleware.auth import current_user_name, require_admin
@@ -38,19 +37,6 @@
router = APIRouter(prefix="/scenario-presets", tags=["scenario-presets"])
-def _storage_unavailable() -> HTTPException:
- """
- Create a sanitized response for unavailable preset storage.
-
- Returns:
- HTTPException: A service-unavailable response without SDK details.
- """
- return HTTPException(
- status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
- detail="Scenario preset storage is temporarily unavailable",
- )
-
-
def _not_found(name: str) -> HTTPException:
"""
Create a not-found response for a missing preset.
@@ -79,8 +65,6 @@ async def _load_preset_or_404_async(name: str) -> ScenarioPresetResponse:
"""
try:
preset = await get_scenario_preset_service().get_preset_async(name=name)
- except AzureError as exc:
- raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
@@ -106,10 +90,7 @@ async def list_scenario_presets( # pyrit-async-suffix-exempt
Returns:
ScenarioPresetListResponse: The configured source and the stored presets.
"""
- try:
- return await get_scenario_preset_service().list_presets_async(include_estimates=include_estimates)
- except AzureError as exc:
- raise _storage_unavailable() from exc
+ return await get_scenario_preset_service().list_presets_async(include_estimates=include_estimates)
@router.get(
@@ -177,8 +158,6 @@ async def create_scenario_preset( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario preset '{preset.name}' already exists",
) from exc
- except AzureError as exc:
- raise _storage_unavailable() from exc
@router.put(
@@ -239,8 +218,6 @@ async def update_scenario_preset( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario preset '{name}' changed since it was read; re-read it and retry",
) from exc
- except AzureError as exc:
- raise _storage_unavailable() from exc
@router.delete(
@@ -261,8 +238,6 @@ async def delete_scenario_preset(name: IdentifierStr) -> None: # pyrit-async-su
"""
try:
deleted = await get_scenario_preset_service().delete_preset_async(name=name)
- except AzureError as exc:
- raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
@@ -312,8 +287,6 @@ async def resolve_scenario_preset( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario preset '{name}' changed since it was read; re-read it and retry",
) from None
- except AzureError as exc:
- raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
diff --git a/pyrit/backend/routes/scenario_suites.py b/pyrit/backend/routes/scenario_suites.py
index 0b4fa7e09a..f1ec16872e 100644
--- a/pyrit/backend/routes/scenario_suites.py
+++ b/pyrit/backend/routes/scenario_suites.py
@@ -6,7 +6,6 @@
from typing import Annotated
from uuid import UUID
-from azure.core.exceptions import AzureError
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pyrit.backend.middleware.auth import current_user_name, require_admin
@@ -31,19 +30,6 @@
router = APIRouter(prefix="/scenario-suites", tags=["scenario-suites"])
-def _storage_unavailable() -> HTTPException:
- """
- Create a sanitized response for unavailable suite storage.
-
- Returns:
- HTTPException: A service-unavailable response without SDK details.
- """
- return HTTPException(
- status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
- detail="Scenario suite storage is temporarily unavailable",
- )
-
-
def _not_found(name: str) -> HTTPException:
"""
Create a not-found response for a missing suite.
@@ -66,8 +52,6 @@ async def _load_suite_or_404_async(name: str) -> ScenarioSuiteResponse:
"""
try:
suite = await get_scenario_suite_service().get_suite_async(name=name)
- except AzureError as exc:
- raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
@@ -84,10 +68,7 @@ async def list_scenario_suites() -> ScenarioSuiteListResponse: # pyrit-async-su
Returns:
ScenarioSuiteListResponse: Suites from both origins with current entry statuses.
"""
- try:
- return await get_scenario_suite_service().list_suites_async()
- except AzureError as exc:
- raise _storage_unavailable() from exc
+ return await get_scenario_suite_service().list_suites_async()
@router.get(
@@ -181,8 +162,6 @@ async def launch_scenario_suite( # pyrit-async-suffix-exempt
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
except ScenarioSuiteExecutionCapacityError as exc:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(exc)) from None
- except AzureError as exc:
- raise _storage_unavailable() from exc
except RuntimeError as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from None
@@ -239,8 +218,6 @@ async def create_scenario_suite( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario suite '{suite.name}' already exists",
) from exc
- except AzureError as exc:
- raise _storage_unavailable() from exc
@router.put(
@@ -289,8 +266,6 @@ async def update_scenario_suite( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario suite '{name}' changed since it was read; re-read it and retry",
) from exc
- except AzureError as exc:
- raise _storage_unavailable() from exc
@router.delete(
@@ -303,8 +278,6 @@ async def delete_scenario_suite(name: IdentifierStr) -> None: # pyrit-async-suf
"""Delete one stored scenario suite."""
try:
deleted = await get_scenario_suite_service().delete_suite_async(name=name)
- except AzureError as exc:
- raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
diff --git a/pyrit/backend/routes/scorers.py b/pyrit/backend/routes/scorers.py
index 445f2830b3..65287aea6e 100644
--- a/pyrit/backend/routes/scorers.py
+++ b/pyrit/backend/routes/scorers.py
@@ -17,6 +17,7 @@
)
from pyrit.backend.services.scorer_service import get_scorer_service
from pyrit.models.catalog import ScorerInstance
+from pyrit.registry import SavedEntryUnavailableError
router = APIRouter(prefix="/scorers", tags=["scorers"])
@@ -65,6 +66,8 @@ async def create_scorer(
return await get_scorer_service().create_scorer_async(request=request)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+ except SavedEntryUnavailableError:
+ raise
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
diff --git a/pyrit/backend/routes/targets.py b/pyrit/backend/routes/targets.py
index c520a42e05..a10164b4bc 100644
--- a/pyrit/backend/routes/targets.py
+++ b/pyrit/backend/routes/targets.py
@@ -23,6 +23,7 @@
)
from pyrit.backend.services import get_target_service
from pyrit.models.catalog import TargetInstance
+from pyrit.registry import SavedEntryUnavailableError
router = APIRouter(prefix="/targets", tags=["targets"])
@@ -132,6 +133,8 @@ async def create_target(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
+ except SavedEntryUnavailableError:
+ raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
diff --git a/pyrit/backend/services/runtime_lifecycle.py b/pyrit/backend/services/runtime_lifecycle.py
index daef21f0ed..c6ae1be279 100644
--- a/pyrit/backend/services/runtime_lifecycle.py
+++ b/pyrit/backend/services/runtime_lifecycle.py
@@ -7,7 +7,7 @@
import logging
import os
import uuid
-from typing import Any
+from typing import TYPE_CHECKING, Any
from fastapi import FastAPI
@@ -26,10 +26,14 @@
from pyrit.common.path import CONFIGURATION_DIRECTORY_PATH
from pyrit.memory import CentralMemory
from pyrit.registry import InitializerRegistry, ScenarioPresetRegistry, ScenarioSuiteRegistry
+from pyrit.registry.registry_recipe_restore import RegistryRecipeRestorer
from pyrit.setup import ConfigurationLoader
from pyrit.setup.environment_loading import resolve_environment_async
from pyrit.setup.initialization import validate_reinitialization_memory
+if TYPE_CHECKING:
+ from pyrit.models.catalog import IgnoredRecipeFile, RestoreFailure
+
logger = logging.getLogger(__name__)
@@ -46,6 +50,8 @@ def __init__(self, *, app: FastAPI, source: ConfigurationFileService) -> None:
self.version: str | None = None
self.outcome = "starting"
self.message = ""
+ self.restore_failures: list[RestoreFailure] = []
+ self.restore_ignored: list[IgnoredRecipeFile] = []
self.operations: set[asyncio.Task[None]] = set()
self.management_operations: set[asyncio.Task[None]] = set()
self.apply_task: asyncio.Task[None] | None = None
@@ -75,6 +81,8 @@ def status(self) -> dict[str, Any]:
"message": self.message,
"enabled": self.topology_supported,
"applying": self.apply_task is not None and not self.apply_task.done(),
+ "restore_failures": [failure.model_dump(mode="json") for failure in self.restore_failures],
+ "restore_ignored": [file.model_dump(mode="json") for file in self.restore_ignored],
}
async def _load_async(self) -> ConfigurationLoader:
@@ -119,6 +127,32 @@ def _publish(self, config: ConfigurationLoader) -> None:
self.generation = str(uuid.uuid4())
self.state, self.outcome, self.message = "ready", "success", "PyRIT is ready."
+ async def _restore_saved_entries_async(self, config: ConfigurationLoader) -> None:
+ """
+ Rebuild saved registry entries after the startup scripts have registered theirs.
+
+ The runtime becomes ready even when some can't be restored. Each one that failed keeps its name,
+ so looking it up or starting a run that uses it fails with the reason instead of finding nothing
+ or something else, and the failures are listed in the runtime status.
+
+ Args:
+ config (ConfigurationLoader): The loaded configuration naming the storage source.
+ """
+ restorer = RegistryRecipeRestorer(source=config.registry_recipes_source)
+ # Reading can wait on storage, so it runs in a worker thread. Entries are built on this loop, as
+ # the target API builds them, because a constructor such as HuggingFaceChatTarget's starts a task
+ # on it. A slow constructor holds the loop while it runs.
+ saved = await asyncio.to_thread(restorer.read)
+ report = restorer.restore(saved=saved)
+ self.restore_failures, self.restore_ignored = report.failures, report.ignored
+ if report.failures:
+ count = len(report.failures)
+ logger.warning(
+ "%d saved registry %s couldn't be restored. Fix or delete them; see GET /api/config/runtime.",
+ count,
+ "entry" if count == 1 else "entries",
+ )
+
async def startup_async(self) -> None:
"""Keep management alive even when parsing or initialization fails."""
self.app.state.allow_custom_initializers = False
@@ -137,6 +171,7 @@ async def startup_async(self) -> None:
await config.initialize_pyrit_async(raise_on_initializer_error=True)
await get_scenario_run_service().reconcile_interrupted_runs_async()
_, self.version = await self.source.read_with_version_async()
+ await self._restore_saved_entries_async(config)
self._publish(config)
except Exception:
self.state, self.outcome = "restart-required", "initialization-failed"
@@ -219,6 +254,7 @@ async def _apply_async(self, *, version: str) -> None:
await config.apply_prepared_reinitialization_async(prepared=prepared)
await self._management_async(config)
self.version = current_version
+ await self._restore_saved_entries_async(config)
self._publish(config)
except (Exception, asyncio.CancelledError, BaseExceptionGroup) as error:
logger.exception(
diff --git a/pyrit/backend/services/scenario_configuration_resolver.py b/pyrit/backend/services/scenario_configuration_resolver.py
index 42a78d86f6..fa5d941657 100644
--- a/pyrit/backend/services/scenario_configuration_resolver.py
+++ b/pyrit/backend/services/scenario_configuration_resolver.py
@@ -7,7 +7,15 @@
from typing import TYPE_CHECKING, Any
-from pyrit.registry import ScenarioRegistry, ScorerRegistry, TargetRegistry
+from pyrit.registry import (
+ AttackTechniqueRegistry,
+ SavedEntryUnavailableError,
+ ScenarioRegistry,
+ ScorerRegistry,
+ TargetRegistry,
+ TechniqueSelectionUnavailableError,
+ UnavailableEntry,
+)
from pyrit.scenario.core import resolve_technique_tokens
from pyrit.scenario.core.scenario_target_defaults import validate_default_adversarial_target
from pyrit.score import TrueFalseScorer
@@ -105,6 +113,41 @@ def resolve_target(*, target_name: str) -> PromptTarget:
f"Target '{target_name}' not found in registry. Available targets: {', '.join(available_names)}"
)
+ @staticmethod
+ def _refuse_broad_selection_while_techniques_are_held(
+ *,
+ scenario_class: type[Scenario],
+ techniques: list[str] | None,
+ technique_override: TechniqueOverride | None,
+ ) -> None:
+ """
+ Refuse choosing techniques by all, default, or a tag while a saved technique couldn't be restored.
+
+ A broken technique's tags may be unknown, so such a selection could quietly leave it out. Naming
+ techniques still works, and scenarios whose techniques don't come from the registry aren't affected.
+
+ Raises:
+ SavedEntryUnavailableError: If a named technique is held for a saved technique that couldn't be restored.
+ TechniqueSelectionUnavailableError: If the selection is broad while a saved technique is held.
+ """
+ held = raise_if_a_named_technique_is_held(techniques)
+ if not held or "scenario_techniques" not in {p.name for p in scenario_class.supported_parameters()}:
+ return
+ selectors = [token.partition(":")[0] for token in techniques or []]
+ if not selectors and technique_override is not None:
+ return
+ try:
+ technique_class = scenario_class()._technique_class # type: ignore[ty:missing-argument]
+ except Exception:
+ technique_class = None
+ if technique_class is not None and getattr(technique_class, "_selection_catalog", None) is None:
+ return
+ aggregates = {"all", "default"}
+ if technique_class is not None:
+ aggregates |= {tag.casefold() for tag in technique_class.get_aggregate_tags()}
+ if not selectors or any(selector.casefold() in aggregates for selector in selectors):
+ raise TechniqueSelectionUnavailableError(names=[entry.name for entry in held if entry.name is not None])
+
@classmethod
def resolve_configuration(
cls,
@@ -131,6 +174,9 @@ def resolve_configuration(
Raises:
ValueError: If techniques or dataset overrides are invalid.
"""
+ cls._refuse_broad_selection_while_techniques_are_held(
+ scenario_class=scenario_class, techniques=techniques, technique_override=technique_override
+ )
resolved: dict[str, Any] = {}
if objective_target is not None:
resolved["objective_target"] = objective_target
@@ -213,3 +259,25 @@ def resolve_techniques_and_converters(
if resolution.issues:
raise ValueError(resolution.issues[0].message)
return resolution.techniques, resolution.technique_converters
+
+
+def raise_if_a_named_technique_is_held(techniques: list[str] | None) -> list[UnavailableEntry]:
+ """
+ Refuse technique tokens that name a saved technique that couldn't be restored, in any letter case.
+
+ Args:
+ techniques (list[str] | None): Technique tokens, each optionally followed by ``:`` and converters.
+
+ Returns:
+ list[UnavailableEntry]: Every held saved technique, and a held folder of them.
+
+ Raises:
+ SavedEntryUnavailableError: If a token names a held technique.
+ """
+ held = AttackTechniqueRegistry.get_registry_singleton().instances.get_unavailable_entries()
+ held_by_name = {entry.name.casefold(): entry for entry in held if entry.name is not None}
+ for token in techniques or []:
+ name = token.partition(":")[0]
+ if (entry := held_by_name.get(name.casefold())) is not None:
+ raise SavedEntryUnavailableError(entry=entry, name=name)
+ return held
diff --git a/pyrit/backend/services/scenario_run_service.py b/pyrit/backend/services/scenario_run_service.py
index ed3b7d5aba..466e1fc397 100644
--- a/pyrit/backend/services/scenario_run_service.py
+++ b/pyrit/backend/services/scenario_run_service.py
@@ -46,7 +46,10 @@
normalize_label_filters,
)
from pyrit.backend.services.saved_conversation_read_service import SavedConversationReadService
-from pyrit.backend.services.scenario_configuration_resolver import ScenarioConfigurationResolver
+from pyrit.backend.services.scenario_configuration_resolver import (
+ ScenarioConfigurationResolver,
+ raise_if_a_named_technique_is_held,
+)
from pyrit.backend.services.scenario_progress_read_model import (
ScenarioPlanLookup,
ScenarioProgressReadModel,
@@ -443,6 +446,7 @@ def _selected_launch_factories(
technique_class = metadata.technique_class
if not isinstance(technique_class, type):
return [local_factory] if local_factory is not None else []
+ raise_if_a_named_technique_is_held(request.techniques)
if configured_techniques is None:
selectors = (
[technique_class(token.partition(":")[0]) for token in request.techniques]
diff --git a/pyrit/backend/services/scenario_suite_service.py b/pyrit/backend/services/scenario_suite_service.py
index 4e668bb4eb..2636d842d6 100644
--- a/pyrit/backend/services/scenario_suite_service.py
+++ b/pyrit/backend/services/scenario_suite_service.py
@@ -9,8 +9,6 @@
from functools import lru_cache
from typing import Literal
-from azure.core.exceptions import AzureError
-
from pyrit.backend.models import (
ScenarioSuiteEntryStatus,
ScenarioSuiteListResponse,
@@ -25,6 +23,7 @@
ScenarioSuiteRegistryEntry,
)
from pyrit.registry import ScenarioPresetRegistry, ScenarioSuiteRegistry
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError
logger = logging.getLogger(__name__)
@@ -209,7 +208,7 @@ def _resolve_preset_projection(
)
stored_version = registry.get_preset_version(name=preset_name)
- except (AzureError, OSError):
+ except DocumentStorageUnavailableError:
logger.warning(
"Could not project scenario preset '%s' because preset storage is unavailable.",
preset_name,
diff --git a/pyrit/common/azure_storage.py b/pyrit/common/azure_storage.py
index d3ed6eeb4f..0195a24956 100644
--- a/pyrit/common/azure_storage.py
+++ b/pyrit/common/azure_storage.py
@@ -3,8 +3,14 @@
"""Lightweight Azure Storage URI helpers."""
+import re
from urllib.parse import parse_qs, urlparse
+# A scheme followed by "://" and everything up to whitespace, so a quote can't end a URL before its query.
+_URL_IN_TEXT = re.compile(r"\b[A-Za-z][A-Za-z0-9+.-]*://\S+")
+# The signature of a SAS token, which can also appear in text without a scheme.
+_SAS_SIGNATURE = re.compile(r"(?i)\bsig=[^&\s]+")
+
def is_azure_blob_uri(value: str, *, min_path_segments: int = 1) -> bool:
"""
@@ -48,9 +54,30 @@ def has_sas_signature(value: str) -> bool:
def redact_url_credentials(value: str) -> str:
"""
- Remove query parameters and fragments from a URL for display or logging.
+ Remove the user name and password, query parameters, and fragment from a URL for display or logging.
+
+ Returns:
+ str: The URL without them.
+ """
+ parsed = urlparse(value)
+ return parsed._replace(netloc=parsed.netloc.rpartition("@")[2], query="", fragment="").geturl()
+
+
+def redact_urls(text: str) -> str:
+ """
+ Remove the user name and password, query parameters, and fragment from every URL in a text.
+
+ A query or user name can hold a credential, such as a SAS token. A URL that can't be parsed is
+ hidden entirely, and a SAS signature is hidden wherever it appears.
Returns:
- str: The URL without query parameters or a fragment.
+ str: The text with each URL redacted.
"""
- return urlparse(value)._replace(query="", fragment="").geturl()
+
+ def redact(match: re.Match[str]) -> str:
+ try:
+ return redact_url_credentials(match.group(0))
+ except ValueError:
+ return ""
+
+ return _SAS_SIGNATURE.sub("sig=", _URL_IN_TEXT.sub(redact, text))
diff --git a/pyrit/models/catalog/__init__.py b/pyrit/models/catalog/__init__.py
index 102788e2a8..a113a8bef0 100644
--- a/pyrit/models/catalog/__init__.py
+++ b/pyrit/models/catalog/__init__.py
@@ -20,6 +20,17 @@
if TYPE_CHECKING:
from pyrit.models.catalog.initializer import RegisteredInitializer
+ from pyrit.models.catalog.registry_recipe import (
+ ComponentRecipe,
+ IgnoredRecipeFile,
+ RegistryRecipeKind,
+ RegistryRestoreReport,
+ RestoreFailure,
+ RestoreFailureReason,
+ SavedEntryRef,
+ StoredRecipe,
+ TechniqueRecipe,
+ )
from pyrit.models.catalog.scenario import (
AttackErrorSummary,
AttackRetrySummary,
@@ -60,9 +71,16 @@
_LAZY_EXPORTS: dict[str, str] = {
"AttackErrorSummary": "pyrit.models.catalog.scenario",
"AttackRetrySummary": "pyrit.models.catalog.scenario",
+ "ComponentRecipe": "pyrit.models.catalog.registry_recipe",
+ "IgnoredRecipeFile": "pyrit.models.catalog.registry_recipe",
"PresetEntry": "pyrit.models.catalog.scenario_preset",
"PresetIssue": "pyrit.models.catalog.scenario_preset",
"RegisteredInitializer": "pyrit.models.catalog.initializer",
+ "RegistryRecipeKind": "pyrit.models.catalog.registry_recipe",
+ "RegistryRestoreReport": "pyrit.models.catalog.registry_recipe",
+ "RestoreFailure": "pyrit.models.catalog.registry_recipe",
+ "RestoreFailureReason": "pyrit.models.catalog.registry_recipe",
+ "SavedEntryRef": "pyrit.models.catalog.registry_recipe",
"ScorerInstance": "pyrit.models.catalog.scorer",
"RegisteredScenario": "pyrit.models.catalog.scenario",
"RunScenarioRequest": "pyrit.models.catalog.scenario",
@@ -87,8 +105,10 @@
"ScenarioRunSummary": "pyrit.models.catalog.scenario",
"ScenarioTechniqueSummary": "pyrit.models.catalog.scenario",
"StoredPreset": "pyrit.models.catalog.scenario_preset",
+ "StoredRecipe": "pyrit.models.catalog.registry_recipe",
"StoredScenarioSuite": "pyrit.models.catalog.scenario_suite",
"TargetInstance": "pyrit.models.catalog.target",
+ "TechniqueRecipe": "pyrit.models.catalog.registry_recipe",
}
__all__ = list(_LAZY_EXPORTS)
diff --git a/pyrit/models/catalog/registry_recipe.py b/pyrit/models/catalog/registry_recipe.py
new file mode 100644
index 0000000000..2ad21c0292
--- /dev/null
+++ b/pyrit/models/catalog/registry_recipe.py
@@ -0,0 +1,206 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Saved registry entries: the recipe kept for each one and what restoring them found."""
+
+from __future__ import annotations
+
+from enum import Enum
+from typing import Annotated, ClassVar
+
+from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator
+
+from pyrit.models.request_limits import MAX_IDENTIFIER_LENGTH, MAX_ITEMS, MAX_TEXT_LENGTH
+
+_Identifier = Annotated[str, Field(max_length=MAX_IDENTIFIER_LENGTH)]
+_TechniqueTag = Annotated[str, Field(pattern=r"^[A-Za-z][A-Za-z0-9_]{0,63}$")]
+
+
+class RegistryRecipeKind(str, Enum):
+ """The registries whose entries can be saved, named after their storage folders."""
+
+ TARGETS = "targets"
+ CONVERTERS = "converters"
+ SCORERS = "scorers"
+ TECHNIQUES = "techniques"
+
+
+class _RegistryRecipe(BaseModel):
+ """Fields every saved recipe shares. The entry's name is its document name, so it isn't repeated."""
+
+ CURRENT_SCHEMA_VERSION: ClassVar[int] = 1
+
+ model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False)
+
+ # A literal 1 rather than CURRENT_SCHEMA_VERSION: a document saved without this field is a version 1
+ # document and must keep reading as one after the constant is bumped, or a migration would skip it.
+ schema_version: int = Field(1, ge=1, description="Shape of this document")
+
+ @field_validator("schema_version")
+ @classmethod
+ def _validate_schema_version(cls, value: int) -> int:
+ """
+ Reject a document saved by a newer PyRIT than this one.
+
+ Returns:
+ int: The schema version.
+
+ Raises:
+ ValueError: If the document's format is newer than this PyRIT reads.
+ """
+ if value > cls.CURRENT_SCHEMA_VERSION:
+ raise ValueError(
+ f"Format version {value} is newer than this PyRIT reads (up to {cls.CURRENT_SCHEMA_VERSION})"
+ )
+ return value
+
+
+class ComponentRecipe(_RegistryRecipe):
+ """How to rebuild one saved target, converter, or scorer."""
+
+ class_module: _Identifier = Field(
+ ..., min_length=1, description="Module that defined the class when the entry was saved"
+ )
+ class_name: _Identifier = Field(..., min_length=1, description="Registered class name")
+ params: dict[_Identifier, JsonValue] = Field(
+ default_factory=dict,
+ max_length=MAX_ITEMS,
+ description="Constructor parameters as the API accepted them; other entries are named, never embedded",
+ )
+
+
+class TechniqueRecipe(_RegistryRecipe):
+ """
+ How to rebuild one saved technique: the fields of its create request, without the name.
+
+ The fields and their limits mirror the technique API's create request, which this library
+ can't import.
+ """
+
+ type: str = Field(..., min_length=1, max_length=256, description="Registered attack type")
+ params: dict[_Identifier, JsonValue] = Field(
+ default_factory=dict, max_length=MAX_ITEMS, description="Attack constructor parameters"
+ )
+ description: str | None = Field(default=None, max_length=MAX_TEXT_LENGTH)
+ tags: list[_TechniqueTag] = Field(default_factory=list, max_length=MAX_ITEMS)
+ request_converters: list[_Identifier] | None = Field(default=None, max_length=MAX_ITEMS)
+ response_converters: list[_Identifier] | None = Field(default=None, max_length=MAX_ITEMS)
+ adversarial_chat: _Identifier | None = None
+ adversarial_system_prompt: str | None = None
+ adversarial_seed_prompt: str | None = None
+ adversarial_prompt_template: str | None = None
+
+ @field_validator("tags")
+ @classmethod
+ def _validate_tags(cls, value: list[str]) -> list[str]:
+ folded = {tag.casefold() for tag in value}
+ if len(folded) != len(value):
+ raise ValueError("Tags must be unique, including letter case")
+ if {"all", "default"} & folded:
+ raise ValueError("all and default are reserved selectors")
+ return value
+
+
+class SavedEntryRef(BaseModel):
+ """Points at one saved entry."""
+
+ model_config = ConfigDict(frozen=True)
+
+ kind: RegistryRecipeKind
+ name: str
+
+
+class StoredRecipe(BaseModel):
+ """A saved recipe and the version of the document it was read from."""
+
+ ref: SavedEntryRef
+ recipe: ComponentRecipe | TechniqueRecipe
+ version: str = Field(..., description="Opaque version of the document this recipe was read from")
+
+ @model_validator(mode="after")
+ def _validate_recipe_kind(self) -> StoredRecipe:
+ """
+ Reject a technique recipe filed under another registry, or another registry's recipe filed as a technique.
+
+ Returns:
+ StoredRecipe: The stored recipe.
+
+ Raises:
+ ValueError: If the recipe's type doesn't match the registry it's filed under.
+ """
+ expects_technique = self.ref.kind is RegistryRecipeKind.TECHNIQUES
+ if expects_technique != isinstance(self.recipe, TechniqueRecipe):
+ expected = TechniqueRecipe if expects_technique else ComponentRecipe
+ raise ValueError(f"A saved {self.ref.kind.value} entry needs a {expected.__name__}")
+ return self
+
+
+class RestoreFailureReason(str, Enum):
+ """Why a saved entry couldn't be restored."""
+
+ UNREADABLE_STORAGE = "unreadable_storage"
+ UNREADABLE_RECIPE = "unreadable_recipe"
+ INVALID_RECIPE = "invalid_recipe"
+ NEWER_SCHEMA_VERSION = "newer_schema_version"
+ UNKNOWN_CLASS = "unknown_class"
+ MODULE_MISMATCH = "module_mismatch"
+ UNSUPPORTED_CLASS = "unsupported_class"
+ UNSUPPORTED_CREDENTIAL = "unsupported_credential"
+ NAME_CLASH = "name_clash"
+ MISSING_DEPENDENCY = "missing_dependency"
+ FAILED_DEPENDENCY = "failed_dependency"
+ DEPENDENCY_LOOP = "dependency_loop"
+ BUILD_ERROR = "build_error"
+
+ @property
+ def explanation(self) -> str:
+ """A fixed explanation of the reason that's safe to show anyone, unlike a failure's message."""
+ return _RESTORE_FAILURE_EXPLANATIONS[self]
+
+
+_RESTORE_FAILURE_EXPLANATIONS: dict[RestoreFailureReason, str] = {
+ RestoreFailureReason.UNREADABLE_STORAGE: "The folder of saved entries couldn't be read.",
+ RestoreFailureReason.UNREADABLE_RECIPE: "The saved file couldn't be read.",
+ RestoreFailureReason.INVALID_RECIPE: "The saved file isn't a valid entry.",
+ RestoreFailureReason.NEWER_SCHEMA_VERSION: "It was saved by a newer version of PyRIT.",
+ RestoreFailureReason.UNKNOWN_CLASS: "Its class or attack type isn't available.",
+ RestoreFailureReason.MODULE_MISMATCH: "Its class is now one from outside PyRIT.",
+ RestoreFailureReason.UNSUPPORTED_CLASS: "Its class can't be rebuilt from a saved file.",
+ RestoreFailureReason.UNSUPPORTED_CREDENTIAL: "It holds a credential, which a saved file can't hold.",
+ RestoreFailureReason.NAME_CLASH: "Its name or one of its tags is already taken.",
+ RestoreFailureReason.MISSING_DEPENDENCY: "It uses an entry that doesn't exist.",
+ RestoreFailureReason.FAILED_DEPENDENCY: "It uses an entry that couldn't be restored.",
+ RestoreFailureReason.DEPENDENCY_LOOP: "It is part of a group of entries that use each other.",
+ RestoreFailureReason.BUILD_ERROR: "It couldn't be built.",
+}
+
+
+class RestoreFailure(BaseModel):
+ """One saved entry, or one whole storage folder, that couldn't be restored."""
+
+ model_config = ConfigDict(frozen=True)
+
+ kind: RegistryRecipeKind
+ name: str | None = Field(None, description="Entry name; None when the whole folder couldn't be read")
+ reason: RestoreFailureReason
+ message: str = Field(..., description="Why, for administrators; any credential in a URL is hidden")
+ version: str | None = Field(None, description="Version of the stored document, when it could be read")
+ dependency: SavedEntryRef | None = Field(None, description="The entry this one needed that is missing or failed")
+
+
+class IgnoredRecipeFile(BaseModel):
+ """A stored file whose name can't be an entry name, so it was never read."""
+
+ model_config = ConfigDict(frozen=True)
+
+ kind: RegistryRecipeKind
+ file_name: str
+ message: str
+
+
+class RegistryRestoreReport(BaseModel):
+ """What a restore rebuilt, what it couldn't, and which files it skipped."""
+
+ restored: list[SavedEntryRef] = Field(default_factory=list)
+ failures: list[RestoreFailure] = Field(default_factory=list)
+ ignored: list[IgnoredRecipeFile] = Field(default_factory=list)
diff --git a/pyrit/registry/__init__.py b/pyrit/registry/__init__.py
index ace59af86d..d4b181a92e 100644
--- a/pyrit/registry/__init__.py
+++ b/pyrit/registry/__init__.py
@@ -32,12 +32,15 @@
TargetMetadata,
TargetRegistry,
)
+ from pyrit.registry.components.attack_technique_registry import TechniqueSelectionUnavailableError
from pyrit.registry.discovery import discover_in_directory
from pyrit.registry.instance_registry import (
DefaultInstanceRegistry,
InstanceRegistry,
RegistryEntry,
+ SavedEntryUnavailableError,
SupportsInstances,
+ UnavailableEntry,
)
from pyrit.registry.registry import InstanceHoldingRegistry, ParamBagRegistry, Registry
from pyrit.registry.registry_metadata import RegistryMetadata
@@ -71,6 +74,8 @@
"InitializerMetadata": "pyrit.registry.components",
"InitializerRegistry": "pyrit.registry.components",
"RegistryEntry": "pyrit.registry.instance_registry",
+ "SavedEntryUnavailableError": "pyrit.registry.instance_registry",
+ "UnavailableEntry": "pyrit.registry.instance_registry",
"ScenarioMetadata": "pyrit.registry.components",
"ScenarioPresetConflictError": "pyrit.registry.scenario_preset_storage",
"ScenarioPresetMetadata": "pyrit.registry.components",
@@ -97,6 +102,7 @@
"TargetRegistry": "pyrit.registry.components",
"TargetMetadata": "pyrit.registry.components",
"TagQuery": "pyrit.registry.tag_query",
+ "TechniqueSelectionUnavailableError": "pyrit.registry.components.attack_technique_registry",
}
__all__ = list(_LAZY_EXPORTS)
diff --git a/pyrit/registry/components/attack_technique_registry.py b/pyrit/registry/components/attack_technique_registry.py
index 059b93afba..b55103965d 100644
--- a/pyrit/registry/components/attack_technique_registry.py
+++ b/pyrit/registry/components/attack_technique_registry.py
@@ -31,12 +31,12 @@
from pyrit.models import AttackIdentifier, ComponentType
from pyrit.models.component_spec import SourceInstanceSpec
-from pyrit.registry.instance_registry import DefaultInstanceRegistry
+from pyrit.registry.instance_registry import DefaultInstanceRegistry, SavedEntryUnavailableError
from pyrit.registry.registry import Registry
from pyrit.registry.registry_metadata import RegistryMetadata
if TYPE_CHECKING:
- from collections.abc import Callable
+ from collections.abc import Callable, Sequence
from typing import Protocol
from pyrit.models import Parameter
@@ -132,6 +132,38 @@ class AttackTechniqueMetadata(RegistryMetadata):
"""Shared metadata type for the inherited, empty technique class catalog."""
+class TechniqueSelectorConflictError(ValueError):
+ """A technique's name or tag conflicts with the name or a tag of a registered technique."""
+
+
+class TechniqueSelectionUnavailableError(Exception):
+ """
+ A launch picks techniques by all, default, or a tag while a saved technique couldn't be restored.
+
+ A broken technique's tags may be unknown, so a broad selection could quietly leave it out. Naming the
+ techniques still works.
+ """
+
+ def __init__(self, *, names: Sequence[str]) -> None:
+ """
+ Initialize the error.
+
+ Args:
+ names (Sequence[str]): The saved techniques that couldn't be restored, or none when their folder
+ couldn't be listed.
+ """
+ if names:
+ listed = ", ".join(f"'{name}'" for name in names)
+ subject = f"Saved technique{'s' if len(names) > 1 else ''} {listed} couldn't be restored"
+ else:
+ subject = "The saved techniques couldn't be listed"
+ super().__init__(
+ f"{subject}, so a launch has to name its techniques. Choosing them by all, default, or a tag "
+ "is refused until that's fixed."
+ )
+ self.names = tuple(names)
+
+
class TechniqueInstanceRegistry(DefaultInstanceRegistry["AttackTechniqueFactory"]):
"""Factory storage with an atomic runtime-admission path."""
@@ -140,27 +172,38 @@ def register_runtime(self, factory: AttackTechniqueFactory) -> None:
Check selector collisions while holding the same lock as registration.
Raises:
- ValueError: If the name or a tag conflicts with an existing selector.
+ SavedEntryUnavailableError: If the name, in any letter case, is held for a saved technique
+ that couldn't be restored.
+ TechniqueSelectorConflictError: If the name or a tag conflicts with an existing selector.
+ ValueError: If the factory doesn't fit the scenarios it would join.
"""
from pyrit.registry.components.scenario_registry import ScenarioRegistry
# Discover builders before taking the instance lock; discovery has its own catalog lock.
ScenarioRegistry.get_registry_singleton().get_class_names()
with self._lock:
+ held = {entry.name.casefold(): entry for entry in self.get_unavailable_entries() if entry.name is not None}
+ if (held_entry := held.get(factory.name.casefold())) is not None:
+ raise SavedEntryUnavailableError(entry=held_entry, name=factory.name)
entries = self.get_all_instances()
- names = {entry.name.casefold(): entry.name for entry in entries}
+ # A held name still counts as taken, so no tag can claim it while its saved technique is broken.
+ names = {entry.name.casefold(): entry.name for entry in entries} | {
+ folded: entry.name for folded, entry in held.items() if entry.name is not None
+ }
tags = {tag.casefold(): tag for entry in entries for tag in entry.instance.technique_tags}
folded_name = factory.name.casefold()
if folded_name in names:
- raise ValueError(f"Technique '{factory.name}' already exists (names are case-insensitive)")
+ raise TechniqueSelectorConflictError(
+ f"Technique '{factory.name}' already exists (names are case-insensitive)"
+ )
if folded_name in tags:
- raise ValueError(f"Technique name '{factory.name}' conflicts with an aggregate tag")
+ raise TechniqueSelectorConflictError(f"Technique name '{factory.name}' conflicts with an aggregate tag")
for tag in factory.technique_tags:
folded = tag.casefold()
if folded in names or folded == folded_name:
- raise ValueError(f"Tag '{tag}' conflicts with a technique name")
+ raise TechniqueSelectorConflictError(f"Tag '{tag}' conflicts with a technique name")
if folded in tags and tags[folded] != tag:
- raise ValueError(f"Tag '{tag}' conflicts with tag '{tags[folded]}'")
+ raise TechniqueSelectorConflictError(f"Tag '{tag}' conflicts with tag '{tags[folded]}'")
_validate_generated_member_collisions(
class_name="ScenarioTechnique",
factories=[factory],
diff --git a/pyrit/registry/components/initializer_registry.py b/pyrit/registry/components/initializer_registry.py
index 6e1e283d2d..53d06fb8a5 100644
--- a/pyrit/registry/components/initializer_registry.py
+++ b/pyrit/registry/components/initializer_registry.py
@@ -420,6 +420,7 @@ def register_from_content(self, *, name: str, script_content: str) -> str:
ValueError: If the source cannot be compiled, does not
define a concrete ``PyRITInitializer`` subclass, or
*name* collides with an existing entry.
+ DocumentStorageUnavailableError: If the script storage can't be written.
"""
self._ensure_discovered()
@@ -429,11 +430,7 @@ def register_from_content(self, *, name: str, script_content: str) -> str:
raise ValueError(f"Initializer '{name}' is already registered. Unregister it first to replace it.")
discovered = self._load_custom_initializer_class(name=name, script_content=script_content)
- storage = self._get_custom_storage()
- try:
- storage.save_script(name=name, content=script_content)
- except OSError as error:
- raise ValueError(f"Failed to write initializer script: {error}") from error
+ self._get_custom_storage().save_script(name=name, content=script_content)
self.register_class(discovered, name=name)
logger.info(f"Registered custom initializer: {name} ({discovered.__name__})")
@@ -484,10 +481,12 @@ def unregister_and_cleanup(self, name: str) -> None:
Raises:
KeyError: If no stored custom initializer has the requested name.
ValueError: If the name refers to a built-in initializer.
+ DocumentStorageUnavailableError: If the script storage can't be used.
"""
self._ensure_discovered()
storage = self._get_custom_storage()
- stored_names = storage.list_scripts().keys()
+ # A script that can't be read or decoded is still deleted, so the API can clean it up.
+ stored_names = sorted(storage.list_script_names())
if name not in stored_names:
if name in self._builtin_names:
raise ValueError(f"Cannot remove built-in initializer '{name}'.")
diff --git a/pyrit/registry/custom_initializer_storage.py b/pyrit/registry/custom_initializer_storage.py
index 572c838c00..90ec10695f 100644
--- a/pyrit/registry/custom_initializer_storage.py
+++ b/pyrit/registry/custom_initializer_storage.py
@@ -67,6 +67,16 @@ def list_scripts(self) -> dict[str, str]:
logger.warning(f"Skipping stored initializer '{name}': it is not valid UTF-8 text.")
return scripts
+ def list_script_names(self) -> set[str]:
+ """
+ List the names of every stored script, including ones that can't be read or decoded.
+
+ Returns:
+ set[str]: The registry names of the stored scripts.
+ """
+ listing = self._list_documents_with_issues()
+ return set(listing.documents) | {unreadable.name for unreadable in listing.unreadable}
+
def save_script(self, *, name: str, content: str) -> None:
"""Persist one custom initializer script."""
self._save_document(name=name, content=content.encode("utf-8"))
diff --git a/pyrit/registry/file_document_storage.py b/pyrit/registry/file_document_storage.py
index f71309a25f..c18a34a2cb 100644
--- a/pyrit/registry/file_document_storage.py
+++ b/pyrit/registry/file_document_storage.py
@@ -12,14 +12,19 @@
from __future__ import annotations
+import errno
+import fnmatch
import hashlib
import logging
import os
+import stat
import sys
import tempfile
import time
from contextlib import contextmanager, suppress
+from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
+from types import MappingProxyType
from typing import TYPE_CHECKING
from urllib.parse import unquote, urlparse
@@ -27,12 +32,100 @@
from pyrit.models import validate_registry_name
if TYPE_CHECKING:
- from collections.abc import Generator
+ from collections.abc import Generator, Mapping
from azure.storage.blob import ContainerClient
logger = logging.getLogger(__name__)
+# Called through these names so tests can change the folder between steps without patching ``os`` for the
+# whole process.
+_open = os.open
+_stat = os.stat
+_lstat = os.lstat
+_scandir = os.scandir
+
+
+@dataclass(frozen=True)
+class UnreadableDocument:
+ """
+ A stored document with a valid name whose content could not be read.
+
+ ``message`` is a short reason that is safe to show. ``error`` is what stopped the read, kept for logging and
+ for telling a passing failure from a broken file; it is None when nothing raised, such as for a folder.
+ """
+
+ name: str
+ file_name: str
+ message: str
+ error: BaseException | None = field(default=None, compare=False, repr=False)
+
+
+@dataclass(frozen=True)
+class IgnoredDocument:
+ """A stored file whose name breaks the naming rule, so no single-document call can address it."""
+
+ file_name: str
+ message: str
+
+
+@dataclass(frozen=True)
+class DocumentListing:
+ """Every document a listing found, including the ones it could not use."""
+
+ documents: Mapping[str, bytes] = field(default_factory=lambda: MappingProxyType({}))
+ unreadable: tuple[UnreadableDocument, ...] = ()
+ ignored: tuple[IgnoredDocument, ...] = ()
+
+
+class DocumentStorageUnavailableError(Exception):
+ """
+ The folder or container that holds the documents couldn't be used, so nothing was read or written.
+
+ The message names the location for logs; ``display_source`` already hides a SAS token. ``reason`` is short and
+ carries no path or URL, so an API response can show it.
+ """
+
+ def __init__(self, *, label: str, source: str, reason: str, message: str | None = None) -> None:
+ """
+ Initialize the error.
+
+ Args:
+ label (str): The kind of document, such as "Scenario preset".
+ source (str): The storage location, without credentials.
+ reason (str): A short reason without a path or URL, such as "Permission denied".
+ message (str | None): The full message, or None for the default one. Defaults to None.
+ """
+ super().__init__(message or f"{label} storage at {source} is unavailable: {reason}")
+ self.label = label
+ self.source = source
+ self.reason = reason
+
+
+class DocumentUnreadableError(DocumentStorageUnavailableError):
+ """One stored document exists but couldn't be read, such as a file without read permission or a folder."""
+
+ def __init__(self, *, label: str, source: str, name: str, file_name: str, reason: str) -> None:
+ """
+ Initialize the error.
+
+ Args:
+ label (str): The kind of document, such as "Scenario preset".
+ source (str): The storage location, without credentials.
+ name (str): The document's name.
+ file_name (str): The file or blob name in storage.
+ reason (str): A short reason without a path or URL, such as "Not a regular file".
+ """
+ super().__init__(
+ label=label,
+ source=source,
+ reason=reason,
+ message=f"{label} '{file_name}' in {source} can't be read: {reason}",
+ )
+ self.name = name
+ self.file_name = file_name
+
+
# Both platforms lock a single byte at offset zero, which they allow past the end of an
# empty file. The lock belongs to the open file handle, so it excludes other threads in
# this process as well as other processes, and the kernel drops it if the holder exits.
@@ -81,6 +174,84 @@ def _release_exclusive_lock(descriptor: int) -> None:
fcntl.flock(descriptor, fcntl.LOCK_UN)
+# Opening without blocking keeps a pipe from hanging the read; it changes nothing for a regular
+# file. Windows has no O_NONBLOCK and needs O_BINARY so the bytes aren't translated.
+_READ_FLAGS = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0)
+
+
+def _read_regular_file(path: str) -> bytes | None:
+ """
+ Read a file only if what was opened is a regular file.
+
+ The opened file is checked rather than the path, so the check still holds if the path
+ was replaced after it was listed. On Windows, opening a folder raises ``PermissionError``,
+ so a folder swapped in after listing is reported as "Permission denied" there.
+
+ Returns:
+ bytes | None: The content, or None if the path names something other than a regular file.
+ """
+ descriptor = _open(path, _READ_FLAGS)
+ try:
+ if not stat.S_ISREG(os.fstat(descriptor).st_mode):
+ return None
+ with open(descriptor, "rb", closefd=False) as file:
+ return file.read()
+ finally:
+ os.close(descriptor)
+
+
+def _affects_whole_store(error: Exception) -> bool:
+ """
+ Return whether a Blob error means the container can't be used, rather than one blob.
+
+ Returns:
+ bool: True for sign-in and permission failures, a missing container, and network errors.
+ """
+ from azure.core.exceptions import ClientAuthenticationError, ServiceRequestError, ServiceResponseError
+ from azure.storage.blob import StorageErrorCode
+
+ if isinstance(error, (ClientAuthenticationError, ServiceRequestError, ServiceResponseError)):
+ return True
+ if getattr(error, "status_code", None) in (401, 403):
+ return True
+ error_code = getattr(error, "error_code", None)
+ return isinstance(error_code, str) and error_code == StorageErrorCode.CONTAINER_NOT_FOUND
+
+
+def _is_missing_blob(error: Exception) -> bool:
+ """
+ Return whether a Blob error says the blob itself doesn't exist, as opposed to its container.
+
+ Returns:
+ bool: True only for ``BlobNotFound``.
+ """
+ from azure.storage.blob import StorageErrorCode
+
+ error_code = getattr(error, "error_code", None)
+ return isinstance(error_code, str) and error_code == StorageErrorCode.BLOB_NOT_FOUND
+
+
+def describe_storage_error(error: Exception) -> str:
+ """
+ Describe a storage error in a few words, leaving out any path or URL it carries.
+
+ Args:
+ error (Exception): The error a local folder or Azure Blob Storage raised.
+
+ Returns:
+ str: For example ``Permission denied`` or ``HttpResponseError (403, AuthorizationPermissionMismatch)``.
+ """
+ if isinstance(error, OSError) and error.strerror:
+ return error.strerror
+ error_code = getattr(error, "error_code", None)
+ details = [
+ str(detail)
+ for detail in (getattr(error, "status_code", None), getattr(error_code, "value", error_code))
+ if detail is not None
+ ]
+ return f"{type(error).__name__} ({', '.join(details)})" if details else type(error).__name__
+
+
class DocumentConflictError(ValueError):
"""A stored document changed after the caller read it."""
@@ -133,6 +304,13 @@ class FileDocumentStorage:
an OS advisory lock held across the whole read-compare-replace locally. That lock binds
only writers using this class; a direct edit is caught by the version instead.
+ A failure of the storage itself (a folder that can't be read, a missing container, a
+ sign-in or network error, a lock that stays taken) raises ``DocumentStorageUnavailableError``.
+ Its message names the location for logs, using ``display_source`` so a SAS token never
+ appears; an API response shows only its short reason. One document that exists but
+ can't be read raises ``DocumentUnreadableError`` from a single read, and is reported in
+ a listing's ``unreadable`` instead.
+
Subclasses supply the extension and an error-message label.
"""
@@ -191,72 +369,262 @@ def _list_documents(self) -> dict[str, bytes]:
"""
Read every stored document that the single-document operations can address.
- A name is checked before its content is read, so a file this class cannot address
- is never opened and cannot fail the listing on its way out.
+ The documents ``_list_documents_with_issues`` can't use are logged and left out. A storage
+ location that can't be listed at all raises instead of looking empty.
Returns:
dict[str, bytes]: Document content keyed by name.
+
+ Raises:
+ DocumentStorageUnavailableError: If the folder or container can't be listed.
+ """
+ listing = self._list_documents_with_issues()
+ for ignored in listing.ignored:
+ logger.warning(
+ f"Ignoring stored document '{ignored.file_name}' in {self.display_source}: {ignored.message}"
+ )
+ for unreadable in listing.unreadable:
+ logger.warning(
+ f"Skipping unreadable document '{unreadable.file_name}' in {self.display_source}: {unreadable.message}"
+ )
+ return dict(listing.documents)
+
+ def _list_documents_with_issues(self) -> DocumentListing:
+ """
+ Read every stored document and report the ones that can't be used.
+
+ A document that can't be read is reported as unreadable, and a file whose name breaks
+ the naming rule is reported as ignored without being read, because no single-document
+ call could address it. A storage location that can't be listed at all raises instead of
+ looking empty, and so does a Blob error that affects the whole container.
+
+ Messages carry only a short reason, never a path or URL, so they can be shown to users
+ without exposing server paths or a SAS token.
+
+ Returns:
+ DocumentListing: Readable documents keyed by name, plus the unreadable and ignored ones.
+
+ Raises:
+ DocumentStorageUnavailableError: If the folder or container can't be listed or read.
"""
- return self._list_blob_documents() if self._is_blob else self._list_local_documents()
+ with self._unavailable_on_storage_error():
+ if self._is_blob:
+ return self._list_blob_documents_with_issues()
+ return self._list_local_documents_with_issues()
- def _list_local_documents(self) -> dict[str, bytes]:
+ def _list_local_documents_with_issues(self) -> DocumentListing:
"""
- Read addressable documents from the configured local directory.
+ Read the local directory, reporting unreadable and badly named documents.
Returns:
- dict[str, bytes]: Document content keyed by file stem.
+ DocumentListing: The documents and issues found in the directory.
"""
directory = self._local_directory(create=True)
+ # ``Path.glob`` returns nothing for a directory it can't read; listing it directly raises instead.
+ # ``fnmatch`` applies the platform's case rule, as ``glob`` does.
+ with _scandir(directory) as entries:
+ candidates = sorted(
+ (entry for entry in entries if fnmatch.fnmatch(entry.name, f"*{self._extension}")),
+ key=lambda entry: entry.name,
+ )
documents: dict[str, bytes] = {}
- for path in sorted(directory.glob(f"*{self._extension}")):
- if not self._is_addressable_name(path.stem):
+ unreadable: list[UnreadableDocument] = []
+ ignored: list[IgnoredDocument] = []
+ for entry in candidates:
+ name = Path(entry.name).stem
+ problem = self._name_problem(name)
+ if problem is not None:
+ ignored.append(IgnoredDocument(file_name=entry.name, message=problem))
continue
try:
- documents[path.stem] = path.read_bytes()
- except OSError as error:
- logger.warning(f"Skipping unreadable document '{path.name}' in {self.display_source}: {error}")
- return documents
+ content = self._read_local_document(path=entry.path, name=name, file_name=entry.name)
+ except DocumentUnreadableError as error:
+ unreadable.append(
+ UnreadableDocument(name=name, file_name=entry.name, message=error.reason, error=error.__cause__)
+ )
+ continue
+ if content is not None:
+ documents[name] = content
+ return DocumentListing(
+ documents=MappingProxyType(documents), unreadable=tuple(unreadable), ignored=tuple(ignored)
+ )
+
+ def _read_local_document(self, *, path: str, name: str, file_name: str) -> bytes | None:
+ """
+ Read one local document, the same way for a listing and for a single read.
+
+ The folder can change after it is listed, so what is opened is checked again: an entry
+ deleted in between counts as missing, whether or not it was a link, and anything that isn't
+ a regular file is reported without being read.
+
+ Returns:
+ bytes | None: The content, or None if nothing exists at the path.
+
+ Raises:
+ DocumentUnreadableError: If something exists at the path but can't be read as a regular file.
+ """
+ try:
+ if stat.S_ISREG(_stat(path).st_mode):
+ content = _read_regular_file(path)
+ if content is not None:
+ return content
+ except FileNotFoundError:
+ pass
+ except OSError as error:
+ raise self._unreadable(name=name, file_name=file_name, reason=describe_storage_error(error)) from error
+ try:
+ _lstat(path)
+ except FileNotFoundError:
+ return None
+ except OSError as error:
+ raise self._unreadable(name=name, file_name=file_name, reason=describe_storage_error(error)) from error
+ # A broken link, a folder, or a pipe, which would block if read.
+ raise self._unreadable(name=name, file_name=file_name, reason="Not a regular file")
+
+ def _list_blob_documents_with_issues(self) -> DocumentListing:
+ """
+ Read the blob container, reporting unreadable and badly named documents.
- def _is_addressable_name(self, name: str) -> bool:
+ Returns:
+ DocumentListing: The documents and issues found under the configured prefix.
+
+ Raises:
+ DocumentStorageUnavailableError: If a blob error affects the whole container.
"""
- Return whether a discovered document name is one this storage can address.
+ from azure.core.exceptions import AzureError
- Ordinary files such as ``__init__.py`` or ``My-Script.py`` can sit alongside
- valid documents, and a caller that fed such a name back into a read, write, or
- delete would get a ``ValueError`` it has no way to anticipate.
+ documents: dict[str, bytes] = {}
+ unreadable: list[UnreadableDocument] = []
+ ignored: list[IgnoredDocument] = []
+ with self._open_container_client() as client:
+ prefix = f"{self._blob_prefix}/" if self._blob_prefix else None
+ blobs = client.list_blobs(name_starts_with=prefix) if prefix else client.list_blobs()
+ blob_names = sorted(
+ blob.name for blob in blobs if self._is_direct_document_blob(blob_name=blob.name, prefix=prefix)
+ )
+ for blob_name in blob_names:
+ file_name = blob_name.removeprefix(prefix or "")
+ name = PurePosixPath(file_name).stem
+ problem = self._name_problem(name)
+ if problem is not None:
+ ignored.append(IgnoredDocument(file_name=file_name, message=problem))
+ continue
+ try:
+ documents[name] = client.download_blob(blob_name).readall()
+ except AzureError as error:
+ if _is_missing_blob(error):
+ # Deleted after the container was listed, so it is no longer part of the listing.
+ continue
+ if _affects_whole_store(error):
+ raise self._unavailable(error) from error
+ unreadable.append(
+ UnreadableDocument(
+ name=name, file_name=file_name, message=describe_storage_error(error), error=error
+ )
+ )
+ return DocumentListing(
+ documents=MappingProxyType(documents), unreadable=tuple(unreadable), ignored=tuple(ignored)
+ )
+
+ @staticmethod
+ def _name_problem(name: str) -> str | None:
+ """
+ Return why a discovered document name can't be addressed, or None if it can.
Returns:
- bool: Whether the name is a legal registry name.
+ str | None: The naming-rule error, or ``None`` for a legal name.
"""
try:
validate_registry_name(name)
except ValueError as error:
- logger.warning(f"Ignoring stored document '{name}{self._extension}' in {self.display_source}: {error}")
- return False
- return True
+ return str(error)
+ return None
def _read_document_bytes(self, name: str) -> bytes | None:
"""
- Read the raw bytes of one document.
+ Read the raw bytes of one document, agreeing with what a listing reports.
Returns:
bytes | None: Document content, or ``None`` if it does not exist.
Raises:
ValueError: If *name* is not a legal registry name.
+ DocumentUnreadableError: If the document exists but can't be read, such as a folder or a file
+ without read permission.
+ DocumentStorageUnavailableError: If the folder or container can't be used.
"""
validate_registry_name(name)
- if self._is_blob:
- from azure.core.exceptions import ResourceNotFoundError
+ file_name = f"{name}{self._extension}"
+ with self._unavailable_on_storage_error():
+ if self._is_blob:
+ return self._read_blob_document(name=name, file_name=file_name)
+ path = self._local_directory() / file_name
+ return self._read_local_document(path=str(path), name=name, file_name=file_name)
- with self._open_container_client() as client:
- try:
- return client.download_blob(self._get_blob_name(name)).readall()
- except ResourceNotFoundError:
+ def _read_blob_document(self, *, name: str, file_name: str) -> bytes | None:
+ """
+ Read one blob, treating only a missing blob as missing.
+
+ Returns:
+ bytes | None: The content, or None if the blob doesn't exist.
+
+ Raises:
+ DocumentUnreadableError: If the blob exists but can't be read.
+ DocumentStorageUnavailableError: If the error affects the whole container.
+ """
+ from azure.core.exceptions import AzureError
+
+ with self._open_container_client() as client:
+ try:
+ return client.download_blob(self._get_blob_name(name)).readall()
+ except AzureError as error:
+ if _is_missing_blob(error):
return None
+ if _affects_whole_store(error):
+ raise self._unavailable(error) from error
+ raise self._unreadable(name=name, file_name=file_name, reason=describe_storage_error(error)) from error
+
+ def _unreadable(self, *, name: str, file_name: str, reason: str) -> DocumentUnreadableError:
+ """
+ Build the error for one document that exists but can't be read.
+
+ Returns:
+ DocumentUnreadableError: The error naming the document and the storage location.
+ """
+ return DocumentUnreadableError(
+ label=self._source_label, source=self.display_source, name=name, file_name=file_name, reason=reason
+ )
+
+ @contextmanager
+ def _unavailable_on_storage_error(self) -> Generator[None, None, None]:
+ """
+ Report a failure of the folder or container itself as ``DocumentStorageUnavailableError``.
+
+ Yields:
+ None: Control while storage is used.
+
+ Raises:
+ DocumentStorageUnavailableError: If an ``OSError`` or Azure error escapes the block.
+ """
+ from azure.core.exceptions import AzureError
+
+ try:
+ yield
+ except DocumentStorageUnavailableError:
+ raise
+ except (OSError, AzureError) as error:
+ raise self._unavailable(error) from error
- path = self._local_directory() / f"{name}{self._extension}"
- return path.read_bytes() if path.is_file() else None
+ def _unavailable(self, error: Exception) -> DocumentStorageUnavailableError:
+ """
+ Build the error for a folder or container that can't be used.
+
+ Returns:
+ DocumentStorageUnavailableError: The error naming the storage location and a short reason.
+ """
+ return DocumentStorageUnavailableError(
+ label=self._source_label, source=self.display_source, reason=describe_storage_error(error)
+ )
def _save_document(self, *, name: str, content: bytes) -> None:
"""
@@ -264,14 +632,16 @@ def _save_document(self, *, name: str, content: bytes) -> None:
Raises:
ValueError: If *name* is not a legal registry name.
+ DocumentStorageUnavailableError: If the folder or container can't be written.
"""
validate_registry_name(name)
- if self._is_blob:
- with self._open_container_client() as client:
- client.upload_blob(name=self._get_blob_name(name), data=content, overwrite=True)
- else:
- directory = self._local_directory(create=True)
- self._replace_file(path=directory / f"{name}{self._extension}", content=content)
+ with self._unavailable_on_storage_error():
+ if self._is_blob:
+ with self._open_container_client() as client:
+ client.upload_blob(name=self._get_blob_name(name), data=content, overwrite=True)
+ else:
+ directory = self._local_directory(create=True)
+ self._replace_file(path=directory / f"{name}{self._extension}", content=content)
@staticmethod
def _compute_version(content: bytes) -> str:
@@ -321,12 +691,14 @@ def _save_document_conditional(self, *, name: str, content: bytes, expected_vers
Raises:
DocumentConflictError: If the stored version does not match *expected_version*.
ValueError: If *name* is not a legal registry name.
- TimeoutError: If a local write could not acquire the document lock.
+ DocumentStorageUnavailableError: If the folder or container can't be written, or a local write
+ could not acquire the document lock.
"""
validate_registry_name(name)
- if self._is_blob:
- return self._save_blob_conditional(name=name, content=content, expected_version=expected_version)
- return self._save_local_conditional(name=name, content=content, expected_version=expected_version)
+ with self._unavailable_on_storage_error():
+ if self._is_blob:
+ return self._save_blob_conditional(name=name, content=content, expected_version=expected_version)
+ return self._save_local_conditional(name=name, content=content, expected_version=expected_version)
def _save_local_conditional(self, *, name: str, content: bytes, expected_version: str | None) -> str:
"""
@@ -337,11 +709,12 @@ def _save_local_conditional(self, *, name: str, content: bytes, expected_version
Raises:
DocumentConflictError: If the stored version does not match *expected_version*.
+ DocumentUnreadableError: If something that can't be read is stored under the name.
TimeoutError: If the document lock could not be acquired.
"""
path = self._local_directory(create=True) / f"{name}{self._extension}"
with self._local_document_lock(path):
- stored = path.read_bytes() if path.is_file() else None
+ stored = self._read_local_document(path=str(path), name=name, file_name=path.name)
actual_version = None if stored is None else self._compute_version(stored)
if actual_version != expected_version:
raise self._conflict_error(name=name, expected_version=expected_version, actual_version=actual_version)
@@ -360,7 +733,7 @@ def _save_blob_conditional(self, *, name: str, content: bytes, expected_version:
Raises:
DocumentConflictError: If the stored version does not match *expected_version*.
- AzureError: If the container is unreachable or does not exist.
+ DocumentStorageUnavailableError: If the container is missing rather than the blob.
"""
from azure.core import MatchConditions
from azure.core.exceptions import ResourceExistsError, ResourceModifiedError, ResourceNotFoundError
@@ -388,7 +761,9 @@ def _save_blob_conditional(self, *, name: str, content: bytes, expected_version:
etag=etag,
match_condition=MatchConditions.IfNotModified,
)
- except (ResourceModifiedError, ResourceNotFoundError):
+ except (ResourceModifiedError, ResourceNotFoundError) as error:
+ if isinstance(error, ResourceNotFoundError) and not _is_missing_blob(error):
+ raise self._unavailable(error) from error
raise self._conflict_error(
name=name,
expected_version=expected_version,
@@ -410,12 +785,15 @@ def _read_unmodified_blob_etag(
Raises:
DocumentConflictError: If the blob is absent or no longer holds that version.
+ DocumentStorageUnavailableError: If the container is missing rather than the blob.
"""
from azure.core.exceptions import ResourceNotFoundError
try:
downloader = client.download_blob(blob_name)
- except ResourceNotFoundError:
+ except ResourceNotFoundError as error:
+ if not _is_missing_blob(error):
+ raise self._unavailable(error) from error
raise self._conflict_error(name=name, expected_version=expected_version, actual_version=None) from None
actual_version = self._compute_version(downloader.readall())
if actual_version != expected_version:
@@ -428,12 +806,17 @@ def _read_blob_version(self, *, client: ContainerClient, blob_name: str) -> str
Returns:
str | None: The version token, or ``None`` if the blob does not exist.
+
+ Raises:
+ DocumentStorageUnavailableError: If the container is missing rather than the blob.
"""
from azure.core.exceptions import ResourceNotFoundError
try:
return self._compute_version(client.download_blob(blob_name).readall())
- except ResourceNotFoundError:
+ except ResourceNotFoundError as error:
+ if not _is_missing_blob(error):
+ raise self._unavailable(error) from error
return None
@contextmanager
@@ -480,7 +863,9 @@ def _acquire_document_lock(self, *, descriptor: int, lock_path: Path) -> None:
deadline = time.monotonic() + self.LOCK_TIMEOUT_SECONDS
while not _try_acquire_exclusive_lock(descriptor):
if time.monotonic() >= deadline:
- raise TimeoutError(f"Timed out waiting to write '{lock_path.name}'; another writer still holds it.")
+ raise TimeoutError(
+ errno.ETIMEDOUT, f"Timed out waiting for another writer to finish with '{lock_path.name}'"
+ )
time.sleep(self.LOCK_POLL_SECONDS)
@staticmethod
@@ -529,27 +914,31 @@ def _delete_document(self, name: str) -> bool:
Raises:
ValueError: If *name* is not a legal registry name.
- TimeoutError: If a local delete could not acquire the document lock.
+ DocumentStorageUnavailableError: If the folder or container can't be written, or a local delete
+ could not acquire the document lock.
"""
validate_registry_name(name)
- if self._is_blob:
- from azure.core.exceptions import ResourceNotFoundError
-
- with self._open_container_client() as client:
+ with self._unavailable_on_storage_error():
+ if self._is_blob:
+ from azure.core.exceptions import ResourceNotFoundError
+
+ with self._open_container_client() as client:
+ try:
+ client.delete_blob(self._get_blob_name(name))
+ except ResourceNotFoundError as error:
+ if not _is_missing_blob(error):
+ raise self._unavailable(error) from error
+ return False
+ return True
+
+ path = self._local_directory(create=True) / f"{name}{self._extension}"
+ with self._local_document_lock(path):
try:
- client.delete_blob(self._get_blob_name(name))
- except ResourceNotFoundError:
+ path.unlink()
+ except FileNotFoundError:
return False
return True
- path = self._local_directory(create=True) / f"{name}{self._extension}"
- with self._local_document_lock(path):
- try:
- path.unlink()
- except FileNotFoundError:
- return False
- return True
-
def _local_directory(self, *, create: bool = False) -> Path:
"""
Resolve the configured local directory.
@@ -562,32 +951,6 @@ def _local_directory(self, *, create: bool = False) -> Path:
directory.mkdir(parents=True, exist_ok=True)
return directory
- def _list_blob_documents(self) -> dict[str, bytes]:
- """
- Read addressable documents from the configured Azure Blob container.
-
- Returns:
- dict[str, bytes]: Document content keyed by blob stem.
- """
- from azure.core.exceptions import AzureError
-
- documents: dict[str, bytes] = {}
- with self._open_container_client() as client:
- prefix = f"{self._blob_prefix}/" if self._blob_prefix else None
- blobs = client.list_blobs(name_starts_with=prefix) if prefix else client.list_blobs()
- blob_names = sorted(
- blob.name for blob in blobs if self._is_direct_document_blob(blob_name=blob.name, prefix=prefix)
- )
- for blob_name in blob_names:
- name = PurePosixPath(blob_name.removeprefix(prefix or "")).stem
- if not self._is_addressable_name(name):
- continue
- try:
- documents[name] = client.download_blob(blob_name).readall()
- except AzureError as error:
- logger.warning(f"Skipping unreadable document '{blob_name}' in {self.display_source}: {error}")
- return documents
-
def _parse_blob_source(self) -> tuple[str, str]:
"""
Split the configured source into a container URL and blob prefix.
diff --git a/pyrit/registry/instance_registry.py b/pyrit/registry/instance_registry.py
index 62f0220a2e..c836a6b9a6 100644
--- a/pyrit/registry/instance_registry.py
+++ b/pyrit/registry/instance_registry.py
@@ -30,6 +30,7 @@
from collections.abc import Callable, Iterator
from pyrit.models import ComponentIdentifier
+ from pyrit.models.catalog import RestoreFailureReason
from pyrit.registry.tag_query import TagQuery
@@ -44,6 +45,63 @@ def get_identifier(self) -> ComponentIdentifier:
T = TypeVar("T", bound=SupportsIdentifier) # The type of items stored
+@dataclass(frozen=True)
+class UnavailableEntry:
+ """
+ A saved entry that couldn't be restored, so its name has no usable instance.
+
+ Attributes:
+ kind (str): What the entry is, such as "target".
+ name (str | None): The entry's name, or None when the whole folder of saved entries of this kind
+ couldn't be listed.
+ reason (RestoreFailureReason): Why it couldn't be restored.
+ message (str): The full explanation, for administrators.
+ """
+
+ kind: str
+ name: str | None
+ reason: RestoreFailureReason
+ message: str
+
+
+class SavedEntryUnavailableError(Exception):
+ """
+ A name belongs to a saved entry that couldn't be restored, so it can't be used or taken.
+
+ Looking the name up raises this instead of finding nothing, so nothing else stands in for the entry.
+ """
+
+ def __init__(self, *, entry: UnavailableEntry, name: str) -> None:
+ """
+ Initialize the error for one name.
+
+ Args:
+ entry (UnavailableEntry): The saved entry, or the folder, that couldn't be restored.
+ name (str): The name that was looked up or registered.
+ """
+ self.entry = entry
+ self.name = name
+ super().__init__(self.describe(with_details=True))
+
+ def describe(self, *, with_details: bool) -> str:
+ """
+ Describe the error, with the full message or with a fixed explanation of the reason.
+
+ Args:
+ with_details (bool): Whether to include the full message, which can quote a server setting.
+
+ Returns:
+ str: The description.
+ """
+ if self.entry.name is None:
+ subject = f"'{self.name}' may be a saved {self.entry.kind} that couldn't be restored"
+ else:
+ subject = f"The saved {self.entry.kind} '{self.name}' couldn't be restored"
+ if with_details:
+ return f"{subject}: {self.entry.message}"
+ return f"{subject}. {self.entry.reason.explanation}"
+
+
@dataclass
class RegistryEntry(Generic[T]):
"""
@@ -99,7 +157,7 @@ def register(
...
def get(self, name: str) -> T | None:
- """Return the instance registered under ``name``, or None."""
+ """Return the instance registered under ``name``, or None; raise if a saved entry holds the name."""
...
def validate_name_available(self, name: str) -> None:
@@ -144,11 +202,23 @@ def list_metadata(
...
def get_names(self) -> list[str]:
- """Return the sorted names of registered instances."""
+ """Return the sorted names of registered instances, without names held for saved entries."""
+ ...
+
+ def mark_unavailable(self, entry: UnavailableEntry) -> None:
+ """Hold a name, or every unregistered name, for a saved entry that couldn't be restored."""
+ ...
+
+ def clear_unavailable(self, name: str) -> None:
+ """Release the hold on a saved entry's name."""
+ ...
+
+ def get_unavailable_entries(self) -> list[UnavailableEntry]:
+ """Return the held saved entries, sorted by name."""
...
def __contains__(self, name: str) -> bool:
- """Check whether an instance name is registered."""
+ """Check whether an instance name is registered or held for a saved entry."""
...
def __len__(self) -> int:
@@ -221,6 +291,8 @@ def __init__(
self._instance_version = 0
self._instance_type: type[T] | Callable[[], type[T]] | None = instance_type
self._reserved_names = frozenset(reserved_names or ())
+ self._unavailable: dict[str, UnavailableEntry] = {}
+ self._unavailable_unregistered: UnavailableEntry | None = None
@property
def revision(self) -> int:
@@ -307,6 +379,7 @@ def register(
with self._lock:
if name in self._reserved_names:
raise ValueError(f"Instance name '{name}' is reserved")
+ self._raise_if_unavailable(name)
if not replace:
self.validate_name_available(name)
self._registry_items[name] = entry
@@ -322,6 +395,9 @@ def get(self, name: str) -> T | None:
Returns:
T | None: The instance, or None if not found.
+
+ Raises:
+ SavedEntryUnavailableError: If the name belongs to a saved entry that couldn't be restored.
"""
entry = self.get_entry(name)
return entry.instance if entry is not None else None
@@ -335,10 +411,12 @@ def validate_name_available(self, name: str) -> None:
Raises:
ValueError: If the name is reserved or already registered.
+ SavedEntryUnavailableError: If the name belongs to a saved entry that couldn't be restored.
"""
with self._lock:
if name in self._reserved_names:
raise ValueError(f"Instance name '{name}' is reserved")
+ self._raise_if_unavailable(name)
if name in self._registry_items:
raise ValueError(f"Instance '{name}' already exists")
@@ -373,29 +451,92 @@ def get_entry(self, name: str) -> RegistryEntry[T] | None:
Returns:
RegistryEntry[T] | None: The entry, or None if not found.
+
+ Raises:
+ SavedEntryUnavailableError: If the name belongs to a saved entry that couldn't be restored.
"""
with self._lock:
+ self._raise_if_unavailable(name)
return self._registry_items.get(name)
def get_all_instances(self) -> list[RegistryEntry[T]]:
"""
- Get all registered entries sorted by name.
+ Get all registered entries sorted by name, leaving out names of saved entries that couldn't be restored.
Returns:
list[RegistryEntry[T]]: The entries sorted by name.
"""
with self._lock:
- return [self._registry_items[name] for name in sorted(self._registry_items)]
+ return [
+ self._registry_items[name] for name in sorted(self._registry_items) if name not in self._unavailable
+ ]
def get_names(self) -> list[str]:
"""
- Get a sorted list of all registered instance names.
+ Get a sorted list of the registered instance names, leaving out names held for saved entries.
Returns:
list[str]: The instance names sorted alphabetically.
"""
with self._lock:
- return sorted(self._registry_items)
+ return [name for name in sorted(self._registry_items) if name not in self._unavailable]
+
+ def mark_unavailable(self, entry: UnavailableEntry) -> None:
+ """
+ Hold a name for a saved entry that couldn't be restored, so using or taking it fails with the reason.
+
+ An instance registered under the same name, such as one from a startup script, is hidden until the
+ hold is cleared. An entry without a name holds every name that isn't registered, because the
+ saved entries of this kind couldn't be listed.
+
+ Args:
+ entry (UnavailableEntry): The saved entry, or the folder, that couldn't be restored.
+ """
+ with self._lock:
+ if entry.name is None:
+ self._unavailable_unregistered = entry
+ else:
+ self._unavailable[entry.name] = entry
+ self._metadata_cache = None
+ self._instance_version += 1
+
+ def clear_unavailable(self, name: str) -> None:
+ """
+ Release the hold on a name, once its saved entry was fixed or deleted.
+
+ Args:
+ name (str): The entry's name.
+ """
+ with self._lock:
+ if self._unavailable.pop(name, None) is not None:
+ self._metadata_cache = None
+ self._instance_version += 1
+
+ def get_unavailable_entries(self) -> list[UnavailableEntry]:
+ """
+ List the held names of saved entries that couldn't be restored, and a held folder last.
+
+ Returns:
+ list[UnavailableEntry]: The held entries, sorted by name.
+ """
+ with self._lock:
+ entries = [self._unavailable[name] for name in sorted(self._unavailable)]
+ if self._unavailable_unregistered is not None:
+ entries.append(self._unavailable_unregistered)
+ return entries
+
+ def _raise_if_unavailable(self, name: str) -> None:
+ """
+ Refuse a name held for a saved entry that couldn't be restored.
+
+ Raises:
+ SavedEntryUnavailableError: If the name is held.
+ """
+ entry = self._unavailable.get(name)
+ if entry is None and name not in self._registry_items:
+ entry = self._unavailable_unregistered
+ if entry is not None:
+ raise SavedEntryUnavailableError(entry=entry, name=name)
def get_by_tag(self, *, tag: str, value: str | None = None) -> list[RegistryEntry[T]]:
"""
@@ -549,23 +690,22 @@ def _build_metadata(self, instance: T) -> ComponentIdentifier:
def __contains__(self, name: str) -> bool:
"""
- Check if an instance name is registered.
+ Check if an instance name is registered or held for a saved entry that couldn't be restored.
Returns:
- bool: True if the instance name is registered, False otherwise.
+ bool: True if the instance name is registered or held, False otherwise.
"""
with self._lock:
- return name in self._registry_items
+ return name in self._registry_items or name in self._unavailable
def __len__(self) -> int:
"""
- Get the count of registered instances.
+ Get the count of registered instances that can be used.
Returns:
int: The number of registered instances.
"""
- with self._lock:
- return len(self._registry_items)
+ return len(self.get_names())
def __iter__(self) -> Iterator[str]:
"""
diff --git a/pyrit/registry/registry_recipe_restore.py b/pyrit/registry/registry_recipe_restore.py
new file mode 100644
index 0000000000..ac98a11290
--- /dev/null
+++ b/pyrit/registry/registry_recipe_restore.py
@@ -0,0 +1,720 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Rebuild saved registry entries in dependency order and report the ones that can't come back."""
+
+from __future__ import annotations
+
+import logging
+import traceback
+from dataclasses import dataclass, field
+from types import MappingProxyType
+from typing import TYPE_CHECKING, Any, ClassVar
+
+from pyrit.common.azure_storage import redact_urls
+from pyrit.models import ComponentType
+from pyrit.models.catalog import (
+ ComponentRecipe,
+ RegistryRecipeKind,
+ RegistryRestoreReport,
+ RestoreFailure,
+ RestoreFailureReason,
+ SavedEntryRef,
+ StoredRecipe,
+ TechniqueRecipe,
+)
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError
+from pyrit.registry.instance_registry import UnavailableEntry
+from pyrit.registry.registry_recipe_storage import (
+ MAX_FAILURE_MESSAGE_LENGTH,
+ RecipeListing,
+ RegistryRecipeStorage,
+ shorten_text,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping, Sequence
+
+ from pyrit.models import Parameter
+ from pyrit.registry.instance_registry import InstanceRegistry
+ from pyrit.registry.registry import InstanceHoldingRegistry, Registry
+ from pyrit.registry.registry_metadata import RegistryMetadata
+
+logger = logging.getLogger(__name__)
+
+# Identifies a failure for the log: one saved entry, or a whole folder when the name is None.
+_FailureKey = tuple[RegistryRecipeKind, str | None]
+
+
+@dataclass(frozen=True)
+class SavedRecipes:
+ """What reading the saved recipes found: each folder that was read, and a failure for each one that wasn't."""
+
+ listings: tuple[RecipeListing, ...]
+ failures: tuple[RestoreFailure, ...]
+ # The storage error behind each folder that couldn't be listed, for the log only.
+ causes: Mapping[RegistryRecipeKind, BaseException] = field(
+ default_factory=lambda: MappingProxyType({}), compare=False, repr=False
+ )
+
+
+@dataclass(frozen=True)
+class _PlannedEntry:
+ """A saved entry whose class and references checked out, waiting to be built."""
+
+ stored: StoredRecipe
+ dependencies: tuple[SavedEntryRef, ...]
+
+
+class RegistryRecipeRestorer:
+ """
+ Rebuild saved targets, converters, scorers, and techniques into their registries.
+
+ Each entry is built after the saved entries it names, through the registry calls the API's create
+ makes: parameters are checked as external input, and a technique is registered the same way. A file
+ never chooses what gets imported: classes and attack types are looked up among the ones already
+ registered. Server paths are refused as they are for API input, so a class that reads a file a
+ caller uploads can't be restored, and a recipe never holds a value for a parameter its class
+ declares as a credential. Entries that can't come back are reported with the reason instead of
+ being skipped.
+
+ The API's create also runs steps around those calls that restore skips. A recipe already holds
+ constructor parameters, so the API's auth-mode translation doesn't apply. The admin-only checks, on
+ code a model repository runs and on server credentials, ran when an admin saved the entry, and
+ restore runs as the server. A target missing its GitHub Copilot token fails with the target's own
+ error. Until the API and restore share one create function, a check added there must be added here.
+
+ Stored files are otherwise trusted the way configuration is. Rules for credentials a class
+ reads from server settings, and for fields that can carry a credential without being declared
+ one, need each class to declare them, so they arrive with saving and apply to restore too.
+ """
+
+ _RESERVED_TECHNIQUE_NAMES: ClassVar[frozenset[str]] = frozenset({"all", "default", "types"})
+ _KIND_BY_COMPONENT_TYPE: ClassVar[dict[ComponentType, RegistryRecipeKind]] = {
+ ComponentType.TARGET: RegistryRecipeKind.TARGETS,
+ ComponentType.CONVERTER: RegistryRecipeKind.CONVERTERS,
+ ComponentType.SCORER: RegistryRecipeKind.SCORERS,
+ }
+ _KIND_ORDER: ClassVar[tuple[RegistryRecipeKind, ...]] = tuple(RegistryRecipeKind)
+
+ def __init__(self, *, source: str | None = None) -> None:
+ """
+ Initialize the restorer.
+
+ Args:
+ source (str | None): Local directory or Azure Blob URI holding the per-registry folders.
+ Defaults to ``registry_recipes`` under the PyRIT configuration directory.
+ """
+ from pyrit.registry.components.converter_registry import ConverterRegistry
+ from pyrit.registry.components.scorer_registry import ScorerRegistry
+ from pyrit.registry.components.target_registry import TargetRegistry
+
+ self._storages = {kind: RegistryRecipeStorage(kind=kind, source=source) for kind in RegistryRecipeKind}
+ self._registry_types: dict[RegistryRecipeKind, Any] = {
+ RegistryRecipeKind.TARGETS: TargetRegistry,
+ RegistryRecipeKind.CONVERTERS: ConverterRegistry,
+ RegistryRecipeKind.SCORERS: ScorerRegistry,
+ }
+
+ def read(self) -> SavedRecipes:
+ """
+ Read every registry's folder without building anything.
+
+ Reading can wait on storage, so a caller on an event loop can run this in a worker thread and
+ pass the result to ``restore`` on the loop. A folder that can't be listed becomes a failure; any
+ other error is a bug rather than a problem with the files, so it isn't caught.
+
+ Returns:
+ SavedRecipes: The folders that were read, and a failure for each one that wasn't.
+ """
+ listings: list[RecipeListing] = []
+ failures: list[RestoreFailure] = []
+ causes: dict[RegistryRecipeKind, BaseException] = {}
+ for kind, storage in self._storages.items():
+ try:
+ listings.append(storage.list_recipes())
+ except DocumentStorageUnavailableError as error:
+ message = f"The saved {kind.value} couldn't be listed: {error.reason}"
+ failures.append(
+ RestoreFailure(kind=kind, reason=RestoreFailureReason.UNREADABLE_STORAGE, message=message)
+ )
+ causes[kind] = error
+ return SavedRecipes(listings=tuple(listings), failures=tuple(failures), causes=MappingProxyType(causes))
+
+ def restore(self, *, saved: SavedRecipes | None = None) -> RegistryRestoreReport:
+ """
+ Rebuild every saved entry that can be rebuilt and register it.
+
+ Entries are built on the calling thread, as the target API builds them, because a constructor
+ can need the running event loop. Call it when the registries hold only startup entries, as they
+ do at startup and after an apply resets them; an entry restored earlier would otherwise clash
+ with itself by name. If any registry's folder can't be read, nothing is built, since entries
+ in the other folders may name entries in the unread one, and each entry left unbuilt is a failure.
+
+ Each failure is logged once, with the error behind it, if any, and URL credentials removed. Then
+ the name of each entry that failed is held in its registry, so looking it up or taking it fails
+ with the reason instead of finding nothing or something else. A folder that couldn't be listed
+ holds every name of its kind that isn't registered.
+
+ Args:
+ saved (SavedRecipes | None): What ``read`` returned. Defaults to reading now.
+
+ Returns:
+ RegistryRestoreReport: What was restored, what failed and why, and which files were ignored.
+ """
+ saved = saved if saved is not None else self.read()
+ listings, failures = list(saved.listings), list(saved.failures)
+ causes: dict[_FailureKey, BaseException] = {(kind, None): error for kind, error in saved.causes.items()}
+ for listing in listings:
+ failures.extend(listing.failures)
+ for failure in listing.failures:
+ cause = listing.causes.get(failure.name) if failure.name is not None else None
+ if cause is not None:
+ causes[(failure.kind, failure.name)] = cause
+ ignored = [file for listing in listings for file in listing.ignored]
+ if saved.failures:
+ unread = " and ".join(failure.kind.value for failure in saved.failures)
+ failures.extend(
+ self._failure(
+ stored=recipe,
+ reason=RestoreFailureReason.UNREADABLE_STORAGE,
+ message=f"Not restored, because the saved {unread} couldn't be listed.",
+ )
+ for listing in listings
+ for recipe in listing.recipes.values()
+ )
+ report = RegistryRestoreReport(failures=self._sorted(failures), ignored=ignored)
+ else:
+ stored = {recipe.ref: recipe for listing in listings for recipe in listing.recipes.values()}
+ planned = self._plan_all(stored=stored, failures=failures, causes=causes)
+ restored = self._build_in_order(planned=planned, failures=failures, causes=causes)
+ report = RegistryRestoreReport(restored=restored, failures=self._sorted(failures), ignored=ignored)
+ self._log_failures(failures=report.failures, causes=causes)
+ self._hold_failed_names(failures=report.failures)
+ return report
+
+ def _hold_failed_names(self, *, failures: Sequence[RestoreFailure]) -> None:
+ """
+ Hold the name of each entry that couldn't be restored, so using or taking it fails with the reason.
+
+ A folder that couldn't be listed holds every unregistered name of its kind, since any of them could
+ be a saved entry in it.
+ """
+ for failure in failures:
+ entry = UnavailableEntry(
+ kind=self._singular(failure.kind), name=failure.name, reason=failure.reason, message=failure.message
+ )
+ self._get_instances(failure.kind).mark_unavailable(entry)
+
+ def _get_instances(self, kind: RegistryRecipeKind) -> InstanceRegistry[Any]:
+ """
+ Get the instances of the registry that holds saved entries of a kind.
+
+ Returns:
+ InstanceRegistry[Any]: The registry's instances.
+ """
+ if kind is RegistryRecipeKind.TECHNIQUES:
+ from pyrit.registry.components.attack_technique_registry import AttackTechniqueRegistry
+
+ return AttackTechniqueRegistry.get_registry_singleton().instances
+ return self._get_registry(kind).instances
+
+ def _log_failures(self, *, failures: Sequence[RestoreFailure], causes: Mapping[_FailureKey, BaseException]) -> None:
+ """Log each failure once, with the error behind it, if any, and URL credentials removed."""
+ for failure in failures:
+ subject = (
+ f"the saved {failure.kind.value}"
+ if failure.name is None
+ else self._describe_ref(SavedEntryRef(kind=failure.kind, name=failure.name))
+ )
+ summary = f"Couldn't restore {subject} ({failure.reason.value}): {failure.message}"
+ cause = causes.get((failure.kind, failure.name))
+ if cause is None:
+ logger.warning(summary)
+ else:
+ logger.warning(f"{summary}\n{redact_urls(''.join(traceback.format_exception(cause))).rstrip()}")
+
+ def _plan_all(
+ self,
+ *,
+ stored: Mapping[SavedEntryRef, StoredRecipe],
+ failures: list[RestoreFailure],
+ causes: dict[_FailureKey, BaseException],
+ ) -> dict[SavedEntryRef, _PlannedEntry]:
+ """
+ Check every readable recipe's class, credentials, name, and references.
+
+ A reference resolves to a saved entry first, even one that failed to load, so an entry is
+ never silently attached to a startup entry that only shares its name.
+
+ Returns:
+ dict[SavedEntryRef, _PlannedEntry]: The entries ready to build. Failures and their errors are added.
+ """
+ saved_refs = set(stored) | {
+ SavedEntryRef(kind=failure.kind, name=failure.name) for failure in failures if failure.name is not None
+ }
+ planned: dict[SavedEntryRef, _PlannedEntry] = {}
+ for ref in sorted(stored, key=self._sort_key):
+ outcome = self._plan(stored=stored[ref], saved_refs=saved_refs, causes=causes)
+ if isinstance(outcome, RestoreFailure):
+ failures.append(outcome)
+ else:
+ planned[ref] = outcome
+ return planned
+
+ def _plan(
+ self,
+ *,
+ stored: StoredRecipe,
+ saved_refs: set[SavedEntryRef],
+ causes: dict[_FailureKey, BaseException],
+ ) -> _PlannedEntry | RestoreFailure:
+ """
+ Check one recipe and collect the saved entries it depends on.
+
+ Returns:
+ _PlannedEntry | RestoreFailure: The entry ready to build, or why it can't be.
+ """
+ recipe = stored.recipe
+ try:
+ references = (
+ self._technique_references(ref=stored.ref, recipe=recipe)
+ if isinstance(recipe, TechniqueRecipe)
+ else self._component_references(ref=stored.ref, recipe=recipe)
+ )
+ except _RecipeRejectedError as rejection:
+ return self._failure(stored=stored, reason=rejection.reason, message=str(rejection))
+ except Exception as error: # A bug here must not stop the others from being checked, or blame the file.
+ causes[(stored.ref.kind, stored.ref.name)] = error
+ return self._failure(
+ stored=stored, reason=RestoreFailureReason.BUILD_ERROR, message=self._safe_message(error)
+ )
+ dependencies: list[SavedEntryRef] = []
+ for reference in references:
+ if reference in saved_refs:
+ dependencies.append(reference)
+ elif reference.name not in self._get_registry(reference.kind).instances:
+ message = f"Uses {self._describe_ref(reference)}, which doesn't exist."
+ return self._failure(
+ stored=stored,
+ reason=RestoreFailureReason.MISSING_DEPENDENCY,
+ message=message,
+ dependency=reference,
+ )
+ return _PlannedEntry(stored=stored, dependencies=tuple(dict.fromkeys(dependencies)))
+
+ def _component_references(self, *, ref: SavedEntryRef, recipe: ComponentRecipe) -> list[SavedEntryRef]:
+ """
+ Check a target, converter, or scorer recipe and list the entries its parameters name.
+
+ Returns:
+ list[SavedEntryRef]: The referenced entries, in parameter order.
+
+ Raises:
+ _RecipeRejectedError: If the class is unknown, is now one from outside PyRIT, or needs a
+ server-chosen upload folder, the recipe holds a credential or sets a server file or
+ folder, or the name is already taken.
+ """
+ registry = self._get_registry(ref.kind)
+ try:
+ cls = registry.get_class(recipe.class_name)
+ except KeyError as error:
+ message = f"'{recipe.class_name}' is not a registered {self._singular(ref.kind)} class."
+ raise _RecipeRejectedError(reason=RestoreFailureReason.UNKNOWN_CLASS, message=message) from error
+ # Classes are found by registered name, so one that moved within PyRIT is still the same class.
+ moved_within_pyrit = _is_pyrit_module(cls.__module__) and _is_pyrit_module(recipe.class_module)
+ if cls.__module__ != recipe.class_module and not moved_within_pyrit:
+ message = (
+ f"'{recipe.class_name}' is registered from '{cls.__module__}', "
+ f"but the saved entry was made with the one in '{recipe.class_module}'."
+ )
+ raise _RecipeRejectedError(reason=RestoreFailureReason.MODULE_MISMATCH, message=message)
+ if getattr(cls, "upload_directory_parameter", None) is not None:
+ message = (
+ f"'{recipe.class_name}' uploads files from a folder the server chooses, which restore can't set yet."
+ )
+ raise _RecipeRejectedError(reason=RestoreFailureReason.UNSUPPORTED_CLASS, message=message)
+ parameters = self._class_parameters(registry=registry, name=recipe.class_name, cls=cls)
+ self._check_stored_credentials(parameters=parameters, params=recipe.params)
+ # As in the target API, a target may let callers set some path parameters, such as a working folder.
+ allowed_paths = (
+ getattr(cls, "caller_path_parameters", frozenset())
+ if ref.kind is RegistryRecipeKind.TARGETS
+ else frozenset()
+ )
+ server_paths = sorted(
+ parameter.name
+ for parameter in parameters
+ if parameter.mentions_path
+ and parameter.name not in allowed_paths
+ and recipe.params.get(parameter.name) not in (None, "")
+ )
+ if server_paths:
+ message = (
+ f"'{recipe.class_name}' reads a server file or folder from '{server_paths[0]}', "
+ "which a saved entry can't set."
+ )
+ raise _RecipeRejectedError(reason=RestoreFailureReason.UNSUPPORTED_CLASS, message=message)
+ try:
+ registry.instances.validate_name_available(ref.name)
+ except ValueError as error:
+ raise _RecipeRejectedError(reason=RestoreFailureReason.NAME_CLASH, message=str(error)) from error
+ return self._parameter_references(parameters=parameters, params=recipe.params)
+
+ def _technique_references(self, *, ref: SavedEntryRef, recipe: TechniqueRecipe) -> list[SavedEntryRef]:
+ """
+ Check a technique recipe's name and attack type, and list the entries it names.
+
+ Returns:
+ list[SavedEntryRef]: The adversarial target, converters, and attack parameter references.
+
+ Raises:
+ _RecipeRejectedError: If the name is reserved, the attack type isn't registered, or the
+ recipe holds a credential.
+ """
+ from pyrit.registry.components.attack_registry import AttackRegistry
+
+ if ref.name.casefold() in self._RESERVED_TECHNIQUE_NAMES:
+ message = f"'{ref.name}' is a reserved technique name."
+ raise _RecipeRejectedError(reason=RestoreFailureReason.INVALID_RECIPE, message=message)
+ attack_registry = AttackRegistry.get_registry_singleton()
+ try:
+ attack_class = attack_registry.get_class(recipe.type)
+ except KeyError as error:
+ message = f"'{recipe.type}' is not a registered attack type."
+ raise _RecipeRejectedError(reason=RestoreFailureReason.UNKNOWN_CLASS, message=message) from error
+ converters = [*(recipe.request_converters or []), *(recipe.response_converters or [])]
+ references = [SavedEntryRef(kind=RegistryRecipeKind.CONVERTERS, name=name) for name in converters]
+ if recipe.adversarial_chat is not None:
+ references.append(SavedEntryRef(kind=RegistryRecipeKind.TARGETS, name=recipe.adversarial_chat))
+ parameters = self._class_parameters(registry=attack_registry, name=recipe.type, cls=attack_class)
+ self._check_stored_credentials(parameters=parameters, params=recipe.params)
+ return references + self._parameter_references(parameters=parameters, params=recipe.params)
+
+ @staticmethod
+ def _class_parameters(*, registry: Registry[Any, Any], name: str, cls: type) -> tuple[Parameter, ...]:
+ """
+ Get a registered class's constructor parameters from the registry's cached metadata.
+
+ Returns:
+ tuple[Parameter, ...]: The parameters the class declares.
+ """
+ metadata: RegistryMetadata = registry.get_registered_class_metadata(name) or registry.get_class_metadata(cls)
+ return metadata.parameters
+
+ @staticmethod
+ def _check_stored_credentials(*, parameters: Sequence[Parameter], params: Mapping[str, Any]) -> None:
+ """
+ Refuse a recipe that stores a value for a parameter its class declares as a credential.
+
+ Raises:
+ _RecipeRejectedError: If a declared credential parameter has a value.
+ """
+ stored = sorted(
+ parameter.name
+ for parameter in parameters
+ if parameter.sensitive and params.get(parameter.name) not in (None, "")
+ )
+ if stored:
+ raise _RecipeRejectedError(
+ reason=RestoreFailureReason.UNSUPPORTED_CREDENTIAL,
+ message=f"Saved entries can't hold credentials ('{stored[0]}').",
+ )
+
+ def _parameter_references(
+ self, *, parameters: Sequence[Parameter], params: Mapping[str, Any]
+ ) -> list[SavedEntryRef]:
+ """
+ List the entries that supplied reference parameters name.
+
+ An empty name leaves the reference unset, as it does when the API builds an entry.
+
+ Returns:
+ list[SavedEntryRef]: One reference per named entry of a kind that can be saved.
+ """
+ references: list[SavedEntryRef] = []
+ for parameter in parameters:
+ if parameter.reference is None or params.get(parameter.name) in (None, ""):
+ continue
+ kind = self._KIND_BY_COMPONENT_TYPE.get(parameter.reference.component_type)
+ value = params[parameter.name]
+ names = value if isinstance(value, list) else [value]
+ if kind is not None:
+ references.extend(SavedEntryRef(kind=kind, name=name) for name in names if isinstance(name, str))
+ return references
+
+ def _build_in_order(
+ self,
+ *,
+ planned: dict[SavedEntryRef, _PlannedEntry],
+ failures: list[RestoreFailure],
+ causes: dict[_FailureKey, BaseException],
+ ) -> list[SavedEntryRef]:
+ """
+ Build entries once everything they depend on has been settled.
+
+ Returns:
+ list[SavedEntryRef]: The entries that were rebuilt and registered. Failures and their errors are added.
+ """
+ failed = {SavedEntryRef(kind=failure.kind, name=failure.name) for failure in failures if failure.name}
+ restored: list[SavedEntryRef] = []
+ pending = dict(planned)
+ ready = self._ready(pending=pending, settled={*failed})
+ while ready:
+ for ref in ready:
+ entry = pending.pop(ref)
+ failure = self._failed_dependency(entry=entry, failed=failed) or self._build(
+ stored=entry.stored, causes=causes
+ )
+ if failure is None:
+ restored.append(ref)
+ else:
+ failures.append(failure)
+ failed.add(ref)
+ ready = self._ready(pending=pending, settled={*failed, *restored})
+ failures.extend(self._unresolved(pending=pending))
+ return restored
+
+ def _ready(
+ self, *, pending: Mapping[SavedEntryRef, _PlannedEntry], settled: set[SavedEntryRef]
+ ) -> list[SavedEntryRef]:
+ """
+ List the pending entries whose dependencies have all been built or have failed.
+
+ Returns:
+ list[SavedEntryRef]: The entries to handle next, in a stable order.
+ """
+ ready = [ref for ref, entry in pending.items() if settled.issuperset(entry.dependencies)]
+ return sorted(ready, key=self._sort_key)
+
+ def _failed_dependency(self, *, entry: _PlannedEntry, failed: set[SavedEntryRef]) -> RestoreFailure | None:
+ """
+ Fail an entry if a saved entry it depends on couldn't be restored.
+
+ Returns:
+ RestoreFailure | None: The failure, or None if every dependency was restored.
+ """
+ dependency = next((ref for ref in entry.dependencies if ref in failed), None)
+ if dependency is None:
+ return None
+ return self._failure(
+ stored=entry.stored,
+ reason=RestoreFailureReason.FAILED_DEPENDENCY,
+ message=f"Uses {self._describe_ref(dependency)}, which couldn't be restored.",
+ dependency=dependency,
+ )
+
+ def _unresolved(self, *, pending: Mapping[SavedEntryRef, _PlannedEntry]) -> list[RestoreFailure]:
+ """
+ Fail the entries left waiting: those on a dependency loop, and those waiting on one.
+
+ Returns:
+ list[RestoreFailure]: One failure per entry that was never built.
+ """
+ failures: list[RestoreFailure] = []
+ for ref in sorted(pending, key=self._sort_key):
+ entry = pending[ref]
+ waiting_on = [dependency for dependency in entry.dependencies if dependency in pending]
+ on_loop = [
+ dependency
+ for dependency in waiting_on
+ if dependency == ref or self._reaches(start=dependency, target=ref, pending=pending)
+ ]
+ blocker = (on_loop or waiting_on)[0]
+ if on_loop:
+ reason = RestoreFailureReason.DEPENDENCY_LOOP
+ message = f"Saved entries use each other in a loop, through {self._describe_ref(blocker)}."
+ else:
+ reason = RestoreFailureReason.FAILED_DEPENDENCY
+ message = f"Uses {self._describe_ref(blocker)}, which is waiting on a loop of saved entries."
+ failures.append(self._failure(stored=entry.stored, reason=reason, message=message, dependency=blocker))
+ return failures
+
+ @staticmethod
+ def _reaches(
+ *, start: SavedEntryRef, target: SavedEntryRef, pending: Mapping[SavedEntryRef, _PlannedEntry]
+ ) -> bool:
+ """
+ Return whether following pending dependencies from ``start`` leads to ``target``.
+
+ Returns:
+ bool: True if ``target`` can be reached.
+ """
+ stack = [dependency for dependency in pending[start].dependencies if dependency in pending]
+ seen: set[SavedEntryRef] = set()
+ while stack:
+ current = stack.pop()
+ if current == target:
+ return True
+ if current in seen:
+ continue
+ seen.add(current)
+ stack.extend(dependency for dependency in pending[current].dependencies if dependency in pending)
+ return False
+
+ def _build(self, *, stored: StoredRecipe, causes: dict[_FailureKey, BaseException]) -> RestoreFailure | None:
+ """
+ Build one entry and register it.
+
+ Returns:
+ RestoreFailure | None: Why the entry couldn't be built or registered, or None on success.
+ """
+ recipe = stored.recipe
+ try:
+ if isinstance(recipe, TechniqueRecipe):
+ self._build_technique(name=stored.ref.name, recipe=recipe)
+ else:
+ registry = self._get_registry(stored.ref.kind)
+ allowed_path_params: frozenset[str] = frozenset()
+ if stored.ref.kind is RegistryRecipeKind.TARGETS:
+ # As in the target API, a target may let callers set some path parameters, such as a working folder.
+ allowed_path_params = registry.get_class(recipe.class_name).caller_path_parameters
+ registry.create_named_instance(
+ name=stored.ref.name,
+ type_name=recipe.class_name,
+ params=recipe.params,
+ external_input=True,
+ allowed_path_params=allowed_path_params,
+ )
+ except _RecipeRejectedError as rejection:
+ return self._failure(stored=stored, reason=rejection.reason, message=str(rejection))
+ except Exception as error: # A constructor can raise anything; one bad entry must not stop the rest.
+ causes[(stored.ref.kind, stored.ref.name)] = error
+ return self._failure(
+ stored=stored, reason=RestoreFailureReason.BUILD_ERROR, message=self._safe_message(error)
+ )
+ return None
+
+ @staticmethod
+ def _build_technique(*, name: str, recipe: TechniqueRecipe) -> None:
+ """
+ Build a technique with the call the technique API uses, then register it.
+
+ Raises:
+ _RecipeRejectedError: If the name or a tag conflicts with a registered technique, or the
+ technique doesn't fit the scenarios it would join.
+ """
+ from pyrit.registry.components.attack_technique_registry import (
+ AttackTechniqueRegistry,
+ TechniqueSelectorConflictError,
+ )
+
+ registry = AttackTechniqueRegistry.get_registry_singleton()
+ args = recipe.model_dump(exclude_unset=True, exclude={"schema_version", "type", "tags"})
+ factory = registry.create_factory(
+ name=name, attack_type=recipe.type, technique_tags=recipe.tags, strict_json=True, **args
+ )
+ try:
+ registry.instances.register_runtime(factory)
+ except TechniqueSelectorConflictError as error:
+ raise _RecipeRejectedError(reason=RestoreFailureReason.NAME_CLASH, message=str(error)) from error
+ except ValueError as error:
+ raise _RecipeRejectedError(reason=RestoreFailureReason.INVALID_RECIPE, message=str(error)) from error
+
+ def _get_registry(self, kind: RegistryRecipeKind) -> InstanceHoldingRegistry[Any, Any]:
+ """
+ Get the registry singleton that holds saved targets, converters, or scorers.
+
+ Returns:
+ InstanceHoldingRegistry[Any, Any]: The registry.
+ """
+ return self._registry_types[kind].get_registry_singleton()
+
+ def _failure(
+ self,
+ *,
+ stored: StoredRecipe,
+ reason: RestoreFailureReason,
+ message: str,
+ dependency: SavedEntryRef | None = None,
+ ) -> RestoreFailure:
+ """
+ Build a failure for one saved entry.
+
+ Returns:
+ RestoreFailure: The failure, carrying the stored document's version.
+ """
+ return RestoreFailure(
+ kind=stored.ref.kind,
+ name=stored.ref.name,
+ reason=reason,
+ message=message,
+ version=stored.version,
+ dependency=dependency,
+ )
+
+ def _sort_key(self, ref: SavedEntryRef) -> tuple[int, str]:
+ """
+ Order entries by registry, then by name, so restores are repeatable.
+
+ Returns:
+ tuple[int, str]: The sort key.
+ """
+ return self._KIND_ORDER.index(ref.kind), ref.name
+
+ def _sorted(self, failures: list[RestoreFailure]) -> list[RestoreFailure]:
+ """
+ Order failures by registry, then by name, with whole-folder failures first.
+
+ Returns:
+ list[RestoreFailure]: The sorted failures.
+ """
+ return sorted(failures, key=lambda failure: (self._KIND_ORDER.index(failure.kind), failure.name or ""))
+
+ def _describe_ref(self, ref: SavedEntryRef) -> str:
+ """
+ Describe an entry for a message, such as "target 'contoso_gpt4o'".
+
+ Returns:
+ str: The description.
+ """
+ return f"{self._singular(ref.kind)} '{ref.name}'"
+
+ @staticmethod
+ def _singular(kind: RegistryRecipeKind) -> str:
+ """
+ Name one entry of a kind, such as "target".
+
+ Returns:
+ str: The singular name.
+ """
+ return kind.value.removesuffix("s")
+
+ @staticmethod
+ def _safe_message(error: Exception) -> str:
+ """
+ Describe an error for the failure report: its type and text, without URL credentials, and shortened.
+
+ Stored files are trusted the way configuration is, so the text is shown as it is, except that each
+ URL in it loses its user name and password, query, and fragment, which can hold a credential such
+ as a SAS token. The log has the whole error.
+
+ Returns:
+ str: The error's type and text, at most ``MAX_FAILURE_MESSAGE_LENGTH`` characters long.
+ """
+ name = type(error).__name__
+ try:
+ text = str(error)
+ except Exception:
+ return name
+ text = redact_urls(text).encode("utf-8", "backslashreplace").decode("utf-8")
+ return shorten_text(f"{name}: {text}" if text else name, limit=MAX_FAILURE_MESSAGE_LENGTH)
+
+
+def _is_pyrit_module(module: str) -> bool:
+ """
+ Return whether a module is part of PyRIT.
+
+ Returns:
+ bool: True for ``pyrit`` and its submodules.
+ """
+ return module == "pyrit" or module.startswith("pyrit.")
+
+
+class _RecipeRejectedError(Exception):
+ """A saved recipe failed a check before or while it was built."""
+
+ def __init__(self, *, reason: RestoreFailureReason, message: str) -> None:
+ """Initialize the error with the reason to report."""
+ super().__init__(message)
+ self.reason = reason
diff --git a/pyrit/registry/registry_recipe_storage.py b/pyrit/registry/registry_recipe_storage.py
new file mode 100644
index 0000000000..f0a222e3c5
--- /dev/null
+++ b/pyrit/registry/registry_recipe_storage.py
@@ -0,0 +1,240 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Storage for saved registry recipes, one folder per registry."""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from pathlib import Path
+from types import MappingProxyType
+from typing import TYPE_CHECKING
+from urllib.parse import urlparse
+
+from pydantic import ValidationError
+
+from pyrit.common.azure_storage import is_azure_blob_uri
+from pyrit.models.catalog import (
+ ComponentRecipe,
+ IgnoredRecipeFile,
+ RegistryRecipeKind,
+ RestoreFailure,
+ RestoreFailureReason,
+ SavedEntryRef,
+ StoredRecipe,
+ TechniqueRecipe,
+)
+from pyrit.registry.file_document_storage import FileDocumentStorage
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping
+
+MAX_FAILURE_MESSAGE_LENGTH = 1_024
+_MAX_LOCATION_PART_LENGTH = 64
+
+
+def shorten_text(text: str, *, limit: int) -> str:
+ """
+ Cut text that's longer than *limit*, marking the cut.
+
+ Returns:
+ str: The text, at most *limit* characters long.
+ """
+ return text if len(text) <= limit else f"{text[: limit - 3]}..."
+
+
+def _escape_invalid_text(value: str) -> str:
+ """
+ Escape what can't be written as UTF-8, such as the bytes of a file name that isn't UTF-8.
+
+ Returns:
+ str: The text, with each such character written as a backslash escape.
+ """
+ return value.encode("utf-8", "backslashreplace").decode("utf-8")
+
+
+@dataclass(frozen=True)
+class RecipeListing:
+ """Every saved recipe of one kind, with the ones that couldn't be read or parsed."""
+
+ recipes: Mapping[str, StoredRecipe] = field(default_factory=lambda: MappingProxyType({}))
+ failures: tuple[RestoreFailure, ...] = ()
+ ignored: tuple[IgnoredRecipeFile, ...] = ()
+ # The error behind each recipe that couldn't be read, by name, for the log only.
+ causes: Mapping[str, BaseException] = field(default_factory=lambda: MappingProxyType({}), compare=False, repr=False)
+
+
+class RegistryRecipeStorage(FileDocumentStorage):
+ """
+ Read the saved recipes of one registry as JSON documents.
+
+ Each registry has its own folder under the configured source (``targets``, ``converters``,
+ ``scorers``, ``techniques``), so a target and a converter can share a name. Storage is read
+ through on every call, like scenario presets, because the folder can be shared between
+ processes.
+ """
+
+ def __init__(self, *, kind: RegistryRecipeKind, source: str | None = None) -> None:
+ """
+ Initialize storage for one registry's folder.
+
+ Args:
+ kind (RegistryRecipeKind): The registry whose recipes this storage reads.
+ source (str | None): Local directory or Azure Blob URI that holds the per-registry
+ folders. Defaults to ``registry_recipes`` under the PyRIT configuration directory.
+
+ Raises:
+ ValueError: If the source has an unsupported URI scheme.
+ """
+ root = source or str(self._get_default_storage_dir())
+ super().__init__(
+ source=self._get_kind_source(root=root, kind=kind),
+ extension=".json",
+ source_label=f"Saved {kind.value}",
+ )
+ self._kind = kind
+
+ @staticmethod
+ def _get_default_storage_dir() -> Path:
+ """
+ Get the default directory that holds the per-registry folders.
+
+ Returns:
+ Path: Path to ``~/.pyrit/registry_recipes/``.
+ """
+ # Deferred: importing pyrit.common.path triggers pyrit __init__.py
+ from pyrit.common.path import CONFIGURATION_DIRECTORY_PATH
+
+ return CONFIGURATION_DIRECTORY_PATH / "registry_recipes"
+
+ @staticmethod
+ def _get_kind_source(*, root: str, kind: RegistryRecipeKind) -> str:
+ """
+ Append the registry's folder to the configured source.
+
+ For Azure Blob the folder goes on the URL path, so a SAS token in the query string is kept.
+
+ Returns:
+ str: The local directory or Blob URI for the registry's folder.
+ """
+ if is_azure_blob_uri(root):
+ parsed = urlparse(root)
+ return parsed._replace(path=f"{parsed.path.rstrip('/')}/{kind.value}").geturl()
+ return str(Path(root) / kind.value)
+
+ def list_recipes(self) -> RecipeListing:
+ """
+ Read every saved recipe of this kind and report the ones that can't be used.
+
+ Text from the OS, such as a file name that isn't UTF-8, is escaped here so a report can be sent as JSON.
+
+ Returns:
+ RecipeListing: Parsed recipes keyed by name, plus the failures and ignored files.
+
+ Raises:
+ DocumentStorageUnavailableError: If the folder or container can't be listed.
+ """
+ listing = self._list_documents_with_issues()
+ recipes: dict[str, StoredRecipe] = {}
+ failures = [
+ self._failure(
+ name=issue.name,
+ reason=RestoreFailureReason.UNREADABLE_RECIPE,
+ message=_escape_invalid_text(issue.message),
+ )
+ for issue in listing.unreadable
+ ]
+ causes = {issue.name: issue.error for issue in listing.unreadable if issue.error is not None}
+ for name, content in listing.documents.items():
+ parsed = self._parse_recipe(name=name, content=content)
+ if isinstance(parsed, RestoreFailure):
+ failures.append(parsed)
+ else:
+ recipes[name] = parsed
+ ignored = tuple(
+ IgnoredRecipeFile(
+ kind=self._kind,
+ file_name=_escape_invalid_text(issue.file_name),
+ message=_escape_invalid_text(issue.message),
+ )
+ for issue in listing.ignored
+ )
+ return RecipeListing(
+ recipes=MappingProxyType(recipes),
+ failures=tuple(failures),
+ ignored=ignored,
+ causes=MappingProxyType(causes),
+ )
+
+ def _parse_recipe(self, *, name: str, content: bytes) -> StoredRecipe | RestoreFailure:
+ """
+ Parse one stored document, checking its schema version before anything else.
+
+ Returns:
+ StoredRecipe | RestoreFailure: The recipe, or why it can't be used.
+ """
+ version = self._compute_version(content)
+ invalid = RestoreFailureReason.INVALID_RECIPE
+ too_deep = "Nested too deeply to read."
+ try:
+ # Recipes can be written by hand, and Windows tools often start a file with a byte-order mark.
+ payload = json.loads(content.decode("utf-8-sig"))
+ except ValueError:
+ return self._failure(name=name, version=version, reason=invalid, message="Not valid UTF-8 JSON.")
+ except RecursionError:
+ return self._failure(name=name, version=version, reason=invalid, message=too_deep)
+ if not isinstance(payload, dict):
+ return self._failure(name=name, version=version, reason=invalid, message="Not a JSON object.")
+ try:
+ json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ except UnicodeEncodeError:
+ message = "Has a \\u escape that isn't a character (an unpaired surrogate)."
+ return self._failure(name=name, version=version, reason=invalid, message=message)
+ except RecursionError:
+ return self._failure(name=name, version=version, reason=invalid, message=too_deep)
+ model = TechniqueRecipe if self._kind is RegistryRecipeKind.TECHNIQUES else ComponentRecipe
+ schema_version = payload.get("schema_version")
+ current = model.CURRENT_SCHEMA_VERSION
+ if isinstance(schema_version, int) and schema_version > current:
+ message = f"Saved by a newer PyRIT (format version {schema_version}; this one reads up to {current})."
+ return self._failure(
+ name=name, version=version, reason=RestoreFailureReason.NEWER_SCHEMA_VERSION, message=message
+ )
+ try:
+ recipe = model.model_validate(payload)
+ except ValidationError as error:
+ return self._failure(
+ name=name, version=version, reason=invalid, message=self._describe_validation_error(error)
+ )
+ except RecursionError:
+ return self._failure(name=name, version=version, reason=invalid, message=too_deep)
+ return StoredRecipe(ref=SavedEntryRef(kind=self._kind, name=name), recipe=recipe, version=version)
+
+ def _failure(
+ self, *, name: str, reason: RestoreFailureReason, message: str, version: str | None = None
+ ) -> RestoreFailure:
+ """
+ Build a failure for one entry in this registry's folder.
+
+ Returns:
+ RestoreFailure: The failure.
+ """
+ return RestoreFailure(kind=self._kind, name=name, reason=reason, message=message, version=version)
+
+ @staticmethod
+ def _describe_validation_error(error: ValidationError) -> str:
+ """
+ Summarize a validation error, naming each field and what's wrong with it, without the stored values.
+
+ A field's location can repeat a stored key, such as an unknown one, so each part of a location
+ is shortened, and so is the whole summary.
+
+ Returns:
+ str: The summary, at most ``MAX_FAILURE_MESSAGE_LENGTH`` characters long.
+ """
+ problems = []
+ for issue in error.errors():
+ location = ".".join(shorten_text(str(part), limit=_MAX_LOCATION_PART_LENGTH) for part in issue["loc"])
+ problems.append(f"{location or 'document'}: {issue['msg']}")
+ return shorten_text("Not a valid saved recipe: " + "; ".join(problems), limit=MAX_FAILURE_MESSAGE_LENGTH)
diff --git a/pyrit/setup/configuration_loader.py b/pyrit/setup/configuration_loader.py
index 417ec2ca92..d96879346d 100644
--- a/pyrit/setup/configuration_loader.py
+++ b/pyrit/setup/configuration_loader.py
@@ -115,6 +115,8 @@ class ConfigurationLoader(YamlLoadable):
followed by a blob prefix, used to persist scenario presets.
scenario_suites_source: Local directory or Azure Blob container URI, optionally
followed by a blob prefix, used to persist scenario suites.
+ registry_recipes_source: Local directory or Azure Blob container URI, optionally
+ followed by a blob prefix, used to persist saved targets, converters, scorers, and techniques.
silent: Whether to suppress initialization messages.
seed: Optional root seed for deterministic converter operations.
operator: Name for the current operator, e.g. a team or username.
@@ -170,6 +172,7 @@ class ConfigurationLoader(YamlLoadable):
custom_initializers_source: str | None = None
scenario_presets_source: str | None = None
scenario_suites_source: str | None = None
+ registry_recipes_source: str | None = None
target_upload_directory: str | None = None
server: dict[str, Any] | None = None
extensions: dict[str, Any] = field(default_factory=dict)
@@ -199,6 +202,7 @@ def __post_init__(self) -> None:
self._validate_custom_initializers_source()
self._validate_scenario_presets_source()
self._validate_scenario_suites_source()
+ self._validate_registry_recipes_source()
self._validate_target_upload_directory()
self._normalize_server()
@@ -239,6 +243,15 @@ def _validate_scenario_suites_source(self) -> None:
"""
self._validate_document_source(name="scenario_suites_source", value=self.scenario_suites_source)
+ def _validate_registry_recipes_source(self) -> None:
+ """
+ Validate the optional storage source for saved registry entries.
+
+ Raises:
+ ValueError: If the source is not a non-empty string.
+ """
+ self._validate_document_source(name="registry_recipes_source", value=self.registry_recipes_source)
+
@staticmethod
def _validate_document_source(*, name: str, value: Any) -> None:
"""
diff --git a/test_support/analytics_benchmark.py b/test_support/analytics_benchmark.py
index 55208ed889..f1086765aa 100644
--- a/test_support/analytics_benchmark.py
+++ b/test_support/analytics_benchmark.py
@@ -65,6 +65,7 @@ def benchmark_directory(tmp_path: Path) -> Iterator[Path]:
"initializers": ["technique"],
"initialization_scripts": [],
"custom_initializers_source": str(directory / "initializers"),
+ "registry_recipes_source": str(directory / "registry_recipes"),
"silent": True,
}
),
diff --git a/tests/unit/backend/conftest.py b/tests/unit/backend/conftest.py
index c015f8bf0c..e962ce7f47 100644
--- a/tests/unit/backend/conftest.py
+++ b/tests/unit/backend/conftest.py
@@ -17,6 +17,7 @@
from pyrit.backend.services.manual_send_scheduler import get_manual_send_scheduler
from pyrit.backend.services.message_send_service import get_message_send_service
from pyrit.memory import SQLiteMemory
+from pyrit.registry.registry_recipe_storage import RegistryRecipeStorage
from test_support.fixtures import authentication_policy, compatibility_headers, compatibility_id # noqa: F401
from unit.async_utils import TEST_WATCHDOG_SECONDS
@@ -56,3 +57,10 @@ def isolated_manual_message_services() -> Iterator[None]:
yield
for factory in (get_attack_service, get_message_send_service, get_manual_send_scheduler, get_dataset_service):
factory.cache_clear()
+
+
+@pytest.fixture(autouse=True)
+def isolated_saved_registry_entries(tmp_path: Path) -> Iterator[None]:
+ """Keep backend startup and apply from restoring entries saved on the machine running the tests."""
+ with patch.object(RegistryRecipeStorage, "_get_default_storage_dir", return_value=tmp_path / "registry_recipes"):
+ yield
diff --git a/tests/unit/backend/test_analytics_benchmark.py b/tests/unit/backend/test_analytics_benchmark.py
index 9e5b75096c..8dcbb30e59 100644
--- a/tests/unit/backend/test_analytics_benchmark.py
+++ b/tests/unit/backend/test_analytics_benchmark.py
@@ -4,6 +4,7 @@
"""Local SQLite smoke coverage and unit tests of the integration workload's failure gates."""
import asyncio
+import json
import os
import subprocess
import sys
@@ -60,6 +61,12 @@ def measurements() -> AnalyticsBenchmark:
return benchmark
+def test_benchmark_configuration_keeps_saved_entries_in_its_own_folder(benchmark_directory: Path) -> None:
+ config = json.loads((benchmark_directory / "config.yaml").read_text(encoding="utf-8"))
+
+ assert config["registry_recipes_source"] == str(benchmark_directory / "registry_recipes")
+
+
@pytest.mark.timeout(120)
@pytest.mark.usefixtures("semantic_analytics_budgets")
@pytest.mark.parametrize("elapsed_report_seconds", [0.0, 31.0], ids=["normal", "expired-report-deadline"])
diff --git a/tests/unit/backend/test_api_routes.py b/tests/unit/backend/test_api_routes.py
index f0487227de..bd5f4b1e06 100644
--- a/tests/unit/backend/test_api_routes.py
+++ b/tests/unit/backend/test_api_routes.py
@@ -59,7 +59,8 @@
from pyrit.memory import MemoryInterface
from pyrit.memory.memory_interface import AttackStateConflictError
from pyrit.models import AttackOutcome, ConverterIdentifier, MessagePiece, Score, TargetCapabilities, TargetIdentifier
-from pyrit.models.catalog import TargetInstance
+from pyrit.models.catalog import RestoreFailureReason, TargetInstance
+from pyrit.registry import SavedEntryUnavailableError, UnavailableEntry
def _make_message_view(*, role: str = "user", value: str = "hello", sequence: int = 1) -> MessageView:
@@ -1755,6 +1756,30 @@ def test_preview_conversion_bad_request(self, client: TestClient) -> None:
assert response.status_code == status.HTTP_400_BAD_REQUEST
+ def test_preview_conversion_with_a_held_converter_is_a_conflict(self, client: TestClient) -> None:
+ """Test that a converter whose saved entry couldn't be restored is named, not reported as a server error."""
+ entry = UnavailableEntry(
+ kind="converter", name="formal", reason=RestoreFailureReason.BUILD_ERROR, message="ValueError: boom"
+ )
+ with patch("pyrit.backend.routes.converters.get_converter_service") as mock_get_service:
+ mock_service = MagicMock()
+ mock_service.preview_conversion_async = AsyncMock(
+ side_effect=SavedEntryUnavailableError(entry=entry, name="formal")
+ )
+ mock_get_service.return_value = mock_service
+
+ response = client.post(
+ "/api/converters/preview",
+ json={
+ "original_value": "test",
+ "original_value_data_type": "text",
+ "converter_ids": ["formal"],
+ },
+ )
+
+ assert response.status_code == status.HTTP_409_CONFLICT
+ assert "'formal' couldn't be restored" in response.json()["detail"]
+
def test_preview_conversion_internal_error(self, client: TestClient) -> None:
"""Test preview conversion with internal error returns 500."""
with patch("pyrit.backend.routes.converters.get_converter_service") as mock_get_service:
diff --git a/tests/unit/backend/test_error_handlers.py b/tests/unit/backend/test_error_handlers.py
index 2992a232c6..e19c5d4009 100644
--- a/tests/unit/backend/test_error_handlers.py
+++ b/tests/unit/backend/test_error_handlers.py
@@ -5,12 +5,18 @@
Tests for backend error handler middleware.
"""
+import logging
+
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
+from pyrit.backend.authentication_policy import AuthenticationPolicy
from pyrit.backend.middleware import register_error_handlers
+from pyrit.models.catalog import RestoreFailureReason
+from pyrit.registry import SavedEntryUnavailableError, TechniqueSelectionUnavailableError, UnavailableEntry
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError, DocumentUnreadableError
class TestErrorHandlers:
@@ -125,6 +131,90 @@ async def test_endpoint() -> dict:
assert data["title"] == "Forbidden"
assert data["status"] == 403
+ def test_storage_failure_returns_503_and_names_the_location_only_in_the_log(
+ self, app: FastAPI, client: TestClient, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ """Test that the log names the storage location and the response doesn't."""
+ error = DocumentStorageUnavailableError(
+ label="Scenario preset", source="/srv/presets", reason="Permission denied"
+ )
+
+ @app.get("/test")
+ async def test_endpoint() -> dict:
+ raise error
+
+ with caplog.at_level(logging.DEBUG, logger="pyrit.backend.middleware.error_handlers"):
+ response = client.get("/test")
+
+ assert response.status_code == 503
+ assert response.json() == {
+ "type": "/errors/storage-unavailable",
+ "title": "Service Unavailable",
+ "status": 503,
+ "detail": "Scenario preset storage is unavailable",
+ "instance": "/test",
+ }
+ assert "Scenario preset storage at /srv/presets is unavailable: Permission denied (GET /test)" in (
+ caplog.messages
+ )
+ assert any(record.exc_info and record.exc_info[1] is error for record in caplog.records)
+
+ def test_unreadable_document_returns_503_with_the_reason(self, app: FastAPI, client: TestClient) -> None:
+ """Test that one document that can't be read is named with its reason."""
+
+ @app.get("/test")
+ async def test_endpoint() -> dict:
+ raise DocumentUnreadableError(
+ label="Scenario preset",
+ source="/srv/presets",
+ name="nightly",
+ file_name="nightly.json",
+ reason="Not a regular file",
+ )
+
+ response = client.get("/test")
+
+ assert response.status_code == 503
+ assert response.json()["detail"] == "Scenario preset 'nightly' can't be read: Not a regular file"
+ assert "/srv/presets" not in response.text
+
+ @pytest.mark.parametrize(
+ ("admin", "detail"),
+ [
+ (True, "The saved target 'broken' couldn't be restored: ValueError: https://host/v1"),
+ (False, "The saved target 'broken' couldn't be restored. It couldn't be built."),
+ ],
+ )
+ def test_a_held_saved_entry_returns_409_with_details_only_for_admins(
+ self, app: FastAPI, client: TestClient, admin: bool, detail: str
+ ) -> None:
+ """Test that a saved entry that couldn't be restored is a conflict naming it and the reason."""
+ app.state.authentication_policy = AuthenticationPolicy(mode="local", allow_unauthenticated_admin=admin)
+ entry = UnavailableEntry(
+ kind="target", name="broken", reason=RestoreFailureReason.BUILD_ERROR, message="ValueError: https://host/v1"
+ )
+
+ @app.get("/test")
+ async def test_endpoint() -> dict:
+ raise SavedEntryUnavailableError(entry=entry, name="broken")
+
+ response = client.get("/test")
+
+ assert response.status_code == 409
+ assert response.json()["detail"] == detail
+
+ def test_a_broad_technique_selection_returns_409(self, app: FastAPI, client: TestClient) -> None:
+ """Test that choosing techniques broadly while one is held is a conflict naming it."""
+
+ @app.get("/test")
+ async def test_endpoint() -> dict:
+ raise TechniqueSelectionUnavailableError(names=["formal"])
+
+ response = client.get("/test")
+
+ assert response.status_code == 409
+ assert "Saved technique 'formal' couldn't be restored" in response.json()["detail"]
+
def test_not_implemented_error_returns_501(self, app: FastAPI, client: TestClient) -> None:
"""Test that NotImplementedError returns 501 with RFC 7807 format."""
diff --git a/tests/unit/backend/test_initializer_service.py b/tests/unit/backend/test_initializer_service.py
index e1d06ed5bf..18c6ea3f2e 100644
--- a/tests/unit/backend/test_initializer_service.py
+++ b/tests/unit/backend/test_initializer_service.py
@@ -5,11 +5,11 @@
Tests for backend initializer service and routes.
"""
+import logging
from collections.abc import Iterator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
-from azure.core.exceptions import AzureError
from fastapi import HTTPException, status
from fastapi.testclient import TestClient
@@ -26,6 +26,7 @@
from pyrit.backend.services import InitializerService, get_initializer_service
from pyrit.models import Parameter
from pyrit.registry import InitializerMetadata
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError, DocumentUnreadableError
@pytest.fixture
@@ -470,13 +471,20 @@ def test_custom_initializer_routes_return_503_for_storage_failure(
self,
client_with_custom_initializers_enabled: TestClient,
operation: str,
+ caplog: pytest.LogCaptureFixture,
) -> None:
- """Test Blob failures are returned without exposing Azure SDK details."""
- with patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service:
+ """Test that the log names the storage location and the response doesn't."""
+ error = DocumentStorageUnavailableError(
+ label="Custom initializer", source="/srv/initializers", reason="Permission denied"
+ )
+ with (
+ patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service,
+ caplog.at_level(logging.WARNING, logger="pyrit.backend.middleware.error_handlers"),
+ ):
mock_service = MagicMock()
- mock_service.list_custom_initializers_async = AsyncMock(side_effect=AzureError("credential details"))
- mock_service.register_initializer_async = AsyncMock(side_effect=AzureError("credential details"))
- mock_service.unregister_initializer_async = AsyncMock(side_effect=AzureError("credential details"))
+ mock_service.list_custom_initializers_async = AsyncMock(side_effect=error)
+ mock_service.register_initializer_async = AsyncMock(side_effect=error)
+ mock_service.unregister_initializer_async = AsyncMock(side_effect=error)
mock_get_service.return_value = mock_service
if operation == "list":
response = client_with_custom_initializers_enabled.get("/api/initializers/custom")
@@ -489,7 +497,29 @@ def test_custom_initializer_routes_return_503_for_storage_failure(
response = client_with_custom_initializers_enabled.delete("/api/initializers/custom")
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
- assert response.json()["detail"] == "Custom initializer storage is temporarily unavailable"
+ assert response.json()["detail"] == "Custom initializer storage is unavailable"
+ assert "/srv/initializers" not in response.text
+ assert "Custom initializer storage at /srv/initializers is unavailable: Permission denied" in caplog.text
+
+ def test_deleting_a_script_that_cant_be_read_returns_503_with_the_reason(
+ self, client_with_custom_initializers_enabled: TestClient
+ ) -> None:
+ error = DocumentUnreadableError(
+ label="Custom initializer",
+ source="/srv/initializers",
+ name="custom",
+ file_name="custom.py",
+ reason="Not a regular file",
+ )
+ with patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service:
+ mock_service = MagicMock()
+ mock_service.unregister_initializer_async = AsyncMock(side_effect=error)
+ mock_get_service.return_value = mock_service
+
+ response = client_with_custom_initializers_enabled.delete("/api/initializers/custom")
+
+ assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
+ assert response.json()["detail"] == "Custom initializer 'custom' can't be read: Not a regular file"
def test_post_root_still_registers_runtime_initializer(
self, client_with_custom_initializers_enabled: TestClient
diff --git a/tests/unit/backend/test_runtime_lifecycle.py b/tests/unit/backend/test_runtime_lifecycle.py
index 9cd6c9fe4a..5557c88b6a 100644
--- a/tests/unit/backend/test_runtime_lifecycle.py
+++ b/tests/unit/backend/test_runtime_lifecycle.py
@@ -4,12 +4,14 @@
"""Admission and fail-fast runtime replacement invariants."""
import asyncio
+import json
+import logging
import os
-from collections.abc import Generator
+from collections.abc import Callable, Generator
from contextlib import ExitStack
from datetime import UTC, datetime
from pathlib import Path
-from threading import Event
+from threading import Event, Thread, current_thread
from unittest.mock import AsyncMock, MagicMock, call, patch
import httpx
@@ -19,9 +21,9 @@
import pyrit.backend.services.runtime_lifecycle as lifecycle_module
from pyrit.backend.authentication_policy import AuthenticationPolicy
-from pyrit.backend.middleware.auth import require_admin
+from pyrit.backend.middleware.auth import AuthenticatedUser, EntraAuthMiddleware, require_admin
from pyrit.backend.middleware.runtime import RuntimeAdmissionMiddleware
-from pyrit.backend.routes import configuration, health
+from pyrit.backend.routes import configuration, health, restore_failures
from pyrit.backend.services import ScenarioRunService
from pyrit.backend.services.configuration_file_service import ConfigurationFileService
from pyrit.backend.services.manual_send_scheduler import get_manual_send_scheduler
@@ -29,10 +31,31 @@
from pyrit.backend.services.scenario_run_service import _PreparedRun
from pyrit.memory import CentralMemory, MemoryInterface
from pyrit.models import ScenarioRunState
-from pyrit.models.catalog import RunScenarioRequest
+from pyrit.models.catalog import RegistryRecipeKind, RestoreFailure, RestoreFailureReason, RunScenarioRequest
+from pyrit.prompt_target import OpenAIChatTarget
+from pyrit.registry import (
+ AttackTechniqueRegistry,
+ ConverterRegistry,
+ SavedEntryUnavailableError,
+ ScorerRegistry,
+ TargetRegistry,
+)
+from pyrit.registry.registry_recipe_restore import RegistryRecipeRestorer, SavedRecipes
from pyrit.scenario import Scenario
from pyrit.setup import ConfigurationLoader
-from unit.mocks import make_scenario_result
+from unit.mocks import MockPromptTarget, make_scenario_result
+
+_RESTORED_REGISTRIES = (TargetRegistry, ConverterRegistry, ScorerRegistry, AttackTechniqueRegistry)
+
+
+@pytest.fixture(autouse=True)
+def fresh_registries() -> Generator[None, None, None]:
+ """Start each test from the registries a new process has, as a real startup or apply does."""
+ for registry in _RESTORED_REGISTRIES:
+ registry.reset_registry_singleton()
+ yield
+ for registry in _RESTORED_REGISTRIES:
+ registry.reset_registry_singleton()
@pytest.fixture
@@ -55,6 +78,7 @@ def runtime(tmp_path: Path) -> Generator[RuntimeLifecycle, None, None]:
app.add_middleware(RuntimeAdmissionMiddleware)
app.include_router(configuration.router, prefix="/api")
app.include_router(health.router, prefix="/api")
+ app.include_router(restore_failures.router, prefix="/api")
app.dependency_overrides[require_admin] = lambda: None
config = ConfigurationLoader(
memory_db_type="in_memory",
@@ -99,6 +123,7 @@ async def test_runtime_readiness_reports_state_async(*, runtime: RuntimeLifecycl
"ready": state == "ready",
"state": state if state is not None else "failed",
"generation": "original" if state is not None else "",
+ "restore_failures": 0,
}
@@ -762,7 +787,12 @@ async def shutdown_async() -> None:
assert runtime.status()["state"] == "stopping"
readiness = await client.get("/api/runtime")
assert readiness.status_code == 200
- assert readiness.json() == {"ready": False, "state": "stopping", "generation": "original"}
+ assert readiness.json() == {
+ "ready": False,
+ "state": "stopping",
+ "generation": "original",
+ "restore_failures": 0,
+ }
liveness = await client.get("/api/health")
assert liveness.status_code == 200
assert liveness.json()["status"] == "healthy"
@@ -930,7 +960,12 @@ async def shutdown_async() -> None:
assert runtime.status()["state"] == "stopping"
readiness = await client.get("/api/runtime")
assert readiness.status_code == 200
- assert readiness.json() == {"ready": False, "state": "stopping", "generation": runtime.generation}
+ assert readiness.json() == {
+ "ready": False,
+ "state": "stopping",
+ "generation": runtime.generation,
+ "restore_failures": 0,
+ }
assert (await client.get("/api/config")).status_code == 503
release_request.set()
await asyncio.wait_for(shutdown, timeout=5)
@@ -977,3 +1012,446 @@ async def close_async() -> None:
finally:
release.set()
await asyncio.gather(shutdown, return_exceptions=True)
+
+
+def _save_recipe(root: Path, *, kind: str, name: str, document: dict[str, object]) -> Path:
+ folder = root / kind
+ folder.mkdir(parents=True, exist_ok=True)
+ path = folder / f"{name}.json"
+ path.write_text(json.dumps(document), encoding="utf-8")
+ return path
+
+
+async def _start_with_recipes_async(
+ runtime: RuntimeLifecycle, *, recipes: Path, initialize: Callable[..., object] | None = None
+) -> None:
+ config = await runtime._load_async()
+ config.registry_recipes_source = str(recipes)
+ with (
+ patch.object(config, "initialize_pyrit_async", AsyncMock(side_effect=initialize)),
+ patch.object(lifecycle_module, "get_scenario_run_service", return_value=MagicMock(spec=ScenarioRunService)),
+ ):
+ await runtime.startup_async()
+
+
+@pytest.fixture
+def startup_chat() -> Generator[OpenAIChatTarget, None, None]:
+ TargetRegistry.reset_registry_singleton()
+ ConverterRegistry.reset_registry_singleton()
+ yield OpenAIChatTarget(
+ endpoint="https://startup.openai.azure.com/openai/v1", model_name="gpt-4o", auth_mode="identity"
+ )
+ TargetRegistry.reset_registry_singleton()
+ ConverterRegistry.reset_registry_singleton()
+
+
+def _save_converter_using_startup_chat(recipes: Path) -> None:
+ _save_recipe(
+ recipes,
+ kind="converters",
+ name="formal",
+ document={
+ "class_module": "pyrit.converter.tone_converter",
+ "class_name": "ToneConverter",
+ "params": {"converter_target": "startup_chat", "tone": "formal"},
+ },
+ )
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_startup_restores_an_entry_that_uses_a_startup_target_async(
+ runtime: RuntimeLifecycle, tmp_path: Path, startup_chat: OpenAIChatTarget
+) -> None:
+ recipes = tmp_path / "recipes"
+ _save_converter_using_startup_chat(recipes)
+ targets = TargetRegistry.get_registry_singleton()
+
+ await _start_with_recipes_async(
+ runtime, recipes=recipes, initialize=lambda **_: targets.instances.register(startup_chat, name="startup_chat")
+ )
+
+ assert (runtime.state, runtime.outcome) == ("ready", "success")
+ converter = ConverterRegistry.get_registry_singleton().instances.get("formal")
+ assert converter is not None
+ assert converter._converter_target is startup_chat
+
+
+class _LoopBoundTarget(MockPromptTarget):
+ """A target whose constructor needs the running event loop, as HuggingFaceChatTarget's does."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.loop = asyncio.get_running_loop()
+
+
+@pytest.mark.usefixtures("patch_central_database", "startup_chat")
+async def test_startup_builds_restored_entries_on_the_runtime_loop_async(
+ runtime: RuntimeLifecycle, tmp_path: Path
+) -> None:
+ TargetRegistry.get_registry_singleton().register_class(_LoopBoundTarget)
+ recipes = tmp_path / "recipes"
+ _save_recipe(
+ recipes,
+ kind="targets",
+ name="loop_bound",
+ document={"class_module": _LoopBoundTarget.__module__, "class_name": "_LoopBoundTarget", "params": {}},
+ )
+
+ await _start_with_recipes_async(runtime, recipes=recipes)
+
+ assert (runtime.state, runtime.outcome) == ("ready", "success")
+ target = TargetRegistry.get_registry_singleton().instances.get("loop_bound")
+ assert isinstance(target, _LoopBoundTarget)
+ assert target.loop is asyncio.get_running_loop()
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_startup_reads_saved_entries_off_the_runtime_loop_async(
+ runtime: RuntimeLifecycle, tmp_path: Path
+) -> None:
+ read = RegistryRecipeRestorer.read
+ read_threads: list[Thread] = []
+
+ def read_noting_thread(restorer: RegistryRecipeRestorer) -> SavedRecipes:
+ read_threads.append(current_thread())
+ return read(restorer)
+
+ with patch.object(RegistryRecipeRestorer, "read", read_noting_thread):
+ await _start_with_recipes_async(runtime, recipes=tmp_path / "recipes")
+
+ assert (runtime.state, runtime.outcome) == ("ready", "success")
+ assert len(read_threads) == 1
+ assert read_threads[0] is not current_thread()
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_apply_restores_an_entry_that_uses_a_startup_target_async(
+ runtime: RuntimeLifecycle, tmp_path: Path, startup_chat: OpenAIChatTarget
+) -> None:
+ recipes = tmp_path / "recipes"
+ _save_converter_using_startup_chat(recipes)
+ targets = TargetRegistry.get_registry_singleton()
+ config = await runtime._load_async()
+ config.registry_recipes_source = str(recipes)
+ config.apply_prepared_reinitialization_async.side_effect = lambda **_: targets.instances.register(
+ startup_chat, name="startup_chat"
+ )
+
+ await apply_async(runtime)
+
+ assert (runtime.state, runtime.outcome) == ("ready", "success")
+ converter = ConverterRegistry.get_registry_singleton().instances.get("formal")
+ assert converter is not None
+ assert converter._converter_target is startup_chat
+
+
+async def test_startup_with_an_unrestorable_entry_is_ready_and_holds_its_name_async(
+ runtime: RuntimeLifecycle, tmp_path: Path, caplog: pytest.LogCaptureFixture
+) -> None:
+ recipes = tmp_path / "recipes"
+ _save_recipe(recipes, kind="targets", name="broken", document={"class_module": "nowhere", "class_name": "Nope"})
+
+ with caplog.at_level(logging.WARNING, logger="pyrit.backend.services.runtime_lifecycle"):
+ await _start_with_recipes_async(runtime, recipes=recipes)
+
+ assert (runtime.state, runtime.outcome) == ("ready", "success")
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=runtime.app), base_url="http://test") as client:
+ health = await client.get("/api/health")
+ status = await client.get("/api/config/runtime")
+ assert health.json()["ready"] is True
+ assert status.json()["version"] == runtime.version
+ (failure,) = status.json()["restore_failures"]
+ assert (failure["kind"], failure["name"], failure["reason"]) == ("targets", "broken", "unknown_class")
+ assert failure["version"]
+ assert "1 saved registry entry couldn't be restored" in caplog.text
+ with pytest.raises(SavedEntryUnavailableError, match="'Nope' is not a registered target class"):
+ TargetRegistry.get_registry_singleton().instances.get("broken")
+
+
+async def test_status_reports_a_recipe_whose_text_is_not_valid_unicode_async(
+ runtime: RuntimeLifecycle, tmp_path: Path
+) -> None:
+ recipes = tmp_path / "recipes"
+ _save_recipe(
+ recipes,
+ kind="targets",
+ name="pool",
+ document={
+ "class_module": "pyrit.prompt_target.round_robin_target",
+ "class_name": "RoundRobinTarget",
+ "params": {"targets": ["missing\udcff"]},
+ },
+ )
+
+ await _start_with_recipes_async(runtime, recipes=recipes)
+
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=runtime.app), base_url="http://test") as client:
+ status = await client.get("/api/config/runtime")
+ assert status.status_code == 200
+ (failure,) = status.json()["restore_failures"]
+ assert (failure["name"], failure["reason"]) == ("pool", "invalid_recipe")
+
+
+async def test_status_lists_a_file_name_that_is_not_valid_text_async(runtime: RuntimeLifecycle, tmp_path: Path) -> None:
+ folder = tmp_path / "recipes" / "converters"
+ folder.mkdir(parents=True)
+ try:
+ (folder / os.fsdecode(b"bad\xffname.json")).write_text("{}")
+ except (OSError, UnicodeError):
+ pytest.skip("This file system only allows UTF-8 file names")
+
+ await _start_with_recipes_async(runtime, recipes=tmp_path / "recipes")
+
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=runtime.app), base_url="http://test") as client:
+ status = await client.get("/api/config/runtime")
+ assert status.status_code == 200
+ assert [item["file_name"] for item in status.json()["restore_ignored"]] == ["bad\\udcffname.json"]
+
+
+async def test_every_signed_in_user_sees_why_saved_entries_were_not_restored_async(
+ runtime: RuntimeLifecycle, tmp_path: Path
+) -> None:
+ recipes = tmp_path / "recipes"
+ _save_recipe(
+ recipes,
+ kind="targets",
+ name="pool",
+ document={
+ "class_module": "pyrit.prompt_target.round_robin_target",
+ "class_name": "RoundRobinTarget",
+ "params": {"targets": ["missing"]},
+ },
+ )
+
+ await _start_with_recipes_async(runtime, recipes=recipes)
+
+ assert runtime.state == "ready"
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=runtime.app), base_url="http://test") as client:
+ failures = await client.get("/api/restore-failures")
+ readiness = await client.get("/api/runtime")
+ health = await client.get("/api/health")
+ assert failures.status_code == 200
+ (item,) = failures.json()["items"]
+ assert (item["kind"], item["name"], item["reason"]) == ("targets", "pool", "missing_dependency")
+ assert item["dependency"] == {"kind": "targets", "name": "missing"}
+ assert item["message"] == "It uses an entry that doesn't exist."
+ assert item["version"]
+ assert readiness.json()["restore_failures"] == 1
+ assert "restore_failures" not in health.json()
+
+
+_SERVER_SECRET = "server-secret-6f1c"
+
+
+class _SecretQuotingTarget(MockPromptTarget):
+ """A target whose constructor error quotes a server setting, as some SDK errors do."""
+
+ def __init__(self) -> None:
+ raise ValueError(f"The server rejected key {_SERVER_SECRET}")
+
+
+@pytest.mark.usefixtures("patch_central_database", "startup_chat")
+async def test_only_administrators_see_the_error_a_constructor_raised_async(
+ runtime: RuntimeLifecycle, tmp_path: Path
+) -> None:
+ TargetRegistry.get_registry_singleton().register_class(_SecretQuotingTarget)
+ recipes = tmp_path / "recipes"
+ _save_recipe(
+ recipes,
+ kind="targets",
+ name="quoting",
+ document={"class_module": _SecretQuotingTarget.__module__, "class_name": "_SecretQuotingTarget", "params": {}},
+ )
+
+ await _start_with_recipes_async(runtime, recipes=recipes)
+
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=runtime.app), base_url="http://test") as client:
+ user_view = await client.get("/api/restore-failures")
+ runtime.app.state.authentication_policy = AuthenticationPolicy(mode="local", allow_unauthenticated_admin=True)
+ admin_view = await client.get("/api/restore-failures")
+ assert _SERVER_SECRET not in user_view.text
+ (item,) = user_view.json()["items"]
+ assert (item["name"], item["reason"], item["message"]) == ("quoting", "build_error", "It couldn't be built.")
+ (admin_item,) = admin_view.json()["items"]
+ assert _SERVER_SECRET in admin_item["message"]
+
+
+@pytest.mark.usefixtures("patch_central_database", "startup_chat")
+@pytest.mark.parametrize("is_admin", [False, True])
+async def test_a_signed_in_user_sees_a_constructor_error_only_as_an_administrator_async(
+ runtime: RuntimeLifecycle, tmp_path: Path, is_admin: bool
+) -> None:
+ TargetRegistry.get_registry_singleton().register_class(_SecretQuotingTarget)
+ recipes = tmp_path / "recipes"
+ _save_recipe(
+ recipes,
+ kind="targets",
+ name="quoting",
+ document={"class_module": _SecretQuotingTarget.__module__, "class_name": "_SecretQuotingTarget", "params": {}},
+ )
+ await _start_with_recipes_async(runtime, recipes=recipes)
+ runtime.app.state.authentication_policy = AuthenticationPolicy(
+ mode="entra",
+ tenant_id="test-tenant",
+ client_id="test-client",
+ allowed_group_ids=("users",),
+ admin_group_id="admins",
+ )
+ runtime.app.add_middleware(EntraAuthMiddleware)
+ user = AuthenticatedUser(oid="user-oid", name="User", email="user@contoso.com", groups=["users"], is_admin=is_admin)
+
+ with patch.object(EntraAuthMiddleware, "_authenticate_with_graph_async", AsyncMock(return_value=user)):
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=runtime.app), base_url="http://test") as client:
+ response = await client.get("/api/restore-failures", headers={"Authorization": "Bearer test-token"})
+
+ assert response.status_code == 200
+ assert (_SERVER_SECRET in response.text) is is_admin
+
+
+async def test_restore_failures_explain_every_reason_without_its_details_async(runtime: RuntimeLifecycle) -> None:
+ runtime.restore_failures = [
+ RestoreFailure(kind=RegistryRecipeKind.SCORERS, name=reason.value, reason=reason, message="detail")
+ for reason in RestoreFailureReason
+ ]
+
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=runtime.app), base_url="http://test") as client:
+ response = await client.get("/api/restore-failures")
+
+ assert response.status_code == 200
+ messages = {item["reason"]: item["message"] for item in response.json()["items"]}
+ assert set(messages) == {reason.value for reason in RestoreFailureReason}
+ assert all(message and message != "detail" for message in messages.values())
+
+
+async def test_restore_failures_need_sign_in_when_it_is_on_async(runtime: RuntimeLifecycle) -> None:
+ runtime.app.state.authentication_policy = AuthenticationPolicy(
+ mode="entra", tenant_id="test-tenant", client_id="test-client"
+ )
+ runtime.app.add_middleware(EntraAuthMiddleware)
+
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=runtime.app), base_url="http://test") as client:
+ anonymous = await client.get("/api/restore-failures")
+ health = await client.get("/api/health")
+
+ assert anonymous.status_code == 401
+ assert health.status_code == 200
+
+
+@pytest.mark.parametrize("state", ["ready", None])
+async def test_restore_failures_are_empty_when_nothing_failed_async(
+ runtime: RuntimeLifecycle, state: str | None
+) -> None:
+ if state is None:
+ del runtime.app.state.runtime_lifecycle
+ else:
+ runtime.state = state
+
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=runtime.app), base_url="http://test") as client:
+ response = await client.get("/api/restore-failures")
+
+ assert (response.status_code, response.json()) == (200, {"items": []})
+
+
+async def test_startup_lists_ignored_files_without_blocking_readiness_async(
+ runtime: RuntimeLifecycle, tmp_path: Path
+) -> None:
+ recipes = tmp_path / "recipes"
+ _save_recipe(recipes, kind="converters", name="Not-A-Name", document={})
+
+ await _start_with_recipes_async(runtime, recipes=recipes)
+
+ assert runtime.state == "ready"
+ status = runtime.status()
+ assert status["restore_failures"] == []
+ assert [(item["kind"], item["file_name"]) for item in status["restore_ignored"]] == [
+ ("converters", "Not-A-Name.json")
+ ]
+
+
+async def test_fixing_the_file_and_restarting_reaches_ready_async(runtime: RuntimeLifecycle, tmp_path: Path) -> None:
+ recipes = tmp_path / "recipes"
+ broken = _save_recipe(recipes, kind="scorers", name="broken", document={"schema_version": 99})
+ await _start_with_recipes_async(runtime, recipes=recipes)
+ assert runtime.status()["restore_failures"][0]["reason"] == "newer_schema_version"
+
+ broken.unlink()
+ await _start_with_recipes_async(runtime, recipes=recipes)
+
+ assert (runtime.state, runtime.outcome) == ("ready", "success")
+ assert runtime.status()["restore_failures"] == []
+
+
+async def test_apply_with_an_unrestorable_entry_becomes_ready_with_the_new_version_async(
+ runtime: RuntimeLifecycle, tmp_path: Path
+) -> None:
+ recipes = tmp_path / "recipes"
+ _save_recipe(recipes, kind="techniques", name="broken", document={"type": "NoSuchAttack"})
+ config = await runtime._load_async()
+ config.registry_recipes_source = str(recipes)
+
+ await apply_async(runtime)
+
+ _, current_version = await runtime.source.read_with_version_async()
+ assert (runtime.state, runtime.outcome) == ("ready", "success")
+ assert runtime.version == current_version
+ assert runtime.status()["restore_failures"][0]["reason"] == "unknown_class"
+ config.apply_prepared_reinitialization_async.assert_awaited_once()
+
+
+async def test_restore_cancellation_propagates_from_startup_async(runtime: RuntimeLifecycle, tmp_path: Path) -> None:
+ runtime.state = "initializing"
+
+ with patch.object(RegistryRecipeRestorer, "read", side_effect=asyncio.CancelledError):
+ with pytest.raises(asyncio.CancelledError):
+ await _start_with_recipes_async(runtime, recipes=tmp_path / "recipes")
+
+ assert runtime.state == "initializing"
+
+
+_RESTORABLE_TARGET = {
+ "class_module": "pyrit.prompt_target.openai.openai_chat_target",
+ "class_name": "OpenAIChatTarget",
+ "params": {
+ "auth_mode": "identity",
+ "endpoint": "https://contoso.openai.azure.com/openai/v1",
+ "model_name": "gpt-4o",
+ },
+}
+_BROKEN_TARGET = {"class_module": "nowhere", "class_name": "Nope"}
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_fixing_the_file_and_applying_restores_the_entry_and_releases_its_name_async(
+ runtime: RuntimeLifecycle, tmp_path: Path
+) -> None:
+ recipes = tmp_path / "recipes"
+ _save_recipe(recipes, kind="targets", name="ghost", document=_BROKEN_TARGET)
+ await _start_with_recipes_async(runtime, recipes=recipes)
+ assert [failure["name"] for failure in runtime.status()["restore_failures"]] == ["ghost"]
+ _save_recipe(recipes, kind="targets", name="ghost", document=_RESTORABLE_TARGET)
+ config = await runtime._load_async()
+ config.apply_prepared_reinitialization_async.side_effect = lambda **_: TargetRegistry.reset_registry_singleton()
+
+ await apply_async(runtime)
+
+ assert (runtime.state, runtime.outcome) == ("ready", "success")
+ assert runtime.status()["restore_failures"] == []
+ assert isinstance(TargetRegistry.get_registry_singleton().instances.get("ghost"), OpenAIChatTarget)
+
+
+async def test_readiness_counts_restore_failures_without_their_details_async(
+ runtime: RuntimeLifecycle, tmp_path: Path
+) -> None:
+ recipes = tmp_path / "recipes"
+ _save_recipe(recipes, kind="targets", name="ghost", document=_BROKEN_TARGET)
+ await _start_with_recipes_async(runtime, recipes=recipes)
+
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=runtime.app), base_url="http://test") as client:
+ readiness = await client.get("/api/runtime")
+
+ assert readiness.json() == {
+ "ready": True,
+ "state": "ready",
+ "generation": runtime.generation,
+ "restore_failures": 1,
+ }
diff --git a/tests/unit/backend/test_scenario_configuration_resolver.py b/tests/unit/backend/test_scenario_configuration_resolver.py
index 29e277fdbf..7ee44ef021 100644
--- a/tests/unit/backend/test_scenario_configuration_resolver.py
+++ b/tests/unit/backend/test_scenario_configuration_resolver.py
@@ -3,6 +3,8 @@
"""Adversarial target resolution validates without changing execution scopes."""
+from collections.abc import Iterator
+from types import SimpleNamespace
from typing import Any, Literal
from unittest.mock import AsyncMock, MagicMock, patch
@@ -12,11 +14,20 @@
from pyrit.datasets import SeedDatasetProvider
from pyrit.memory import CentralMemory, MemoryInterface
from pyrit.models import DatasetSourceInput, ScenarioDatasetOptions, ScenarioDatasetRequest, SeedObjective
-from pyrit.models.catalog import RunScenarioRequest
+from pyrit.models.catalog import RestoreFailureReason, RunScenarioRequest
from pyrit.models.component_spec import RegisteredComponentReference
from pyrit.models.dataset_limit import DatasetLimit, ResolvedDatasetLimit
from pyrit.prompt_target import TargetCapabilities
-from pyrit.registry import ScenarioRegistry, ScorerRegistry, TargetRegistry
+from pyrit.registry import (
+ AttackTechniqueRegistry,
+ Registry,
+ SavedEntryUnavailableError,
+ ScenarioRegistry,
+ ScorerRegistry,
+ TargetRegistry,
+ TechniqueSelectionUnavailableError,
+ UnavailableEntry,
+)
from pyrit.scenario import DatasetAttackConfiguration, DatasetFetchPolicy, DatasetSource, Scenario
from pyrit.scenario.core import (
get_default_adversarial_target,
@@ -306,3 +317,77 @@ def test_dataset_overrides_are_rejected_for_a_fixed_dataset_scenario(overrides:
datasets=ScenarioDatasetRequest(**overrides),
)
Exploitation().set_params_from_args(args=resolved)
+
+
+class _RegistryCatalog:
+ _selection_catalog = ("builder", "revision")
+
+ @classmethod
+ def get_aggregate_tags(cls) -> set[str]:
+ return {"all", "default", "custom"}
+
+
+class _FixedCatalog:
+ @classmethod
+ def get_aggregate_tags(cls) -> set[str]:
+ return {"all"}
+
+
+def _scenario(catalog: type, *, takes_techniques: bool = True) -> MagicMock:
+ scenario_class = MagicMock()
+ parameters = [SimpleNamespace(name="scenario_techniques")] if takes_techniques else []
+ scenario_class.supported_parameters.return_value = parameters
+ scenario_class.return_value._technique_class = catalog
+ return scenario_class
+
+
+@pytest.fixture
+def held_technique(patch_central_database: object) -> Iterator[None]:
+ with patch.dict(Registry._singletons, {}, clear=True):
+ AttackTechniqueRegistry.get_registry_singleton().instances.mark_unavailable(
+ UnavailableEntry(kind="technique", name="formal", reason=RestoreFailureReason.BUILD_ERROR, message="boom")
+ )
+ yield
+
+
+@pytest.mark.usefixtures("held_technique")
+@pytest.mark.parametrize("techniques", [None, [], ["all"], ["default"], ["Custom"], ["base", "all:base64"]])
+def test_a_broad_technique_selection_is_refused_while_a_saved_technique_is_held(techniques: list[str] | None) -> None:
+ with pytest.raises(TechniqueSelectionUnavailableError, match="'formal'"):
+ ScenarioConfigurationResolver._refuse_broad_selection_while_techniques_are_held(
+ scenario_class=_scenario(_RegistryCatalog), techniques=techniques, technique_override=None
+ )
+
+
+@pytest.mark.usefixtures("held_technique")
+def test_the_scenario_default_is_refused_before_resolution_returns_early() -> None:
+ with pytest.raises(TechniqueSelectionUnavailableError):
+ ScenarioConfigurationResolver.resolve_configuration(
+ scenario_name="registry_scenario", scenario_class=_scenario(_RegistryCatalog)
+ )
+
+
+@pytest.mark.usefixtures("held_technique")
+def test_naming_a_held_technique_fails_with_its_reason() -> None:
+ with pytest.raises(SavedEntryUnavailableError, match="'Formal' couldn't be restored: boom"):
+ ScenarioConfigurationResolver._refuse_broad_selection_while_techniques_are_held(
+ scenario_class=_scenario(_RegistryCatalog), techniques=["Formal:base64"], technique_override=None
+ )
+
+
+@pytest.mark.usefixtures("held_technique")
+@pytest.mark.parametrize(
+ ("scenario_class", "techniques"),
+ [
+ (_scenario(_RegistryCatalog), ["base", "other:base64"]),
+ (_scenario(_FixedCatalog), None),
+ (_scenario(_FixedCatalog), ["all"]),
+ (_scenario(_RegistryCatalog, takes_techniques=False), None),
+ ],
+)
+def test_named_techniques_and_other_scenarios_are_unaffected(
+ scenario_class: MagicMock, techniques: list[str] | None
+) -> None:
+ ScenarioConfigurationResolver._refuse_broad_selection_while_techniques_are_held(
+ scenario_class=scenario_class, techniques=techniques, technique_override=None
+ )
diff --git a/tests/unit/backend/test_scenario_preset_routes.py b/tests/unit/backend/test_scenario_preset_routes.py
index 92722eaacb..31750c98e9 100644
--- a/tests/unit/backend/test_scenario_preset_routes.py
+++ b/tests/unit/backend/test_scenario_preset_routes.py
@@ -3,11 +3,11 @@
"""Tests for the scenario preset routes."""
+import logging
from collections.abc import Iterator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
-from azure.core.exceptions import AzureError
from fastapi.testclient import TestClient
from pyrit.backend.main import app
@@ -24,6 +24,7 @@
ScenarioPresetNameRegisteredError,
resolve_scenario_preset_run_request,
)
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError
PRESET_NAME = "quick_scan"
SCENARIO_NAME = "foundry.red_team_agent"
@@ -146,13 +147,22 @@ def test_list_is_readable_without_admin(self, anonymous_client: TestClient, serv
assert anonymous_client.get("/api/scenario-presets").status_code == 200
- def test_storage_failure_is_reported_without_sdk_detail(self, client: TestClient, service: MagicMock) -> None:
- service.list_presets_async = AsyncMock(side_effect=AzureError("container 'x' key=secret"))
+ def test_storage_failure_names_the_source_only_in_the_log(
+ self, client: TestClient, service: MagicMock, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ service.list_presets_async = AsyncMock(
+ side_effect=DocumentStorageUnavailableError(
+ label="Scenario preset", source="/srv/presets", reason="Permission denied"
+ )
+ )
- response = client.get("/api/scenario-presets")
+ with caplog.at_level(logging.WARNING, logger="pyrit.backend.middleware.error_handlers"):
+ response = client.get("/api/scenario-presets")
assert response.status_code == 503
- assert "secret" not in response.text
+ assert response.json()["detail"] == "Scenario preset storage is unavailable"
+ assert "/srv/presets" not in response.text
+ assert "Scenario preset storage at /srv/presets is unavailable: Permission denied" in caplog.text
def test_each_preset_carries_its_own_run_size(self, client: TestClient, service: MagicMock) -> None:
estimate = _estimate(42)
@@ -416,7 +426,9 @@ def test_a_missing_preset_still_conflicts_instead_of_being_recreated(
def test_a_failed_author_read_is_reported_without_storage_details(
self, *, client: TestClient, service: MagicMock
) -> None:
- service.get_preset_async.side_effect = AzureError("key=secret")
+ service.get_preset_async.side_effect = DocumentStorageUnavailableError(
+ label="Scenario preset", source="/srv/presets", reason="Permission denied"
+ )
response = client.put(
f"/api/scenario-presets/{PRESET_NAME}",
@@ -424,7 +436,7 @@ def test_a_failed_author_read_is_reported_without_storage_details(
)
assert response.status_code == 503
- assert "secret" not in response.text
+ assert "/srv/presets" not in response.text
service.save_preset_async.assert_not_called()
def test_a_body_naming_a_different_preset_is_rejected(self, client: TestClient, service: MagicMock) -> None:
diff --git a/tests/unit/backend/test_scenario_suite_routes.py b/tests/unit/backend/test_scenario_suite_routes.py
index e53fa141b8..fd6f1345fc 100644
--- a/tests/unit/backend/test_scenario_suite_routes.py
+++ b/tests/unit/backend/test_scenario_suite_routes.py
@@ -3,6 +3,7 @@
"""Tests for scenario suite definition routes."""
+import logging
from collections.abc import Iterator
from datetime import UTC, datetime
from pathlib import Path
@@ -11,7 +12,6 @@
from uuid import UUID
import pytest
-from azure.core.exceptions import AzureError
from fastapi.testclient import TestClient
from pyrit.backend.main import app
@@ -48,6 +48,7 @@
ScenarioSuiteRegistry,
ScenarioSuiteStorage,
)
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError
SUITE_NAME = "weekly"
ENTRY_ID = UUID("11111111-1111-4111-8111-111111111111")
@@ -181,7 +182,9 @@ def unavailable_preset_backend(
) -> Iterator[tuple[ScenarioSuiteService, ScenarioSuiteStorage]]:
"""Run routes against real suite storage and an unavailable preset registry."""
registry = MagicMock(spec=ScenarioPresetRegistry)
- registry.get_preset.side_effect = AzureError("expired preset container signature")
+ registry.get_preset.side_effect = DocumentStorageUnavailableError(
+ label="Scenario preset", source="/srv/presets", reason="Permission denied"
+ )
storage = ScenarioSuiteStorage(source=str(tmp_path))
scenario_service = ScenarioSuiteService(registry=ScenarioSuiteRegistry(storage=storage))
with (
@@ -257,7 +260,10 @@ def test_launch_is_rejected_during_runtime_reinitialization(
(ScenarioSuiteLaunchConflictError("changed"), 409),
(ScenarioSuiteNotRunnableError("not runnable"), 400),
(ScenarioSuiteExecutionCapacityError("wait for an active suite"), 429),
- (AzureError("storage unavailable"), 503),
+ (
+ DocumentStorageUnavailableError(label="Scenario suite", source="/srv/suites", reason="Permission denied"),
+ 503,
+ ),
(RuntimeError("scheduling is stopping"), 503),
],
)
@@ -273,8 +279,8 @@ def test_launch_maps_preflight_failures(
response = anonymous_client.post(f"/api/scenario-suites/{SUITE_NAME}/launch", json=_launch_body())
assert response.status_code == expected_status
- if isinstance(error, AzureError):
- assert "storage unavailable" not in response.text
+ if isinstance(error, DocumentStorageUnavailableError):
+ assert response.json()["detail"] == "Scenario suite storage is unavailable"
if isinstance(error, ScenarioSuiteExecutionCapacityError):
assert response.json()["detail"] == "wait for an active suite"
@@ -369,14 +375,25 @@ def test_get_registered_suite_returns_origin_and_null_version(client: TestClient
assert response.json()["version"] is None
-def test_storage_failure_is_sanitized(client: TestClient, service: MagicMock) -> None:
- """Test that Azure SDK details do not reach API callers."""
- service.list_suites_async = AsyncMock(side_effect=AzureError("container key=secret"))
+def test_storage_failure_names_the_source_only_in_the_log(
+ client: TestClient, service: MagicMock, caplog: pytest.LogCaptureFixture
+) -> None:
+ """Test that the log names the storage location and the response doesn't."""
+ service.list_suites_async = AsyncMock(
+ side_effect=DocumentStorageUnavailableError(
+ label="Scenario suite",
+ source="https://account.blob.core.windows.net/suites",
+ reason="ClientAuthenticationError",
+ )
+ )
- response = client.get("/api/scenario-suites")
+ with caplog.at_level(logging.WARNING, logger="pyrit.backend.middleware.error_handlers"):
+ response = client.get("/api/scenario-suites")
assert response.status_code == 503
- assert "secret" not in response.text
+ assert response.json()["detail"] == "Scenario suite storage is unavailable"
+ assert "account.blob.core.windows.net" not in response.text
+ assert "https://account.blob.core.windows.net/suites is unavailable: ClientAuthenticationError" in caplog.text
def test_create_succeeds_when_preset_storage_is_unavailable(
diff --git a/tests/unit/backend/test_scenario_suite_service.py b/tests/unit/backend/test_scenario_suite_service.py
index 1b80a3325e..f1e60b5990 100644
--- a/tests/unit/backend/test_scenario_suite_service.py
+++ b/tests/unit/backend/test_scenario_suite_service.py
@@ -9,7 +9,6 @@
from uuid import UUID
import pytest
-from azure.core.exceptions import AzureError
from pyrit.backend.services import ScenarioSuiteService, get_scenario_suite_service
from pyrit.models import PresetEntry, PresetIssue, ScenarioPreset, ScenarioSuite, ScenarioSuiteEntry
@@ -19,6 +18,7 @@
ScenarioSuiteRegistry,
ScenarioSuiteStorage,
)
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError, DocumentUnreadableError
ENTRY_ONE = UUID("11111111-1111-4111-8111-111111111111")
ENTRY_TWO = UUID("22222222-2222-4222-8222-222222222222")
@@ -119,8 +119,17 @@ async def test_registered_suite_is_listed_with_nullable_version(
@pytest.mark.parametrize(
"error",
- [AzureError("expired preset credentials"), OSError("preset directory unavailable")],
- ids=["azure", "local"],
+ [
+ DocumentStorageUnavailableError(label="Scenario preset", source="/srv/presets", reason="Permission denied"),
+ DocumentUnreadableError(
+ label="Scenario preset",
+ source="/srv/presets",
+ name="nightly",
+ file_name="nightly.json",
+ reason="Not a regular file",
+ ),
+ ],
+ ids=["storage", "one_document"],
)
async def test_preset_storage_failure_is_advisory_after_committed_save(
service: ScenarioSuiteService,
diff --git a/tests/unit/common/test_azure_storage.py b/tests/unit/common/test_azure_storage.py
index 300ca706dc..9e7c0309cd 100644
--- a/tests/unit/common/test_azure_storage.py
+++ b/tests/unit/common/test_azure_storage.py
@@ -5,7 +5,7 @@
import pytest
-from pyrit.common.azure_storage import has_sas_signature, is_azure_blob_uri, redact_url_credentials
+from pyrit.common.azure_storage import has_sas_signature, is_azure_blob_uri, redact_url_credentials, redact_urls
@pytest.mark.parametrize(
@@ -56,3 +56,22 @@ def test_redact_url_credentials_removes_query_and_fragment() -> None:
uri = "https://account.blob.core.windows.net/container/blob.yaml?sp=rw&sig=secret#fragment"
assert redact_url_credentials(uri) == "https://account.blob.core.windows.net/container/blob.yaml"
+
+
+def test_redact_url_credentials_removes_user_name_and_password() -> None:
+ assert redact_url_credentials("https://user:SECRET@host:8443/path?q=1") == "https://host:8443/path"
+
+
+@pytest.mark.parametrize(
+ ("text", "expected"),
+ [
+ ("got https://acct.blob.core.windows.net/c/b?sv=1&sig=SECRET.", "got https://acct.blob.core.windows.net/c/b"),
+ ("to wss://user:SECRET@host/p#part failed", "to wss://host/p failed"),
+ ("first http://a/b?x=1 then https://c/d", "first http://a/b then https://c/d"),
+ ("bad https://[x?sig=SECRET", "bad "),
+ ("host/path?api-version=1&sig=SECRET&se=2030", "host/path?api-version=1&sig=&se=2030"),
+ ("could not be coerced to float: 'hot'", "could not be coerced to float: 'hot'"),
+ ],
+)
+def test_redact_urls_removes_credentials_from_each_url_in_text(text: str, expected: str) -> None:
+ assert redact_urls(text) == expected
diff --git a/tests/unit/registry/test_attack_technique_registry.py b/tests/unit/registry/test_attack_technique_registry.py
index 14d8915a90..475c36a5bb 100644
--- a/tests/unit/registry/test_attack_technique_registry.py
+++ b/tests/unit/registry/test_attack_technique_registry.py
@@ -12,8 +12,16 @@
from pyrit.executor.attack import AttackScoringConfig
from pyrit.memory import MemoryInterface
from pyrit.models import ComponentIdentifier
+from pyrit.models.catalog import RestoreFailureReason
from pyrit.prompt_target import PromptTarget, TargetRequirements
-from pyrit.registry import AttackTechniqueRegistry, Registry, TargetRegistry
+from pyrit.registry import (
+ AttackTechniqueRegistry,
+ Registry,
+ SavedEntryUnavailableError,
+ TargetRegistry,
+ UnavailableEntry,
+)
+from pyrit.registry.components.attack_technique_registry import TechniqueSelectorConflictError
from pyrit.scenario import AttackTechniqueFactory, ScenarioTechnique
from pyrit.scenario.core import ScorerOverridePolicy
from pyrit.setup.initializers.techniques import build_technique_factories
@@ -140,6 +148,44 @@ def test_inherited_enum_attributes_fail_before_runtime_registration(
)
+def _hold(registry: AttackTechniqueRegistry, name: str) -> None:
+ registry.instances.mark_unavailable(
+ UnavailableEntry(kind="technique", name=name, reason=RestoreFailureReason.BUILD_ERROR, message="boom")
+ )
+
+
+def test_a_held_technique_name_cant_be_registered_in_another_case(
+ scenario_selection_registry: AttackTechniqueRegistry,
+) -> None:
+ _hold(scenario_selection_registry, "formal")
+
+ with pytest.raises(SavedEntryUnavailableError, match="'Formal' couldn't be restored"):
+ scenario_selection_registry.instances.register_runtime(
+ AttackTechniqueFactory(name="Formal", attack_class=_StubAttack)
+ )
+
+
+def test_a_tag_cant_take_a_held_technique_name(scenario_selection_registry: AttackTechniqueRegistry) -> None:
+ _hold(scenario_selection_registry, "formal")
+ factory = AttackTechniqueFactory(name="new", attack_class=_StubAttack, technique_tags=["formal"])
+
+ with pytest.raises(TechniqueSelectorConflictError, match="conflicts with a technique name"):
+ scenario_selection_registry.instances.register_runtime(factory)
+
+
+def test_a_held_technique_leaves_the_scenario_selection(scenario_selection_registry: AttackTechniqueRegistry) -> None:
+ scenario_selection_registry.register_from_factories(
+ [AttackTechniqueFactory(name="other", attack_class=_StubAttack)]
+ )
+ before = _build_selection_enum()
+
+ _hold(scenario_selection_registry, "base")
+
+ current = _build_selection_enum()
+ assert current is not before
+ assert [technique.value for technique in current.get_all_techniques()] == ["other"]
+
+
class TestAttackTechniqueRegistrySingleton:
"""Tests for the singleton pattern."""
diff --git a/tests/unit/registry/test_file_document_storage.py b/tests/unit/registry/test_file_document_storage.py
new file mode 100644
index 0000000000..48c6fa68ca
--- /dev/null
+++ b/tests/unit/registry/test_file_document_storage.py
@@ -0,0 +1,610 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import os
+import sys
+from collections.abc import Callable, Generator, Iterator
+from contextlib import contextmanager
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+from azure.core.exceptions import (
+ ClientAuthenticationError,
+ HttpResponseError,
+ ResourceNotFoundError,
+ ServiceRequestError,
+ ServiceResponseError,
+)
+from azure.storage.blob import ContainerClient, StorageErrorCode
+
+import pyrit.registry.file_document_storage as storage_module
+from pyrit.registry.file_document_storage import (
+ DocumentConflictError,
+ DocumentListing,
+ DocumentStorageUnavailableError,
+ DocumentUnreadableError,
+ FileDocumentStorage,
+ UnreadableDocument,
+ _read_regular_file,
+ describe_storage_error,
+)
+
+_SAS_SOURCE = "https://account.blob.core.windows.net/documents/saved?sp=rl&sig=secret-signature"
+
+
+def _local_storage(directory: Path) -> FileDocumentStorage:
+ return FileDocumentStorage(source=str(directory), extension=".json", source_label="Test documents")
+
+
+def _blob_storage() -> FileDocumentStorage:
+ return FileDocumentStorage(source=_SAS_SOURCE, extension=".json", source_label="Test documents")
+
+
+def _blob_client(*, names: list[str]) -> MagicMock:
+ client = MagicMock(spec=ContainerClient)
+ client.__enter__.return_value = client
+ client.list_blobs.return_value = [SimpleNamespace(name=name) for name in names]
+ return client
+
+
+def _blob_error(
+ *, status_code: int, error_code: str, message: str = "Storage error.", missing: bool = False
+) -> HttpResponseError:
+ error_type = ResourceNotFoundError if missing else HttpResponseError
+ error = error_type(message=message)
+ error.status_code = status_code
+ error.error_code = error_code
+ return error
+
+
+def _count_open_files() -> int | None:
+ # Linux lists this process's open files here; elsewhere the count is skipped.
+ descriptors = Path("/proc/self/fd")
+ return len(list(descriptors.iterdir())) if descriptors.is_dir() else None
+
+
+@contextmanager
+def _change_after_listing(*, change: Callable[[], None], cache_types: bool = False) -> Generator[None, None, None]:
+ # Runs `change` after the folder is listed and before its entries are read. With `cache_types`,
+ # each entry's type is looked up first, so it is out of date once `change` runs.
+ real_scandir = os.scandir
+
+ @contextmanager
+ def scandir(path: Path) -> Generator[Iterator[os.DirEntry[str]], None, None]:
+ with real_scandir(path) as entries:
+ listed = list(entries)
+ if cache_types:
+ for entry in listed:
+ entry.is_file()
+ yield iter(listed)
+ change()
+
+ with patch.object(storage_module, "_scandir", side_effect=scandir):
+ yield
+
+
+class TestLocalListingWithIssues:
+ def test_reports_unreadable_document_and_keeps_the_others(self, tmp_path: Path) -> None:
+ (tmp_path / "first.json").write_bytes(b"one")
+ (tmp_path / "second.json").write_bytes(b"two")
+
+ def read(path: str) -> bytes | None:
+ if Path(path).name == "second.json":
+ raise PermissionError(13, "Permission denied", path)
+ return _read_regular_file(path)
+
+ with patch("pyrit.registry.file_document_storage._read_regular_file", side_effect=read):
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+ documents = _local_storage(tmp_path)._list_documents()
+
+ assert listing == DocumentListing(
+ documents={"first": b"one"},
+ unreadable=(UnreadableDocument(name="second", file_name="second.json", message="Permission denied"),),
+ )
+ assert isinstance(listing.unreadable[0].error, PermissionError)
+ assert documents == {"first": b"one"}
+
+ def test_reports_badly_named_files_as_ignored_without_reading_them(self, tmp_path: Path) -> None:
+ (tmp_path / "valid_name.json").write_bytes(b"ok")
+ (tmp_path / "Bad-Name.json").write_bytes(b"never read")
+ (tmp_path / "notes.txt").write_bytes(b"other extension")
+ (tmp_path / "nested").mkdir()
+ (tmp_path / "nested" / "inner.json").write_bytes(b"not a direct child")
+ read_names: list[str] = []
+
+ def read(path: str) -> bytes | None:
+ read_names.append(Path(path).name)
+ return _read_regular_file(path)
+
+ with patch("pyrit.registry.file_document_storage._read_regular_file", side_effect=read):
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing.documents == {"valid_name": b"ok"}
+ assert listing.unreadable == ()
+ assert [issue.file_name for issue in listing.ignored] == ["Bad-Name.json"]
+ assert "Bad-Name" in listing.ignored[0].message
+ assert read_names == ["valid_name.json"]
+ assert _local_storage(tmp_path)._list_documents() == listing.documents
+
+ def test_skips_a_document_deleted_after_listing(self, tmp_path: Path) -> None:
+ (tmp_path / "kept.json").write_bytes(b"{}\r\n")
+ (tmp_path / "empty.json").write_bytes(b"")
+ deleted = tmp_path / "deleted.json"
+ deleted.write_bytes(b"gone")
+
+ with _change_after_listing(change=deleted.unlink, cache_types=True):
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing == DocumentListing(documents={"empty": b"", "kept": b"{}\r\n"})
+
+ def test_skips_a_link_deleted_after_listing(self, tmp_path: Path) -> None:
+ (tmp_path / "kept.json").write_bytes(b"kept")
+ (tmp_path / "target.txt").write_bytes(b"linked")
+ link = tmp_path / "linked.json"
+ try:
+ link.symlink_to(tmp_path / "target.txt")
+ except OSError:
+ pytest.skip("Creating symbolic links isn't permitted here")
+
+ with _change_after_listing(change=link.unlink):
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing == DocumentListing(documents={"kept": b"kept"})
+
+ @pytest.mark.parametrize("replacement", ["pipe", "folder", "dangling link"])
+ @pytest.mark.timeout(60, method="thread")
+ def test_reports_a_non_file_put_in_place_of_a_listed_document_without_reading_it(
+ self, tmp_path: Path, replacement: str
+ ) -> None:
+ if replacement == "pipe" and not hasattr(os, "mkfifo"):
+ pytest.skip("Named pipes need os.mkfifo")
+ if replacement == "folder" and sys.platform == "win32":
+ pytest.skip("Windows can't open a folder as a file")
+ if replacement == "dangling link" and sys.platform == "win32":
+ pytest.skip("Creating symbolic links may not be permitted on Windows")
+ (tmp_path / "kept.json").write_bytes(b"kept")
+ swapped = tmp_path / "swapped.json"
+ swapped.write_bytes(b"replaced")
+
+ def replace() -> None:
+ swapped.unlink()
+ if replacement == "pipe":
+ os.mkfifo(swapped)
+ elif replacement == "folder":
+ swapped.mkdir()
+ else:
+ swapped.symlink_to(tmp_path / "missing_target.json")
+
+ open_files = _count_open_files()
+ with _change_after_listing(change=replace, cache_types=True):
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing == DocumentListing(
+ documents={"kept": b"kept"},
+ unreadable=(UnreadableDocument(name="swapped", file_name="swapped.json", message="Not a regular file"),),
+ )
+ assert listing.unreadable[0].error is None
+ assert _count_open_files() == open_files
+
+ @pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="Named pipes need os.mkfifo")
+ @pytest.mark.timeout(60, method="thread")
+ def test_checks_the_opened_file_rather_than_the_path(self, tmp_path: Path) -> None:
+ document = tmp_path / "document.json"
+ document.write_bytes(b"original")
+ real_open = os.open
+
+ def open_then_replace(path: str, flags: int, *args: int) -> int:
+ descriptor = real_open(path, flags, *args)
+ os.unlink(path)
+ os.mkfifo(path)
+ return descriptor
+
+ with patch.object(storage_module, "_open", side_effect=open_then_replace):
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing == DocumentListing(documents={"document": b"original"})
+ assert document.is_fifo()
+
+ @pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="Named pipes need os.mkfifo")
+ @pytest.mark.timeout(60, method="thread")
+ def test_checks_a_file_replaced_just_before_it_is_opened(self, tmp_path: Path) -> None:
+ (tmp_path / "document.json").write_bytes(b"original")
+ real_open = os.open
+
+ def replace_then_open(path: str, flags: int, *args: int) -> int:
+ os.unlink(path)
+ os.mkfifo(path)
+ return real_open(path, flags, *args)
+
+ with patch.object(storage_module, "_open", side_effect=replace_then_open):
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing == DocumentListing(
+ unreadable=(UnreadableDocument(name="document", file_name="document.json", message="Not a regular file"),)
+ )
+
+ def test_reports_entries_that_are_not_regular_files_without_reading_them(self, tmp_path: Path) -> None:
+ (tmp_path / "folder.json").mkdir()
+ try:
+ (tmp_path / "broken_link.json").symlink_to(tmp_path / "missing_target.json")
+ except OSError:
+ pytest.skip("Creating symbolic links isn't permitted here")
+
+ with patch("pyrit.registry.file_document_storage._read_regular_file") as read:
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing.unreadable == (
+ UnreadableDocument(name="broken_link", file_name="broken_link.json", message="Not a regular file"),
+ UnreadableDocument(name="folder", file_name="folder.json", message="Not a regular file"),
+ )
+ read.assert_not_called()
+
+ @pytest.mark.skipif(sys.platform == "win32", reason="The null device is a POSIX character device here")
+ def test_reads_only_regular_files(self) -> None:
+ assert _read_regular_file(os.devnull) is None
+
+ def test_reports_an_entry_that_cannot_be_checked_after_listing(self, tmp_path: Path) -> None:
+ deleted = tmp_path / "deleted.json"
+ deleted.write_bytes(b"gone")
+
+ with (
+ _change_after_listing(change=deleted.unlink, cache_types=True),
+ patch.object(storage_module, "_lstat", side_effect=PermissionError(13, "Permission denied")),
+ ):
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing == DocumentListing(
+ unreadable=(UnreadableDocument(name="deleted", file_name="deleted.json", message="Permission denied"),)
+ )
+
+ def test_reports_a_link_loop_and_keeps_the_others(self, tmp_path: Path) -> None:
+ (tmp_path / "good.json").write_bytes(b"ok")
+ try:
+ (tmp_path / "loop.json").symlink_to(tmp_path / "loop.json")
+ except OSError:
+ pytest.skip("Creating symbolic links isn't permitted here")
+
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing.documents == {"good": b"ok"}
+ assert [issue.name for issue in listing.unreadable] == ["loop"]
+
+ def test_matches_extensions_with_the_platform_case_rule(self, tmp_path: Path) -> None:
+ (tmp_path / "lower.json").write_bytes(b"one")
+ (tmp_path / "upper.JSON").write_bytes(b"two")
+
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert sorted(listing.documents) == sorted(path.stem for path in tmp_path.glob("*.json"))
+
+ def test_creates_missing_directory_and_lists_nothing(self, tmp_path: Path) -> None:
+ listing = _local_storage(tmp_path / "missing")._list_documents_with_issues()
+
+ assert listing == DocumentListing()
+ assert (tmp_path / "missing").is_dir()
+
+ def test_raises_when_the_directory_cannot_be_listed(self, tmp_path: Path) -> None:
+ (tmp_path / "first.json").write_bytes(b"one")
+
+ with patch.object(storage_module, "_scandir", side_effect=PermissionError(13, "denied")):
+ with pytest.raises(DocumentStorageUnavailableError) as raised:
+ _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert isinstance(raised.value.__cause__, PermissionError)
+ assert (raised.value.label, raised.value.source, raised.value.reason) == (
+ "Test documents",
+ str(tmp_path),
+ "denied",
+ )
+ assert str(raised.value) == f"Test documents storage at {tmp_path} is unavailable: denied"
+
+
+class TestBlobListingWithIssues:
+ def test_reports_unreadable_and_ignored_blobs_without_exposing_the_url(self) -> None:
+ client = _blob_client(
+ names=[
+ "saved/good.json",
+ "saved/broken.json",
+ "saved/Bad-Name.json",
+ "saved/notes.txt",
+ "saved/deeper/inner.json",
+ ]
+ )
+
+ def download_blob(blob_name: str) -> SimpleNamespace:
+ if blob_name == "saved/broken.json":
+ raise _blob_error(status_code=409, error_code="BlobArchived", message=f"Archived: {_SAS_SOURCE}")
+ return SimpleNamespace(readall=lambda: b"content")
+
+ client.download_blob.side_effect = download_blob
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ listing = _blob_storage()._list_documents_with_issues()
+
+ assert listing.documents == {"good": b"content"}
+ assert listing.unreadable == (
+ UnreadableDocument(name="broken", file_name="broken.json", message="HttpResponseError (409, BlobArchived)"),
+ )
+ assert isinstance(listing.unreadable[0].error, HttpResponseError)
+ assert [issue.file_name for issue in listing.ignored] == ["Bad-Name.json"]
+ assert all("secret-signature" not in issue.message for issue in (*listing.unreadable, *listing.ignored))
+ client.list_blobs.assert_called_once_with(name_starts_with="saved/")
+ downloaded = [call.args[0] for call in client.download_blob.call_args_list]
+ assert downloaded == ["saved/broken.json", "saved/good.json"]
+
+ def test_skips_only_a_blob_deleted_after_listing(self) -> None:
+ client = _blob_client(names=["saved/kept.json", "saved/deleted.json"])
+
+ def download_blob(blob_name: str) -> SimpleNamespace:
+ if blob_name == "saved/deleted.json":
+ raise _blob_error(status_code=404, error_code=StorageErrorCode.BLOB_NOT_FOUND, missing=True)
+ return SimpleNamespace(readall=lambda: b"kept")
+
+ client.download_blob.side_effect = download_blob
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ listing = _blob_storage()._list_documents_with_issues()
+
+ assert listing == DocumentListing(documents={"kept": b"kept"})
+
+ @pytest.mark.parametrize(
+ "error",
+ [
+ _blob_error(status_code=404, error_code=StorageErrorCode.CONTAINER_NOT_FOUND, missing=True),
+ _blob_error(status_code=403, error_code=StorageErrorCode.AUTHORIZATION_FAILURE),
+ _blob_error(status_code=401, error_code="InvalidAuthenticationInfo"),
+ ClientAuthenticationError(message="no token"),
+ ServiceRequestError(message="connection refused"),
+ ServiceResponseError(message="connection reset"),
+ ],
+ )
+ def test_raises_when_an_error_affects_the_whole_container(self, error: Exception) -> None:
+ client = _blob_client(names=["saved/first.json", "saved/second.json"])
+ client.download_blob.side_effect = error
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(DocumentStorageUnavailableError) as raised:
+ _blob_storage()._list_documents_with_issues()
+
+ assert raised.value.__cause__ is error
+ assert "secret-signature" not in str(raised.value)
+
+ def test_raises_when_the_container_cannot_be_listed(self) -> None:
+ def failing_pages() -> Iterator[SimpleNamespace]:
+ raise HttpResponseError(message="listing failed")
+ yield SimpleNamespace(name="saved/never.json")
+
+ client = _blob_client(names=[])
+ client.list_blobs.return_value = failing_pages()
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(DocumentStorageUnavailableError) as raised:
+ _blob_storage()._list_documents_with_issues()
+
+ assert isinstance(raised.value.__cause__, HttpResponseError)
+
+
+class TestReadDocument:
+ def test_reads_a_document_and_treats_a_missing_one_as_missing(self, tmp_path: Path) -> None:
+ (tmp_path / "present.json").write_bytes(b"content")
+ storage = _local_storage(tmp_path)
+
+ assert storage._read_document_bytes("present") == b"content"
+ assert storage._read_document_bytes("absent") is None
+ assert _local_storage(tmp_path / "missing_folder")._read_document_bytes("absent") is None
+
+ @pytest.mark.parametrize("entry", ["folder", "broken link", "pipe"])
+ @pytest.mark.timeout(60, method="thread")
+ def test_reports_what_the_listing_reports_as_unreadable(self, tmp_path: Path, entry: str) -> None:
+ path = tmp_path / "odd.json"
+ if entry == "folder":
+ path.mkdir()
+ elif entry == "broken link":
+ try:
+ path.symlink_to(tmp_path / "missing_target.json")
+ except OSError:
+ pytest.skip("Creating symbolic links isn't permitted here")
+ elif not hasattr(os, "mkfifo"):
+ pytest.skip("Named pipes need os.mkfifo")
+ else:
+ os.mkfifo(path)
+ storage = _local_storage(tmp_path)
+
+ with pytest.raises(DocumentUnreadableError) as raised:
+ storage._read_document_bytes("odd")
+
+ assert (raised.value.name, raised.value.file_name, raised.value.reason) == (
+ "odd",
+ "odd.json",
+ "Not a regular file",
+ )
+ assert [issue.name for issue in storage._list_documents_with_issues().unreadable] == ["odd"]
+
+ def test_reports_a_file_it_cannot_open(self, tmp_path: Path) -> None:
+ (tmp_path / "locked.json").write_bytes(b"secret")
+
+ with patch.object(storage_module, "_open", side_effect=PermissionError(13, "Permission denied")):
+ with pytest.raises(DocumentUnreadableError) as raised:
+ _local_storage(tmp_path)._read_document_bytes("locked")
+
+ assert raised.value.reason == "Permission denied"
+ assert isinstance(raised.value.__cause__, PermissionError)
+ assert str(raised.value) == f"Test documents 'locked.json' in {tmp_path} can't be read: Permission denied"
+
+ def test_blob_treats_only_a_missing_blob_as_missing(self) -> None:
+ client = _blob_client(names=[])
+ client.download_blob.side_effect = _blob_error(
+ status_code=404, error_code=StorageErrorCode.BLOB_NOT_FOUND, missing=True
+ )
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ assert _blob_storage()._read_document_bytes("absent") is None
+
+ def test_blob_raises_when_the_container_is_gone(self) -> None:
+ client = _blob_client(names=[])
+ client.download_blob.side_effect = _blob_error(
+ status_code=404, error_code=StorageErrorCode.CONTAINER_NOT_FOUND, missing=True
+ )
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(DocumentStorageUnavailableError) as raised:
+ _blob_storage()._read_document_bytes("present")
+
+ assert not isinstance(raised.value, DocumentUnreadableError)
+
+ def test_blob_reports_a_blob_it_cannot_read(self) -> None:
+ client = _blob_client(names=[])
+ client.download_blob.side_effect = _blob_error(status_code=409, error_code="BlobArchived")
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(DocumentUnreadableError) as raised:
+ _blob_storage()._read_document_bytes("archived")
+
+ assert raised.value.reason == "HttpResponseError (409, BlobArchived)"
+
+
+class TestDeleteDocument:
+ def test_blob_reports_a_missing_blob_as_already_gone(self) -> None:
+ client = _blob_client(names=[])
+ client.delete_blob.side_effect = _blob_error(
+ status_code=404, error_code=StorageErrorCode.BLOB_NOT_FOUND, missing=True
+ )
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ assert _blob_storage()._delete_document("absent") is False
+
+ def test_blob_raises_when_the_container_is_gone(self) -> None:
+ client = _blob_client(names=[])
+ client.delete_blob.side_effect = _blob_error(
+ status_code=404, error_code=StorageErrorCode.CONTAINER_NOT_FOUND, missing=True
+ )
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(DocumentStorageUnavailableError):
+ _blob_storage()._delete_document("present")
+
+
+class TestConditionalSave:
+ def test_local_refuses_to_replace_something_it_cannot_read(self, tmp_path: Path) -> None:
+ (tmp_path / "odd.json").mkdir()
+
+ with pytest.raises(DocumentUnreadableError):
+ _local_storage(tmp_path)._save_document_conditional(name="odd", content=b"new", expected_version=None)
+
+ assert (tmp_path / "odd.json").is_dir()
+
+ def test_blob_update_of_a_deleted_blob_is_a_conflict(self) -> None:
+ client = _blob_client(names=[])
+ client.download_blob.side_effect = _blob_error(
+ status_code=404, error_code=StorageErrorCode.BLOB_NOT_FOUND, missing=True
+ )
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(DocumentConflictError) as raised:
+ _blob_storage()._save_document_conditional(name="gone", content=b"new", expected_version="v1")
+
+ assert raised.value.actual_version is None
+
+ def test_blob_update_raises_when_the_container_is_gone(self) -> None:
+ client = _blob_client(names=[])
+ client.download_blob.side_effect = _blob_error(
+ status_code=404, error_code=StorageErrorCode.CONTAINER_NOT_FOUND, missing=True
+ )
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(DocumentStorageUnavailableError) as raised:
+ _blob_storage()._save_document_conditional(name="present", content=b"new", expected_version="v1")
+
+ assert not isinstance(raised.value, DocumentUnreadableError)
+ client.upload_blob.assert_not_called()
+
+
+def test_a_listing_cannot_be_changed_by_its_reader(tmp_path: Path) -> None:
+ (tmp_path / "first.json").write_bytes(b"one")
+
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ with pytest.raises(TypeError):
+ listing.documents["second"] = b"two" # type: ignore[index]
+
+
+class TestListDocuments:
+ def test_logs_what_it_leaves_out_and_returns_the_rest(self, tmp_path: Path) -> None:
+ (tmp_path / "good.json").write_bytes(b"ok")
+ (tmp_path / "Bad-Name.json").write_bytes(b"never read")
+ (tmp_path / "folder.json").mkdir()
+
+ with patch("pyrit.registry.file_document_storage.logger") as log:
+ documents = _local_storage(tmp_path)._list_documents()
+
+ assert documents == {"good": b"ok"}
+ messages = [call.args[0] for call in log.warning.call_args_list]
+ assert len(messages) == 2
+ assert messages[0].startswith(f"Ignoring stored document 'Bad-Name.json' in {tmp_path}: ")
+ assert messages[1] == f"Skipping unreadable document 'folder.json' in {tmp_path}: Not a regular file"
+
+ @pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="Named pipes need os.mkfifo")
+ @pytest.mark.timeout(60, method="thread")
+ def test_leaves_out_a_pipe_named_like_a_document_without_blocking(self, tmp_path: Path) -> None:
+ (tmp_path / "good.json").write_bytes(b"ok")
+ os.mkfifo(tmp_path / "pipe.json")
+
+ assert _local_storage(tmp_path)._list_documents() == {"good": b"ok"}
+
+ def test_raises_when_the_directory_cannot_be_listed(self, tmp_path: Path) -> None:
+ with patch.object(storage_module, "_scandir", side_effect=PermissionError(13, "denied")):
+ with pytest.raises(DocumentStorageUnavailableError):
+ _local_storage(tmp_path)._list_documents()
+
+ def test_logs_an_unreadable_blob_without_exposing_the_url(self) -> None:
+ client = _blob_client(names=["saved/good.json", "saved/broken.json"])
+
+ def download_blob(blob_name: str) -> SimpleNamespace:
+ if blob_name == "saved/broken.json":
+ raise HttpResponseError(message=f"Forbidden: {_SAS_SOURCE}")
+ return SimpleNamespace(readall=lambda: b"content")
+
+ client.download_blob.side_effect = download_blob
+
+ with (
+ patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client),
+ patch("pyrit.registry.file_document_storage.logger") as log,
+ ):
+ documents = _blob_storage()._list_documents()
+
+ assert documents == {"good": b"content"}
+ message = log.warning.call_args.args[0]
+ assert message.startswith(
+ "Skipping unreadable document 'broken.json' in https://account.blob.core.windows.net/"
+ )
+ assert message.endswith(": HttpResponseError")
+ assert "secret-signature" not in message
+
+
+class TestDescribeStorageError:
+ @pytest.mark.parametrize(
+ ("error", "expected"),
+ [
+ (PermissionError(13, "Permission denied", "/server/path/secret.json"), "Permission denied"),
+ (OSError(), "OSError"),
+ (ServiceRequestError(message="https://account.blob.core.windows.net/x?sig=secret"), "ServiceRequestError"),
+ ],
+ )
+ def test_describes_errors_without_their_details(self, error: Exception, expected: str) -> None:
+ assert describe_storage_error(error) == expected
+
+ @pytest.mark.parametrize(
+ ("error_code", "expected"),
+ [
+ (StorageErrorCode.AUTHORIZATION_FAILURE, "HttpResponseError (403, AuthorizationFailure)"),
+ ("AuthorizationPermissionMismatch", "HttpResponseError (403, AuthorizationPermissionMismatch)"),
+ ],
+ )
+ def test_includes_the_azure_status_and_error_code(self, error_code: str, expected: str) -> None:
+ error = HttpResponseError(message="https://account.blob.core.windows.net/x?sig=secret")
+ error.status_code = 403
+ error.error_code = error_code
+
+ assert describe_storage_error(error) == expected
diff --git a/tests/unit/registry/test_initializer_registry.py b/tests/unit/registry/test_initializer_registry.py
index e2762ce122..a57321845e 100644
--- a/tests/unit/registry/test_initializer_registry.py
+++ b/tests/unit/registry/test_initializer_registry.py
@@ -180,7 +180,7 @@ def test_unregister_and_cleanup_removes_entry_and_file(lazy_registry):
def test_unregister_and_cleanup_keeps_entry_when_storage_delete_fails(lazy_registry: InitializerRegistry) -> None:
"""Test that failed storage cleanup leaves the runtime registration retryable."""
storage = MagicMock(spec=CustomInitializerStorage)
- storage.list_scripts.return_value = {"custom": _VALID_SCRIPT}
+ storage.list_script_names.return_value = {"custom"}
storage.delete_script.side_effect = OSError("storage unavailable")
lazy_registry._custom_storage = storage
lazy_registry._classes["custom"] = _ParamInitializer
@@ -194,7 +194,7 @@ def test_unregister_and_cleanup_keeps_entry_when_storage_delete_fails(lazy_regis
def test_unregister_and_cleanup_removes_invalid_stored_source(lazy_registry: InitializerRegistry) -> None:
"""Test invalid stored scripts can be removed without a runtime registration."""
storage = MagicMock(spec=CustomInitializerStorage)
- storage.list_scripts.return_value = {"invalid": "not Python"}
+ storage.list_script_names.return_value = {"invalid"}
lazy_registry._custom_storage = storage
lazy_registry.unregister_and_cleanup("invalid")
@@ -205,7 +205,7 @@ def test_unregister_and_cleanup_removes_invalid_stored_source(lazy_registry: Ini
def test_unregister_and_cleanup_removes_source_shadowed_by_builtin(lazy_registry: InitializerRegistry) -> None:
"""Test deleting stored source preserves a built-in registration with the same name."""
storage = MagicMock(spec=CustomInitializerStorage)
- storage.list_scripts.return_value = {"builtin": _VALID_SCRIPT}
+ storage.list_script_names.return_value = {"builtin"}
lazy_registry._custom_storage = storage
lazy_registry._classes["builtin"] = _ParamInitializer
lazy_registry._builtin_names.add("builtin")
@@ -216,6 +216,24 @@ def test_unregister_and_cleanup_removes_source_shadowed_by_builtin(lazy_registry
assert lazy_registry.get_class("builtin") is _ParamInitializer
+def test_unregister_and_cleanup_removes_a_script_that_cannot_be_read(
+ lazy_registry: InitializerRegistry, tmp_path: Path
+) -> None:
+ """Test that a stored script the listing reports as unreadable, or can't decode, can still be deleted."""
+ lazy_registry.configure_custom_scripts_source(str(tmp_path))
+ (tmp_path / "not_utf8.py").write_bytes(b"\xff\xfe")
+ try:
+ (tmp_path / "broken_link.py").symlink_to(tmp_path / "missing_target.py")
+ except OSError:
+ pytest.skip("Creating symbolic links isn't permitted here")
+
+ lazy_registry.unregister_and_cleanup("not_utf8")
+ lazy_registry.unregister_and_cleanup("broken_link")
+
+ assert not (tmp_path / "not_utf8.py").exists()
+ assert not (tmp_path / "broken_link.py").is_symlink()
+
+
def test_register_from_content_uses_configured_storage(lazy_registry: InitializerRegistry, tmp_path: Path) -> None:
"""Test runtime registration persists source in the configured directory."""
lazy_registry.configure_custom_scripts_source(str(tmp_path))
diff --git a/tests/unit/registry/test_instance_registry.py b/tests/unit/registry/test_instance_registry.py
index c6a2959f96..fb832f1f9c 100644
--- a/tests/unit/registry/test_instance_registry.py
+++ b/tests/unit/registry/test_instance_registry.py
@@ -15,12 +15,15 @@
import pytest
from pyrit.models import ComponentIdentifier, Identifiable
+from pyrit.models.catalog import RestoreFailureReason
from pyrit.registry import (
DefaultInstanceRegistry,
InstanceRegistry,
RegistryEntry,
+ SavedEntryUnavailableError,
SupportsInstances,
TagQuery,
+ UnavailableEntry,
)
@@ -614,6 +617,82 @@ def test_multiple_dependents_returned_sorted(self, registry: DefaultInstanceRegi
assert [d.name for d in dependents] == ["a_wrapper", "m_wrapper", "z_wrapper"]
+def _held(name: str | None, *, message: str = "ValueError: boom") -> UnavailableEntry:
+ return UnavailableEntry(kind="target", name=name, reason=RestoreFailureReason.BUILD_ERROR, message=message)
+
+
+class TestUnavailableEntries:
+ """Tests for names held by saved entries that couldn't be restored."""
+
+ def test_a_held_name_fails_lookups_with_the_reason(self, registry: DefaultInstanceRegistry[_TestItem]) -> None:
+ registry.mark_unavailable(_held("broken"))
+
+ with pytest.raises(SavedEntryUnavailableError, match="'broken' couldn't be restored: ValueError: boom"):
+ registry.get("broken")
+ with pytest.raises(SavedEntryUnavailableError):
+ registry.get_entry("broken")
+ assert "broken" in registry
+ assert registry.get("missing") is None
+
+ def test_a_held_name_hides_an_instance_registered_under_it(
+ self, registry: DefaultInstanceRegistry[_TestItem]
+ ) -> None:
+ registry.register(_item("startup"), name="shared")
+ registry.register(_item("other"), name="other")
+ revision = registry.revision
+
+ registry.mark_unavailable(_held("shared"))
+
+ assert registry.revision > revision
+ assert registry.get_names() == ["other"]
+ assert [entry.name for entry in registry.get_all_instances()] == ["other"]
+ assert len(registry) == 1
+ with pytest.raises(SavedEntryUnavailableError):
+ registry.get("shared")
+
+ @pytest.mark.parametrize("replace", [False, True])
+ def test_a_held_name_cant_be_taken(self, registry: DefaultInstanceRegistry[_TestItem], replace: bool) -> None:
+ registry.mark_unavailable(_held("broken"))
+
+ with pytest.raises(SavedEntryUnavailableError):
+ registry.validate_name_available("broken")
+ with pytest.raises(SavedEntryUnavailableError):
+ registry.register(_item("stand_in"), name="broken", replace=replace)
+
+ def test_clearing_a_hold_releases_the_name(self, registry: DefaultInstanceRegistry[_TestItem]) -> None:
+ registry.mark_unavailable(_held("broken"))
+
+ registry.clear_unavailable("broken")
+ registry.register(_item("fixed"), name="broken")
+
+ assert registry.get("broken") == "fixed"
+ assert registry.get_unavailable_entries() == []
+
+ def test_a_held_folder_holds_every_unregistered_name(self, registry: DefaultInstanceRegistry[_TestItem]) -> None:
+ registry.register(_item("startup"), name="startup")
+ folder = _held(None, message="The saved targets couldn't be listed: Permission denied")
+
+ registry.mark_unavailable(folder)
+
+ assert registry.get("startup") == "startup"
+ with pytest.raises(SavedEntryUnavailableError, match="'maybe_saved' may be a saved target"):
+ registry.get("maybe_saved")
+ with pytest.raises(SavedEntryUnavailableError):
+ registry.register(_item("new"), name="maybe_saved")
+ assert registry.get_unavailable_entries() == [folder]
+
+ def test_the_description_hides_the_message_when_asked(self) -> None:
+ entry = _held("broken", message="ValueError: https://host/v1")
+
+ error = SavedEntryUnavailableError(entry=entry, name="broken")
+
+ assert (
+ error.describe(with_details=False)
+ == "The saved target 'broken' couldn't be restored. It couldn't be built."
+ )
+ assert "https://host/v1" in str(error)
+
+
class TestProtocolConformance:
"""Tests that DefaultInstanceRegistry satisfies the registry protocols."""
diff --git a/tests/unit/registry/test_registry_recipe_restore.py b/tests/unit/registry/test_registry_recipe_restore.py
new file mode 100644
index 0000000000..bc578c9ca2
--- /dev/null
+++ b/tests/unit/registry/test_registry_recipe_restore.py
@@ -0,0 +1,808 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import dataclasses
+import json
+import os
+import sys
+from collections.abc import Iterator
+from pathlib import Path
+from types import ModuleType
+from typing import Any
+from unittest.mock import patch
+
+import pytest
+from pydantic import ValidationError
+
+from pyrit.backend.models.techniques import CreateTechniqueRequest
+from pyrit.converter import Base64Converter, ColloquialWordswapConverter
+from pyrit.models import Parameter
+from pyrit.models.catalog import (
+ RegistryRecipeKind,
+ RestoreFailure,
+ RestoreFailureReason,
+ SavedEntryRef,
+ TechniqueRecipe,
+)
+from pyrit.prompt_target import OpenAIChatTarget, RoundRobinTarget
+from pyrit.registry import (
+ AttackRegistry,
+ AttackTechniqueRegistry,
+ ConverterRegistry,
+ SavedEntryUnavailableError,
+ ScorerRegistry,
+ TargetRegistry,
+)
+from pyrit.registry.registry_recipe_restore import RegistryRecipeRestorer
+from pyrit.registry.registry_recipe_storage import MAX_FAILURE_MESSAGE_LENGTH, RegistryRecipeStorage
+
+_OPENAI_MODULE = "pyrit.prompt_target.openai.openai_chat_target"
+_IDENTITY_TARGET = {
+ "class_module": _OPENAI_MODULE,
+ "class_name": "OpenAIChatTarget",
+ "params": {
+ "auth_mode": "identity",
+ "endpoint": "https://contoso.openai.azure.com/openai/v1",
+ "model_name": "gpt-4o",
+ },
+}
+_ROUND_ROBIN_MODULE = "pyrit.prompt_target.round_robin_target"
+_TECHNIQUE = {
+ "type": "RedTeamingAttack",
+ "params": {"max_turns": 5},
+ "description": "Red teaming with a formal tone",
+ "tags": ["custom"],
+ "adversarial_chat": "contoso_gpt4o",
+ "adversarial_system_prompt": "You are a red teaming assistant.",
+ "request_converters": ["tone_formal"],
+ "response_converters": [],
+}
+
+_REGISTRIES = (TargetRegistry, ConverterRegistry, ScorerRegistry, AttackTechniqueRegistry)
+
+
+@pytest.fixture(autouse=True)
+def isolated_registries() -> Iterator[None]:
+ for registry in _REGISTRIES:
+ registry.reset_registry_singleton()
+ yield
+ for registry in _REGISTRIES:
+ registry.reset_registry_singleton()
+
+
+def _save(root: Path, kind: RegistryRecipeKind, name: str, document: dict[str, Any]) -> None:
+ _save_text(root, kind, name, json.dumps(document))
+
+
+def _save_text(root: Path, kind: RegistryRecipeKind, name: str, text: str) -> None:
+ folder = root / kind.value
+ folder.mkdir(parents=True, exist_ok=True)
+ (folder / f"{name}.json").write_text(text)
+
+
+def _round_robin(*targets: str) -> dict[str, Any]:
+ return {"class_module": _ROUND_ROBIN_MODULE, "class_name": "RoundRobinTarget", "params": {"targets": list(targets)}}
+
+
+def _failures_by_name(failures: list[RestoreFailure]) -> dict[str | None, RestoreFailure]:
+ return {failure.name: failure for failure in failures}
+
+
+@pytest.mark.usefixtures("patch_central_database")
+class TestRestoreOrderAndRebuild:
+ def test_restores_every_kind_after_the_entries_it_uses(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "formal_red_team", _TECHNIQUE)
+ _save(
+ tmp_path,
+ RegistryRecipeKind.SCORERS,
+ "refusal",
+ {
+ "class_module": "pyrit.score.true_false.self_ask_refusal_scorer",
+ "class_name": "SelfAskRefusalScorer",
+ "params": {"chat_target": "contoso_gpt4o"},
+ },
+ )
+ _save(
+ tmp_path,
+ RegistryRecipeKind.CONVERTERS,
+ "tone_formal",
+ {
+ "class_module": "pyrit.converter.tone_converter",
+ "class_name": "ToneConverter",
+ "params": {"converter_target": "contoso_gpt4o", "tone": "formal"},
+ },
+ )
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ assert report.failures == []
+ assert [(ref.kind, ref.name) for ref in report.restored] == [
+ (RegistryRecipeKind.TARGETS, "contoso_gpt4o"),
+ (RegistryRecipeKind.CONVERTERS, "tone_formal"),
+ (RegistryRecipeKind.SCORERS, "refusal"),
+ (RegistryRecipeKind.TECHNIQUES, "formal_red_team"),
+ ]
+ target = TargetRegistry.get_registry_singleton().instances.get("contoso_gpt4o")
+ assert isinstance(target, OpenAIChatTarget)
+ converter = ConverterRegistry.get_registry_singleton().instances.get("tone_formal")
+ assert converter is not None
+ assert converter._converter_target is target
+ factory = AttackTechniqueRegistry.get_registry_singleton().instances.get("formal_red_team")
+ assert factory is not None
+ assert factory.technique_tags == ["custom"]
+
+ def test_finds_a_startup_entry_by_its_exact_name(self, tmp_path: Path) -> None:
+ registry = TargetRegistry.get_registry_singleton()
+ # Each name has a different normalized form, so a lookup that normalized names would miss them.
+ for name in ("cafe\u0301", "target\uff10"):
+ startup_target = OpenAIChatTarget(
+ endpoint="https://startup.openai.azure.com/openai/v1", model_name="gpt-4o", auth_mode="identity"
+ )
+ registry.instances.register(startup_target, name=name)
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "a_pool", _round_robin("cafe\u0301", "target\uff10"))
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ assert report.failures == []
+ assert isinstance(registry.instances.get("a_pool"), RoundRobinTarget)
+
+ def test_reads_without_building_and_restores_what_was_read(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+ restorer = RegistryRecipeRestorer(source=str(tmp_path))
+
+ with patch.object(TargetRegistry, "create_instance_from_external_input") as build:
+ saved = restorer.read()
+ build.assert_not_called()
+ with patch.object(RegistryRecipeStorage, "list_recipes", side_effect=AssertionError("read again")):
+ report = restorer.restore(saved=saved)
+
+ assert report.failures == []
+ assert [ref.name for ref in report.restored] == ["contoso_gpt4o"]
+
+ def test_orders_entries_of_the_same_kind(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "a_pool", _round_robin("b_member", "c_member"))
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "b_member", _IDENTITY_TARGET)
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "c_member", _IDENTITY_TARGET)
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ assert report.failures == []
+ assert [ref.name for ref in report.restored] == ["b_member", "c_member", "a_pool"]
+ assert isinstance(TargetRegistry.get_registry_singleton().instances.get("a_pool"), RoundRobinTarget)
+
+ def test_technique_recipe_matches_the_create_request(self) -> None:
+ recipe_fields = set(TechniqueRecipe.model_fields) - {"schema_version"}
+ request_fields = set(CreateTechniqueRequest.model_fields) - {"name"}
+ request = CreateTechniqueRequest.model_validate({"name": "formal_red_team", **_TECHNIQUE})
+ recipe = TechniqueRecipe.model_validate(_TECHNIQUE)
+
+ assert recipe_fields == request_fields
+ assert recipe.model_dump(exclude_unset=True, exclude={"schema_version", "type", "tags"}) == (
+ request.model_dump(exclude_unset=True, exclude={"name", "type", "tags"})
+ )
+
+ def test_technique_recipe_defaults_match_the_create_request(self) -> None:
+ recipe = TechniqueRecipe.model_validate({"type": "RedTeamingAttack"})
+ request = CreateTechniqueRequest.model_validate({"name": "formal_red_team", "type": "RedTeamingAttack"})
+
+ assert recipe.model_dump(exclude={"schema_version"}) == request.model_dump(exclude={"name"})
+
+ @pytest.mark.parametrize(
+ "changes",
+ [
+ {"tags": ["all"]},
+ {"tags": ["Custom", "custom"]},
+ {"tags": ["bad-tag"]},
+ {"type": ""},
+ {"params": {"x" * 300: 1}},
+ {"request_converters": ["c"] * 101},
+ {"params": {"temperature": float("nan")}},
+ {"description": "x" * 100_001},
+ {"unexpected": True},
+ ],
+ )
+ def test_technique_recipe_rejects_what_the_create_request_rejects(self, changes: dict[str, Any]) -> None:
+ document = {**_TECHNIQUE, **changes}
+
+ with pytest.raises(ValidationError):
+ CreateTechniqueRequest.model_validate({"name": "formal_red_team", **document})
+ with pytest.raises(ValidationError):
+ TechniqueRecipe.model_validate(document)
+
+
+@pytest.mark.usefixtures("patch_central_database")
+class TestRestoreFailures:
+ def test_a_technique_waits_for_its_saved_adversarial_target(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", {**_IDENTITY_TARGET, "class_name": "NoSuchTarget"})
+ without_converters = {key: value for key, value in _TECHNIQUE.items() if key != "request_converters"}
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "formal_red_team", without_converters)
+ startup_target = OpenAIChatTarget(
+ endpoint="https://startup.openai.azure.com/openai/v1", model_name="gpt-4o", auth_mode="identity"
+ )
+ TargetRegistry.get_registry_singleton().instances.register(startup_target, name="contoso_gpt4o")
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ failure = _failures_by_name(report.failures)["formal_red_team"]
+ assert failure.reason is RestoreFailureReason.FAILED_DEPENDENCY
+ assert failure.dependency == SavedEntryRef(kind=RegistryRecipeKind.TARGETS, name="contoso_gpt4o")
+
+ @pytest.mark.parametrize("field", ["request_converters", "response_converters"])
+ def test_a_technique_waits_for_a_saved_converter_in_either_list(self, tmp_path: Path, field: str) -> None:
+ ConverterRegistry.get_registry_singleton().instances.register(Base64Converter(), name="shared_name")
+ _save_text(tmp_path, RegistryRecipeKind.CONVERTERS, "shared_name", "{not json")
+ _save(
+ tmp_path, RegistryRecipeKind.TECHNIQUES, "uses_shared", {"type": "RedTeamingAttack", field: ["shared_name"]}
+ )
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ failure = _failures_by_name(report.failures)["uses_shared"]
+ assert failure.reason is RestoreFailureReason.FAILED_DEPENDENCY
+ assert failure.dependency == SavedEntryRef(kind=RegistryRecipeKind.CONVERTERS, name="shared_name")
+
+ def test_reports_an_unknown_attack_type(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "unknown_attack", {"type": "NoSuchAttack"})
+
+ (failure,) = RegistryRecipeRestorer(source=str(tmp_path)).restore().failures
+
+ assert (failure.name, failure.reason) == ("unknown_attack", RestoreFailureReason.UNKNOWN_CLASS)
+
+ def test_technique_parameters_are_checked_as_strict_json(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+ without_converters = {key: value for key, value in _TECHNIQUE.items() if key != "request_converters"}
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "loose", {**without_converters, "params": {"max_turns": "5"}})
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("loose", RestoreFailureReason.BUILD_ERROR)
+
+ def test_reports_dependency_loops_and_entries_waiting_on_them(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "loop_a", _round_robin("loop_b"))
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "loop_b", _round_robin("loop_a"))
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "waiting", _round_robin("loop_a"))
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "self_loop", _round_robin("self_loop"))
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ failures = _failures_by_name(report.failures)
+ assert report.restored == []
+ assert failures["loop_a"].reason is RestoreFailureReason.DEPENDENCY_LOOP
+ assert failures["loop_b"].reason is RestoreFailureReason.DEPENDENCY_LOOP
+ assert failures["self_loop"].reason is RestoreFailureReason.DEPENDENCY_LOOP
+ assert failures["waiting"].reason is RestoreFailureReason.FAILED_DEPENDENCY
+ assert failures["waiting"].dependency == SavedEntryRef(kind=RegistryRecipeKind.TARGETS, name="loop_a")
+
+ def test_names_a_loop_member_as_the_reason_for_a_loop(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "a", _round_robin("c", "b"))
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "b", _round_robin("a"))
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "c", _round_robin("d"))
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "d", _round_robin("c"))
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ failures = _failures_by_name(report.failures)
+ assert failures["a"].reason is RestoreFailureReason.DEPENDENCY_LOOP
+ assert failures["a"].dependency == SavedEntryRef(kind=RegistryRecipeKind.TARGETS, name="b")
+
+ def test_reports_missing_and_failed_dependencies(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "broken", {**_IDENTITY_TARGET, "class_name": "NoSuchTarget"})
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "uses_broken", _round_robin("broken"))
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "uses_ghost", _round_robin("ghost"))
+ without_adversarial = {key: value for key, value in _TECHNIQUE.items() if key != "adversarial_chat"}
+ _save(
+ tmp_path,
+ RegistryRecipeKind.TECHNIQUES,
+ "uses_unparsable",
+ {**without_adversarial, "request_converters": ["bad"]},
+ )
+ _save_text(tmp_path, RegistryRecipeKind.CONVERTERS, "bad", "{not json")
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ failures = _failures_by_name(report.failures)
+ assert failures["broken"].reason is RestoreFailureReason.UNKNOWN_CLASS
+ assert failures["uses_broken"].reason is RestoreFailureReason.FAILED_DEPENDENCY
+ assert failures["uses_ghost"].reason is RestoreFailureReason.MISSING_DEPENDENCY
+ assert failures["uses_ghost"].dependency == SavedEntryRef(kind=RegistryRecipeKind.TARGETS, name="ghost")
+ assert failures["bad"].reason is RestoreFailureReason.INVALID_RECIPE
+ assert failures["uses_unparsable"].reason is RestoreFailureReason.FAILED_DEPENDENCY
+ assert failures["uses_unparsable"].dependency == SavedEntryRef(kind=RegistryRecipeKind.CONVERTERS, name="bad")
+
+ def test_restores_a_class_that_moved_within_pyrit(self, tmp_path: Path) -> None:
+ _save(
+ tmp_path,
+ RegistryRecipeKind.TARGETS,
+ "contoso_gpt4o",
+ {**_IDENTITY_TARGET, "class_module": "pyrit.prompt_target.openai_chat_target"},
+ )
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ assert report.failures == []
+ assert [ref.name for ref in report.restored] == ["contoso_gpt4o"]
+
+ def test_never_imports_a_module_named_by_a_file(self, tmp_path: Path) -> None:
+ module = "tests_registry_restore_never_imported"
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "moved", {**_IDENTITY_TARGET, "class_module": module})
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "unknown", {"class_module": "os", "class_name": "system"})
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ failures = _failures_by_name(report.failures)
+ assert failures["moved"].reason is RestoreFailureReason.MODULE_MISMATCH
+ assert _OPENAI_MODULE in failures["moved"].message
+ assert failures["unknown"].reason is RestoreFailureReason.UNKNOWN_CLASS
+ assert module not in sys.modules
+
+ def test_refuses_a_stored_credential(self, tmp_path: Path) -> None:
+ stored_key = {**_IDENTITY_TARGET, "params": {**_IDENTITY_TARGET["params"], "api_key": "sk-secret-value"}}
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "stored_key", stored_key)
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "good", _IDENTITY_TARGET)
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("stored_key", RestoreFailureReason.UNSUPPORTED_CREDENTIAL)
+ assert "sk-secret-value" not in failure.message
+ assert [ref.name for ref in report.restored] == ["good"]
+
+ def test_restores_a_class_whose_optional_credentials_are_left_empty(self, tmp_path: Path) -> None:
+ _save(
+ tmp_path,
+ RegistryRecipeKind.TARGETS,
+ "anonymous_agent",
+ {
+ "class_module": "pyrit.prompt_target.a2a_target",
+ "class_name": "A2ATarget",
+ "params": {"endpoint": "https://agent.example.test"},
+ },
+ )
+
+ with patch.object(TargetRegistry, "create_named_instance") as build:
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ assert report.failures == []
+ build.assert_called_once_with(
+ name="anonymous_agent",
+ type_name="A2ATarget",
+ params={"endpoint": "https://agent.example.test"},
+ external_input=True,
+ allowed_path_params=frozenset(),
+ )
+
+ def test_refuses_a_class_that_needs_the_server_upload_folder(self, tmp_path: Path) -> None:
+ _save(
+ tmp_path,
+ RegistryRecipeKind.TARGETS,
+ "uploader",
+ {
+ "class_module": "pyrit.prompt_target.http_target.httpx_api_target",
+ "class_name": "HTTPXAPITarget",
+ "params": {"http_url": "https://example.test/upload", "file_path": "/etc/hosts"},
+ },
+ )
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ (failure,) = report.failures
+ assert failure.reason is RestoreFailureReason.UNSUPPORTED_CLASS
+ assert "uploader" not in TargetRegistry.get_registry_singleton().instances.get_names()
+
+ @pytest.mark.parametrize("auth_mode", [[], {}, ["identity"]])
+ def test_reports_malformed_values_without_stopping_the_restore(self, tmp_path: Path, auth_mode: object) -> None:
+ malformed = {**_IDENTITY_TARGET, "params": {**_IDENTITY_TARGET["params"], "auth_mode": auth_mode}}
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "malformed", malformed)
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "good", _IDENTITY_TARGET)
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ assert [failure.name for failure in report.failures] == ["malformed"]
+ assert [ref.name for ref in report.restored] == ["good"]
+
+ def test_a_planning_error_fails_only_that_entry(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "a_first", _IDENTITY_TARGET)
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "b_second", _IDENTITY_TARGET)
+
+ with patch.object(
+ RegistryRecipeRestorer, "_parameter_references", side_effect=[RuntimeError("unexpected"), []]
+ ):
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("a_first", RestoreFailureReason.BUILD_ERROR)
+ assert failure.message == "RuntimeError: unexpected"
+ assert [ref.name for ref in report.restored] == ["b_second"]
+
+ @pytest.mark.parametrize(
+ "endpoint",
+ [
+ "https://evil.example/openai/v1?sig=SYNTHETIC-SAS#frag",
+ "https://evil.example/openai/v1'part?sig=SYNTHETIC-SAS",
+ "https://evil.example/openai/v1#SYNTHETIC-SAS",
+ "https://user:hunter2@evil.example/openai/v1",
+ "evil.example/openai/v1?api-version=1&sig=SYNTHETIC-SAS",
+ ],
+ )
+ def test_build_errors_drop_credentials_from_urls(self, tmp_path: Path, endpoint: str) -> None:
+ no_endpoint = {**_IDENTITY_TARGET, "params": {"auth_mode": "identity", "model_name": "gpt-4o"}}
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "server_endpoint", no_endpoint)
+
+ with patch.dict(os.environ, {"OPENAI_CHAT_ENDPOINT": endpoint}):
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ (failure,) = report.failures
+ assert failure.reason is RestoreFailureReason.BUILD_ERROR
+ assert failure.message.startswith("ValueError: Identity-based authentication requires")
+ assert "evil.example/openai/v1" in failure.message
+ for secret in ("hunter2", "SYNTHETIC", "frag"):
+ assert secret not in failure.message
+
+ def test_build_errors_keep_text_without_a_credential(self, tmp_path: Path) -> None:
+ no_endpoint = {**_IDENTITY_TARGET, "params": {"auth_mode": "identity", "model_name": "gpt-4o"}}
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "server_endpoint", no_endpoint)
+
+ with patch.dict(os.environ, {"OPENAI_CHAT_ENDPOINT": "https://api.example.test/v1"}):
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ (failure,) = report.failures
+ assert "'https://api.example.test/v1'. Pass auth_mode" in failure.message
+
+ def test_error_text_that_is_not_a_valid_url_fails_only_that_entry(self, tmp_path: Path) -> None:
+ malformed = {
+ **_IDENTITY_TARGET,
+ "params": {**_IDENTITY_TARGET["params"], "temperature": "https://[x?sig=SECRET"},
+ }
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "malformed", malformed)
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "good", _IDENTITY_TARGET)
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("malformed", RestoreFailureReason.BUILD_ERROR)
+ assert failure.version
+ assert "could not be coerced" in failure.message
+ assert "SECRET" not in failure.message
+ assert [ref.name for ref in report.restored] == ["good"]
+
+ @pytest.mark.parametrize(
+ ("text", "expected"),
+ [
+ ("got https://host/v1?sig=SECRET", "ValueError: got https://host/v1"),
+ ("got https://user:SECRET@host/v1#part tail", "ValueError: got https://host/v1 tail"),
+ ("got 'https://host/v1?api-key=SECRET' next", "ValueError: got 'https://host/v1 next"),
+ ("could not be coerced to float: 'hot'", "ValueError: could not be coerced to float: 'hot'"),
+ ("Which one? Ask admin@example.com", "ValueError: Which one? Ask admin@example.com"),
+ ("", "ValueError"),
+ ],
+ )
+ def test_safe_message_removes_url_credentials_and_keeps_the_rest(self, text: str, expected: str) -> None:
+ assert RegistryRecipeRestorer._safe_message(ValueError(text)) == expected
+
+ def test_safe_message_is_shortened(self) -> None:
+ message = RegistryRecipeRestorer._safe_message(ValueError("x" * 5_000))
+
+ assert len(message) == MAX_FAILURE_MESSAGE_LENGTH
+ assert message.endswith("...")
+
+ def test_safe_message_escapes_text_that_is_not_valid_unicode(self) -> None:
+ assert RegistryRecipeRestorer._safe_message(ValueError("bad \udcff")) == "ValueError: bad \\udcff"
+
+ def test_safe_message_survives_an_error_that_cannot_be_printed(self) -> None:
+ class UnprintableError(Exception):
+ def __str__(self) -> str:
+ raise RuntimeError("cannot print")
+
+ assert RegistryRecipeRestorer._safe_message(UnprintableError()) == "UnprintableError"
+
+ def test_logs_each_failure_once_with_its_traceback_and_without_url_credentials(
+ self, tmp_path: Path, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ leaky = {
+ **_IDENTITY_TARGET,
+ "params": {**_IDENTITY_TARGET["params"], "temperature": "https://host/v1?sig=SECRET"},
+ }
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "leaky", leaky)
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "pool", _round_robin("leaky"))
+
+ with caplog.at_level("WARNING", logger="pyrit.registry.registry_recipe_restore"):
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ assert {failure.name for failure in report.failures} == {"leaky", "pool"}
+ leaky_records = [record for record in caplog.records if "target 'leaky' (" in record.getMessage()]
+ assert len(leaky_records) == 1
+ assert "Traceback (most recent call last)" in leaky_records[0].getMessage()
+ assert len([record for record in caplog.records if "target 'pool' (" in record.getMessage()]) == 1
+ assert "SECRET" not in caplog.text
+
+ def test_reports_a_reference_name_that_is_not_valid_text_as_invalid(self, tmp_path: Path) -> None:
+ _save_text(
+ tmp_path,
+ RegistryRecipeKind.TARGETS,
+ "pool",
+ '{"class_module": "pyrit.prompt_target.round_robin_target", "class_name": "RoundRobinTarget", '
+ '"params": {"targets": ["ghost\\ud800"]}}',
+ )
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("pool", RestoreFailureReason.INVALID_RECIPE)
+ assert json.loads(report.model_dump_json())["failures"][0]["name"] == "pool"
+
+ def test_refuses_a_stored_credential_in_attack_parameters(self, tmp_path: Path) -> None:
+ attack_registry = AttackRegistry.get_registry_singleton()
+ original_get_registered_class_metadata = attack_registry.get_registered_class_metadata
+
+ def with_credential(name: str) -> Any:
+ metadata = original_get_registered_class_metadata(name)
+ credential = Parameter(name="api_key", description="", sensitive=True)
+ return dataclasses.replace(metadata, parameters=(*metadata.parameters, credential))
+
+ _save(
+ tmp_path,
+ RegistryRecipeKind.TECHNIQUES,
+ "keyed",
+ {"type": "RedTeamingAttack", "params": {"api_key": "sk-x"}},
+ )
+
+ with patch.object(attack_registry, "get_registered_class_metadata", side_effect=with_credential):
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("keyed", RestoreFailureReason.UNSUPPORTED_CREDENTIAL)
+ assert "sk-x" not in failure.message
+
+ def test_an_empty_reference_is_left_unset(self, tmp_path: Path) -> None:
+ _save(
+ tmp_path,
+ RegistryRecipeKind.CONVERTERS,
+ "puzzled",
+ {
+ "class_module": "pyrit.converter.puzzled.puzzled_converter",
+ "class_name": "PuzzledConverter",
+ "params": {"converter_target": ""},
+ },
+ )
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ assert report.failures == []
+ assert [ref.name for ref in report.restored] == ["puzzled"]
+
+ def test_follows_converter_and_scorer_references(self, tmp_path: Path) -> None:
+ _save(
+ tmp_path,
+ RegistryRecipeKind.CONVERTERS,
+ "selective",
+ {
+ "class_module": "pyrit.converter.selective_text_converter",
+ "class_name": "SelectiveTextConverter",
+ "params": {"sub_converter": "ghost_converter"},
+ },
+ )
+ _save(
+ tmp_path,
+ RegistryRecipeKind.SCORERS,
+ "composite",
+ {
+ "class_module": "pyrit.score.true_false.true_false_composite_scorer",
+ "class_name": "TrueFalseCompositeScorer",
+ "params": {"scorers": ["ghost_scorer"]},
+ },
+ )
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ failures = _failures_by_name(report.failures)
+ assert failures["selective"].dependency == SavedEntryRef(
+ kind=RegistryRecipeKind.CONVERTERS, name="ghost_converter"
+ )
+ assert failures["composite"].dependency == SavedEntryRef(kind=RegistryRecipeKind.SCORERS, name="ghost_scorer")
+ assert {failure.reason for failure in report.failures} == {RestoreFailureReason.MISSING_DEPENDENCY}
+
+ def test_refuses_reserved_technique_names_and_tags(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "types", {"type": "RedTeamingAttack"})
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "tagged_all", {"type": "RedTeamingAttack", "tags": ["all"]})
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ failures = _failures_by_name(report.failures)
+ assert failures["types"].reason is RestoreFailureReason.INVALID_RECIPE
+ assert failures["tagged_all"].reason is RestoreFailureReason.INVALID_RECIPE
+ assert report.restored == []
+
+ def test_rejects_server_paths_the_way_the_api_does(self, tmp_path: Path) -> None:
+ _save(
+ tmp_path,
+ RegistryRecipeKind.CONVERTERS,
+ "wordswap",
+ {
+ "class_module": "pyrit.converter.colloquial_wordswap_converter",
+ "class_name": "ColloquialWordswapConverter",
+ "params": {"wordswap_path": "/etc/passwd"},
+ },
+ )
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ (failure,) = report.failures
+ assert failure.reason is RestoreFailureReason.UNSUPPORTED_CLASS
+ assert "wordswap_path" in failure.message
+ assert "wordswap" not in ConverterRegistry.get_registry_singleton().instances.get_names()
+
+ def test_lets_a_target_set_the_path_parameters_its_class_allows_callers(self, tmp_path: Path) -> None:
+ workspace = tmp_path / "workspace"
+ workspace.mkdir()
+ recipes = tmp_path / "recipes"
+ _save(
+ recipes,
+ RegistryRecipeKind.TARGETS,
+ "copilot",
+ {
+ "class_module": "pyrit.prompt_target.github_copilot_target",
+ "class_name": "GitHubCopilotTarget",
+ "params": {"model_name": "gpt-5-mini", "working_directory": str(workspace)},
+ },
+ )
+
+ with patch.dict(sys.modules, {"copilot": ModuleType("copilot")}):
+ report = RegistryRecipeRestorer(source=str(recipes)).restore()
+
+ assert report.failures == []
+ target = TargetRegistry.get_registry_singleton().instances.get("copilot")
+ assert target is not None
+ assert target._working_directory == str(workspace.resolve())
+
+ def test_gives_only_targets_the_path_parameters_their_class_allows_callers(self, tmp_path: Path) -> None:
+ class CallerPathWordswap(ColloquialWordswapConverter):
+ caller_path_parameters = frozenset({"wordswap_path"})
+
+ ConverterRegistry.get_registry_singleton().register_class(CallerPathWordswap)
+ _save(
+ tmp_path,
+ RegistryRecipeKind.CONVERTERS,
+ "wordswap",
+ {
+ "class_module": CallerPathWordswap.__module__,
+ "class_name": "CallerPathWordswap",
+ "params": {"wordswap_path": "/etc/passwd"},
+ },
+ )
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ (failure,) = report.failures
+ assert failure.reason is RestoreFailureReason.UNSUPPORTED_CLASS
+ assert "wordswap_path" in failure.message
+ assert "wordswap" not in ConverterRegistry.get_registry_singleton().instances.get_names()
+
+ def test_reports_a_name_taken_at_startup_and_fails_its_dependents(self, tmp_path: Path) -> None:
+ startup_target = OpenAIChatTarget(
+ endpoint="https://startup.openai.azure.com/openai/v1", model_name="gpt-4o", auth_mode="identity"
+ )
+ TargetRegistry.get_registry_singleton().instances.register(startup_target, name="contoso_gpt4o")
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "pool", _round_robin("contoso_gpt4o"))
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ failures = _failures_by_name(report.failures)
+ assert failures["contoso_gpt4o"].reason is RestoreFailureReason.NAME_CLASH
+ assert failures["pool"].reason is RestoreFailureReason.FAILED_DEPENDENCY
+ # The saved entry holds its name, so the startup target doesn't quietly stand in for it.
+ with pytest.raises(SavedEntryUnavailableError, match="already exists"):
+ TargetRegistry.get_registry_singleton().instances.get("contoso_gpt4o")
+
+ def test_reports_a_technique_name_that_matches_a_tag_in_another_case(self, tmp_path: Path) -> None:
+ without_converters = {key: value for key, value in _TECHNIQUE.items() if key != "request_converters"}
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "formal_red_team", {**without_converters, "tags": ["Custom"]})
+ assert RegistryRecipeRestorer(source=str(tmp_path)).restore().failures == []
+ TargetRegistry.reset_registry_singleton()
+ (tmp_path / "techniques" / "formal_red_team.json").unlink()
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "custom", {**without_converters, "tags": []})
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("custom", RestoreFailureReason.NAME_CLASH)
+ assert "custom" in failure.message
+
+ def test_builds_nothing_when_a_folder_cannot_be_listed(
+ self, tmp_path: Path, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+ (tmp_path / "converters").write_text("a file where the folder should be")
+
+ with caplog.at_level("WARNING", logger="pyrit.registry.registry_recipe_restore"):
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ unbuilt, folder = report.failures
+ assert (folder.kind, folder.name, folder.reason) == (
+ RegistryRecipeKind.CONVERTERS,
+ None,
+ RestoreFailureReason.UNREADABLE_STORAGE,
+ )
+ assert (unbuilt.name, unbuilt.reason) == ("contoso_gpt4o", RestoreFailureReason.UNREADABLE_STORAGE)
+ assert unbuilt.message == "Not restored, because the saved converters couldn't be listed."
+ assert str(tmp_path) not in folder.message
+ assert str(tmp_path / "converters") in caplog.text
+ assert report.restored == []
+ assert TargetRegistry.get_registry_singleton().instances.get_names() == []
+
+ @pytest.mark.parametrize(
+ ("kind", "document", "registry"),
+ [
+ (RegistryRecipeKind.TARGETS, {**_IDENTITY_TARGET, "class_name": "NoSuchTarget"}, TargetRegistry),
+ (RegistryRecipeKind.TECHNIQUES, {"type": "NoSuchAttack"}, AttackTechniqueRegistry),
+ ],
+ )
+ def test_holds_the_name_of_an_entry_that_failed(
+ self, tmp_path: Path, kind: RegistryRecipeKind, document: dict[str, Any], registry: Any
+ ) -> None:
+ _save(tmp_path, kind, "broken", document)
+
+ RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ with pytest.raises(SavedEntryUnavailableError, match="'broken' couldn't be restored: 'NoSuch"):
+ registry.get_registry_singleton().instances.get("broken")
+
+ def test_a_folder_that_cannot_be_listed_holds_every_unregistered_name_of_its_kind(self, tmp_path: Path) -> None:
+ startup_target = OpenAIChatTarget(
+ endpoint="https://startup.openai.azure.com/openai/v1", model_name="gpt-4o", auth_mode="identity"
+ )
+ instances = TargetRegistry.get_registry_singleton().instances
+ instances.register(startup_target, name="startup_chat")
+ (tmp_path / "targets").write_text("a file where the folder should be")
+
+ RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ assert instances.get("startup_chat") is startup_target
+ with pytest.raises(SavedEntryUnavailableError, match="'adversarial_chat' may be a saved target"):
+ instances.get("adversarial_chat")
+
+ def test_raises_a_listing_bug_instead_of_blaming_storage(self, tmp_path: Path) -> None:
+ with patch.object(RegistryRecipeStorage, "list_recipes", side_effect=KeyError("bug")):
+ with pytest.raises(KeyError, match="bug"):
+ RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ def test_reports_a_planning_bug_as_a_build_error(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+
+ with (
+ patch.object(RegistryRecipeRestorer, "_component_references", side_effect=KeyError("bug")),
+ caplog.at_level("WARNING", logger="pyrit.registry.registry_recipe_restore"),
+ ):
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("contoso_gpt4o", RestoreFailureReason.BUILD_ERROR)
+ assert "KeyError: 'bug'" in caplog.text
+ assert "Traceback (most recent call last)" in caplog.text
+
+ def test_reports_a_technique_that_does_not_fit_its_scenarios_as_invalid(self, tmp_path: Path) -> None:
+ without_converters = {key: value for key, value in _TECHNIQUE.items() if key != "request_converters"}
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "formal_red_team", without_converters)
+
+ with patch.object(AttackTechniqueRegistry, "validate_scenario_pools", side_effect=ValueError("pool conflict")):
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("formal_red_team", RestoreFailureReason.INVALID_RECIPE)
+ assert "pool conflict" in failure.message
+
+ def test_reports_constructor_errors_and_keeps_going(self, tmp_path: Path) -> None:
+ bad_value = {**_IDENTITY_TARGET, "params": {**_IDENTITY_TARGET["params"], "temperature": "hot"}}
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "bad_value", bad_value)
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "good", _IDENTITY_TARGET)
+
+ report = RegistryRecipeRestorer(source=str(tmp_path)).restore()
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("bad_value", RestoreFailureReason.BUILD_ERROR)
+ assert failure.version
+ assert [ref.name for ref in report.restored] == ["good"]
diff --git a/tests/unit/registry/test_registry_recipe_storage.py b/tests/unit/registry/test_registry_recipe_storage.py
new file mode 100644
index 0000000000..301cd8fade
--- /dev/null
+++ b/tests/unit/registry/test_registry_recipe_storage.py
@@ -0,0 +1,282 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import hashlib
+import json
+import os
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+from azure.storage.blob import ContainerClient
+from pydantic import ValidationError
+
+from pyrit.common.path import CONFIGURATION_DIRECTORY_PATH
+from pyrit.models.catalog import (
+ ComponentRecipe,
+ RegistryRecipeKind,
+ RestoreFailureReason,
+ SavedEntryRef,
+ StoredRecipe,
+ TechniqueRecipe,
+)
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError, _read_regular_file
+from pyrit.registry.registry_recipe_storage import MAX_FAILURE_MESSAGE_LENGTH, RegistryRecipeStorage
+
+_TARGET = {
+ "schema_version": 1,
+ "class_module": "pyrit.prompt_target.openai.openai_chat_target",
+ "class_name": "OpenAIChatTarget",
+ "params": {"auth_mode": "identity", "endpoint": "https://contoso.openai.azure.com/openai/v1"},
+}
+
+
+def _write(directory: Path, name: str, content: str | bytes) -> bytes:
+ directory.mkdir(parents=True, exist_ok=True)
+ data = content.encode("utf-8") if isinstance(content, str) else content
+ (directory / f"{name}.json").write_bytes(data)
+ return data
+
+
+class TestRegistryRecipeStorageSource:
+ def test_local_source_uses_a_folder_per_registry(self, tmp_path: Path) -> None:
+ storage = RegistryRecipeStorage(kind=RegistryRecipeKind.CONVERTERS, source=str(tmp_path))
+
+ assert storage.display_source == str(tmp_path / "converters")
+
+ def test_default_source_is_under_the_pyrit_configuration_directory(self) -> None:
+ storage = RegistryRecipeStorage(kind=RegistryRecipeKind.SCORERS)
+
+ assert storage.display_source == str(CONFIGURATION_DIRECTORY_PATH / "registry_recipes" / "scorers")
+
+ def test_blob_source_adds_the_folder_and_keeps_the_sas_token(self) -> None:
+ source = "https://account.blob.core.windows.net/recipes/team?sp=rl&sig=secret"
+
+ kind_source = RegistryRecipeStorage._get_kind_source(root=source, kind=RegistryRecipeKind.TARGETS)
+
+ assert kind_source == "https://account.blob.core.windows.net/recipes/team/targets?sp=rl&sig=secret"
+
+ def test_blob_container_source_uses_the_folder_as_prefix(self) -> None:
+ client = MagicMock(spec=ContainerClient)
+ client.__enter__.return_value = client
+ client.list_blobs.return_value = [SimpleNamespace(name="techniques/formal.json")]
+ client.download_blob.return_value.readall.return_value = json.dumps({"type": "RedTeamingAttack"}).encode()
+ storage = RegistryRecipeStorage(
+ kind=RegistryRecipeKind.TECHNIQUES, source="https://account.blob.core.windows.net/recipes?sig=secret"
+ )
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ listing = storage.list_recipes()
+
+ client.list_blobs.assert_called_once_with(name_starts_with="techniques/")
+ assert list(listing.recipes) == ["formal"]
+
+
+class TestRecipeModels:
+ def test_a_document_without_a_schema_version_is_version_one(self) -> None:
+ document = {key: value for key, value in _TARGET.items() if key != "schema_version"}
+
+ with patch.object(ComponentRecipe, "CURRENT_SCHEMA_VERSION", 2):
+ assert ComponentRecipe.model_validate(document).schema_version == 1
+
+ def test_rejects_a_schema_version_newer_than_the_model_reads(self) -> None:
+ with pytest.raises(ValidationError, match="newer than this PyRIT reads"):
+ ComponentRecipe.model_validate({**_TARGET, "schema_version": 2})
+
+ def test_every_restore_failure_reason_has_an_explanation(self) -> None:
+ assert all(reason.explanation for reason in RestoreFailureReason)
+
+ @pytest.mark.parametrize(
+ ("kind", "recipe"),
+ [
+ (RegistryRecipeKind.TECHNIQUES, ComponentRecipe.model_validate(_TARGET)),
+ (RegistryRecipeKind.TARGETS, TechniqueRecipe.model_validate({"type": "RedTeamingAttack"})),
+ ],
+ )
+ def test_a_stored_recipe_must_match_its_registry(
+ self, kind: RegistryRecipeKind, recipe: ComponentRecipe | TechniqueRecipe
+ ) -> None:
+ with pytest.raises(ValidationError, match="entry needs a"):
+ StoredRecipe(ref=SavedEntryRef(kind=kind, name="mismatched"), recipe=recipe, version="v1")
+
+
+class TestRegistryRecipeStorageListing:
+ def test_reads_component_recipes_with_their_versions(self, tmp_path: Path) -> None:
+ content = _write(tmp_path / "targets", "contoso_gpt4o", json.dumps(_TARGET))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ stored = listing.recipes["contoso_gpt4o"]
+ assert stored.ref.kind is RegistryRecipeKind.TARGETS
+ assert stored.ref.name == "contoso_gpt4o"
+ assert stored.recipe == ComponentRecipe.model_validate(_TARGET)
+ assert stored.version == hashlib.sha256(content).hexdigest()
+ assert listing.failures == ()
+
+ def test_reads_technique_recipes_as_technique_recipes(self, tmp_path: Path) -> None:
+ document = {"type": "RedTeamingAttack", "tags": ["custom"], "adversarial_chat": "contoso_gpt4o"}
+ _write(tmp_path / "techniques", "formal_red_team", json.dumps(document))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TECHNIQUES, source=str(tmp_path)).list_recipes()
+
+ assert listing.recipes["formal_red_team"].recipe == TechniqueRecipe.model_validate(document)
+
+ def test_reports_documents_that_are_not_recipes(self, tmp_path: Path) -> None:
+ folder = tmp_path / "targets"
+ _write(folder, "not_json", "{oops")
+ _write(folder, "not_utf8", b"\xff\xfe")
+ _write(folder, "not_object", "[1, 2]")
+ _write(folder, "extra_field", json.dumps({**_TARGET, "credentials": {"api_key": "sk-secret-value"}}))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ assert listing.recipes == {}
+ assert {failure.name for failure in listing.failures} == {"not_json", "not_utf8", "not_object", "extra_field"}
+ assert all(failure.reason is RestoreFailureReason.INVALID_RECIPE for failure in listing.failures)
+ assert all(failure.version for failure in listing.failures)
+ extra = next(failure for failure in listing.failures if failure.name == "extra_field")
+ assert "credentials" in extra.message
+ assert "sk-secret-value" not in extra.message
+
+ def test_reports_a_document_nested_too_deeply(self, tmp_path: Path) -> None:
+ _write(tmp_path / "targets", "deep", "[" * 100_000 + "]" * 100_000)
+ _write(tmp_path / "targets", "contoso_gpt4o", json.dumps(_TARGET))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ (failure,) = listing.failures
+ assert (failure.name, failure.reason) == ("deep", RestoreFailureReason.INVALID_RECIPE)
+ assert list(listing.recipes) == ["contoso_gpt4o"]
+
+ def test_limits_a_technique_description_like_the_create_request(self, tmp_path: Path) -> None:
+ folder = tmp_path / "techniques"
+ _write(folder, "at_limit", json.dumps({"type": "RedTeamingAttack", "description": "x" * 100_000}))
+ _write(folder, "over_limit", json.dumps({"type": "RedTeamingAttack", "description": "x" * 100_001}))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TECHNIQUES, source=str(tmp_path)).list_recipes()
+
+ assert list(listing.recipes) == ["at_limit"]
+ (failure,) = listing.failures
+ assert (failure.name, failure.reason) == ("over_limit", RestoreFailureReason.INVALID_RECIPE)
+ assert "description" in failure.message
+
+ def test_reports_a_newer_schema_version_before_checking_fields(self, tmp_path: Path) -> None:
+ _write(tmp_path / "targets", "future", json.dumps({"schema_version": 2, "unknown_field": True}))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ (failure,) = listing.failures
+ assert failure.reason is RestoreFailureReason.NEWER_SCHEMA_VERSION
+ assert "newer PyRIT" in failure.message
+
+ def test_reports_a_file_name_that_is_not_utf8(self, tmp_path: Path) -> None:
+ folder = tmp_path / "targets"
+ folder.mkdir()
+ try:
+ (folder / os.fsdecode(b"bad\xffname.json")).write_text("{}")
+ except (OSError, UnicodeError):
+ pytest.skip("This file system only allows UTF-8 file names")
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ (ignored,) = listing.ignored
+ assert ignored.file_name == "bad\\udcffname.json"
+ assert "bad\\udcffname" in ignored.message
+ assert json.loads(ignored.model_dump_json())["file_name"] == "bad\\udcffname.json"
+
+ def test_reports_unreadable_and_ignored_files(self, tmp_path: Path) -> None:
+ folder = tmp_path / "scorers"
+ _write(folder, "locked", json.dumps(_TARGET))
+ _write(folder, "Bad-Name", "{}")
+
+ def read(path: str) -> bytes | None:
+ if Path(path).name == "locked.json":
+ raise PermissionError(13, "Permission denied", path)
+ return _read_regular_file(path)
+
+ with patch("pyrit.registry.file_document_storage._read_regular_file", side_effect=read):
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.SCORERS, source=str(tmp_path)).list_recipes()
+
+ (failure,) = listing.failures
+ assert (failure.name, failure.reason, failure.message) == (
+ "locked",
+ RestoreFailureReason.UNREADABLE_RECIPE,
+ "Permission denied",
+ )
+ assert failure.version is None
+ assert isinstance(listing.causes["locked"], PermissionError)
+ (ignored,) = listing.ignored
+ assert (ignored.kind, ignored.file_name) == (RegistryRecipeKind.SCORERS, "Bad-Name.json")
+ assert "Bad-Name" in ignored.message
+
+ def test_accepts_a_byte_order_mark(self, tmp_path: Path) -> None:
+ _write(tmp_path / "targets", "from_notepad", b"\xef\xbb\xbf" + json.dumps(_TARGET).encode("utf-8"))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ assert list(listing.recipes) == ["from_notepad"]
+
+ @pytest.mark.parametrize(
+ "document",
+ [
+ '{"class_module": "m", "class_name": "C", "params": {"text": "bad \\udcff"}}',
+ '{"class_module": "m", "class_name": "C", "params": {"bad \\ud800": 1}}',
+ ],
+ ids=["value", "key"],
+ )
+ def test_reports_an_unpaired_surrogate_as_invalid(self, tmp_path: Path, document: str) -> None:
+ _write(tmp_path / "targets", "odd_text", document)
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ (failure,) = listing.failures
+ assert (failure.name, failure.reason) == ("odd_text", RestoreFailureReason.INVALID_RECIPE)
+ assert "unpaired surrogate" in failure.message
+
+ def test_keeps_text_that_is_valid_unicode(self, tmp_path: Path) -> None:
+ document = {**_TARGET, "params": {"greeting": "caf\u00e9 \U0001f600"}}
+ _write(tmp_path / "targets", "friendly", json.dumps(document))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ assert listing.recipes["friendly"].recipe.params["greeting"] == "caf\u00e9 \U0001f600"
+
+ def test_checks_a_technique_against_the_technique_format_version(self, tmp_path: Path) -> None:
+ _write(tmp_path / "techniques", "next_format", json.dumps({"schema_version": 2, "type": "RedTeamingAttack"}))
+ _write(tmp_path / "targets", "next_format", json.dumps({**_TARGET, "schema_version": 2}))
+
+ with patch.object(TechniqueRecipe, "CURRENT_SCHEMA_VERSION", 2):
+ techniques = RegistryRecipeStorage(kind=RegistryRecipeKind.TECHNIQUES, source=str(tmp_path)).list_recipes()
+ targets = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ assert list(techniques.recipes) == ["next_format"]
+ assert targets.failures[0].reason is RestoreFailureReason.NEWER_SCHEMA_VERSION
+
+ def test_shortens_what_a_validation_error_repeats(self, tmp_path: Path) -> None:
+ long_key = "k" * 10_000
+ many_keys = {f"unknown_{index:03d}_{'x' * 40}": True for index in range(100)}
+ _write(tmp_path / "targets", "long_key", json.dumps({**_TARGET, long_key: True}))
+ _write(tmp_path / "targets", "many_keys", json.dumps({**_TARGET, **many_keys}))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ messages = {failure.name: failure.message for failure in listing.failures}
+ assert "k" * 61 + "..." in messages["long_key"]
+ assert "k" * 65 not in messages["long_key"]
+ assert len(messages["many_keys"]) == MAX_FAILURE_MESSAGE_LENGTH
+ assert messages["many_keys"].endswith("...")
+
+ def test_a_listing_cannot_be_changed_by_its_reader(self, tmp_path: Path) -> None:
+ _write(tmp_path / "targets", "contoso_gpt4o", json.dumps(_TARGET))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ with pytest.raises(TypeError):
+ listing.recipes["other"] = listing.recipes["contoso_gpt4o"] # type: ignore[index]
+
+ def test_raises_when_the_folder_cannot_be_listed(self, tmp_path: Path) -> None:
+ (tmp_path / "targets").write_text("a file where the folder should be")
+
+ with pytest.raises(DocumentStorageUnavailableError):
+ RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
diff --git a/tests/unit/registry/test_scenario_preset_storage.py b/tests/unit/registry/test_scenario_preset_storage.py
index 137ef3bfd3..2dcb6af285 100644
--- a/tests/unit/registry/test_scenario_preset_storage.py
+++ b/tests/unit/registry/test_scenario_preset_storage.py
@@ -15,7 +15,11 @@
from pyrit.models import DatasetSourceInput, ScenarioDatasetRequest, ScenarioPreset
from pyrit.models.catalog.scenario_preset import PRESET_SCHEMA_VERSION
from pyrit.registry import ScenarioPresetConflictError, ScenarioPresetStorage
-from pyrit.registry.file_document_storage import _release_exclusive_lock, _try_acquire_exclusive_lock
+from pyrit.registry.file_document_storage import (
+ DocumentStorageUnavailableError,
+ _release_exclusive_lock,
+ _try_acquire_exclusive_lock,
+)
def _make_preset(**overrides: object) -> ScenarioPreset:
@@ -415,6 +419,7 @@ def test_blob_storage_reads_missing_document_as_none() -> None:
client = MagicMock()
client.__enter__.return_value = client
client.download_blob.side_effect = ResourceNotFoundError("missing")
+ client.download_blob.side_effect.error_code = "BlobNotFound"
storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/presets?sig=secret")
with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
@@ -440,6 +445,7 @@ def test_blob_delete_of_a_missing_document_reports_absence_instead_of_raising()
client = MagicMock()
client.__enter__.return_value = client
client.delete_blob.side_effect = ResourceNotFoundError("missing")
+ client.delete_blob.side_effect.error_code = "BlobNotFound"
storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/presets?sig=secret")
with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
@@ -552,7 +558,7 @@ def fail(source: object, target: object) -> None:
raise OSError(28, "No space left on device")
with patch("os.replace", fail):
- with pytest.raises(OSError):
+ with pytest.raises(DocumentStorageUnavailableError):
storage.save_preset(preset=_make_preset(description="second"), expected_version=first.version)
loaded = storage.load_preset("nightly")
@@ -627,7 +633,7 @@ def test_a_second_writer_cannot_enter_while_a_save_is_in_flight(tmp_path: Path)
real_replace_file = ScenarioPresetStorage._replace_file
def replace_once_a_competitor_has_tried(*, path: Path, content: bytes) -> None:
- with pytest.raises(TimeoutError) as error:
+ with pytest.raises(DocumentStorageUnavailableError) as error:
competitor.save_preset(preset=_make_preset(description="racer"), expected_version=first.version)
refusals.append(str(error.value))
real_replace_file(path=path, content=content)
@@ -651,7 +657,7 @@ def test_a_delete_cannot_enter_while_a_save_is_in_flight(tmp_path: Path) -> None
real_replace_file = ScenarioPresetStorage._replace_file
def replace_once_a_deleter_has_tried(*, path: Path, content: bytes) -> None:
- with pytest.raises(TimeoutError) as error:
+ with pytest.raises(DocumentStorageUnavailableError) as error:
competitor.delete_preset("nightly")
refusals.append(str(error.value))
real_replace_file(path=path, content=content)
@@ -699,7 +705,7 @@ def test_a_lock_held_by_a_live_writer_is_respected(tmp_path: Path) -> None:
try:
with patch.object(ScenarioPresetStorage, "LOCK_TIMEOUT_SECONDS", 0.1):
- with pytest.raises(TimeoutError, match="nightly.json.lock"):
+ with pytest.raises(DocumentStorageUnavailableError, match="nightly.json.lock"):
storage.save_preset(preset=_make_preset(), expected_version=None)
finally:
_release_exclusive_lock(descriptor)
@@ -772,7 +778,7 @@ def test_blob_create_refuses_to_overwrite_a_document_another_writer_just_created
def test_blob_create_against_a_missing_container_is_not_reported_as_a_conflict() -> None:
"""Test that an absent container surfaces as a storage error instead of a phantom concurrent edit."""
- from azure.core.exceptions import AzureError, ResourceNotFoundError
+ from azure.core.exceptions import ResourceNotFoundError
client = MagicMock()
client.__enter__.return_value = client
@@ -780,7 +786,7 @@ def test_blob_create_against_a_missing_container_is_not_reported_as_a_conflict()
storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/absent?sig=secret")
with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
- with pytest.raises(AzureError):
+ with pytest.raises(DocumentStorageUnavailableError):
storage.save_preset(preset=_make_preset(), expected_version=None)
@@ -822,6 +828,7 @@ def test_blob_update_of_a_deleted_document_is_a_conflict() -> None:
client = MagicMock()
client.__enter__.return_value = client
client.download_blob.side_effect = ResourceNotFoundError("missing")
+ client.download_blob.side_effect.error_code = "BlobNotFound"
storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/presets?sig=secret")
with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
diff --git a/tests/unit/registry/test_scenario_suite_storage.py b/tests/unit/registry/test_scenario_suite_storage.py
index 8de826ac48..52f2585d76 100644
--- a/tests/unit/registry/test_scenario_suite_storage.py
+++ b/tests/unit/registry/test_scenario_suite_storage.py
@@ -15,6 +15,7 @@
from pyrit.models import ScenarioSuite, ScenarioSuiteEntry
from pyrit.models.catalog.scenario_suite import SUITE_SCHEMA_VERSION
from pyrit.registry import ScenarioSuiteConflictError, ScenarioSuiteStorage
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError
ENTRY_ONE = UUID("11111111-1111-4111-8111-111111111111")
ENTRY_TWO = UUID("22222222-2222-4222-8222-222222222222")
@@ -95,7 +96,7 @@ def test_failed_atomic_replace_preserves_previous_suite(tmp_path: Path) -> None:
created = storage.save_suite(suite=_suite(description="original"), expected_version=None)
with patch("os.replace", side_effect=OSError(28, "No space left on device")):
- with pytest.raises(OSError):
+ with pytest.raises(DocumentStorageUnavailableError):
storage.save_suite(suite=_suite(description="replacement"), expected_version=created.version)
loaded = storage.load_suite("weekly")
@@ -112,7 +113,7 @@ def test_local_lock_excludes_a_second_writer(tmp_path: Path) -> None:
real_replace = ScenarioSuiteStorage._replace_file
def replace_after_competitor_times_out(*, path: Path, content: bytes) -> None:
- with pytest.raises(TimeoutError):
+ with pytest.raises(DocumentStorageUnavailableError):
competitor.save_suite(suite=_suite(description="racer"), expected_version=created.version)
real_replace(path=path, content=content)
diff --git a/tests/unit/setup/test_configuration_loader.py b/tests/unit/setup/test_configuration_loader.py
index 535d105056..99ef6437bf 100644
--- a/tests/unit/setup/test_configuration_loader.py
+++ b/tests/unit/setup/test_configuration_loader.py
@@ -87,6 +87,25 @@ def test_scenario_suites_source_rejects_invalid_value(self, invalid_value: objec
with pytest.raises(ValueError, match="scenario_suites_source"):
ConfigurationLoader(scenario_suites_source=invalid_value) # type: ignore[arg-type]
+ def test_registry_recipes_source_loads_from_yaml(self, tmp_path: pathlib.Path) -> None:
+ """Test loading an Azure Blob container URI for saved registry entries."""
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text(
+ "registry_recipes_source: https://account.blob.core.windows.net/pyrit-storage/registry-recipes\n",
+ encoding="utf-8",
+ )
+
+ config = ConfigurationLoader.from_yaml_file(config_path)
+
+ assert config.registry_recipes_source == "https://account.blob.core.windows.net/pyrit-storage/registry-recipes"
+ assert ConfigurationLoader().registry_recipes_source is None
+
+ @pytest.mark.parametrize("invalid_value", ["", " ", 42])
+ def test_registry_recipes_source_rejects_invalid_value(self, invalid_value: object) -> None:
+ """Test rejecting empty or non-string sources for saved registry entries."""
+ with pytest.raises(ValueError, match="registry_recipes_source"):
+ ConfigurationLoader(registry_recipes_source=invalid_value) # type: ignore[arg-type]
+
def test_target_upload_directory_loads_from_yaml(self, tmp_path: pathlib.Path) -> None:
config_path = tmp_path / "config.yaml"
config_path.write_text("target_upload_directory: /srv/uploads\n", encoding="utf-8")