Back to flin
flin

R2 and Google Cloud Storage Backends

Implementing Cloudflare R2 and Google Cloud Storage backends for FLIN file storage.

Thales & Claude | March 25, 2026 9 min flin
flinr2gcscloudflaregoogle-cloudstorage

Implementing one cloud storage backend teaches you the protocol. Implementing two teaches you how protocols diverge under the surface. Cloudflare R2 and Google Cloud Storage both store and serve files, but their authentication models, URL signing algorithms, and API conventions differ in ways that test the limits of a unified abstraction.

Sessions 215 and 216 implemented these two backends back-to-back, each in about 45 minutes. The speed was possible because the StorageBackend trait (from Session 214) defined exactly what each backend needed to do. The challenge was not the interface -- it was the implementation details hiding behind each cloud provider's documentation.

Cloudflare R2: S3 With a Twist

R2's selling point is S3 compatibility. It speaks the same protocol, accepts the same SDK calls, and even uses the same credential format. In theory, you point an S3 client at R2's endpoint and everything works. In practice, there are differences.

Endpoint and Path Style

S3 defaults to virtual-hosted-style URLs: https://{bucket}.s3.amazonaws.com/{key}. R2 uses a custom endpoint format with path-style addressing:

rustpub struct R2Backend {
    bucket: Box<Bucket>,       // rust-s3 Bucket client
    prefix: String,            // Key organization prefix
    account_id: String,        // Cloudflare account ID
    bucket_name: String,       // For URL generation
    runtime: Runtime,          // Tokio runtime for async operations
}

impl R2Backend {
    pub fn new(
        bucket: &str,
        account_id: &str,
        access_key: &str,
        secret_key: &str,
        prefix: &str,
    ) -> Result<Self, StorageError> {
        let region = Region::Custom {
            region: "auto".to_string(),
            endpoint: format!("https://{}.r2.cloudflarestorage.com", account_id),
        };

        let credentials = Credentials::new(
            Some(access_key),
            Some(secret_key),
            None, None, None,
        )?;

        let bucket = Bucket::new(bucket, region, credentials)?
            .with_path_style();  // Required for R2

        Ok(Self {
            bucket,
            prefix: prefix.to_string(),
            account_id: account_id.to_string(),
            bucket_name: bucket.to_string(),
            runtime: Runtime::new()?,
        })
    }
}

The with_path_style() call is critical. Without it, the rust-s3 crate constructs virtual-hosted URLs that R2 does not recognize. This single method call was the difference between a working backend and cryptic 403 errors.

Region: "auto"

S3 requires a region (us-east-1, eu-west-1, etc.) and rejects requests sent to the wrong one. R2 uses "auto" -- Cloudflare routes requests to the nearest data center automatically. This is a small but meaningful convenience for applications deployed globally. You do not need to decide where your files live; Cloudflare decides based on where your users are.

Deduplication via HEAD

Before uploading a file, R2Backend checks if the blob already exists using a HEAD request:

rustimpl StorageBackend for R2Backend {
    fn put(&self, hash: &str, data: &[u8], extension: &str) -> StorageResult<String> {
        validate_hash(hash)?;
        let key = self.build_key(hash, extension);

        // Check if blob already exists (deduplication)
        let exists = self.runtime.block_on(async {
            self.bucket.head_object(&key).await
        });

        if exists.is_ok() {
            return Ok(format!("r2://{}/{}", self.bucket_name, key));
        }

        // Upload
        self.runtime.block_on(async {
            self.bucket.put_object(&key, data).await
        })?;

        Ok(format!("r2://{}/{}", self.bucket_name, key))
    }
}

HEAD requests on R2 are free (no cost per request for metadata operations). This makes deduplication essentially zero-cost on R2, which is not always the case with other providers.

Presigned URLs

R2's presigned URLs use the same S3 signature algorithm, so rust-s3's presign_get method works directly:

rustfn signed_url(&self, hash: &str, extension: &str, duration: Duration) -> StorageResult<String> {
    validate_hash(hash)?;
    let key = self.build_key(hash, extension);
    let seconds = duration.as_secs() as u32;

    let url = self.runtime.block_on(async {
        self.bucket.presign_get(&key, seconds, None).await
    })?;

    Ok(url)
}

No custom signing logic needed. The S3 compatibility layer handles it. This is where R2's S3 compatibility genuinely saves implementation time.

Google Cloud Storage: A Different World

GCS does not speak S3. It has its own REST API, its own authentication system (OAuth2 with service accounts), and its own signed URL algorithm (V4 with RSA-SHA256). Implementing the GCS backend required substantially more code than R2.

Service Account Authentication

GCS uses service account JSON files for authentication. These files contain a client email, a private RSA key, and project metadata. The authentication flow is:

  1. Load the JSON file and parse the credentials.
  2. Create a JWT (JSON Web Token) asserting the service account's identity.
  3. Sign the JWT with the RSA private key.
  4. Exchange the signed JWT for an OAuth2 access token.
  5. Cache the access token (valid for 1 hour).
  6. Refresh the token 60 seconds before expiry.
rustpub struct GcsBackend {
    bucket: String,
    prefix: String,
    credentials: GcsCredentials,
    token_cache: Arc<RwLock<CachedToken>>,
}

struct GcsCredentials {
    client_email: String,
    private_key: String,     // RSA private key in PEM format
    project_id: Option<String>,
}

struct CachedToken {
    access_token: String,
    expires_at: u64,         // Unix timestamp
}

The Arc<RwLock<CachedToken>> is the thread-safety mechanism for token caching. Multiple request handlers can read the cached token simultaneously (shared read lock). When the token needs refreshing, a single handler acquires an exclusive write lock, refreshes the token, and releases the lock. Other handlers waiting for the lock then read the fresh token.

JWT Signing With RSA

The JWT for Google's token exchange must be signed with RSA-SHA256. FLIN implements this using the rsa and pkcs8 crates rather than pulling in a full JWT library:

rustfn create_signed_jwt(&self) -> Result<String, StorageError> {
    let header = base64_url_encode(r#"{"alg":"RS256","typ":"JWT"}"#);

    let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
    let claims = json!({
        "iss": self.credentials.client_email,
        "scope": "https://www.googleapis.com/auth/devstorage.full_control",
        "aud": "https://oauth2.googleapis.com/token",
        "iat": now,
        "exp": now + 3600,
    });
    let payload = base64_url_encode(&claims.to_string());

    let message = format!("{}.{}", header, payload);
    let signature = rsa_sha256_sign(&self.credentials.private_key, message.as_bytes())?;
    let encoded_signature = base64_url_encode(&signature);

    Ok(format!("{}.{}.{}", header, payload, encoded_signature))
}

This is more code than R2 needed for its entire backend. Authentication alone in GCS is more complex than R2's full implementation.

V4 Signed URLs

GCS signed URLs use a four-step algorithm that Google calls V4 signing:

  1. Build a canonical request (HTTP method, path, query parameters, headers).
  2. Hash the canonical request with SHA-256.
  3. Build a string-to-sign that includes the algorithm name, datetime, credential scope, and canonical request hash.
  4. Sign the string-to-sign with RSA-SHA256 using the service account's private key.
  5. Append the hex-encoded signature to the URL as a query parameter.
rustfn signed_url(&self, hash: &str, extension: &str, duration: Duration) -> StorageResult<String> {
    validate_hash(hash)?;
    let key = self.build_key(hash, extension);
    let now = Utc::now();
    let datestamp = now.format("%Y%m%d").to_string();
    let datetime = now.format("%Y%m%dT%H%M%SZ").to_string();

    let credential_scope = format!("{}/auto/storage/goog4_request", datestamp);
    let credential = format!("{}/{}", self.credentials.client_email, credential_scope);

    // Step 1: Canonical request
    let canonical_request = format!(
        "GET\n/{}/{}\n{}\nhost:{}\n\nhost\nUNSIGNED-PAYLOAD",
        self.bucket, key,
        build_query_string(&credential, &datetime, duration),
        "storage.googleapis.com"
    );

    // Step 2: String to sign
    let string_to_sign = format!(
        "GOOG4-RSA-SHA256\n{}\n{}\n{}",
        datetime, credential_scope,
        sha256_hex(&canonical_request)
    );

    // Step 3: Sign
    let signature = rsa_sha256_sign(&self.credentials.private_key, string_to_sign.as_bytes())?;

    // Step 4: Build URL
    Ok(format!(
        "https://storage.googleapis.com/{}/{}?{}&X-Goog-Signature={}",
        self.bucket, key,
        build_query_string(&credential, &datetime, duration),
        hex_encode(&signature)
    ))
}

This algorithm is specified in Google's documentation, but implementing it correctly requires careful attention to URL encoding, header canonicalization, and newline placement. A single extra newline or a missing header invalidates the signature.

Sync Over Async

Both cloud backends face the same architectural tension: the StorageBackend trait is synchronous (&self, not async fn), but cloud operations are inherently asynchronous (HTTP requests over the network).

The solution is a Tokio runtime embedded in each backend that blocks on async operations:

rust// R2Backend and GcsBackend both use this pattern
self.runtime.block_on(async {
    self.bucket.put_object(&key, data).await
})?;

This design choice was deliberate. Making the trait async would require async_trait or Rust's nascent native async trait support, both of which add complexity. More importantly, the VM's file operations are synchronous -- the FLIN bytecode interpreter does not have an async executor. Keeping the trait synchronous means file operations integrate cleanly with the rest of the runtime without requiring an async bridge at every call site.

The trade-off is that file operations block the calling thread. For the HTTP server (which runs on Tokio), these operations are dispatched to a blocking thread pool, so they do not starve the async event loop. For the VM executing FLIN code, blocking is acceptable because FLIN applications are not latency-sensitive at the level of individual file operations.

Testing Without Credentials

Both backends include unit tests that run without cloud credentials and integration tests that require them:

rust#[test]
fn test_r2_build_key_with_prefix() {
    // Tests key construction logic without touching R2
    let key = R2Backend::build_key_static("files", "abcdef1234", ".jpg");
    assert_eq!(key, "files/ab/abcdef1234/data.jpg");
}

#[test]
#[ignore] // Requires R2_BUCKET, R2_ACCOUNT_ID, R2_ACCESS_KEY, R2_SECRET_KEY
fn test_r2_integration_put_get_delete() {
    // Full round-trip with real R2 bucket
    let backend = create_r2_from_env();
    let path = backend.put("testHash123abc", b"test data", ".txt").unwrap();
    let data = backend.get(&path).unwrap();
    assert_eq!(data, b"test data");
    backend.delete(&path).unwrap();
}

The R2 backend added 12 unit tests and 2 integration tests. The GCS backend added 13 unit tests and 2 integration tests. Combined with the local backend's 15 tests and the existing S3 tests, the storage system has comprehensive coverage at every level.

Comparative Summary

AspectR2GCS
ProtocolS3-compatibleCustom REST API
AuthenticationAccess key + secretService account + OAuth2
SigningS3 presigned URLsV4 RSA-SHA256
Dependenciesrust-s3 (existing)rsa, pkcs8 (new)
Implementation size282 lines580 lines
Egress costsZeroStandard rates
Region config"auto"Per-bucket
New tests1415

R2 is the simpler implementation because it rides on S3's ecosystem. GCS is the more complex implementation because it requires custom authentication and signing. Both are first-class citizens in FLIN's storage system, and switching between them requires changing one configuration value.

With four storage backends complete, the next step was securing file access. In the next article, we implement download grants and access keys -- the system that controls who can download files, for how long, and with what restrictions.


This is Part 128 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.

Series Navigation: - [127] The Storage Backend Trait Pattern - [128] R2 and Google Cloud Storage Backends (you are here) - [129] Download Grants and Access Keys

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles