Skip to Content
SimulatorsGoogle Drive

Simulators

Google Drive simulator

google-drive-twin is a local, stateful simulator of the Google Drive API v3, built from the API’s Discovery document (revision 20260916) converted to OpenAPI.

Quickstart

You need an API key from Account → API keys  (how).

curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_…                                                              # your API key
rystic run google-drive-twin -d --port 0 --scenario google-drive_media_vault   # prints the URL it serves on
curl -s http://localhost:<port>/_rystic/version

Expect "service_hosts":["www.googleapis.com"]. The examples below use localhost:8080; use the port rystic run printed. On Enterprise, rystic login ./rystic-license.json takes the place of the key (license files).

Read the vault, then upload a file and download it back:

curl -s -H 'Authorization: Bearer ya29.placeholder' 'localhost:8080/drive/v3/files?q=trashed%3Dfalse&fields=files(id,name)'
# -> {"files":[{"id":"1MVfolder2025Shoots02","name":"2025 Shoots"},...,{"id":"1MVjpgBeachSunrise03","name":"beach-sunrise.jpg"}]}

curl -s -H 'Authorization: Bearer ya29.placeholder' -H 'Content-Type: text/plain' --data-binary 'hello' \
  'localhost:8080/upload/drive/v3/files?uploadType=media&fields=id,name,size'
# -> {"id":"1rysticFile0000000000000000000001","name":"Untitled","size":"5"}
curl -s -H 'Authorization: Bearer ya29.placeholder' 'localhost:8080/drive/v3/files/1rysticFile0000000000000000000001?alt=media'
# -> hello

Point your client at it

Google’s client libraries build every URL on https://www.googleapis.com, and the base-URL option alone does not move their upload URLs. An upload left unpointed goes to the real Google Drive.

Node (@googleapis/drive or googleapis): the client’s rootUrl skips upload URLs, so hand it a fetch that rewrites Google’s origin to the twin’s and refuses anything else.

import { auth, drive as makeDrive } from "@googleapis/drive";
const twin = "http://localhost:8080";
const user = new auth.OAuth2();
user.setCredentials({ access_token: "ya29.placeholder" });
const drive = makeDrive({
  version: "v3",
  auth: user,
  fetchImplementation: (url, init) => {
    const raw = String(url instanceof Request ? url.url : url);
    const target = raw.replace(/^https:\/\/www\.googleapis\.com/, twin);
    if (!target.startsWith(twin + "/")) throw new Error(`refusing ${target}`);
    return fetch(target, init);
  },
});
const { data: f } = await drive.files.create({
  requestBody: { name: "hello.txt" },
  media: { mimeType: "text/plain", body: "hello" },
  fields: "id,name",
});

Without the fetch, pass { rootUrl: "http://localhost:8080/" } as the second argument of every call that carries media, as well as to drive({...}).

Python (google-api-python-client): client_options api_endpoint keeps uploads on https, so build from the bundled Discovery document with its rootUrl rewritten; no request reaches Google, the document included.

import json
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build_from_document
from googleapiclient.discovery_cache import get_static_doc
from googleapiclient.http import MediaInMemoryUpload

doc = json.loads(get_static_doc("drive", "v3"))
doc["rootUrl"] = "http://localhost:8080/"
drive = build_from_document(doc, credentials=Credentials(token="ya29.placeholder"))
f = drive.files().create(body={"name": "hello.txt"},
                         media_body=MediaInMemoryUpload(b"hello", mimetype="text/plain"),
                         fields="id,name").execute()
drive.files().get_media(fileId=f["id"]).execute()   # b"hello"

Keep uploads non-resumable: the twin answers uploadType=resumable with 501. Both clients send a media upload as uploadType=multipart by default.

Plans

google-drive-twin is on Pro and Enterprise, not Free (plans); on a Free key, rystic pull google-drive-twin and a start are refused with product_not_in_plan. Both plans serve all 35 Drive routes, the whole control plane, custom latency and fault injection included, and every world. Without --scenario the twin boots one user with no files, and POST /_rystic/reset wipes it back to that.

Auth

Drive reads an OAuth 2 access token as Authorization: Bearer ya29.…. The twin accepts any non-empty bearer token; no Google Cloud project, OAuth client or token is involved.

  1. A request with no Authorization header, an empty bearer value or another scheme is refused as Drive refuses a missing credential on that method, before the file lookup: 401 required “Login Required.” with the method’s ErrorInfo (CREDENTIALS_MISSING) on most methods, 403 forbidden “Method doesn’t allow unregistered callers” on files.list, files.get and the comments and replies methods, and 403 forbidden “The request is missing a valid API key.” on files.get with alt=media and on files.export (RYS-2725).
  2. Every request speaks as the world’s own user, permission id member, whatever the token says. X-Rystic-Actor: <permissionId> speaks as another of the world’s users (GET /_rystic/inspect lists them under state_user), and POST /_rystic/credentials with {"bindings":{"<token>":"<permissionId>"}} makes a token speak as one. Loading a world clears the bindings.

Coverage

The twin serves 35 operations in three scopes. Metadata CRUD on the files resource: list files, create a file or a folder from its metadata, read one, update its metadata, copy it and delete it. Content and search: create and update a file by uploadType=media and uploadType=multipart on /upload/drive/v3, download a file’s bytes and a revision’s bytes with alt=media (a Range answers 206, and one past the end 416 with Drive’s JSON error), export a Google Docs Editors file, about.get, list and read revisions, files.generateIds, whose ids a create may name, and files.emptyTrash. Sharing and collaboration: permissions, comments and replies, each listed, read, created, updated and deleted, and updating and deleting a revision. Every route answers a caller by its role on the file: the owner, or the role a permission on the file or on a folder above it grants, so a file the caller holds no role on is not found, and ownedByMe, capabilities, shared, permissionIds and modifiedByMe are worked out for each caller. Every comments and replies method but delete requires fields. Each route answers Drive’s default members of its resource (a file’s kind, id, name and mimeType), and a fields selector narrows a success answer to the members it names from the whole resource, * for all of it. files.list filters by q, fullText, sharedWithMe and a caller’s role among them, and sorts by orderBy, newest first without one. addParents and removeParents move a file, a file inside a trashed folder reads trashed, a file created without parents lands in the caller’s My Drive root, and the id root names that folder on every route. The probe suite measures every route live inside one Google user’s own folder in My Drive. What a second user sees — a file not shared with them, a reader, a commenter, a comment’s non-author — is measured by the sealed suite’s probes that sign as a second user, and exercised by the designated SDK caller, which acts as two users against the twin.

35 routes across 8 resources, counted from the sim’s literal registrations.

ResourceOperations
About (1)GET /drive/v3/about
Comment (3)POST /drive/v3/files/{fileId}/comments; PATCH, DELETE /drive/v3/files/{fileId}/comments/{commentId}
File (22)GET, POST /drive/v3/files; DELETE /drive/v3/files/trash; GET, PATCH, DELETE /drive/v3/files/{fileId}; GET /drive/v3/files/{fileId}?alt=media; GET /drive/v3/files/{fileId}/comments; GET /drive/v3/files/{fileId}/comments/{commentId}; GET /drive/v3/files/{fileId}/comments/{commentId}/replies; GET /drive/v3/files/{fileId}/comments/{commentId}/replies/{replyId}; POST /drive/v3/files/{fileId}/copy; GET /drive/v3/files/{fileId}/export; GET /drive/v3/files/{fileId}/permissions; GET /drive/v3/files/{fileId}/permissions/{permissionId}; GET /drive/v3/files/{fileId}/revisions; GET /drive/v3/files/{fileId}/revisions/{revisionId}; GET /drive/v3/files/{fileId}/revisions/{revisionId}?alt=media; POST /upload/drive/v3/files?uploadType=media; POST /upload/drive/v3/files?uploadType=multipart; PATCH /upload/drive/v3/files/{fileId}?uploadType=media; PATCH /upload/drive/v3/files/{fileId}?uploadType=multipart
Generated file ID (1)GET /drive/v3/files/generateIds
Permission (3)POST /drive/v3/files/{fileId}/permissions; PATCH, DELETE /drive/v3/files/{fileId}/permissions/{permissionId}
Reply (3)POST /drive/v3/files/{fileId}/comments/{commentId}/replies; PATCH, DELETE /drive/v3/files/{fileId}/comments/{commentId}/replies/{replyId}
Revision (2)PATCH, DELETE /drive/v3/files/{fileId}/revisions/{revisionId}

Control plane

Every route under /_rystic/ answers on both plans; routes by plan lists them.

Scenarios

  • google-drive_fresh_mydrive: A freshly provisioned personal Drive at its floor: one owner and the My Drive root folder with nothing in it, so every listing starts empty, root names that folder, and the first created file is unmistakable.
  • google-drive_media_vault: A photographer’s binary archive — colour-coded folders holding a JPEG, an MP4, a camera RAW and a tar.gz of proofs, each with real byte sizes, md5/sha checksums, head revisions and app-set properties, one of them published to anyone with the link.
  • google-drive_shared_inbox: An account whose interesting files belong to other people: three items shared in by a vendor and a partner with sharedWithMeTime set and ownedByMe false, plus a shortcut filed in the caller’s own folder pointing at one of them.
  • google-drive_team_workspace: A lived-in My Drive tree Dana Okafor owns, shared with Milo Reyes (writer) and Priya Shah (commenter): a two-level folder tree under the My Drive root, three Google-native editor files, a binary PDF and a content-restricted policy doc, so files.list returns real rows from the first call and files.get reads each seeded file. Each caller’s access follows its role on the file: Dana, the default caller, owns every file, Milo edits and shares, Priya can read but not update, share or delete, and a caller with no role gets 404. Its Google-native files carry the mimeTypes files.export converts from.
  • google-drive_trash_backlog: A My Drive whose bin is already full from boot — a folder trashed outright, a doc trashed only because its parent was, a trashed sheet and image, and one live doc — so files.list returns trashed rows and files.update with trashed false has standing targets. files.emptyTrash answers 204 and deletes the folder, the doc inside it, the sheet and the image, leaving the root and the live doc.

Seeding state

POST /_rystic/seed takes a register map — state_user (keyed by permission id), state_file (keyed by file id), state_revision (keyed <fileId>/<revisionId>, its bytes in body_b64), state_permission, state_comment, state_reply — and merges it into the running Drive; Worlds covers seed files, GET /_rystic/registers and the seed gate. A file with bytes is a state_file whose headRevisionId names a state_revision holding them:

curl -X POST localhost:8080/_rystic/seed -d '{
  "state_file": {"notes1": {"id": "notes1", "name": "notes.txt", "mimeType": "text/plain",
    "parents": [], "owners": [{"permissionId": "member"}], "headRevisionId": "r1"}},
  "state_revision": {"notes1/r1": {"fileId": "notes1", "id": "r1", "mimeType": "text/plain",
    "body_b64": "aGVsbG8gd29ybGQ="}}
}'
# -> 204; alt=media on notes1 then answers "hello world"

Determinism & reset

Ids are sequences the reset rewinds, so the first file created after a reset has the same id every time, and the clock starts at a pinned instant — 2026-01-01T00:00:00Z empty, 2026-02-02T00:00:00Z in every world — and moves one millisecond per request that stamps a time — every write, none of the reads — never by wall time. Two identical runs from the same reset give byte-identical files. Reset and determinism covers reset, the clock and the RNG.

Egress

Drive declares no outbound effects: no request sends an email or calls a webhook, and GET /_rystic/egress is always empty.

Limitations

  • fields selects as Drive does: it narrows the 2xx answer and leaves an error reply whole, a selector that does not parse answers 400 invalidParameter, required on about.get (a create refused for its selector still writes the file), and an empty one answers {}, except on the comments and replies methods, which answer an absent or empty selector with 400 required as Drive does. A selector naming a member the method’s resource does not have, at any depth and in any case but its own (nope, owners(nope), capabilities/nope, ID), answers 400 invalidParameter “Invalid field selection nope” at location fields, as Drive does; a files.create refused this way still writes the file, while a media or multipart upload, create or update, refused this way writes nothing and is refused before any other check.
  • files.list answers q=name < 'x' with 400 invalid, where Drive answers 500 internalError (RYS-2831). visibility and Drive’s other unlisted search terms answer 400 invalid.
  • A file shared with the caller answers no sharedWithMeTime, and viewedByMe is false for every caller. What a writer is answered when it trashes, restores or deletes a file it does not own, and the writer’s and commenter’s capabilities, are worked out from the owner’s and the reader’s measured sets, not measured (RYS-2672).
  • What one user sees of another’s files is measured by the sealed probe suite, whose sharing probes sign as a second user (a reader, a commenter, and a user the file is not shared with), and exercised by the designated SDK caller.
  • A shipped world’s binary files download small real payloads that match their size and checksums; a file you upload or copy downloads byte for byte. An upload holds at most 64 MiB: a request body over it, a multipart body’s metadata and part headers included, answers 413 request body too large, where Drive takes up to 5,120 GB. Exports are stand-ins built from the file’s name and description, never a rendering.
  • A file’s permissions member answers [] for a file you create and keeps a seeded file’s old roles after a share or transfer, where permissions.list is right (RYS-2767).
  • An unserved route answers 404 notFound naming a file that does not exist, and a resumable upload answers 501. There are no rate limits: never a 403 userRateLimitExceeded or a 429.

Left out on purpose, with no later scope planned before the first release: shared drives, changes, files.download with operations.get, resumable upload and batch; files.watch, changes.watch and channels.stop, whose notifications go to an address the caller registers; approvals; access proposals; files.listLabels and files.modifyLabels; apps and files.generateCseToken; and the deprecated teamdrives methods.

Known issues.

  • A file’s permissions member answers [] for a file you create, and a seeded file’s inline permissions keep their old roles after a share or an ownership transfer; permissions.list is right (RYS-2767)
  • files.list with q=name < 'x' answers 400 invalid, where Drive answers 500 internalError (RYS-2831)
  • DELETE /drive/v3/files/trash permanently deletes a collaborator’s file inside the caller’s trashed folder, where Drive deletes only the caller’s own files (RYS-2884)
  • A folder’s owner can’t read a collaborator’s file in her own folder (404, and '<folder>' in parents leaves it out), and that file lists two owners (RYS-2886)
  • A link-only file (anyone with allowFileDiscovery: false) shows up in any user’s files.list (RYS-2887)
  • starred is one flag shared by every user, a reader or commenter can’t star a file, and starring bumps version and modifiedTime (RYS-2888)
  • Restrictions the twin shows in capabilities are not enforced: addParents into a folder with canAddChildren: false, a rename or comment on a read-only file, and a copy under copyRequiresWriterPermission (RYS-2889)
  • files.copy points the copy’s webViewLink, webContentLink and thumbnailLink at the source, ignores an id from generateIds, and leaves a reader’s copy with no parent (RYS-2890)
  • comments.get never returns replies, and its photoLink reads https:// where Drive answers // (RYS-2891)
  • The folder tree’s write rules are not enforced: a file can take two parents, a folder can move into itself or its child, a plain file is accepted as a parent, a body can change mimeType, and read-only members such as size are ignored rather than refused (RYS-2892)
  • A DELETE or trash of root is unmeasured: Drive reports canDelete and canTrash true on the root, so neither is ever sent to Drive, and the twin’s answer to them is not Drive’s (RYS-2894)
  • A media upload onto a folder answers 200 and leaves the folder unchanged, as Drive does; what Drive does with the uploaded bytes is unmeasured (RYS-2895)

Not served. 30 of the 65 operations in the published spec are not served.

  • files, 14 of 19 served: GET /drive/v3/files/generateCseToken, POST /drive/v3/files/{fileId}/download, GET /drive/v3/files/{fileId}/listLabels, POST /drive/v3/files/{fileId}/modifyLabels, POST /drive/v3/files/{fileId}/watch
  • Not served at all: accessproposals (3), approvals (8), apps (2), changes (3), channels (1), drives (7), operations (1)

Not measured. The fidelity suite runs 363 probes, and 157 documented branches have none. 58 need a credential the measuring account does not hold (401 ×28, 403 ×30). The other 99:

OperationStatusWhy
DELETE /drive/v3/files/trash400a well-formed request does not provoke it
DELETE /drive/v3/files/trash429no request a client sends provokes it
DELETE /drive/v3/files/trash500no request a client sends provokes it
DELETE /drive/v3/files/{fileId}400a well-formed request does not provoke it
DELETE /drive/v3/files/{fileId}429no request a client sends provokes it
DELETE /drive/v3/files/{fileId}500no request a client sends provokes it
DELETE /drive/v3/files/{fileId}/comments/{commentId}429no request a client sends provokes it
DELETE /drive/v3/files/{fileId}/comments/{commentId}500no request a client sends provokes it
DELETE /drive/v3/files/{fileId}/comments/{commentId}/replies/{replyId}404nothing the twin serves creates the record it needs
DELETE /drive/v3/files/{fileId}/comments/{commentId}/replies/{replyId}429no request a client sends provokes it
DELETE /drive/v3/files/{fileId}/revisions/{revisionId}204nothing the twin serves creates the record it needs
GET /drive/v3/about400a well-formed request does not provoke it
GET /drive/v3/about429no request a client sends provokes it
GET /drive/v3/about500no request a client sends provokes it
GET /drive/v3/files429no request a client sends provokes it
GET /drive/v3/files500no request a client sends provokes it
GET /drive/v3/files/generateIds429no request a client sends provokes it
GET /drive/v3/files/generateIds500no request a client sends provokes it
GET /drive/v3/files/{fileId}400nothing the twin serves creates the record it needs
GET /drive/v3/files/{fileId}429no request a client sends provokes it
… and 75 more

1 probe from earlier rounds is kept as unmeasured.

Not modeled. What the model’s operations state they leave out:

  • Deletion visibility lags: a trashed file read right after the 204 can still answer 200 for about 2 s. That lag belongs to the reading vector’s metadata.x_eventually_consistent; this writer declares none. (DELETE /drive/v3/files/trash)
  • The 400 branch’s trigger on this method is unmeasured: the evidence’s invalid-selector and invalid-value samples are from files.get and files.list, so no violating query value is declared here. (DELETE /drive/v3/files/trash)
  • driveId: no shared-drive state family is in scope and the oracle account seeds none, so the shared-drive 404 and any shared-drive success are documented but carry no executable recipe. (DELETE /drive/v3/files/trash)
  • Deleting or trashing the root itself (fileId root, or the root’s id) is unmeasured and is never sent to the oracle: Drive reports capabilities canDelete and canTrash true on the root (root-and-folder-writes/01), so a measurement could empty the oracle account’s My Drive. (RYS-2894) (DELETE /drive/v3/files/{fileId})
  • No evidence names a request that provokes the 400 branch on this operation (the BadRequest sample is files.create with an unparsable body, and DELETE carries no body); whether a non-boolean supportsAllDrives is rejected, coerced or defaulted is unmeasured, so the branch carries no derivable recipe. (DELETE /drive/v3/files/{fileId})
  • The 429 and 500 branches are usage-limit and backend conditions the harness cannot provoke deliberately. (DELETE /drive/v3/files/{fileId})
  • The shared-drive path (supportsAllDrives=true, driveId-bearing files, organizer-on-parent-folder permission) is documented but not reachable with the declared state families. (DELETE /drive/v3/files/{fileId})
  • Whether the deletion is visible to a later read immediately or only after a delay is unmeasured: no evidence states a lag, and the 204 act is itself the compared step, so no settled readback is declared. The same is true of a folder’s descendants. (DELETE /drive/v3/files/{fileId})
  • The operation documents a 400 BadRequest response but the evidence names no trigger for it on this method: it takes no fields and no request body, so no condition in the bundle reaches a 400. The service-wide malformed-selector refusal (state_model.partial_response.malformed, applies_write true) does not list this vector among partial_response.vectors, so whether a malformed fields value is refused or ignored here is unmeasured. (DELETE /drive/v3/files/{fileId}/comments/{commentId})
  • Whether the 403 is reached only by a comment authored by another user, or also by a file the caller may read but not comment on (the file-level insufficientFilePermissions case), is not separated by the evidence; both are modelled as one branch. (DELETE /drive/v3/files/{fileId}/comments/{commentId})
  • The operation declares a 400 BadRequest and a 500 BackendError; it takes no request body and no fields parameter, and the evidence names no condition on this method that provokes either, so no branch is modeled for them. (DELETE /drive/v3/files/{fileId}/comments/{commentId}/replies/{replyId})
  • The query parameters $.xgafv, access_token, alt, callback, fields, key, oauth_token, prettyPrint, quotaUser, uploadType and upload_protocol are service-wide system parameters; the evidence states this method takes no fields and documents no behavior for the others here, so none is declared in query_shape. (DELETE /drive/v3/files/{fileId}/comments/{commentId}/replies/{replyId})
  • Whether a reply may be deleted by a caller who is neither its author nor the file owner (the 403 trigger) is not measured. (DELETE /drive/v3/files/{fileId}/comments/{commentId}/replies/{replyId})
  • 429 rateLimitExceeded and 500 BackendError are declared by the shared responses but have no evidenced per-request trigger here; they stay unmodeled. (DELETE /drive/v3/files/{fileId}/permissions/{permissionId})
  • Cross-actor access lag after a delete (about 1.8 s before actor B’s read turns 404) is a property of the reading operation, not of this delete. (DELETE /drive/v3/files/{fileId}/permissions/{permissionId})
  • The documented 400 BadRequest on this method has no evidenced trigger for a delete that carries no body (the fields selector selects nothing on a 204); no branch is declared rather than guessing one. (DELETE /drive/v3/files/{fileId}/permissions/{permissionId})
  • supportsAllDrives / useDomainAdminAccess only matter for shared drives, which the oracle account’s My Drive fixture does not exercise. (DELETE /drive/v3/files/{fileId}/permissions/{permissionId})
  • 400 BadRequest on a malformed fields selector: the service-wide partial_response policy answers it; no operation-specific 400 trigger is documented for this method. (DELETE /drive/v3/files/{fileId}/revisions/{revisionId})
  • 403 insufficientFilePermissions when the caller lacks write access; the live note records only that a reader deleting a non-head revision answers 404 (a reader sees only the head), so the reader-specific 404 is not modelled as its own branch. (DELETE /drive/v3/files/{fileId}/revisions/{revisionId})
  • 429 rateLimitExceeded and 500 backend error: ambient usage-limit and backend conditions, not provokable by a well-formed probe. (DELETE /drive/v3/files/{fileId}/revisions/{revisionId})
  • … and 122 more

Release notes: Changelog

Last updated on