From 81199e7c45c8a547fd4418ca7cd0abd9de06ac5b Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Wed, 30 Sep 2026 20:33:48 -0300 Subject: [PATCH 1/6] fix(persistence): migrate pre-partition arn:aws: ARNs in persisted state State written before fakecloud minted ARNs in the region's partition spells them arn:aws: even for China, GovCloud and ISO regions, so a cn/gov/iso server restarted on such a data directory mixed partitions in its responses and kept ARN-keyed state (SNS topics, tag maps, ...) under the stale spelling. Before any service loads, a one-time migration now rewrites every ARN token in the persisted state: values, object keys, and ARNs embedded in stored documents (policies, templates, definitions), in place so the document text is otherwise unchanged. A regional ARN whose region belongs to another partition takes that partition; a region-less ARN takes the server region's partition when that is not aws, except AWS-owned ones (managed policies), which fakecloud keeps as arn:aws:. Hash-verified payloads (SQS bodies/attributes, ECR manifests) and bulk payloads (log events, S3 object bodies, container volumes) are left alone. S3 sidecar TOML is covered too. The run is recorded once per directory as arn_partitions_migrated in fakecloud.version.toml; directories created by this binary start with it set, so newer state is never re-migrated. Closes #2610 --- Cargo.lock | 2 + .../arn_partition_migration_persistence.rs | 475 ++++++++++++++++++ crates/fakecloud-persistence/Cargo.toml | 2 + .../src/arn_partition.rs | 371 ++++++++++++++ .../src/arn_partition_tests.rs | 405 +++++++++++++++ crates/fakecloud-persistence/src/lib.rs | 1 + crates/fakecloud-persistence/src/version.rs | 52 +- crates/fakecloud-server/src/main.rs | 15 + scripts/publish-crates.sh | 2 +- website/content/docs/reference/persistence.md | 15 +- 10 files changed, 1331 insertions(+), 9 deletions(-) create mode 100644 crates/fakecloud-e2e/tests/arn_partition_migration_persistence.rs create mode 100644 crates/fakecloud-persistence/src/arn_partition.rs create mode 100644 crates/fakecloud-persistence/src/arn_partition_tests.rs diff --git a/Cargo.lock b/Cargo.lock index da8da1aff..7357a975b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6368,9 +6368,11 @@ version = "0.47.0" dependencies = [ "bytes", "chrono", + "fakecloud-aws", "parking_lot", "percent-encoding", "serde", + "serde_json", "sha2 0.10.9", "tempfile", "thiserror 2.0.18", diff --git a/crates/fakecloud-e2e/tests/arn_partition_migration_persistence.rs b/crates/fakecloud-e2e/tests/arn_partition_migration_persistence.rs new file mode 100644 index 000000000..aa9beaf7e --- /dev/null +++ b/crates/fakecloud-e2e/tests/arn_partition_migration_persistence.rs @@ -0,0 +1,475 @@ +//! State persisted by a China-region server before fakecloud minted ARNs in +//! the region's partition spells them `arn:aws:`. Restarting on such a data +//! directory with `--region cn-north-1` migrates them to `arn:aws-cn:` once, +//! so responses stop mixing partitions and lookups by the current ARN work. +//! +//! The legacy directory is produced for real: a commercial-region server +//! persists the resources (all ARNs `arn:aws:`), then the snapshots are moved +//! to `cn-north-1` textually and the version file loses its migration marker. +//! That is exactly what a pre-partition server running in `cn-north-1` wrote. + +mod helpers; + +use std::path::Path; + +use aws_sdk_lambda::primitives::Blob; +use helpers::TestServer; +use md5::{Digest, Md5}; + +const ACCOUNT: &str = "123456789012"; + +fn make_python_zip() -> Vec { + use std::io::Write; + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + writer + .start_file("index.py", zip::write::SimpleFileOptions::default()) + .unwrap(); + writer + .write_all(b"def handler(event, context):\n return {}\n") + .unwrap(); + writer.finish().unwrap().into_inner() +} + +async fn start(data_path: &Path, region: &str) -> TestServer { + let data = data_path.display().to_string(); + TestServer::start_full( + &[("FAKECLOUD_CONTAINER_CLI", "false")], + &[ + "--storage-mode", + "persistent", + "--data-path", + &data, + "--region", + region, + ], + ) + .await +} + +/// Rewrite every persisted JSON/TOML state file from `us-east-1` to +/// `cn-north-1` and drop the version file's migration marker. +fn make_legacy_china_dir(dir: &Path) { + fn walk(dir: &Path) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path); + continue; + } + let ext = path.extension().and_then(|e| e.to_str()); + if !matches!(ext, Some("json" | "toml")) { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + let moved = text.replace("us-east-1", "cn-north-1"); + if moved != text { + std::fs::write(&path, moved).unwrap(); + } + } + } + walk(dir); + let version_path = dir.join("fakecloud.version.toml"); + let version = std::fs::read_to_string(&version_path).unwrap(); + assert!( + version.contains("arn_partitions_migrated = true"), + "{version}" + ); + let legacy: String = version + .lines() + .filter(|l| !l.starts_with("arn_partitions_migrated")) + .map(|l| format!("{l}\n")) + .collect(); + std::fs::write(&version_path, legacy).unwrap(); +} + +#[tokio::test] +async fn china_server_migrates_legacy_aws_partition_arns() { + let tmp = tempfile::tempdir().unwrap(); + + // Phase 1: a commercial server persists resources with `arn:aws:` ARNs. + let server = start(tmp.path(), "us-east-1").await; + let config = server.aws_config_in("us-east-1").await; + + let kms = aws_sdk_kms::Client::new(&config); + let key = kms.create_key().send().await.unwrap(); + let key_id = key.key_metadata().unwrap().key_id().to_string(); + kms.create_alias() + .alias_name("alias/legacy") + .target_key_id(&key_id) + .send() + .await + .unwrap(); + + let sns = aws_sdk_sns::Client::new(&config); + sns.create_topic() + .name("legacy-topic") + .send() + .await + .unwrap(); + + let iam = aws_sdk_iam::Client::new(&config); + let trust = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"arn:aws:iam::{ACCOUNT}:root","Service":"lambda.amazonaws.com"}},"Action":"sts:AssumeRole"}}]}}"# + ); + iam.create_role() + .role_name("legacy-role") + .assume_role_policy_document(&trust) + .send() + .await + .unwrap(); + let inline = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Action":"sqs:SendMessage","Resource":"arn:aws:sqs:us-east-1:{ACCOUNT}:legacy-queue"}}]}}"# + ); + iam.put_role_policy() + .role_name("legacy-role") + .policy_name("send") + .policy_document(&inline) + .send() + .await + .unwrap(); + let customer_policy = iam + .create_policy() + .policy_name("legacy-policy") + .policy_document(&inline) + .send() + .await + .unwrap(); + let customer_policy_arn = customer_policy.policy().unwrap().arn().unwrap().to_string(); + iam.attach_role_policy() + .role_name("legacy-role") + .policy_arn(&customer_policy_arn) + .send() + .await + .unwrap(); + iam.attach_role_policy() + .role_name("legacy-role") + .policy_arn("arn:aws:iam::aws:policy/ReadOnlyAccess") + .send() + .await + .unwrap(); + + let sqs = aws_sdk_sqs::Client::new(&config); + sqs.create_queue() + .queue_name("legacy-dlq") + .send() + .await + .unwrap(); + let redrive = format!( + r#"{{"deadLetterTargetArn":"arn:aws:sqs:us-east-1:{ACCOUNT}:legacy-dlq","maxReceiveCount":"3"}}"# + ); + let queue_url = sqs + .create_queue() + .queue_name("legacy-queue") + .attributes( + aws_sdk_sqs::types::QueueAttributeName::RedrivePolicy, + &redrive, + ) + .attributes( + aws_sdk_sqs::types::QueueAttributeName::SqsManagedSseEnabled, + "false", + ) + .send() + .await + .unwrap() + .queue_url() + .unwrap() + .to_string(); + // A message whose body names a legacy China ARN: it is a payload verified + // by its MD5, so the migration must leave it alone. (SSE is off so the + // body is stored as plaintext, where the migration could see it.) + let body = format!(r#"{{"TopicArn":"arn:aws:sns:cn-north-1:{ACCOUNT}:legacy-topic"}}"#); + sqs.send_message() + .queue_url(&queue_url) + .message_body(&body) + .send() + .await + .unwrap(); + + let s3 = aws_sdk_s3::Client::from_conf( + aws_sdk_s3::config::Builder::from(&config) + .force_path_style(true) + .build(), + ); + s3.create_bucket() + .bucket("legacy-bucket") + .send() + .await + .unwrap(); + let bucket_policy = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"arn:aws:iam::{ACCOUNT}:role/legacy-role"}},"Action":"s3:GetObject","Resource":"arn:aws:s3:::legacy-bucket/*"}}]}}"# + ); + s3.put_bucket_policy() + .bucket("legacy-bucket") + .policy(&bucket_policy) + .send() + .await + .unwrap(); + + let lambda = aws_sdk_lambda::Client::new(&config); + lambda + .create_function() + .function_name("legacy-fn") + .runtime(aws_sdk_lambda::types::Runtime::Python312) + .role(format!("arn:aws:iam::{ACCOUNT}:role/legacy-role")) + .handler("index.handler") + .code( + aws_sdk_lambda::types::FunctionCode::builder() + .zip_file(Blob::new(make_python_zip())) + .build(), + ) + .send() + .await + .unwrap(); + drop(server); + + make_legacy_china_dir(tmp.path()); + let sqs_snapshot = std::fs::read_to_string(tmp.path().join("sqs/snapshot.json")).unwrap(); + assert!( + sqs_snapshot.contains(r#"\"TopicArn\":\"arn:aws:sns:cn-north-1"#), + "the message body is stored in plaintext" + ); + for service in ["kms", "sns", "iam", "sqs", "lambda"] { + let snapshot = + std::fs::read_to_string(tmp.path().join(service).join("snapshot.json")).unwrap(); + assert!(snapshot.contains("arn:aws:"), "{service} has no legacy ARN"); + assert!( + !snapshot.contains("arn:aws-cn:"), + "{service} already migrated" + ); + } + + // Phase 2: the same directory served from cn-north-1. + let server = start(tmp.path(), "cn-north-1").await; + let config = server.aws_config_in("cn-north-1").await; + + // KMS: key and alias ARNs, lookups by the new ARN and by alias. + let kms = aws_sdk_kms::Client::new(&config); + let key_arn = format!("arn:aws-cn:kms:cn-north-1:{ACCOUNT}:key/{key_id}"); + let described = kms.describe_key().key_id(&key_id).send().await.unwrap(); + assert_eq!( + described.key_metadata().unwrap().arn(), + Some(key_arn.as_str()) + ); + let by_arn = kms.describe_key().key_id(&key_arn).send().await.unwrap(); + assert_eq!(by_arn.key_metadata().unwrap().key_id(), key_id); + let by_alias = kms + .describe_key() + .key_id("alias/legacy") + .send() + .await + .unwrap(); + assert_eq!( + by_alias.key_metadata().unwrap().arn(), + Some(key_arn.as_str()) + ); + let aliases = kms.list_aliases().send().await.unwrap(); + let alias = aliases + .aliases() + .iter() + .find(|a| a.alias_name() == Some("alias/legacy")) + .expect("legacy alias survives"); + assert_eq!( + alias.alias_arn(), + Some(format!("arn:aws-cn:kms:cn-north-1:{ACCOUNT}:alias/legacy").as_str()) + ); + + // SNS: topics are keyed by ARN; the migrated key is what lookups hit, and + // a topic created now lists alongside it in the same partition. + let sns = aws_sdk_sns::Client::new(&config); + let topic_arn = format!("arn:aws-cn:sns:cn-north-1:{ACCOUNT}:legacy-topic"); + sns.create_topic().name("new-topic").send().await.unwrap(); + let mut topics: Vec = sns + .list_topics() + .send() + .await + .unwrap() + .topics() + .iter() + .filter_map(|t| t.topic_arn().map(str::to_string)) + .collect(); + topics.sort(); + assert_eq!( + topics, + vec![ + topic_arn.clone(), + format!("arn:aws-cn:sns:cn-north-1:{ACCOUNT}:new-topic"), + ] + ); + let attrs = sns + .get_topic_attributes() + .topic_arn(&topic_arn) + .send() + .await + .unwrap(); + let attrs = attrs.attributes().unwrap(); + assert_eq!(attrs.get("TopicArn"), Some(&topic_arn)); + assert!( + !attrs.values().any(|v| v.contains("arn:aws:")), + "legacy ARN left in topic attributes: {attrs:?}" + ); + + // IAM: role, trust and inline policy documents, attached policies. The + // AWS-managed policy keeps its `aws` spelling (fakecloud's catalog does). + let iam = aws_sdk_iam::Client::new(&config); + let role = iam + .get_role() + .role_name("legacy-role") + .send() + .await + .unwrap(); + let role = role.role().unwrap(); + let role_arn = format!("arn:aws-cn:iam::{ACCOUNT}:role/legacy-role"); + assert_eq!(role.arn(), role_arn); + let trust_doc = urlencoding_decode(role.assume_role_policy_document().unwrap()); + assert!( + trust_doc.contains(&format!("arn:aws-cn:iam::{ACCOUNT}:root")), + "{trust_doc}" + ); + let inline_doc = iam + .get_role_policy() + .role_name("legacy-role") + .policy_name("send") + .send() + .await + .unwrap(); + let inline_doc = urlencoding_decode(inline_doc.policy_document()); + assert!( + inline_doc.contains(&format!("arn:aws-cn:sqs:cn-north-1:{ACCOUNT}:legacy-queue")), + "{inline_doc}" + ); + let mut attached: Vec = iam + .list_attached_role_policies() + .role_name("legacy-role") + .send() + .await + .unwrap() + .attached_policies() + .iter() + .filter_map(|p| p.policy_arn().map(str::to_string)) + .collect(); + attached.sort(); + let customer_arn = format!("arn:aws-cn:iam::{ACCOUNT}:policy/legacy-policy"); + assert_eq!( + attached, + vec![ + customer_arn.clone(), + "arn:aws:iam::aws:policy/ReadOnlyAccess".to_string(), + ] + ); + let policy = iam + .get_policy() + .policy_arn(&customer_arn) + .send() + .await + .unwrap(); + assert_eq!(policy.policy().unwrap().arn(), Some(customer_arn.as_str())); + iam.get_policy() + .policy_arn("arn:aws:iam::aws:policy/ReadOnlyAccess") + .send() + .await + .expect("the managed policy still resolves"); + + // SQS: queue and redrive ARNs migrate; the stored message body does not, + // so its MD5 still matches. + let sqs = aws_sdk_sqs::Client::new(&config); + let queue_url = sqs + .get_queue_url() + .queue_name("legacy-queue") + .send() + .await + .unwrap() + .queue_url() + .unwrap() + .to_string(); + let attrs = sqs + .get_queue_attributes() + .queue_url(&queue_url) + .attribute_names(aws_sdk_sqs::types::QueueAttributeName::All) + .send() + .await + .unwrap(); + let attrs = attrs.attributes().unwrap(); + assert_eq!( + attrs.get(&aws_sdk_sqs::types::QueueAttributeName::QueueArn), + Some(&format!("arn:aws-cn:sqs:cn-north-1:{ACCOUNT}:legacy-queue")) + ); + let redrive = attrs + .get(&aws_sdk_sqs::types::QueueAttributeName::RedrivePolicy) + .unwrap(); + assert!( + redrive.contains(&format!("arn:aws-cn:sqs:cn-north-1:{ACCOUNT}:legacy-dlq")), + "{redrive}" + ); + let received = sqs + .receive_message() + .queue_url(&queue_url) + .send() + .await + .unwrap(); + let message = &received.messages()[0]; + assert_eq!(message.body(), Some(body.as_str())); + assert_eq!( + message.md5_of_body(), + Some(hex::encode(Md5::digest(body.as_bytes())).as_str()) + ); + + // S3: the bucket policy (a stored document) names the bucket in aws-cn. + let s3 = aws_sdk_s3::Client::from_conf( + aws_sdk_s3::config::Builder::from(&config) + .force_path_style(true) + .build(), + ); + let policy = s3 + .get_bucket_policy() + .bucket("legacy-bucket") + .send() + .await + .unwrap(); + let policy = policy.policy().unwrap(); + assert!( + policy.contains("arn:aws-cn:s3:::legacy-bucket/*"), + "{policy}" + ); + assert!( + policy.contains(&format!("arn:aws-cn:iam::{ACCOUNT}:role/legacy-role")), + "{policy}" + ); + assert!(!policy.contains("arn:aws:"), "{policy}"); + + // Lambda: function and role ARNs; lookup by the new function ARN. + let lambda = aws_sdk_lambda::Client::new(&config); + let function_arn = format!("arn:aws-cn:lambda:cn-north-1:{ACCOUNT}:function:legacy-fn"); + let function = lambda + .get_function() + .function_name(&function_arn) + .send() + .await + .unwrap(); + let configuration = function.configuration().unwrap(); + assert_eq!(configuration.function_arn(), Some(function_arn.as_str())); + assert_eq!(configuration.role(), Some(role_arn.as_str())); + + // The directory is marked, so the migration never runs again. + let version = std::fs::read_to_string(tmp.path().join("fakecloud.version.toml")).unwrap(); + assert!( + version.contains("arn_partitions_migrated = true"), + "{version}" + ); +} + +/// IAM returns policy documents URL-encoded. +fn urlencoding_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + out.push(hex::decode(&s[i + 1..i + 3]).unwrap()[0]); + i += 3; + } else { + out.push(if bytes[i] == b'+' { b' ' } else { bytes[i] }); + i += 1; + } + } + String::from_utf8(out).unwrap() +} diff --git a/crates/fakecloud-persistence/Cargo.toml b/crates/fakecloud-persistence/Cargo.toml index 548442eb3..acc8a8539 100644 --- a/crates/fakecloud-persistence/Cargo.toml +++ b/crates/fakecloud-persistence/Cargo.toml @@ -8,6 +8,8 @@ homepage.workspace = true version.workspace = true [dependencies] +fakecloud-aws = { workspace = true } +serde_json = { workspace = true } serde = { workspace = true } toml = { workspace = true } thiserror = { workspace = true } diff --git a/crates/fakecloud-persistence/src/arn_partition.rs b/crates/fakecloud-persistence/src/arn_partition.rs new file mode 100644 index 000000000..e25ab3a88 --- /dev/null +++ b/crates/fakecloud-persistence/src/arn_partition.rs @@ -0,0 +1,371 @@ +//! One-time migration of `arn:aws:` ARNs persisted before fakecloud minted +//! ARNs in the region's partition. +//! +//! Before the ARN partition work, every ARN fakecloud produced used the `aws` +//! partition, so a server run with a China, GovCloud or ISO `--region` persisted +//! values such as `arn:aws:kms:cn-north-1:...` and `arn:aws:iam::...:role/r`. +//! Current code mints `arn:aws-cn:...`, so without a migration responses mix +//! both forms and ARN-keyed state (SNS topics, tag maps, ...) is only reachable +//! through the stale spelling. +//! +//! [`migrate_data_dir`] rewrites those ARNs once per data directory, before any +//! service loads its state, and records that it ran in the directory's +//! `fakecloud.version.toml` so it never runs again. +//! +//! What is rewritten is one rule, applied to every ARN token in the persisted +//! state (values, object keys, and ARNs embedded in stored documents such as +//! IAM/bucket/key policies, CloudFormation templates or state-machine +//! definitions, rewritten in place so the document text is otherwise +//! untouched): +//! +//! * A regional ARN (`arn:aws:::...`) whose region belongs to a +//! non-`aws` partition takes that partition. Such an ARN never names a real +//! resource, so this holds whatever region the server runs in. +//! * A region-less ARN (`arn:aws:iam::...`, `arn:aws:s3:::bucket`) carries no +//! partition of its own. Current code mints those in the partition of the +//! server's `--region`, so they are rewritten only when that partition is not +//! `aws`. AWS-owned ones (account `aws`, i.e. managed policies +//! `arn:aws:iam::aws:policy/...`) stay as they are: fakecloud's managed-policy +//! catalog keeps the `aws` spelling in every partition. +//! +//! Payloads that are verified against a stored content hash are left alone: +//! rewriting an SQS message body would break the `MD5OfBody` the SDKs check, +//! and an ECR manifest is addressed by its digest. Bulk payloads streamed +//! outside the snapshots (S3 object bodies, CloudWatch Logs event segments, +//! container data volumes) are not read at all. + +use std::borrow::Cow; +use std::io; +use std::path::{Path, PathBuf}; + +use fakecloud_aws::arn::partition_for; +use serde_json::{Map, Value}; + +const ARN_PREFIX: &str = "arn:aws:"; + +/// How ARNs are migrated: the partition region-less ARNs move to, if any. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ArnPartitionMigration { + /// The server region's partition. Region-less ARNs are rewritten to it + /// when it is not `aws`. + global_partition: &'static str, +} + +impl ArnPartitionMigration { + /// The migration for a server started with `--region server_region`. + pub fn for_server_region(server_region: &str) -> Self { + Self { + global_partition: partition_for(server_region), + } + } + + /// The partition the ARN starting at `arn:aws:` + `rest` moves to, or + /// `None` when it stays in `aws` (or is not an ARN at all). + fn target_partition(&self, rest: &[u8]) -> Option<&'static str> { + let (service, rest) = split_field(rest, |b| { + b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' + })?; + if service.is_empty() { + return None; + } + let (region, rest) = split_field(rest, |b| { + b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' + })?; + let (account, _) = split_field(rest, |b| b.is_ascii_alphanumeric() || b == b'-')?; + if region.is_empty() { + (self.global_partition != "aws" && account != b"aws").then_some(self.global_partition) + } else { + // The field was checked to be ASCII. + let region = std::str::from_utf8(region).ok()?; + Some(partition_for(region)).filter(|p| *p != "aws") + } + } + + /// `text` with every ARN token that belongs to another partition + /// rewritten, borrowed when nothing changes. A token must start the string + /// or follow a non-alphanumeric byte (`"`, `/`, `:`, whitespace, ...) and + /// look like `arn:aws::::` -- free text that + /// merely contains `arn:aws` is left alone. + pub fn rewrite_str<'a>(&self, text: &'a str) -> Cow<'a, str> { + let bytes = text.as_bytes(); + let mut out: Option = None; + let mut copied = 0; + let mut from = 0; + while let Some(found) = text[from..].find(ARN_PREFIX) { + let start = from + found; + from = start + ARN_PREFIX.len(); + if start > 0 && bytes[start - 1].is_ascii_alphanumeric() { + continue; + } + let Some(partition) = self.target_partition(&bytes[from..]) else { + continue; + }; + let buf = out.get_or_insert_with(|| String::with_capacity(text.len() + 16)); + // Replace the `aws` between `arn:` and the following `:`. + buf.push_str(&text[copied..start + "arn:".len()]); + buf.push_str(partition); + copied = start + "arn:aws".len(); + } + match out { + Some(mut buf) => { + buf.push_str(&text[copied..]); + Cow::Owned(buf) + } + None => Cow::Borrowed(text), + } + } + + /// Whether `bytes` holds any ARN token [`Self::rewrite_str`] would rewrite. + /// Serialized JSON and TOML spell ARNs verbatim (none of their characters + /// is escaped), so this is a cheap pre-check before parsing a file. + pub fn needs_rewrite(&self, bytes: &[u8]) -> bool { + let prefix = ARN_PREFIX.as_bytes(); + let mut from = 0; + while let Some(found) = find_bytes(&bytes[from..], prefix) { + let start = from + found; + from = start + prefix.len(); + if start > 0 && bytes[start - 1].is_ascii_alphanumeric() { + continue; + } + if self.target_partition(&bytes[from..]).is_some() { + return true; + } + } + false + } + + /// Rewrite every string and object key in `value`, except the values of + /// keys named in `opaque_keys`, which are kept verbatim. Returns whether + /// anything changed. + /// + /// When a rewritten object key collides with a key already spelled in the + /// target partition, the existing entry wins: it is the one current code + /// created and has been serving, the legacy one is the stale duplicate. + pub fn rewrite_json(&self, value: &mut Value, opaque_keys: &[&str]) -> bool { + match value { + Value::String(s) => match self.rewrite_str(s) { + Cow::Owned(new) => { + *s = new; + true + } + Cow::Borrowed(_) => false, + }, + Value::Array(items) => { + let mut changed = false; + for item in items { + changed |= self.rewrite_json(item, opaque_keys); + } + changed + } + Value::Object(map) => self.rewrite_object(map, opaque_keys), + Value::Null | Value::Bool(_) | Value::Number(_) => false, + } + } + + fn rewrite_object(&self, map: &mut Map, opaque_keys: &[&str]) -> bool { + let mut changed = false; + let mut entries = Vec::with_capacity(map.len()); + for (key, mut value) in std::mem::take(map) { + if !opaque_keys.contains(&key.as_str()) { + changed |= self.rewrite_json(&mut value, opaque_keys); + } + let new_key = match self.rewrite_str(&key) { + Cow::Owned(new) => Some(new), + Cow::Borrowed(_) => None, + }; + entries.push((key, new_key, value)); + } + let kept: std::collections::HashSet<&str> = entries + .iter() + .filter(|(_, new_key, _)| new_key.is_none()) + .map(|(key, _, _)| key.as_str()) + .collect(); + let dropped: Vec = entries + .iter() + .map(|(_, new_key, _)| new_key.as_deref().is_some_and(|k| kept.contains(k))) + .collect(); + for ((key, new_key, value), dropped) in entries.into_iter().zip(dropped) { + match new_key { + None => { + map.insert(key, value); + } + Some(new_key) if dropped => { + tracing::warn!( + legacy = %key, + current = %new_key, + "dropping legacy arn:aws: entry shadowed by an entry already in the region's partition" + ); + changed = true; + } + Some(new_key) => { + map.insert(new_key, value); + changed = true; + } + } + } + changed + } +} + +/// Split `bytes` at the first `:`, requiring every byte before it to satisfy +/// `allowed`. `None` when a disallowed byte comes first or there is no `:`. +fn split_field(bytes: &[u8], allowed: impl Fn(u8) -> bool) -> Option<(&[u8], &[u8])> { + let end = bytes.iter().position(|&b| !allowed(b))?; + (bytes[end] == b':').then(|| (&bytes[..end], &bytes[end + 1..])) +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} + +/// Keys whose values are kept verbatim, per service data directory: payloads +/// verified against a stored content hash, and log events. See the module docs. +fn opaque_keys_for(service_dir: &str) -> &'static [&'static str] { + match service_dir { + // `md5_of_body` / `MD5OfMessageAttributes` are checked by the SDKs. + "sqs" => &["body", "message_attributes"], + // Manifests are addressed by their sha256 digest. + "ecr" => &["image_manifest"], + // Log events are bulk payloads; the segmented store never rewrites + // them, and a legacy whole-state snapshot is treated the same. + "logs" => &["message"], + _ => &[], + } +} + +/// What [`migrate_data_dir`] did. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct MigrationReport { + /// The directory was already migrated (or created by a binary that mints + /// partition-aware ARNs); nothing was read. + pub already_migrated: bool, + /// Files whose contents were rewritten. + pub rewritten: Vec, + /// Files that held candidate ARNs but could not be parsed; left untouched + /// for the owning service's loader to report. + pub unparseable: Vec, +} + +/// Run the ARN partition migration over the persistence directory `dir` for +/// a server started with `--region server_region`, then mark the directory +/// migrated. A no-op when the directory is already marked. +/// +/// Covered files: every service's JSON state directly under its directory +/// (`/snapshot.json`, `logs/manifest.json`, ...) and the S3 metadata +/// sidecars (`s3/**/*.toml`: bucket configuration including policies, +/// notifications, replication and encryption, object metadata). Each file is +/// replaced atomically, and the marker is written only after all of them, so +/// an interrupted migration simply runs again (the rewrite is idempotent). +pub fn migrate_data_dir( + dir: &Path, + server_region: &str, +) -> Result { + if crate::version::arn_partitions_migrated(dir)? { + return Ok(MigrationReport { + already_migrated: true, + ..MigrationReport::default() + }); + } + let migration = ArnPartitionMigration::for_server_region(server_region); + let mut report = MigrationReport::default(); + let io_err = |path: &Path| { + let path = path.to_path_buf(); + move |source: io::Error| crate::version::VersionError::Io { path, source } + }; + + for entry in read_dir_sorted(dir).map_err(io_err(dir))? { + if !entry.is_dir() { + continue; + } + let Some(service_dir) = entry.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if service_dir == "s3" { + migrate_s3_sidecars(&migration, &entry, &mut report).map_err(io_err(&entry))?; + continue; + } + let opaque = opaque_keys_for(service_dir); + for file in read_dir_sorted(&entry).map_err(io_err(&entry))? { + if file.is_file() && file.extension().is_some_and(|e| e == "json") { + migrate_json_file(&migration, &file, opaque, &mut report).map_err(io_err(&file))?; + } + } + } + + crate::version::mark_arn_partitions_migrated(dir)?; + Ok(report) +} + +fn read_dir_sorted(dir: &Path) -> io::Result> { + let mut entries = std::fs::read_dir(dir)? + .map(|e| e.map(|e| e.path())) + .collect::>>()?; + entries.sort(); + Ok(entries) +} + +fn migrate_json_file( + migration: &ArnPartitionMigration, + path: &Path, + opaque_keys: &[&str], + report: &mut MigrationReport, +) -> io::Result<()> { + let bytes = std::fs::read(path)?; + if !migration.needs_rewrite(&bytes) { + return Ok(()); + } + let mut value: Value = match serde_json::from_slice(&bytes) { + Ok(value) => value, + Err(err) => { + tracing::warn!( + path = %path.display(), + "skipping ARN partition migration of an unparseable file: {err}" + ); + report.unparseable.push(path.to_path_buf()); + return Ok(()); + } + }; + if !migration.rewrite_json(&mut value, opaque_keys) { + return Ok(()); + } + let out = serde_json::to_vec(&value) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + crate::atomic::write_atomic_bytes(path, &out)?; + report.rewritten.push(path.to_path_buf()); + Ok(()) +} + +/// S3 sidecars are TOML. ARNs appear only inside TOML strings (or quoted keys), +/// spelled verbatim, so the text is rewritten in place. +fn migrate_s3_sidecars( + migration: &ArnPartitionMigration, + dir: &Path, + report: &mut MigrationReport, +) -> io::Result<()> { + for path in read_dir_sorted(dir)? { + if path.is_dir() { + migrate_s3_sidecars(migration, &path, report)?; + continue; + } + if !path.extension().is_some_and(|e| e == "toml") { + continue; + } + let bytes = std::fs::read(&path)?; + if !migration.needs_rewrite(&bytes) { + continue; + } + let Ok(text) = std::str::from_utf8(&bytes) else { + report.unparseable.push(path); + continue; + }; + if let Cow::Owned(new) = migration.rewrite_str(text) { + crate::atomic::write_atomic_bytes(&path, new.as_bytes())?; + report.rewritten.push(path); + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "arn_partition_tests.rs"] +mod tests; diff --git a/crates/fakecloud-persistence/src/arn_partition_tests.rs b/crates/fakecloud-persistence/src/arn_partition_tests.rs new file mode 100644 index 000000000..b97bc62bd --- /dev/null +++ b/crates/fakecloud-persistence/src/arn_partition_tests.rs @@ -0,0 +1,405 @@ +use super::*; +use serde_json::json; + +fn cn() -> ArnPartitionMigration { + ArnPartitionMigration::for_server_region("cn-north-1") +} + +fn commercial() -> ArnPartitionMigration { + ArnPartitionMigration::for_server_region("us-east-1") +} + +fn rewrite(m: &ArnPartitionMigration, s: &str) -> String { + m.rewrite_str(s).into_owned() +} + +#[test] +fn regional_arns_take_their_region_partition() { + let m = commercial(); + assert_eq!( + rewrite(&m, "arn:aws:kms:cn-north-1:123456789012:key/k"), + "arn:aws-cn:kms:cn-north-1:123456789012:key/k" + ); + assert_eq!( + rewrite(&m, "arn:aws:sqs:us-gov-west-1:123456789012:q"), + "arn:aws-us-gov:sqs:us-gov-west-1:123456789012:q" + ); + assert_eq!( + rewrite(&m, "arn:aws:sns:us-iso-east-1:123456789012:t"), + "arn:aws-iso:sns:us-iso-east-1:123456789012:t" + ); + assert_eq!( + rewrite(&m, "arn:aws:sns:us-isob-east-1:123456789012:t"), + "arn:aws-iso-b:sns:us-isob-east-1:123456789012:t" + ); + assert_eq!( + rewrite(&m, "arn:aws:lambda:us-isof-south-1:1:function:f"), + "arn:aws-iso-f:lambda:us-isof-south-1:1:function:f" + ); + assert_eq!( + rewrite(&m, "arn:aws:lambda:eu-isoe-west-1:1:function:f"), + "arn:aws-iso-e:lambda:eu-isoe-west-1:1:function:f" + ); +} + +#[test] +fn commercial_region_arns_are_untouched() { + for m in [cn(), commercial()] { + let s = "arn:aws:kms:us-east-1:123456789012:key/k"; + assert!(matches!(m.rewrite_str(s), Cow::Borrowed(_))); + assert!(!m.needs_rewrite(s.as_bytes())); + } +} + +#[test] +fn already_partitioned_arns_are_untouched_and_rewrite_is_idempotent() { + let m = cn(); + for s in [ + "arn:aws-cn:kms:cn-north-1:123456789012:key/k", + "arn:aws-cn:iam::123456789012:role/r", + "arn:aws-us-gov:sqs:us-gov-west-1:1:q", + ] { + assert!(matches!(m.rewrite_str(s), Cow::Borrowed(_)), "{s}"); + } + let once = rewrite( + &m, + "arn:aws:iam::123456789012:role/r arn:aws:sns:cn-north-1:1:t", + ); + assert_eq!(rewrite(&m, &once), once); +} + +#[test] +fn region_less_arns_follow_the_server_partition() { + let m = cn(); + assert_eq!( + rewrite(&m, "arn:aws:iam::123456789012:role/r"), + "arn:aws-cn:iam::123456789012:role/r" + ); + assert_eq!( + rewrite(&m, "arn:aws:s3:::bucket/*"), + "arn:aws-cn:s3:::bucket/*" + ); + assert_eq!( + rewrite(&m, "arn:aws:states:::lambda:invoke"), + "arn:aws-cn:states:::lambda:invoke" + ); + let gov = ArnPartitionMigration::for_server_region("us-gov-east-1"); + assert_eq!( + rewrite(&gov, "arn:aws:iam::1:user/u"), + "arn:aws-us-gov:iam::1:user/u" + ); + + // A commercial server keeps them: they are genuinely `aws`. + let m = commercial(); + assert_eq!( + rewrite(&m, "arn:aws:iam::123456789012:role/r"), + "arn:aws:iam::123456789012:role/r" + ); + assert!(!m.needs_rewrite(b"arn:aws:s3:::bucket")); +} + +#[test] +fn aws_managed_policy_arns_keep_the_aws_partition() { + let m = cn(); + for s in [ + "arn:aws:iam::aws:policy/AdministratorAccess", + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ] { + assert_eq!(rewrite(&m, s), s); + assert!(!m.needs_rewrite(s.as_bytes())); + } +} + +#[test] +fn free_text_and_malformed_tokens_are_untouched() { + let m = cn(); + for s in [ + "arn:aws", + "arn:aws:", + "the arn:aws prefix", + "arn:aws:kms", + "arn:aws:kms:cn-north-1", + "arn:aws:kms:cn-north-1:", + "arn:aws:KMS:cn-north-1:1:key/k", + "arn:aws::cn-north-1:1:key/k", + "arn:aws:kms:cn north:1:key/k", + "xarn:aws:kms:cn-north-1:1:key/k", + "barn:aws:iam::1:role/r", + "", + ] { + assert_eq!(rewrite(&m, s), s, "{s:?}"); + assert!(!m.needs_rewrite(s.as_bytes()), "{s:?}"); + } +} + +#[test] +fn embedded_arns_are_rewritten_in_place() { + let m = cn(); + // A policy document stored as a string keeps its exact formatting. + let policy = r#"{ + "Version": "2012-10-17", + "Statement": [{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"}, + "Action":"sqs:*","Resource":["arn:aws:sqs:cn-north-1:123456789012:q","arn:aws:sqs:us-east-1:1:other"]}] +}"#; + let expected = policy + .replace("arn:aws:iam::", "arn:aws-cn:iam::") + .replace("arn:aws:sqs:cn-north-1", "arn:aws-cn:sqs:cn-north-1"); + assert_eq!(rewrite(&m, policy), expected); + assert!(m.needs_rewrite(policy.as_bytes())); + + // Composite keys and delimited lists. + assert_eq!( + rewrite( + &m, + "123456789012|arn:aws:sns:cn-north-1:1:t,arn:aws:sns:cn-north-1:1:u" + ), + "123456789012|arn:aws-cn:sns:cn-north-1:1:t,arn:aws-cn:sns:cn-north-1:1:u" + ); + // A token at the very end of the string. + assert_eq!(rewrite(&m, "see arn:aws:s3:::b"), "see arn:aws-cn:s3:::b"); + // Non-ASCII text around a token. + assert_eq!( + rewrite(&m, "é arn:aws:sns:cn-north-1:1:t ü"), + "é arn:aws-cn:sns:cn-north-1:1:t ü" + ); +} + +#[test] +fn json_values_keys_and_nesting_are_rewritten() { + let m = cn(); + let mut v = json!({ + "accounts": { + "123456789012": { + "topics": { + "arn:aws:sns:cn-north-1:123456789012:t": { + "topic_arn": "arn:aws:sns:cn-north-1:123456789012:t", + "attributes": { + "Policy": "{\"Resource\":\"arn:aws:sns:cn-north-1:123456789012:t\"}" + }, + "count": 3, + "flags": [true, null, "arn:aws:iam::123456789012:role/r"] + } + }, + "managed": ["arn:aws:iam::aws:policy/ReadOnlyAccess"], + } + } + }); + assert!(m.rewrite_json(&mut v, &[])); + assert_eq!( + v, + json!({ + "accounts": { + "123456789012": { + "topics": { + "arn:aws-cn:sns:cn-north-1:123456789012:t": { + "topic_arn": "arn:aws-cn:sns:cn-north-1:123456789012:t", + "attributes": { + "Policy": "{\"Resource\":\"arn:aws-cn:sns:cn-north-1:123456789012:t\"}" + }, + "count": 3, + "flags": [true, null, "arn:aws-cn:iam::123456789012:role/r"] + } + }, + "managed": ["arn:aws:iam::aws:policy/ReadOnlyAccess"], + } + } + }) + ); + // Idempotent. + assert!(!m.rewrite_json(&mut v, &[])); +} + +#[test] +fn opaque_keys_are_kept_verbatim() { + let m = cn(); + let body = "{\"TopicArn\":\"arn:aws:sns:cn-north-1:1:t\"}"; + let mut v = json!({ + "queue_arn": "arn:aws:sqs:cn-north-1:1:q", + "messages": [{"body": body, "md5_of_body": "x"}] + }); + assert!(m.rewrite_json(&mut v, opaque_keys_for("sqs"))); + assert_eq!(v["queue_arn"], "arn:aws-cn:sqs:cn-north-1:1:q"); + assert_eq!(v["messages"][0]["body"], body); +} + +#[test] +fn legacy_key_colliding_with_a_current_key_is_dropped() { + let m = cn(); + for (first, second) in [ + ( + "arn:aws:sns:cn-north-1:1:t", + "arn:aws-cn:sns:cn-north-1:1:t", + ), + ( + "arn:aws-cn:sns:cn-north-1:1:t", + "arn:aws:sns:cn-north-1:1:t", + ), + ] { + let mut map = Map::new(); + map.insert( + first.to_string(), + json!(if first.contains("aws-cn") { + "current" + } else { + "legacy" + }), + ); + map.insert( + second.to_string(), + json!(if second.contains("aws-cn") { + "current" + } else { + "legacy" + }), + ); + let mut v = Value::Object(map); + assert!(m.rewrite_json(&mut v, &[])); + assert_eq!(v, json!({"arn:aws-cn:sns:cn-north-1:1:t": "current"})); + } +} + +fn legacy_dir() -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join(crate::version::VERSION_FILE_NAME), + "format_version = 1\nfakecloud_version = \"0.40.0\"\ncreated_at = \"2026-01-01T00:00:00Z\"\n", + ) + .unwrap(); + tmp +} + +fn write(path: &Path, contents: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, contents).unwrap(); +} + +fn read(path: &Path) -> String { + std::fs::read_to_string(path).unwrap() +} + +#[test] +fn migrates_a_legacy_data_dir_once() { + let tmp = legacy_dir(); + let dir = tmp.path(); + let kms = dir.join("kms/snapshot.json"); + write( + &kms, + r#"{"schema_version":3,"accounts":{"k":{"arn":"arn:aws:kms:cn-north-1:1:key/k"}}}"#, + ); + let sqs = dir.join("sqs/snapshot.json"); + write( + &sqs, + r#"{"q":{"arn":"arn:aws:sqs:cn-north-1:1:q","messages":[{"body":"arn:aws:sqs:cn-north-1:1:q"}]}}"#, + ); + let logs_manifest = dir.join("logs/manifest.json"); + write( + &logs_manifest, + r#"{"group_arn":"arn:aws:logs:cn-north-1:1:log-group:g"}"#, + ); + let logs_segment = dir.join("logs/0f8fad5b-d9cb-469f-a165-70867728950e.jsonl"); + let segment = "{\"message\":\"arn:aws:logs:cn-north-1:1:log-group:g\"}\n"; + write(&logs_segment, segment); + let untouched = dir.join("ssm/snapshot.json"); + let untouched_text = r#"{"p":"arn:aws:ssm:us-east-1:1:parameter/p"}"#; + write(&untouched, untouched_text); + let policy = dir.join("s3/buckets/b/policy.toml"); + write( + &policy, + "policy = \"{\\\"Resource\\\":\\\"arn:aws:s3:::b/*\\\"}\"\n", + ); + let object_meta = dir.join("s3/buckets/b/objects/k/null.toml"); + write( + &object_meta, + "sse_kms_key_id = \"arn:aws:kms:cn-north-1:1:key/k\"\n", + ); + let body = dir.join("s3/buckets/b/objects/k/null.bin"); + write(&body, "arn:aws:kms:cn-north-1:1:key/k"); + let broken = dir.join("sns/snapshot.json"); + write(&broken, "{\"arn:aws:sns:cn-north-1:1:t\""); + + let report = migrate_data_dir(dir, "cn-north-1").unwrap(); + assert!(!report.already_migrated); + assert_eq!(report.unparseable, vec![broken.clone()]); + let mut rewritten = report.rewritten.clone(); + rewritten.sort(); + let mut expected = vec![ + kms.clone(), + sqs.clone(), + logs_manifest.clone(), + policy.clone(), + object_meta.clone(), + ]; + expected.sort(); + assert_eq!(rewritten, expected); + + let kms_v: Value = serde_json::from_str(&read(&kms)).unwrap(); + assert_eq!( + kms_v["accounts"]["k"]["arn"], + "arn:aws-cn:kms:cn-north-1:1:key/k" + ); + assert_eq!(kms_v["schema_version"], 3); + let sqs_v: Value = serde_json::from_str(&read(&sqs)).unwrap(); + assert_eq!(sqs_v["q"]["arn"], "arn:aws-cn:sqs:cn-north-1:1:q"); + assert_eq!( + sqs_v["q"]["messages"][0]["body"], + "arn:aws:sqs:cn-north-1:1:q" + ); + assert!(read(&logs_manifest).contains("arn:aws-cn:logs:cn-north-1")); + assert_eq!(read(&logs_segment), segment); + assert_eq!(read(&untouched), untouched_text); + assert_eq!( + read(&policy), + "policy = \"{\\\"Resource\\\":\\\"arn:aws-cn:s3:::b/*\\\"}\"\n" + ); + assert!(toml::from_str::(&read(&policy)).is_ok()); + assert!(read(&object_meta).contains("arn:aws-cn:kms:cn-north-1")); + assert_eq!(read(&body), "arn:aws:kms:cn-north-1:1:key/k"); + assert_eq!(read(&broken), "{\"arn:aws:sns:cn-north-1:1:t\""); + + // Marked: the version file keeps its fields and gains the marker. + let version: crate::version::FormatVersion = + toml::from_str(&read(&dir.join(crate::version::VERSION_FILE_NAME))).unwrap(); + assert!(version.arn_partitions_migrated); + assert_eq!(version.fakecloud_version, "0.40.0"); + assert_eq!(version.created_at, "2026-01-01T00:00:00Z"); + + // A second start reads nothing, even with new legacy-looking state. + let later = r#"{"arn":"arn:aws:iam::1:role/created-in-us-east-1"}"#; + write(&dir.join("iam/snapshot.json"), later); + let again = migrate_data_dir(dir, "cn-north-1").unwrap(); + assert!(again.already_migrated); + assert_eq!(read(&dir.join("iam/snapshot.json")), later); +} + +#[test] +fn commercial_server_rewrites_only_regional_arns() { + let tmp = legacy_dir(); + let dir = tmp.path(); + let iam = dir.join("iam/snapshot.json"); + let iam_text = r#"{"arn":"arn:aws:iam::1:role/r"}"#; + write(&iam, iam_text); + let kms = dir.join("kms/snapshot.json"); + write(&kms, r#"{"arn":"arn:aws:kms:cn-north-1:1:key/k"}"#); + + let report = migrate_data_dir(dir, "us-east-1").unwrap(); + assert_eq!(report.rewritten, vec![kms.clone()]); + assert_eq!(read(&iam), iam_text); + assert!(read(&kms).contains("arn:aws-cn:kms:cn-north-1")); + assert!(crate::version::arn_partitions_migrated(dir).unwrap()); +} + +#[test] +fn fresh_data_dir_is_born_migrated() { + let tmp = tempfile::tempdir().unwrap(); + crate::version::ensure_version_file(tmp.path(), "test").unwrap(); + assert!(crate::version::arn_partitions_migrated(tmp.path()).unwrap()); + let report = migrate_data_dir(tmp.path(), "cn-north-1").unwrap(); + assert!(report.already_migrated); +} + +#[test] +fn legacy_version_file_without_marker_is_accepted_by_the_version_check() { + let tmp = legacy_dir(); + crate::version::check_version_file(tmp.path()).unwrap(); + assert!(!crate::version::arn_partitions_migrated(tmp.path()).unwrap()); +} diff --git a/crates/fakecloud-persistence/src/lib.rs b/crates/fakecloud-persistence/src/lib.rs index f7d26a43a..b5189b48d 100644 --- a/crates/fakecloud-persistence/src/lib.rs +++ b/crates/fakecloud-persistence/src/lib.rs @@ -1,3 +1,4 @@ +pub mod arn_partition; pub mod atomic; pub mod cache; pub mod config; diff --git a/crates/fakecloud-persistence/src/version.rs b/crates/fakecloud-persistence/src/version.rs index 20b00982b..74f10994a 100644 --- a/crates/fakecloud-persistence/src/version.rs +++ b/crates/fakecloud-persistence/src/version.rs @@ -28,6 +28,13 @@ pub struct FormatVersion { pub format_version: u32, pub fakecloud_version: String, pub created_at: String, + /// Whether the directory's state spells ARNs in their region's partition. + /// Absent (false) in directories written before fakecloud minted + /// partition-aware ARNs; [`crate::arn_partition::migrate_data_dir`] + /// rewrites those once and sets it. A directory created by a binary that + /// already mints partition-aware ARNs starts with it set. + #[serde(default)] + pub arn_partitions_migrated: bool, } #[derive(Debug, Error)] @@ -72,6 +79,7 @@ pub fn write_version_file(dir: &Path, fakecloud_version: &str) -> Result<(), Ver format_version: FORMAT_VERSION, fakecloud_version: fakecloud_version.to_string(), created_at: chrono::Utc::now().to_rfc3339(), + arn_partitions_migrated: true, }; crate::atomic::write_atomic_toml(&path, &value).map_err(|source| VersionError::Io { path: path.clone(), @@ -80,19 +88,49 @@ pub fn write_version_file(dir: &Path, fakecloud_version: &str) -> Result<(), Ver Ok(()) } -pub fn check_version_file(dir: &Path) -> Result<(), VersionError> { +fn read_version_file(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|source| VersionError::Io { + path: path.to_path_buf(), + source, + })?; + toml::from_str(&text).map_err(|source| VersionError::Parse { + path: path.to_path_buf(), + source, + }) +} + +/// Whether `dir`'s version file records the ARN partition migration as done. +/// A directory with no version file holds no state yet, so it counts as +/// migrated. +pub fn arn_partitions_migrated(dir: &Path) -> Result { let path = version_file_path(dir); if !path.exists() { + return Ok(true); + } + Ok(read_version_file(&path)?.arn_partitions_migrated) +} + +/// Record in `dir`'s version file that the ARN partition migration ran, +/// keeping its other fields. +pub fn mark_arn_partitions_migrated(dir: &Path) -> Result<(), VersionError> { + let path = version_file_path(dir); + let mut value = read_version_file(&path)?; + if value.arn_partitions_migrated { return Ok(()); } - let text = std::fs::read_to_string(&path).map_err(|source| VersionError::Io { - path: path.clone(), - source, - })?; - let parsed: FormatVersion = toml::from_str(&text).map_err(|source| VersionError::Parse { + value.arn_partitions_migrated = true; + crate::atomic::write_atomic_toml(&path, &value).map_err(|source| VersionError::Io { path: path.clone(), source, - })?; + }) +} + +pub fn check_version_file(dir: &Path) -> Result<(), VersionError> { + let path = version_file_path(dir); + if !path.exists() { + return Ok(()); + } + let parsed = read_version_file(&path)?; if parsed.format_version != FORMAT_VERSION { return Err(VersionError::FormatMismatch { path, diff --git a/crates/fakecloud-server/src/main.rs b/crates/fakecloud-server/src/main.rs index 855270137..a63d205ab 100644 --- a/crates/fakecloud-server/src/main.rs +++ b/crates/fakecloud-server/src/main.rs @@ -260,6 +260,21 @@ async fn main() { data_path.display() )); } + // State persisted before ARNs were minted in the region's + // partition still spells them `arn:aws:`; rewrite it once, before + // any service loads its snapshot. + match fakecloud_persistence::arn_partition::migrate_data_dir(data_path, &cli.region) { + Ok(report) if report.already_migrated => {} + Ok(report) => tracing::info!( + rewritten = report.rewritten.len(), + unparseable = report.unparseable.len(), + "migrated persisted ARNs to their region's partition" + ), + Err(err) => fatal_exit(format_args!( + "ARN partition migration failed at {}: {err}", + data_path.display() + )), + } } } // Bind early so we know the actual port before initialising service state. diff --git a/scripts/publish-crates.sh b/scripts/publish-crates.sh index 74be44ba2..34324cb33 100755 --- a/scripts/publish-crates.sh +++ b/scripts/publish-crates.sh @@ -55,10 +55,10 @@ CRATES=( # Layer 1: no internal deps fakecloud-aws fakecloud-sdk - fakecloud-persistence fakecloud-k8s # only external deps (kube/k8s-openapi); dependents: lambda/elasticache/rds/ecs/server # Layer 2: depends on fakecloud-aws + fakecloud-persistence fakecloud-core # Layer 3a: depend on aws + core + persistence only diff --git a/website/content/docs/reference/persistence.md b/website/content/docs/reference/persistence.md index 00308dd92..8585e8523 100644 --- a/website/content/docs/reference/persistence.md +++ b/website/content/docs/reference/persistence.md @@ -73,7 +73,20 @@ These data volumes default **on** under `--storage-mode=persistent` and **off** On startup fakecloud reads `/fakecloud.version.toml`. The file records the on-disk format version and the fakecloud version that created the directory. If the format version doesn't match the running binary, startup fails with an actionable error that points at the file. -Except for the legacy CloudWatch Logs migration described below, there is no automatic migration. The intent is that you either keep using the binary that wrote the directory or start from an empty data path. +Except for the ARN partition migration and the legacy CloudWatch Logs migration described below, there is no automatic migration. The intent is that you either keep using the binary that wrote the directory or start from an empty data path. + +### ARN partition migration + +fakecloud mints ARNs in the partition of their region: `arn:aws-cn:` for `cn-*` regions, `arn:aws-us-gov:` for GovCloud, and the `aws-iso*` partitions for the isolated regions. Older releases used `arn:aws:` everywhere, so a data directory written by a China, GovCloud or ISO-region server before that change holds `arn:aws:` ARNs. + +The first time a newer binary opens such a directory it rewrites those ARNs once, before any service loads its state, and records `arn_partitions_migrated = true` in `fakecloud.version.toml` so it never runs again. Directories created by a current binary start with the marker set. + +- A regional ARN whose region belongs to another partition (`arn:aws:kms:cn-north-1:...`) is rewritten to that partition (`arn:aws-cn:kms:cn-north-1:...`), whatever `--region` the server runs with. +- A region-less ARN (`arn:aws:iam::123456789012:role/app`, `arn:aws:s3:::bucket`) is rewritten to the partition of the server's `--region`, and only when that partition is not `aws`. AWS-managed policy ARNs (`arn:aws:iam::aws:policy/...`) keep the `aws` spelling, matching what fakecloud serves for them in every partition. +- The rewrite covers every service's state: resource fields, maps keyed by ARN (SNS topics, tag maps, ...), and ARNs inside stored documents such as IAM, bucket, queue and key policies, which are otherwise left byte-for-byte unchanged. S3 bucket configuration and object metadata are covered too. +- Payloads are not rewritten: SQS message bodies and attributes (their MD5 digests would stop matching), ECR image manifests (addressed by digest), CloudWatch Logs events, S3 object bodies, and container data volumes. + +After the migration, responses return the partition-correct ARN for these resources, the same as for resources created afterwards, and ARN-keyed lookups (for example SNS `GetTopicAttributes`) take the new ARN. ## S3 object body handling From ae0176cc1a06f401c595d86fbf66953324ee4607 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Wed, 30 Sep 2026 20:55:55 -0300 Subject: [PATCH 2/6] fix(persistence): keep customer data and wildcards right in the ARN migration Review fixes for the one-time ARN partition migration: - Skip filesystem artifacts (lost+found, dotfiles) and service directories the server cannot read instead of failing startup. - Leave customer data verbatim: DynamoDB items and stream images, SSM parameter values, Secrets Manager secrets, and S3 object keys, user metadata and tags (rewriting the key stranded the object in the directory derived from the old key). Object sidecars are rewritten structurally; bucket subresource files hold raw JSON/XML payloads and are rewritten as text. - Rewrite policy wildcards (`arn:aws:kms:*:acct:key/*`) with the resources they match, so stored allows keep matching. - Drop the token-boundary check from the byte pre-check: an ARN behind a serialized escape (`\narn:aws:...`) made a file look clean and it was then marked migrated for good. --- .../arn_partition_migration_persistence.rs | 82 +++++- .../src/arn_partition.rs | 227 ++++++++++++---- .../src/arn_partition_tests.rs | 248 +++++++++++++++++- crates/fakecloud-persistence/src/version.rs | 2 +- crates/fakecloud-server/src/main.rs | 2 +- website/content/docs/reference/persistence.md | 3 +- 6 files changed, 504 insertions(+), 60 deletions(-) diff --git a/crates/fakecloud-e2e/tests/arn_partition_migration_persistence.rs b/crates/fakecloud-e2e/tests/arn_partition_migration_persistence.rs index aa9beaf7e..943b85798 100644 --- a/crates/fakecloud-e2e/tests/arn_partition_migration_persistence.rs +++ b/crates/fakecloud-e2e/tests/arn_partition_migration_persistence.rs @@ -205,6 +205,52 @@ async fn china_server_migrates_legacy_aws_partition_arns() { .await .unwrap(); + // An object whose key names an ARN, with an SSE-KMS key ARN in its + // metadata sidecar: the key is customer data, the KMS ARN is not. + let object_key = format!("exports/arn:aws:iam::{ACCOUNT}:role/x.json"); + s3.put_object() + .bucket("legacy-bucket") + .key(&object_key) + .server_side_encryption(aws_sdk_s3::types::ServerSideEncryption::AwsKms) + .ssekms_key_id(&key_id) + .body(aws_sdk_s3::primitives::ByteStream::from_static(b"payload")) + .send() + .await + .unwrap(); + + // A DynamoDB item keyed by an ARN string stays reachable by that key. + let ddb = aws_sdk_dynamodb::Client::new(&config); + ddb.create_table() + .table_name("legacy-table") + .attribute_definitions( + aws_sdk_dynamodb::types::AttributeDefinition::builder() + .attribute_name("pk") + .attribute_type(aws_sdk_dynamodb::types::ScalarAttributeType::S) + .build() + .unwrap(), + ) + .key_schema( + aws_sdk_dynamodb::types::KeySchemaElement::builder() + .attribute_name("pk") + .key_type(aws_sdk_dynamodb::types::KeyType::Hash) + .build() + .unwrap(), + ) + .billing_mode(aws_sdk_dynamodb::types::BillingMode::PayPerRequest) + .send() + .await + .unwrap(); + let item_key = format!("arn:aws:iam::{ACCOUNT}:role/legacy-role"); + ddb.put_item() + .table_name("legacy-table") + .item( + "pk", + aws_sdk_dynamodb::types::AttributeValue::S(item_key.clone()), + ) + .send() + .await + .unwrap(); + let lambda = aws_sdk_lambda::Client::new(&config); lambda .create_function() @@ -228,7 +274,7 @@ async fn china_server_migrates_legacy_aws_partition_arns() { sqs_snapshot.contains(r#"\"TopicArn\":\"arn:aws:sns:cn-north-1"#), "the message body is stored in plaintext" ); - for service in ["kms", "sns", "iam", "sqs", "lambda"] { + for service in ["kms", "sns", "iam", "sqs", "lambda", "dynamodb"] { let snapshot = std::fs::read_to_string(tmp.path().join(service).join("snapshot.json")).unwrap(); assert!(snapshot.contains("arn:aws:"), "{service} has no legacy ARN"); @@ -436,6 +482,40 @@ async fn china_server_migrates_legacy_aws_partition_arns() { ); assert!(!policy.contains("arn:aws:"), "{policy}"); + let object = s3 + .get_object() + .bucket("legacy-bucket") + .key(&object_key) + .send() + .await + .expect("the object is still reachable by the key it was written with"); + assert_eq!(object.ssekms_key_id(), Some(key_arn.as_str())); + let body = object.body.collect().await.unwrap().into_bytes(); + assert_eq!(&body[..], b"payload"); + + let ddb = aws_sdk_dynamodb::Client::new(&config); + let table = ddb + .describe_table() + .table_name("legacy-table") + .send() + .await + .unwrap(); + assert_eq!( + table.table().unwrap().table_arn(), + Some(format!("arn:aws-cn:dynamodb:cn-north-1:{ACCOUNT}:table/legacy-table").as_str()) + ); + let item = ddb + .get_item() + .table_name("legacy-table") + .key( + "pk", + aws_sdk_dynamodb::types::AttributeValue::S(item_key.clone()), + ) + .send() + .await + .unwrap(); + assert!(item.item().is_some(), "item keyed by an ARN string is kept"); + // Lambda: function and role ARNs; lookup by the new function ARN. let lambda = aws_sdk_lambda::Client::new(&config); let function_arn = format!("arn:aws-cn:lambda:cn-north-1:{ACCOUNT}:function:legacy-fn"); diff --git a/crates/fakecloud-persistence/src/arn_partition.rs b/crates/fakecloud-persistence/src/arn_partition.rs index e25ab3a88..57e7d0a40 100644 --- a/crates/fakecloud-persistence/src/arn_partition.rs +++ b/crates/fakecloud-persistence/src/arn_partition.rs @@ -28,11 +28,15 @@ //! `arn:aws:iam::aws:policy/...`) stay as they are: fakecloud's managed-policy //! catalog keeps the `aws` spelling in every partition. //! -//! Payloads that are verified against a stored content hash are left alone: +//! Payloads are left alone. Some are verified against a stored content hash: //! rewriting an SQS message body would break the `MD5OfBody` the SDKs check, -//! and an ECR manifest is addressed by its digest. Bulk payloads streamed -//! outside the snapshots (S3 object bodies, CloudWatch Logs event segments, -//! container data volumes) are not read at all. +//! and an ECR manifest is addressed by its digest. Others are customer data +//! the service stores rather than describes, where a rewrite would change +//! what the application wrote or break a lookup by it: DynamoDB items, SSM +//! parameter values, Secrets Manager secrets, S3 object keys, user metadata +//! and object tags, log events. Bulk payloads streamed outside the snapshots +//! (S3 object bodies, CloudWatch Logs event segments, container data volumes) +//! are not read at all. use std::borrow::Cow; use std::io; @@ -61,23 +65,33 @@ impl ArnPartitionMigration { /// The partition the ARN starting at `arn:aws:` + `rest` moves to, or /// `None` when it stays in `aws` (or is not an ARN at all). + /// + /// Policy wildcards (`*`, `?`) are accepted in the service, region and + /// account fields, so a stored policy's patterns move with the resources + /// they match: a region pattern that pins a partition (`cn-*`) takes it, + /// one that does not (`*`) is treated like a region-less ARN. fn target_partition(&self, rest: &[u8]) -> Option<&'static str> { let (service, rest) = split_field(rest, |b| { - b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' + b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'*' })?; if service.is_empty() { return None; } let (region, rest) = split_field(rest, |b| { - b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' + b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'*' | b'?') })?; - let (account, _) = split_field(rest, |b| b.is_ascii_alphanumeric() || b == b'-')?; - if region.is_empty() { - (self.global_partition != "aws" && account != b"aws").then_some(self.global_partition) - } else { - // The field was checked to be ASCII. - let region = std::str::from_utf8(region).ok()?; - Some(partition_for(region)).filter(|p| *p != "aws") + let (account, _) = split_field(rest, |b| { + b.is_ascii_alphanumeric() || matches!(b, b'-' | b'*' | b'?') + })?; + // The field was checked to be ASCII. + let region = std::str::from_utf8(region).ok()?; + let wildcard = region.contains(['*', '?']); + match partition_for(region) { + "aws" if region.is_empty() || wildcard => (self.global_partition != "aws" + && account != b"aws") + .then_some(self.global_partition), + "aws" => None, + partition => Some(partition), } } @@ -115,18 +129,17 @@ impl ArnPartitionMigration { } } - /// Whether `bytes` holds any ARN token [`Self::rewrite_str`] would rewrite. - /// Serialized JSON and TOML spell ARNs verbatim (none of their characters - /// is escaped), so this is a cheap pre-check before parsing a file. + /// Whether `bytes` may hold an ARN token [`Self::rewrite_str`] would + /// rewrite: a cheap pre-check before parsing a file. Serialized JSON and + /// TOML spell ARNs verbatim (none of their characters is escaped), but the + /// byte before one may be the tail of an escape (`\narn:aws:...`), so + /// unlike [`Self::rewrite_str`] this does not require a token boundary. + /// It can only err towards parsing a file that needs nothing. pub fn needs_rewrite(&self, bytes: &[u8]) -> bool { let prefix = ARN_PREFIX.as_bytes(); let mut from = 0; while let Some(found) = find_bytes(&bytes[from..], prefix) { - let start = from + found; - from = start + prefix.len(); - if start > 0 && bytes[start - 1].is_ascii_alphanumeric() { - continue; - } + from += found + prefix.len(); if self.target_partition(&bytes[from..]).is_some() { return true; } @@ -219,7 +232,8 @@ fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { } /// Keys whose values are kept verbatim, per service data directory: payloads -/// verified against a stored content hash, and log events. See the module docs. +/// verified against a stored content hash, and customer data the service +/// stores rather than describes. See the module docs. fn opaque_keys_for(service_dir: &str) -> &'static [&'static str] { match service_dir { // `md5_of_body` / `MD5OfMessageAttributes` are checked by the SDKs. @@ -229,10 +243,21 @@ fn opaque_keys_for(service_dir: &str) -> &'static [&'static str] { // Log events are bulk payloads; the segmented store never rewrites // them, and a legacy whole-state snapshot is treated the same. "logs" => &["message"], + // Table and backup rows, and stream record images: an item keyed by + // an ARN string must stay reachable by the key the application wrote. + "dynamodb" => &["items", "keys", "new_image", "old_image"], + // Parameter values (current and every version). + "ssm" => &["value"], + "secretsmanager" => &["secret_string"], _ => &[], } } +/// Object and multipart-upload sidecar fields that are customer data: the +/// object key (its directory on disk is derived from it, so rewriting the +/// field would strand the object), user metadata and object tags. +const S3_OBJECT_OPAQUE_KEYS: &[&str] = &["key", "metadata", "tags"]; + /// What [`migrate_data_dir`] did. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct MigrationReport { @@ -241,9 +266,10 @@ pub struct MigrationReport { pub already_migrated: bool, /// Files whose contents were rewritten. pub rewritten: Vec, - /// Files that held candidate ARNs but could not be parsed; left untouched - /// for the owning service's loader to report. - pub unparseable: Vec, + /// Files that held candidate ARNs but could not be parsed, and + /// directories that could not be read; left untouched for the owning + /// service's loader to report. + pub skipped: Vec, } /// Run the ARN partition migration over the persistence directory `dir` for @@ -274,21 +300,39 @@ pub fn migrate_data_dir( }; for entry in read_dir_sorted(dir).map_err(io_err(dir))? { - if !entry.is_dir() { - continue; - } - let Some(service_dir) = entry.file_name().and_then(|n| n.to_str()) else { + let Some(name) = entry.file_name() else { continue; }; - if service_dir == "s3" { - migrate_s3_sidecars(&migration, &entry, &mut report).map_err(io_err(&entry))?; + // `lost+found`, `.snapshot` and dotfiles are filesystem artifacts, + // often unreadable to a non-root server; they hold no state. + if crate::version::is_benign_entry(name) || !entry.is_dir() { continue; } - let opaque = opaque_keys_for(service_dir); - for file in read_dir_sorted(&entry).map_err(io_err(&entry))? { - if file.is_file() && file.extension().is_some_and(|e| e == "json") { - migrate_json_file(&migration, &file, opaque, &mut report).map_err(io_err(&file))?; + let Some(service_dir) = name.to_str() else { + continue; + }; + let result = if service_dir == "s3" { + migrate_s3_sidecars(&migration, &entry, false, &mut report) + } else { + migrate_service_dir( + &migration, + &entry, + opaque_keys_for(service_dir), + &mut report, + ) + }; + if let Err(err) = result { + // A directory the server cannot read is one its own loader + // cannot read either; it reports that, not the migration. + if err.kind() == io::ErrorKind::PermissionDenied { + tracing::warn!( + path = %entry.display(), + "skipping ARN partition migration of an unreadable directory: {err}" + ); + report.skipped.push(entry); + continue; } + return Err(io_err(&entry)(err)); } } @@ -296,6 +340,20 @@ pub fn migrate_data_dir( Ok(report) } +fn migrate_service_dir( + migration: &ArnPartitionMigration, + dir: &Path, + opaque_keys: &[&str], + report: &mut MigrationReport, +) -> io::Result<()> { + for file in read_dir_sorted(dir)? { + if file.is_file() && file.extension().is_some_and(|e| e == "json") { + migrate_json_file(migration, &file, opaque_keys, report)?; + } + } + Ok(()) +} + fn read_dir_sorted(dir: &Path) -> io::Result> { let mut entries = std::fs::read_dir(dir)? .map(|e| e.map(|e| e.path())) @@ -321,7 +379,7 @@ fn migrate_json_file( path = %path.display(), "skipping ARN partition migration of an unparseable file: {err}" ); - report.unparseable.push(path.to_path_buf()); + report.skipped.push(path.to_path_buf()); return Ok(()); } }; @@ -335,16 +393,28 @@ fn migrate_json_file( Ok(()) } -/// S3 sidecars are TOML. ARNs appear only inside TOML strings (or quoted keys), -/// spelled verbatim, so the text is rewritten in place. +/// Rewrite the S3 store's sidecars under `dir`. +/// +/// Bucket-level files (`meta.toml`, and the subresource files such as +/// `policy.toml` / `notification.toml`, which hold the configuration payload +/// verbatim -- JSON, XML or TOML) spell ARNs as plain text and are rewritten +/// as text. Below a bucket's `objects/` and `mpu/` directories each sidecar is +/// an `ObjectMeta`-style TOML table whose [`S3_OBJECT_OPAQUE_KEYS`] must stay +/// verbatim, so those are rewritten structurally. Object bodies (`.bin`) are +/// never read. fn migrate_s3_sidecars( migration: &ArnPartitionMigration, dir: &Path, + in_objects: bool, report: &mut MigrationReport, ) -> io::Result<()> { for path in read_dir_sorted(dir)? { if path.is_dir() { - migrate_s3_sidecars(migration, &path, report)?; + let objects = in_objects + || path + .file_name() + .is_some_and(|n| n == "objects" || n == "mpu"); + migrate_s3_sidecars(migration, &path, objects, report)?; continue; } if !path.extension().is_some_and(|e| e == "toml") { @@ -355,17 +425,86 @@ fn migrate_s3_sidecars( continue; } let Ok(text) = std::str::from_utf8(&bytes) else { - report.unparseable.push(path); + report.skipped.push(path); continue; }; - if let Cow::Owned(new) = migration.rewrite_str(text) { - crate::atomic::write_atomic_bytes(&path, new.as_bytes())?; - report.rewritten.push(path); - } + let rewritten = if in_objects { + let Ok(mut table) = text.parse::() else { + tracing::warn!( + path = %path.display(), + "skipping ARN partition migration of an unparseable file" + ); + report.skipped.push(path); + continue; + }; + if !migration.rewrite_toml_table(&mut table, S3_OBJECT_OPAQUE_KEYS) { + continue; + } + toml::to_string_pretty(&table) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))? + } else { + match migration.rewrite_str(text) { + Cow::Owned(new) => new, + Cow::Borrowed(_) => continue, + } + }; + crate::atomic::write_atomic_bytes(&path, rewritten.as_bytes())?; + report.rewritten.push(path); } Ok(()) } +impl ArnPartitionMigration { + /// [`Self::rewrite_json`] for a TOML table: strings, keys, arrays and + /// nested tables, except the values of `opaque_keys`. + fn rewrite_toml_table(&self, table: &mut toml::Table, opaque_keys: &[&str]) -> bool { + let mut changed = false; + let mut renamed = Vec::new(); + for (key, mut value) in std::mem::take(table) { + if !opaque_keys.contains(&key.as_str()) { + changed |= self.rewrite_toml(&mut value, opaque_keys); + } + match self.rewrite_str(&key) { + Cow::Owned(new) => renamed.push((new, value)), + Cow::Borrowed(_) => { + table.insert(key, value); + } + } + } + // As in `rewrite_object`: an entry already in the target partition + // wins over the legacy one it collides with. + for (key, value) in renamed { + changed = true; + table.entry(key).or_insert(value); + } + changed + } + + fn rewrite_toml(&self, value: &mut toml::Value, opaque_keys: &[&str]) -> bool { + match value { + toml::Value::String(s) => match self.rewrite_str(s) { + Cow::Owned(new) => { + *s = new; + true + } + Cow::Borrowed(_) => false, + }, + toml::Value::Array(items) => { + let mut changed = false; + for item in items { + changed |= self.rewrite_toml(item, opaque_keys); + } + changed + } + toml::Value::Table(table) => self.rewrite_toml_table(table, opaque_keys), + toml::Value::Integer(_) + | toml::Value::Float(_) + | toml::Value::Boolean(_) + | toml::Value::Datetime(_) => false, + } + } +} + #[cfg(test)] #[path = "arn_partition_tests.rs"] mod tests; diff --git a/crates/fakecloud-persistence/src/arn_partition_tests.rs b/crates/fakecloud-persistence/src/arn_partition_tests.rs index b97bc62bd..31d5d4455 100644 --- a/crates/fakecloud-persistence/src/arn_partition_tests.rs +++ b/crates/fakecloud-persistence/src/arn_partition_tests.rs @@ -123,13 +123,77 @@ fn free_text_and_malformed_tokens_are_untouched() { "arn:aws:KMS:cn-north-1:1:key/k", "arn:aws::cn-north-1:1:key/k", "arn:aws:kms:cn north:1:key/k", - "xarn:aws:kms:cn-north-1:1:key/k", - "barn:aws:iam::1:role/r", "", ] { assert_eq!(rewrite(&m, s), s, "{s:?}"); assert!(!m.needs_rewrite(s.as_bytes()), "{s:?}"); } + // Glued to a word: not a token. (The byte pre-check deliberately does + // not look at the boundary; see the escape test.) + for s in ["xarn:aws:kms:cn-north-1:1:key/k", "barn:aws:iam::1:role/r"] { + assert_eq!(rewrite(&m, s), s, "{s:?}"); + } +} + +#[test] +fn pre_check_sees_arns_behind_serialized_escapes() { + // Regression: the raw bytes of `"a\narn:aws:..."` put `n` before the + // token, which a boundary check on raw bytes rejected, so a file whose + // only legacy ARN followed an escape was skipped and marked migrated. + let m = cn(); + let value = json!({"doc": "line\narn:aws:iam::1:role/r\tarn:aws:sns:cn-north-1:1:t"}); + let raw = serde_json::to_vec(&value).unwrap(); + assert!(m.needs_rewrite(&raw)); + let mut value = value; + assert!(m.rewrite_json(&mut value, &[])); + assert_eq!( + value["doc"], + "line\narn:aws-cn:iam::1:role/r\tarn:aws-cn:sns:cn-north-1:1:t" + ); +} + +#[test] +fn policy_wildcards_move_with_the_resources_they_match() { + // Regression: `*` in the region or account field made the token + // unparseable, so patterns stayed `arn:aws:` while the resources they + // match became `arn:aws-cn:` and stored allows stopped matching. + let m = cn(); + for (legacy, migrated) in [ + ( + "arn:aws:kms:*:123456789012:key/*", + "arn:aws-cn:kms:*:123456789012:key/*", + ), + ( + "arn:aws:sqs:cn-north-1:*:q", + "arn:aws-cn:sqs:cn-north-1:*:q", + ), + ("arn:aws:sqs:cn-*:1:q", "arn:aws-cn:sqs:cn-*:1:q"), + ("arn:aws:iam::*:role/x", "arn:aws-cn:iam::*:role/x"), + ("arn:aws:s3:::*", "arn:aws-cn:s3:::*"), + ("arn:aws:*:*:*:*", "arn:aws-cn:*:*:*:*"), + ( + "arn:aws:ec2:cn-north-?:1:vpc/*", + "arn:aws-cn:ec2:cn-north-?:1:vpc/*", + ), + ] { + assert_eq!(rewrite(&m, legacy), migrated, "{legacy}"); + } + // A pattern that pins a partition keeps doing so on any server. + let us = commercial(); + assert_eq!( + rewrite(&us, "arn:aws:sqs:cn-*:1:q"), + "arn:aws-cn:sqs:cn-*:1:q" + ); + // A partition-agnostic pattern on a commercial server is genuinely aws. + assert_eq!( + rewrite(&us, "arn:aws:kms:*:1:key/*"), + "arn:aws:kms:*:1:key/*" + ); + // Any other partition-agnostic pattern follows the server's partition. + assert_eq!( + rewrite(&m, "arn:aws:sqs:us-*:1:q"), + "arn:aws-cn:sqs:us-*:1:q" + ); } #[test] @@ -303,10 +367,8 @@ fn migrates_a_legacy_data_dir_once() { let untouched_text = r#"{"p":"arn:aws:ssm:us-east-1:1:parameter/p"}"#; write(&untouched, untouched_text); let policy = dir.join("s3/buckets/b/policy.toml"); - write( - &policy, - "policy = \"{\\\"Resource\\\":\\\"arn:aws:s3:::b/*\\\"}\"\n", - ); + // Subresource files hold the configuration payload verbatim. + write(&policy, r#"{"Resource":"arn:aws:s3:::b/*"}"#); let object_meta = dir.join("s3/buckets/b/objects/k/null.toml"); write( &object_meta, @@ -319,7 +381,7 @@ fn migrates_a_legacy_data_dir_once() { let report = migrate_data_dir(dir, "cn-north-1").unwrap(); assert!(!report.already_migrated); - assert_eq!(report.unparseable, vec![broken.clone()]); + assert_eq!(report.skipped, vec![broken.clone()]); let mut rewritten = report.rewritten.clone(); rewritten.sort(); let mut expected = vec![ @@ -347,11 +409,7 @@ fn migrates_a_legacy_data_dir_once() { assert!(read(&logs_manifest).contains("arn:aws-cn:logs:cn-north-1")); assert_eq!(read(&logs_segment), segment); assert_eq!(read(&untouched), untouched_text); - assert_eq!( - read(&policy), - "policy = \"{\\\"Resource\\\":\\\"arn:aws-cn:s3:::b/*\\\"}\"\n" - ); - assert!(toml::from_str::(&read(&policy)).is_ok()); + assert_eq!(read(&policy), r#"{"Resource":"arn:aws-cn:s3:::b/*"}"#); assert!(read(&object_meta).contains("arn:aws-cn:kms:cn-north-1")); assert_eq!(read(&body), "arn:aws:kms:cn-north-1:1:key/k"); assert_eq!(read(&broken), "{\"arn:aws:sns:cn-north-1:1:t\""); @@ -403,3 +461,169 @@ fn legacy_version_file_without_marker_is_accepted_by_the_version_check() { crate::version::check_version_file(tmp.path()).unwrap(); assert!(!crate::version::arn_partitions_migrated(tmp.path()).unwrap()); } + +#[test] +fn customer_data_is_kept_verbatim() { + // Regression: DynamoDB rows, SSM values and secrets were rewritten, so an + // item keyed by an ARN string was no longer reachable by the key the + // application wrote, and reads returned values nobody stored. + let tmp = legacy_dir(); + let dir = tmp.path(); + let role = "arn:aws:iam::123456789012:role/app"; + let ddb = dir.join("dynamodb/snapshot.json"); + write( + &ddb, + &json!({ + "table": { + "arn": "arn:aws:dynamodb:cn-north-1:1:table/t", + "items": [{"pk": {"S": role}}], + "stream_records": [{"keys": {"pk": {"S": role}}, "new_image": {"pk": {"S": role}}, "old_image": null}], + "backups": [{"items": [{"pk": {"S": role}}]}] + } + }) + .to_string(), + ); + let ssm = dir.join("ssm/snapshot.json"); + write( + &ssm, + &json!({"p": {"arn": "arn:aws:ssm:cn-north-1:1:parameter/p", "value": role, "history": [{"value": role}]}}) + .to_string(), + ); + let secrets = dir.join("secretsmanager/snapshot.json"); + write( + &secrets, + &json!({"s": {"arn": "arn:aws:secretsmanager:cn-north-1:1:secret:s", "secret_string": role}}) + .to_string(), + ); + + migrate_data_dir(dir, "cn-north-1").unwrap(); + + let ddb: Value = serde_json::from_str(&read(&ddb)).unwrap(); + assert_eq!( + ddb["table"]["arn"], + "arn:aws-cn:dynamodb:cn-north-1:1:table/t" + ); + assert_eq!(ddb["table"]["items"][0]["pk"]["S"], role); + assert_eq!(ddb["table"]["stream_records"][0]["keys"]["pk"]["S"], role); + assert_eq!( + ddb["table"]["stream_records"][0]["new_image"]["pk"]["S"], + role + ); + assert_eq!(ddb["table"]["backups"][0]["items"][0]["pk"]["S"], role); + let ssm: Value = serde_json::from_str(&read(&ssm)).unwrap(); + assert_eq!(ssm["p"]["arn"], "arn:aws-cn:ssm:cn-north-1:1:parameter/p"); + assert_eq!(ssm["p"]["value"], role); + assert_eq!(ssm["p"]["history"][0]["value"], role); + let secrets: Value = serde_json::from_str(&read(&secrets)).unwrap(); + assert_eq!( + secrets["s"]["arn"], + "arn:aws-cn:secretsmanager:cn-north-1:1:secret:s" + ); + assert_eq!(secrets["s"]["secret_string"], role); +} + +#[test] +fn s3_object_keys_metadata_and_tags_are_kept_verbatim() { + // Regression: rewriting the sidecar's `key` moved the object to a new key + // while its files stayed in the directory derived from the old one. + let tmp = legacy_dir(); + let dir = tmp.path(); + let object = dir.join("s3/buckets/b/objects/escaped-key/null.toml"); + write( + &object, + "key = \"exports/arn:aws:iam::1:role/x.json\"\n\ + sse_kms_key_id = \"arn:aws:kms:cn-north-1:1:key/k\"\n\ + last_modified = \"2026-01-01T00:00:00Z\"\n\ + part_sizes = [[1, 5]]\n\n\ + [metadata]\nsource = \"arn:aws:sns:cn-north-1:1:t\"\n\n\ + [tags]\nowner = \"arn:aws:iam::1:role/x\"\n", + ); + let upload = dir.join("s3/buckets/b/mpu/u1/init.toml"); + write( + &upload, + "key = \"arn:aws:iam::1:role/x\"\nsse_kms_key_id = \"arn:aws:kms:cn-north-1:1:key/k\"\n", + ); + let replication = dir.join("s3/buckets/b/replication.toml"); + write( + &replication, + "arn:aws:iam::1:role/replicator\ + arn:aws:s3:::dest\ + ", + ); + let meta = dir.join("s3/buckets/b/meta.toml"); + let meta_text = "name = \"b\"\nregion = \"cn-north-1\"\n"; + write(&meta, meta_text); + + migrate_data_dir(dir, "cn-north-1").unwrap(); + + let object: toml::Table = read(&object).parse().unwrap(); + assert_eq!( + object["key"].as_str(), + Some("exports/arn:aws:iam::1:role/x.json") + ); + assert_eq!( + object["sse_kms_key_id"].as_str(), + Some("arn:aws-cn:kms:cn-north-1:1:key/k") + ); + assert_eq!( + object["last_modified"].as_str(), + Some("2026-01-01T00:00:00Z") + ); + assert_eq!( + object["part_sizes"], + toml::Value::Array(vec![toml::Value::Array(vec![1.into(), 5.into()])]) + ); + assert_eq!( + object["metadata"]["source"].as_str(), + Some("arn:aws:sns:cn-north-1:1:t") + ); + assert_eq!( + object["tags"]["owner"].as_str(), + Some("arn:aws:iam::1:role/x") + ); + let upload: toml::Table = read(&upload).parse().unwrap(); + assert_eq!(upload["key"].as_str(), Some("arn:aws:iam::1:role/x")); + assert_eq!( + upload["sse_kms_key_id"].as_str(), + Some("arn:aws-cn:kms:cn-north-1:1:key/k") + ); + assert_eq!( + read(&replication), + "arn:aws-cn:iam::1:role/replicator\ + arn:aws-cn:s3:::dest\ + " + ); + assert_eq!(read(&meta), meta_text); +} + +#[cfg(unix)] +#[test] +fn unreadable_and_filesystem_artifact_directories_do_not_stop_startup() { + // Regression: `read_dir` on a root-only `lost+found` at the root of a + // volume failed the migration, and with it the server's first start. + use std::os::unix::fs::PermissionsExt; + let tmp = legacy_dir(); + let dir = tmp.path(); + let kms = dir.join("kms/snapshot.json"); + write(&kms, r#"{"arn":"arn:aws:kms:cn-north-1:1:key/k"}"#); + let locked = [dir.join("lost+found"), dir.join("elasticache")]; + for path in &locked { + std::fs::create_dir(path).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o000)).unwrap(); + } + // Running as root ignores the mode; nothing to prove then. + let enforced = std::fs::read_dir(&locked[1]).is_err(); + + let report = migrate_data_dir(dir, "cn-north-1"); + for path in &locked { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let report = report.unwrap(); + assert_eq!(report.rewritten, vec![kms.clone()]); + if enforced { + // `lost+found` is never even opened; a service directory the server + // cannot read is reported and left to its loader. + assert_eq!(report.skipped, vec![locked[1].clone()]); + } + assert!(crate::version::arn_partitions_migrated(dir).unwrap()); +} diff --git a/crates/fakecloud-persistence/src/version.rs b/crates/fakecloud-persistence/src/version.rs index 74f10994a..666b87234 100644 --- a/crates/fakecloud-persistence/src/version.rs +++ b/crates/fakecloud-persistence/src/version.rs @@ -16,7 +16,7 @@ const IGNORED_DIR_ENTRIES: &[&str] = &["lost+found", ".snapshot"]; /// Whether a directory entry is a benign filesystem artifact that should not make /// the data directory count as "non-empty". -fn is_benign_entry(name: &OsStr) -> bool { +pub(crate) fn is_benign_entry(name: &OsStr) -> bool { match name.to_str() { Some(s) => s.starts_with('.') || IGNORED_DIR_ENTRIES.contains(&s), None => false, // non-UTF8 name => treat as a real entry, be conservative diff --git a/crates/fakecloud-server/src/main.rs b/crates/fakecloud-server/src/main.rs index a63d205ab..78a43a4d8 100644 --- a/crates/fakecloud-server/src/main.rs +++ b/crates/fakecloud-server/src/main.rs @@ -267,7 +267,7 @@ async fn main() { Ok(report) if report.already_migrated => {} Ok(report) => tracing::info!( rewritten = report.rewritten.len(), - unparseable = report.unparseable.len(), + skipped = report.skipped.len(), "migrated persisted ARNs to their region's partition" ), Err(err) => fatal_exit(format_args!( diff --git a/website/content/docs/reference/persistence.md b/website/content/docs/reference/persistence.md index 8585e8523..b4a4149ff 100644 --- a/website/content/docs/reference/persistence.md +++ b/website/content/docs/reference/persistence.md @@ -84,7 +84,8 @@ The first time a newer binary opens such a directory it rewrites those ARNs once - A regional ARN whose region belongs to another partition (`arn:aws:kms:cn-north-1:...`) is rewritten to that partition (`arn:aws-cn:kms:cn-north-1:...`), whatever `--region` the server runs with. - A region-less ARN (`arn:aws:iam::123456789012:role/app`, `arn:aws:s3:::bucket`) is rewritten to the partition of the server's `--region`, and only when that partition is not `aws`. AWS-managed policy ARNs (`arn:aws:iam::aws:policy/...`) keep the `aws` spelling, matching what fakecloud serves for them in every partition. - The rewrite covers every service's state: resource fields, maps keyed by ARN (SNS topics, tag maps, ...), and ARNs inside stored documents such as IAM, bucket, queue and key policies, which are otherwise left byte-for-byte unchanged. S3 bucket configuration and object metadata are covered too. -- Payloads are not rewritten: SQS message bodies and attributes (their MD5 digests would stop matching), ECR image manifests (addressed by digest), CloudWatch Logs events, S3 object bodies, and container data volumes. +- Policy wildcards move with the resources they match: `arn:aws:kms:*:123456789012:key/*` becomes `arn:aws-cn:kms:*:123456789012:key/*` on a China server. +- Payloads and customer data are not rewritten: SQS message bodies and attributes (their MD5 digests would stop matching), ECR image manifests (addressed by digest), DynamoDB items, SSM parameter values, Secrets Manager secrets, S3 object keys, user metadata and object tags, CloudWatch Logs events, S3 object bodies, and container data volumes. An item or object keyed by an ARN string stays reachable by the key your application wrote. After the migration, responses return the partition-correct ARN for these resources, the same as for resources created afterwards, and ARN-keyed lookups (for example SNS `GetTopicAttributes`) take the new ARN. From 91c8a9dc5fd01e601841f78281c071db725469ad Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Wed, 30 Sep 2026 21:08:21 -0300 Subject: [PATCH 3/6] fix(persistence): cover policy variables and per-path skips in the ARN migration Second review round on the ARN partition migration: - Accept `${...}` policy variables and Fn::Sub references in the region and account fields, so `arn:aws:iam::${aws:PrincipalAccount}:role/x` moves with the roles it matches; a variable region follows the server's partition like a wildcard. - Detect S3 object trees only at `s3/buckets//{objects,mpu}`, so a bucket named `objects` or `mpu` keeps its configuration rewritten. - Skip an unreadable file or directory individually instead of abandoning the rest of its service (or every later S3 bucket). - Keep EventBridge events and CloudWatch dashboard bodies verbatim: they are customer payloads measured by a stored size_bytes. --- .../src/arn_partition.rs | 185 ++++++++++++------ .../src/arn_partition_tests.rs | 130 ++++++++++++ website/content/docs/reference/persistence.md | 4 +- 3 files changed, 254 insertions(+), 65 deletions(-) diff --git a/crates/fakecloud-persistence/src/arn_partition.rs b/crates/fakecloud-persistence/src/arn_partition.rs index 57e7d0a40..54d131b0d 100644 --- a/crates/fakecloud-persistence/src/arn_partition.rs +++ b/crates/fakecloud-persistence/src/arn_partition.rs @@ -33,8 +33,9 @@ //! and an ECR manifest is addressed by its digest. Others are customer data //! the service stores rather than describes, where a rewrite would change //! what the application wrote or break a lookup by it: DynamoDB items, SSM -//! parameter values, Secrets Manager secrets, S3 object keys, user metadata -//! and object tags, log events. Bulk payloads streamed outside the snapshots +//! parameter values, Secrets Manager secrets, EventBridge events, CloudWatch +//! dashboard bodies, S3 object keys, user metadata and object tags, log +//! events. Bulk payloads streamed outside the snapshots //! (S3 object bodies, CloudWatch Logs event segments, container data volumes) //! are not read at all. @@ -66,10 +67,11 @@ impl ArnPartitionMigration { /// The partition the ARN starting at `arn:aws:` + `rest` moves to, or /// `None` when it stays in `aws` (or is not an ARN at all). /// - /// Policy wildcards (`*`, `?`) are accepted in the service, region and - /// account fields, so a stored policy's patterns move with the resources - /// they match: a region pattern that pins a partition (`cn-*`) takes it, - /// one that does not (`*`) is treated like a region-less ARN. + /// Policy wildcards (`*`, `?`) and `${...}` variables are accepted in the + /// service, region and account fields, so a stored policy's patterns move + /// with the resources they match: a region pattern that pins a partition + /// (`cn-*`) takes it, one that does not (`*`, `${aws:RequestedRegion}`) is + /// treated like a region-less ARN. fn target_partition(&self, rest: &[u8]) -> Option<&'static str> { let (service, rest) = split_field(rest, |b| { b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'*' @@ -85,7 +87,7 @@ impl ArnPartitionMigration { })?; // The field was checked to be ASCII. let region = std::str::from_utf8(region).ok()?; - let wildcard = region.contains(['*', '?']); + let wildcard = region.contains(['*', '?']) || region.contains("${"); match partition_for(region) { "aws" if region.is_empty() || wildcard => (self.global_partition != "aws" && account != b"aws") @@ -221,10 +223,37 @@ impl ArnPartitionMigration { } /// Split `bytes` at the first `:`, requiring every byte before it to satisfy -/// `allowed`. `None` when a disallowed byte comes first or there is no `:`. +/// `allowed` or belong to a `${...}` variable (an IAM policy variable such as +/// `${aws:PrincipalAccount}`, or a CloudFormation `Fn::Sub` reference such as +/// `${AWS::AccountId}`, whose own `:`s do not end the field). `None` when a +/// disallowed byte comes first or there is no `:`. fn split_field(bytes: &[u8], allowed: impl Fn(u8) -> bool) -> Option<(&[u8], &[u8])> { - let end = bytes.iter().position(|&b| !allowed(b))?; - (bytes[end] == b':').then(|| (&bytes[..end], &bytes[end + 1..])) + /// Longest variable accepted; anything longer is not a policy variable. + const MAX_VARIABLE: usize = 128; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b':' => return Some((&bytes[..i], &bytes[i + 1..])), + b'$' if bytes.get(i + 1) == Some(&b'{') => { + let close = bytes[i + 2..] + .iter() + .take(MAX_VARIABLE) + .position(|&b| b == b'}')?; + let name = &bytes[i + 2..i + 2 + close]; + if name.is_empty() + || !name.iter().all(|&b| { + b.is_ascii_alphanumeric() || matches!(b, b':' | b'_' | b'-' | b'.' | b'/') + }) + { + return None; + } + i += close + 3; + } + b if allowed(b) => i += 1, + _ => return None, + } + } + None } fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { @@ -249,6 +278,11 @@ fn opaque_keys_for(service_dir: &str) -> &'static [&'static str] { // Parameter values (current and every version). "ssm" => &["value"], "secretsmanager" => &["secret_string"], + // Published events (the bus log and archives): customer payloads, + // and an archive's `size_bytes` measures them. + "eventbridge" => &["events"], + // Dashboard bodies: customer documents measured by `size_bytes`. + "cloudwatch" => &["body"], _ => &[], } } @@ -312,7 +346,7 @@ pub fn migrate_data_dir( continue; }; let result = if service_dir == "s3" { - migrate_s3_sidecars(&migration, &entry, false, &mut report) + migrate_s3_sidecars(&migration, &entry, 0, false, &mut report) } else { migrate_service_dir( &migration, @@ -321,19 +355,7 @@ pub fn migrate_data_dir( &mut report, ) }; - if let Err(err) = result { - // A directory the server cannot read is one its own loader - // cannot read either; it reports that, not the migration. - if err.kind() == io::ErrorKind::PermissionDenied { - tracing::warn!( - path = %entry.display(), - "skipping ARN partition migration of an unreadable directory: {err}" - ); - report.skipped.push(entry); - continue; - } - return Err(io_err(&entry)(err)); - } + skip_unreadable(result, &entry, &mut report).map_err(io_err(&entry))?; } crate::version::mark_arn_partitions_migrated(dir)?; @@ -348,12 +370,35 @@ fn migrate_service_dir( ) -> io::Result<()> { for file in read_dir_sorted(dir)? { if file.is_file() && file.extension().is_some_and(|e| e == "json") { - migrate_json_file(migration, &file, opaque_keys, report)?; + let result = migrate_json_file(migration, &file, opaque_keys, report); + skip_unreadable(result, &file, report)?; } } Ok(()) } +/// Turn a permission error on `path` into a skipped entry. A file or +/// directory the server cannot read is one its own loader cannot read either +/// (the S3 loader isolates such failures per bucket); that loader reports it, +/// and everything else is still migrated. +fn skip_unreadable( + result: io::Result<()>, + path: &Path, + report: &mut MigrationReport, +) -> io::Result<()> { + match result { + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { + tracing::warn!( + path = %path.display(), + "skipping ARN partition migration of an unreadable path: {err}" + ); + report.skipped.push(path.to_path_buf()); + Ok(()) + } + other => other, + } +} + fn read_dir_sorted(dir: &Path) -> io::Result> { let mut entries = std::fs::read_dir(dir)? .map(|e| e.map(|e| e.path())) @@ -405,52 +450,66 @@ fn migrate_json_file( fn migrate_s3_sidecars( migration: &ArnPartitionMigration, dir: &Path, + depth: usize, in_objects: bool, report: &mut MigrationReport, ) -> io::Result<()> { for path in read_dir_sorted(dir)? { - if path.is_dir() { + let result = if path.is_dir() { + // `s3/buckets//{objects,mpu}`: only that level names the + // object trees (a bucket may itself be called `objects`). let objects = in_objects - || path - .file_name() - .is_some_and(|n| n == "objects" || n == "mpu"); - migrate_s3_sidecars(migration, &path, objects, report)?; - continue; - } - if !path.extension().is_some_and(|e| e == "toml") { - continue; - } - let bytes = std::fs::read(&path)?; - if !migration.needs_rewrite(&bytes) { - continue; - } - let Ok(text) = std::str::from_utf8(&bytes) else { - report.skipped.push(path); - continue; - }; - let rewritten = if in_objects { - let Ok(mut table) = text.parse::() else { - tracing::warn!( - path = %path.display(), - "skipping ARN partition migration of an unparseable file" - ); - report.skipped.push(path); - continue; - }; - if !migration.rewrite_toml_table(&mut table, S3_OBJECT_OPAQUE_KEYS) { - continue; - } - toml::to_string_pretty(&table) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))? + || (depth + 1 == 3 + && path + .file_name() + .is_some_and(|n| n == "objects" || n == "mpu")); + migrate_s3_sidecars(migration, &path, depth + 1, objects, report) + } else if path.extension().is_some_and(|e| e == "toml") { + migrate_s3_sidecar(migration, &path, in_objects, report) } else { - match migration.rewrite_str(text) { - Cow::Owned(new) => new, - Cow::Borrowed(_) => continue, - } + Ok(()) }; - crate::atomic::write_atomic_bytes(&path, rewritten.as_bytes())?; - report.rewritten.push(path); + skip_unreadable(result, &path, report)?; + } + Ok(()) +} + +fn migrate_s3_sidecar( + migration: &ArnPartitionMigration, + path: &Path, + in_objects: bool, + report: &mut MigrationReport, +) -> io::Result<()> { + let bytes = std::fs::read(path)?; + if !migration.needs_rewrite(&bytes) { + return Ok(()); } + let Ok(text) = std::str::from_utf8(&bytes) else { + report.skipped.push(path.to_path_buf()); + return Ok(()); + }; + let rewritten = if in_objects { + let Ok(mut table) = text.parse::() else { + tracing::warn!( + path = %path.display(), + "skipping ARN partition migration of an unparseable file" + ); + report.skipped.push(path.to_path_buf()); + return Ok(()); + }; + if !migration.rewrite_toml_table(&mut table, S3_OBJECT_OPAQUE_KEYS) { + return Ok(()); + } + toml::to_string_pretty(&table) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))? + } else { + match migration.rewrite_str(text) { + Cow::Owned(new) => new, + Cow::Borrowed(_) => return Ok(()), + } + }; + crate::atomic::write_atomic_bytes(path, rewritten.as_bytes())?; + report.rewritten.push(path.to_path_buf()); Ok(()) } diff --git a/crates/fakecloud-persistence/src/arn_partition_tests.rs b/crates/fakecloud-persistence/src/arn_partition_tests.rs index 31d5d4455..268181aa0 100644 --- a/crates/fakecloud-persistence/src/arn_partition_tests.rs +++ b/crates/fakecloud-persistence/src/arn_partition_tests.rs @@ -627,3 +627,133 @@ fn unreadable_and_filesystem_artifact_directories_do_not_stop_startup() { } assert!(crate::version::arn_partitions_migrated(dir).unwrap()); } + +#[test] +fn policy_and_template_variables_move_with_the_resources_they_match() { + // Regression: `${...}` in the account or region field made the token + // unparseable, so a stored `Resource` stopped matching the migrated role. + let m = cn(); + for (legacy, migrated) in [ + ( + "arn:aws:iam::${aws:PrincipalAccount}:role/app", + "arn:aws-cn:iam::${aws:PrincipalAccount}:role/app", + ), + ( + "arn:aws:sqs:${aws:RequestedRegion}:1:q", + "arn:aws-cn:sqs:${aws:RequestedRegion}:1:q", + ), + ( + "arn:aws:iam::${AWS::AccountId}:role/x", + "arn:aws-cn:iam::${AWS::AccountId}:role/x", + ), + ( + "arn:aws:sns:${AWS::Region}:${AWS::AccountId}:t", + "arn:aws-cn:sns:${AWS::Region}:${AWS::AccountId}:t", + ), + ( + "arn:aws:sqs:cn-north-1:${AWS::AccountId}:q", + "arn:aws-cn:sqs:cn-north-1:${AWS::AccountId}:q", + ), + ] { + assert_eq!(rewrite(&m, legacy), migrated, "{legacy}"); + assert!(m.needs_rewrite(legacy.as_bytes()), "{legacy}"); + } + // A variable region on a commercial server stays aws. + assert_eq!( + rewrite(&commercial(), "arn:aws:sqs:${aws:RequestedRegion}:1:q"), + "arn:aws:sqs:${aws:RequestedRegion}:1:q" + ); + // Not variables: unterminated, empty, or carrying other characters. + for s in [ + "arn:aws:iam::${aws:PrincipalAccount:role/app", + "arn:aws:iam::${}:role/app", + "arn:aws:iam::${a b}:role/app", + ] { + assert_eq!(rewrite(&m, s), s, "{s}"); + } +} + +#[test] +fn a_bucket_named_like_an_object_tree_keeps_text_rewrites() { + // Regression: any directory called `objects`/`mpu` switched to the + // field-by-field path, so a bucket with that name had its raw JSON/XML + // configuration skipped as unparseable TOML, then marked migrated. + let tmp = legacy_dir(); + let dir = tmp.path(); + let mut paths = Vec::new(); + for bucket in ["objects", "mpu"] { + let policy = dir.join(format!("s3/buckets/{bucket}/policy.toml")); + write(&policy, r#"{"Resource":"arn:aws:s3:::b/*"}"#); + paths.push(policy); + } + let report = migrate_data_dir(dir, "cn-north-1").unwrap(); + assert!(report.skipped.is_empty(), "{:?}", report.skipped); + for policy in paths { + assert_eq!(read(&policy), r#"{"Resource":"arn:aws-cn:s3:::b/*"}"#); + } +} + +#[test] +fn size_measured_payloads_are_kept_verbatim() { + // Regression: dashboard bodies and archived events were rewritten while + // the `size_bytes` stored next to them was not. + let tmp = legacy_dir(); + let dir = tmp.path(); + let body = r#"{"widgets":[{"properties":{"title":"arn:aws:sqs:cn-north-1:1:q"}}]}"#; + let cw = dir.join("cloudwatch/snapshot.json"); + write( + &cw, + &json!({"d": {"arn": "arn:aws:cloudwatch::1:dashboard/d", "body": body, "size_bytes": body.len()}}) + .to_string(), + ); + let event = json!({"resources": ["arn:aws:sqs:cn-north-1:1:q"]}); + let eb = dir.join("eventbridge/snapshot.json"); + write( + &eb, + &json!({ + "events": [event], + "archives": {"a": {"arn": "arn:aws:events:cn-north-1:1:archive/a", "size_bytes": 10, "events": [event]}} + }) + .to_string(), + ); + + migrate_data_dir(dir, "cn-north-1").unwrap(); + + let cw: Value = serde_json::from_str(&read(&cw)).unwrap(); + assert_eq!(cw["d"]["arn"], "arn:aws-cn:cloudwatch::1:dashboard/d"); + assert_eq!(cw["d"]["body"], body); + let eb: Value = serde_json::from_str(&read(&eb)).unwrap(); + assert_eq!( + eb["archives"]["a"]["arn"], + "arn:aws-cn:events:cn-north-1:1:archive/a" + ); + assert_eq!(eb["archives"]["a"]["events"][0], event); + assert_eq!(eb["events"][0], event); +} + +#[cfg(unix)] +#[test] +fn an_unreadable_s3_file_does_not_stop_other_buckets() { + // Regression: the first permission error in `s3/` abandoned every later + // bucket, and the directory was still marked migrated. + use std::os::unix::fs::PermissionsExt; + let tmp = legacy_dir(); + let dir = tmp.path(); + let locked = dir.join("s3/buckets/a/objects/k/null.toml"); + write( + &locked, + "sse_kms_key_id = \"arn:aws:kms:cn-north-1:1:key/k\"\n", + ); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + let enforced = std::fs::read(&locked).is_err(); + let later = dir.join("s3/buckets/b/policy.toml"); + write(&later, r#"{"Resource":"arn:aws:s3:::b/*"}"#); + + let report = migrate_data_dir(dir, "cn-north-1"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o644)).unwrap(); + let report = report.unwrap(); + assert_eq!(read(&later), r#"{"Resource":"arn:aws-cn:s3:::b/*"}"#); + if enforced { + assert_eq!(report.skipped, vec![locked]); + } +} diff --git a/website/content/docs/reference/persistence.md b/website/content/docs/reference/persistence.md index b4a4149ff..ed453964a 100644 --- a/website/content/docs/reference/persistence.md +++ b/website/content/docs/reference/persistence.md @@ -84,8 +84,8 @@ The first time a newer binary opens such a directory it rewrites those ARNs once - A regional ARN whose region belongs to another partition (`arn:aws:kms:cn-north-1:...`) is rewritten to that partition (`arn:aws-cn:kms:cn-north-1:...`), whatever `--region` the server runs with. - A region-less ARN (`arn:aws:iam::123456789012:role/app`, `arn:aws:s3:::bucket`) is rewritten to the partition of the server's `--region`, and only when that partition is not `aws`. AWS-managed policy ARNs (`arn:aws:iam::aws:policy/...`) keep the `aws` spelling, matching what fakecloud serves for them in every partition. - The rewrite covers every service's state: resource fields, maps keyed by ARN (SNS topics, tag maps, ...), and ARNs inside stored documents such as IAM, bucket, queue and key policies, which are otherwise left byte-for-byte unchanged. S3 bucket configuration and object metadata are covered too. -- Policy wildcards move with the resources they match: `arn:aws:kms:*:123456789012:key/*` becomes `arn:aws-cn:kms:*:123456789012:key/*` on a China server. -- Payloads and customer data are not rewritten: SQS message bodies and attributes (their MD5 digests would stop matching), ECR image manifests (addressed by digest), DynamoDB items, SSM parameter values, Secrets Manager secrets, S3 object keys, user metadata and object tags, CloudWatch Logs events, S3 object bodies, and container data volumes. An item or object keyed by an ARN string stays reachable by the key your application wrote. +- Policy wildcards and variables move with the resources they match: `arn:aws:kms:*:123456789012:key/*` becomes `arn:aws-cn:kms:*:123456789012:key/*`, and `arn:aws:iam::${aws:PrincipalAccount}:role/app` becomes `arn:aws-cn:iam::${aws:PrincipalAccount}:role/app` on a China server. +- Payloads and customer data are not rewritten: SQS message bodies and attributes (their MD5 digests would stop matching), ECR image manifests (addressed by digest), DynamoDB items, SSM parameter values, Secrets Manager secrets, EventBridge published and archived events, CloudWatch dashboard bodies, S3 object keys, user metadata and object tags, CloudWatch Logs events, S3 object bodies, and container data volumes. An item or object keyed by an ARN string stays reachable by the key your application wrote. After the migration, responses return the partition-correct ARN for these resources, the same as for resources created afterwards, and ARN-keyed lookups (for example SNS `GetTopicAttributes`) take the new ARN. From 213bfe094f60cb1be646f6f4c75d4489f9aae076 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Wed, 30 Sep 2026 22:11:59 -0300 Subject: [PATCH 4/6] fix(persistence): fail on rewrite errors and keep SES/SNS messages verbatim Third review round on the ARN partition migration: - A failure writing a migrated file is no longer mistaken for an unreadable path and skipped: the loader would read the stale file and the marker would stop the migration for good. It now fails startup, and the next start retries. - Keep SES sent emails (subject, bodies, raw data, template data, headers: the stored DKIM signature covers them) and SNS published messages (kept like the SQS copies of the same publish) verbatim. --- .../src/arn_partition.rs | 28 +++++- .../src/arn_partition_tests.rs | 92 +++++++++++++++++++ website/content/docs/reference/persistence.md | 2 +- 3 files changed, 117 insertions(+), 5 deletions(-) diff --git a/crates/fakecloud-persistence/src/arn_partition.rs b/crates/fakecloud-persistence/src/arn_partition.rs index 54d131b0d..835823f32 100644 --- a/crates/fakecloud-persistence/src/arn_partition.rs +++ b/crates/fakecloud-persistence/src/arn_partition.rs @@ -33,8 +33,8 @@ //! and an ECR manifest is addressed by its digest. Others are customer data //! the service stores rather than describes, where a rewrite would change //! what the application wrote or break a lookup by it: DynamoDB items, SSM -//! parameter values, Secrets Manager secrets, EventBridge events, CloudWatch -//! dashboard bodies, S3 object keys, user metadata and object tags, log +//! parameter values, Secrets Manager secrets, SES sent emails, SNS published +//! messages, EventBridge events, CloudWatch dashboard bodies, S3 object keys, user metadata and object tags, log //! events. Bulk payloads streamed outside the snapshots //! (S3 object bodies, CloudWatch Logs event segments, container data volumes) //! are not read at all. @@ -278,6 +278,17 @@ fn opaque_keys_for(service_dir: &str) -> &'static [&'static str] { // Parameter values (current and every version). "ssm" => &["value"], "secretsmanager" => &["secret_string"], + // Sent emails: the stored DKIM signature covers the body and headers. + "ses" => &[ + "subject", + "text_body", + "html_body", + "raw_data", + "template_data", + "headers", + ], + // Published messages, kept as the SQS copies of the same publish are. + "sns" => &["message", "subject", "message_attributes", "sms_messages"], // Published events (the bus log and archives): customer payloads, // and an archive's `size_bytes` measures them. "eventbridge" => &["events"], @@ -377,6 +388,15 @@ fn migrate_service_dir( Ok(()) } +/// Replace `path` with its migrated contents. A failure here is never a +/// skippable "unreadable" file: the loader would read the stale file fine and +/// the marker would stop the migration from ever running again, so it fails +/// the migration (and startup) instead, whatever its kind. +fn write_migrated(path: &Path, bytes: &[u8]) -> io::Result<()> { + crate::atomic::write_atomic_bytes(path, bytes) + .map_err(|err| io::Error::other(format!("writing {}: {err}", path.display()))) +} + /// Turn a permission error on `path` into a skipped entry. A file or /// directory the server cannot read is one its own loader cannot read either /// (the S3 loader isolates such failures per bucket); that loader reports it, @@ -433,7 +453,7 @@ fn migrate_json_file( } let out = serde_json::to_vec(&value) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; - crate::atomic::write_atomic_bytes(path, &out)?; + write_migrated(path, &out)?; report.rewritten.push(path.to_path_buf()); Ok(()) } @@ -508,7 +528,7 @@ fn migrate_s3_sidecar( Cow::Borrowed(_) => return Ok(()), } }; - crate::atomic::write_atomic_bytes(path, rewritten.as_bytes())?; + write_migrated(path, rewritten.as_bytes())?; report.rewritten.push(path.to_path_buf()); Ok(()) } diff --git a/crates/fakecloud-persistence/src/arn_partition_tests.rs b/crates/fakecloud-persistence/src/arn_partition_tests.rs index 268181aa0..811b3a84d 100644 --- a/crates/fakecloud-persistence/src/arn_partition_tests.rs +++ b/crates/fakecloud-persistence/src/arn_partition_tests.rs @@ -757,3 +757,95 @@ fn an_unreadable_s3_file_does_not_stop_other_buckets() { assert_eq!(report.skipped, vec![locked]); } } + +#[test] +fn sent_emails_and_published_messages_are_kept_verbatim() { + // Regression: SES bodies were rewritten under an unchanged DKIM + // signature, and SNS messages diverged from the SQS copies of the same + // publish (which are kept verbatim). + let tmp = legacy_dir(); + let dir = tmp.path(); + let text = "alarm on arn:aws:sns:cn-north-1:1:t"; + let ses = dir.join("ses/snapshot.json"); + write( + &ses, + &json!({ + "identity_arn": "arn:aws:ses:cn-north-1:1:identity/example.com", + "sent_emails": [{ + "subject": text, "text_body": text, "html_body": text, "raw_data": text, + "template_data": text, "headers": [["X-Topic", text]], "dkim_signature": "bh=x" + }] + }) + .to_string(), + ); + let sns = dir.join("sns/snapshot.json"); + write( + &sns, + &json!({ + "published": [{ + "topic_arn": "arn:aws:sns:cn-north-1:1:t", "message": text, "subject": text, + "message_attributes": {"src": {"string_value": text}} + }], + "sms_messages": [["+15555550100", text]] + }) + .to_string(), + ); + + migrate_data_dir(dir, "cn-north-1").unwrap(); + + let ses: Value = serde_json::from_str(&read(&ses)).unwrap(); + assert_eq!( + ses["identity_arn"], + "arn:aws-cn:ses:cn-north-1:1:identity/example.com" + ); + let email = &ses["sent_emails"][0]; + for field in [ + "subject", + "text_body", + "html_body", + "raw_data", + "template_data", + ] { + assert_eq!(email[field], text, "{field}"); + } + assert_eq!(email["headers"][0][1], text); + let sns: Value = serde_json::from_str(&read(&sns)).unwrap(); + let published = &sns["published"][0]; + assert_eq!(published["topic_arn"], "arn:aws-cn:sns:cn-north-1:1:t"); + assert_eq!(published["message"], text); + assert_eq!(published["subject"], text); + assert_eq!(published["message_attributes"]["src"]["string_value"], text); + assert_eq!(sns["sms_messages"][0][1], text); +} + +#[cfg(unix)] +#[test] +fn a_failed_rewrite_fails_the_migration_instead_of_being_skipped() { + // Regression: a write failure (a readable but read-only service + // directory) was skipped as "unreadable" and the directory still marked + // migrated, so the loader served the stale ARNs for good. + use std::os::unix::fs::PermissionsExt; + let tmp = legacy_dir(); + let dir = tmp.path(); + let sqs_dir = dir.join("sqs"); + let snapshot = sqs_dir.join("snapshot.json"); + write(&snapshot, r#"{"arn":"arn:aws:sqs:cn-north-1:1:q"}"#); + std::fs::set_permissions(&sqs_dir, std::fs::Permissions::from_mode(0o555)).unwrap(); + let probe = sqs_dir.join("probe"); + let enforced = std::fs::write(&probe, b"x").is_err(); + let _ = std::fs::remove_file(&probe); + + let result = migrate_data_dir(dir, "cn-north-1"); + std::fs::set_permissions(&sqs_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + if !enforced { + return; // root ignores the mode + } + assert!(result.is_err(), "{result:?}"); + assert!(!crate::version::arn_partitions_migrated(dir).unwrap()); + // Once writable, the next start migrates it. + migrate_data_dir(dir, "cn-north-1").unwrap(); + assert_eq!( + read(&snapshot), + r#"{"arn":"arn:aws-cn:sqs:cn-north-1:1:q"}"# + ); +} diff --git a/website/content/docs/reference/persistence.md b/website/content/docs/reference/persistence.md index ed453964a..c776552b1 100644 --- a/website/content/docs/reference/persistence.md +++ b/website/content/docs/reference/persistence.md @@ -85,7 +85,7 @@ The first time a newer binary opens such a directory it rewrites those ARNs once - A region-less ARN (`arn:aws:iam::123456789012:role/app`, `arn:aws:s3:::bucket`) is rewritten to the partition of the server's `--region`, and only when that partition is not `aws`. AWS-managed policy ARNs (`arn:aws:iam::aws:policy/...`) keep the `aws` spelling, matching what fakecloud serves for them in every partition. - The rewrite covers every service's state: resource fields, maps keyed by ARN (SNS topics, tag maps, ...), and ARNs inside stored documents such as IAM, bucket, queue and key policies, which are otherwise left byte-for-byte unchanged. S3 bucket configuration and object metadata are covered too. - Policy wildcards and variables move with the resources they match: `arn:aws:kms:*:123456789012:key/*` becomes `arn:aws-cn:kms:*:123456789012:key/*`, and `arn:aws:iam::${aws:PrincipalAccount}:role/app` becomes `arn:aws-cn:iam::${aws:PrincipalAccount}:role/app` on a China server. -- Payloads and customer data are not rewritten: SQS message bodies and attributes (their MD5 digests would stop matching), ECR image manifests (addressed by digest), DynamoDB items, SSM parameter values, Secrets Manager secrets, EventBridge published and archived events, CloudWatch dashboard bodies, S3 object keys, user metadata and object tags, CloudWatch Logs events, S3 object bodies, and container data volumes. An item or object keyed by an ARN string stays reachable by the key your application wrote. +- Payloads and customer data are not rewritten: SQS message bodies and attributes (their MD5 digests would stop matching), ECR image manifests (addressed by digest), DynamoDB items, SSM parameter values, Secrets Manager secrets, SES sent emails (their stored DKIM signatures cover them), SNS published messages, EventBridge published and archived events, CloudWatch dashboard bodies, S3 object keys, user metadata and object tags, CloudWatch Logs events, S3 object bodies, and container data volumes. An item or object keyed by an ARN string stays reachable by the key your application wrote. After the migration, responses return the partition-correct ARN for these resources, the same as for resources created afterwards, and ARN-keyed lookups (for example SNS `GetTopicAttributes`) take the new ARN. From 4bea8d2f6a7bfe3b60bd90660765dd52584819b9 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Wed, 30 Sep 2026 22:14:49 -0300 Subject: [PATCH 5/6] docs(persistence): say which SES content the ARN migration keeps verbatim --- crates/fakecloud-persistence/src/arn_partition.rs | 8 +++++--- website/content/docs/reference/persistence.md | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/fakecloud-persistence/src/arn_partition.rs b/crates/fakecloud-persistence/src/arn_partition.rs index 835823f32..4109b8ea6 100644 --- a/crates/fakecloud-persistence/src/arn_partition.rs +++ b/crates/fakecloud-persistence/src/arn_partition.rs @@ -33,8 +33,8 @@ //! and an ECR manifest is addressed by its digest. Others are customer data //! the service stores rather than describes, where a rewrite would change //! what the application wrote or break a lookup by it: DynamoDB items, SSM -//! parameter values, Secrets Manager secrets, SES sent emails, SNS published -//! messages, EventBridge events, CloudWatch dashboard bodies, S3 object keys, user metadata and object tags, log +//! parameter values, Secrets Manager secrets, SES email template content, SNS +//! published messages, EventBridge events, CloudWatch dashboard bodies, S3 object keys, user metadata and object tags, log //! events. Bulk payloads streamed outside the snapshots //! (S3 object bodies, CloudWatch Logs event segments, container data volumes) //! are not read at all. @@ -278,7 +278,9 @@ fn opaque_keys_for(service_dir: &str) -> &'static [&'static str] { // Parameter values (current and every version). "ssm" => &["value"], "secretsmanager" => &["secret_string"], - // Sent emails: the stored DKIM signature covers the body and headers. + // Customer-written email content: templates (persisted), and sent + // emails, whose DKIM signature covers body and headers, should they + // ever be persisted. "ses" => &[ "subject", "text_body", diff --git a/website/content/docs/reference/persistence.md b/website/content/docs/reference/persistence.md index c776552b1..65fa8af95 100644 --- a/website/content/docs/reference/persistence.md +++ b/website/content/docs/reference/persistence.md @@ -85,7 +85,7 @@ The first time a newer binary opens such a directory it rewrites those ARNs once - A region-less ARN (`arn:aws:iam::123456789012:role/app`, `arn:aws:s3:::bucket`) is rewritten to the partition of the server's `--region`, and only when that partition is not `aws`. AWS-managed policy ARNs (`arn:aws:iam::aws:policy/...`) keep the `aws` spelling, matching what fakecloud serves for them in every partition. - The rewrite covers every service's state: resource fields, maps keyed by ARN (SNS topics, tag maps, ...), and ARNs inside stored documents such as IAM, bucket, queue and key policies, which are otherwise left byte-for-byte unchanged. S3 bucket configuration and object metadata are covered too. - Policy wildcards and variables move with the resources they match: `arn:aws:kms:*:123456789012:key/*` becomes `arn:aws-cn:kms:*:123456789012:key/*`, and `arn:aws:iam::${aws:PrincipalAccount}:role/app` becomes `arn:aws-cn:iam::${aws:PrincipalAccount}:role/app` on a China server. -- Payloads and customer data are not rewritten: SQS message bodies and attributes (their MD5 digests would stop matching), ECR image manifests (addressed by digest), DynamoDB items, SSM parameter values, Secrets Manager secrets, SES sent emails (their stored DKIM signatures cover them), SNS published messages, EventBridge published and archived events, CloudWatch dashboard bodies, S3 object keys, user metadata and object tags, CloudWatch Logs events, S3 object bodies, and container data volumes. An item or object keyed by an ARN string stays reachable by the key your application wrote. +- Payloads and customer data are not rewritten: SQS message bodies and attributes (their MD5 digests would stop matching), ECR image manifests (addressed by digest), DynamoDB items, SSM parameter values, Secrets Manager secrets, SES email template content, SNS published messages, EventBridge published and archived events, CloudWatch dashboard bodies, S3 object keys, user metadata and object tags, CloudWatch Logs events, S3 object bodies, and container data volumes. An item or object keyed by an ARN string stays reachable by the key your application wrote. After the migration, responses return the partition-correct ARN for these resources, the same as for resources created afterwards, and ARN-keyed lookups (for example SNS `GetTopicAttributes`) take the new ARN. From 551e5818e39259c596729cde8debceea389a3867 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Wed, 30 Sep 2026 22:35:10 -0300 Subject: [PATCH 6/6] fix(persistence): address Cubic review of the ARN partition migration - Accept `?` in the service field like the region and account fields. - Treat a backslash-escape tail (`\narn:aws:...`) as a token start, so the raw-text rewrite of bucket subresource payloads agrees with the byte pre-check instead of leaving a stale ARN behind the marker. - Keep a multipart upload's `tagging` and the client-supplied object headers (Content-Type, Content-Disposition, ...) verbatim, like object tags and user metadata. - Docs: say which S3 metadata is rewritten and which fields are kept. --- .../src/arn_partition.rs | 40 +++++++-- .../src/arn_partition_tests.rs | 82 +++++++++++++++++++ website/content/docs/reference/persistence.md | 4 +- 3 files changed, 118 insertions(+), 8 deletions(-) diff --git a/crates/fakecloud-persistence/src/arn_partition.rs b/crates/fakecloud-persistence/src/arn_partition.rs index 4109b8ea6..c3c16aa40 100644 --- a/crates/fakecloud-persistence/src/arn_partition.rs +++ b/crates/fakecloud-persistence/src/arn_partition.rs @@ -74,7 +74,7 @@ impl ArnPartitionMigration { /// treated like a region-less ARN. fn target_partition(&self, rest: &[u8]) -> Option<&'static str> { let (service, rest) = split_field(rest, |b| { - b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'*' + b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'*' | b'?') })?; if service.is_empty() { return None; @@ -99,8 +99,9 @@ impl ArnPartitionMigration { /// `text` with every ARN token that belongs to another partition /// rewritten, borrowed when nothing changes. A token must start the string - /// or follow a non-alphanumeric byte (`"`, `/`, `:`, whitespace, ...) and - /// look like `arn:aws::::` -- free text that + /// or follow a non-alphanumeric byte (`"`, `/`, `:`, whitespace, ...) or + /// the tail of a backslash escape (`\narn:aws:...` in raw JSON/XML text), + /// and look like `arn:aws::::` -- free text that /// merely contains `arn:aws` is left alone. pub fn rewrite_str<'a>(&self, text: &'a str) -> Cow<'a, str> { let bytes = text.as_bytes(); @@ -110,7 +111,7 @@ impl ArnPartitionMigration { while let Some(found) = text[from..].find(ARN_PREFIX) { let start = from + found; from = start + ARN_PREFIX.len(); - if start > 0 && bytes[start - 1].is_ascii_alphanumeric() { + if !at_token_start(bytes, start) { continue; } let Some(partition) = self.target_partition(&bytes[from..]) else { @@ -222,6 +223,18 @@ impl ArnPartitionMigration { } } +/// Whether an ARN token starting at `start` begins a token: at the start of +/// the text, after a non-alphanumeric byte, or after a backslash escape such +/// as `\n` / `\t`, which raw (still-escaped) JSON or XML text puts right +/// before a value's first character. +fn at_token_start(bytes: &[u8], start: usize) -> bool { + match start { + 0 => true, + 1 => !bytes[0].is_ascii_alphanumeric(), + _ => !bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 2] == b'\\', + } +} + /// Split `bytes` at the first `:`, requiring every byte before it to satisfy /// `allowed` or belong to a `${...}` variable (an IAM policy variable such as /// `${aws:PrincipalAccount}`, or a CloudFormation `Fn::Sub` reference such as @@ -302,8 +315,23 @@ fn opaque_keys_for(service_dir: &str) -> &'static [&'static str] { /// Object and multipart-upload sidecar fields that are customer data: the /// object key (its directory on disk is derived from it, so rewriting the -/// field would strand the object), user metadata and object tags. -const S3_OBJECT_OPAQUE_KEYS: &[&str] = &["key", "metadata", "tags"]; +/// field would strand the object), user metadata, object tags and the +/// client-supplied object headers. +const S3_OBJECT_OPAQUE_KEYS: &[&str] = &[ + "key", + "metadata", + "tags", + // A multipart upload's tags, as the `x-amz-tagging` query string. + "tagging", + // Headers the client supplied with the object, returned verbatim. + "content_type", + "content_encoding", + "cache_control", + "content_disposition", + "content_language", + "expires", + "website_redirect_location", +]; /// What [`migrate_data_dir`] did. #[derive(Debug, Default, Clone, PartialEq, Eq)] diff --git a/crates/fakecloud-persistence/src/arn_partition_tests.rs b/crates/fakecloud-persistence/src/arn_partition_tests.rs index 811b3a84d..13b548ec7 100644 --- a/crates/fakecloud-persistence/src/arn_partition_tests.rs +++ b/crates/fakecloud-persistence/src/arn_partition_tests.rs @@ -849,3 +849,85 @@ fn a_failed_rewrite_fails_the_migration_instead_of_being_skipped() { r#"{"arn":"arn:aws-cn:sqs:cn-north-1:1:q"}"# ); } + +#[test] +fn service_field_question_mark_wildcard_is_a_pattern() { + // Regression: `?` was accepted in the region and account fields but not + // the service field, so such a pattern kept matching only `arn:aws:`. + let m = cn(); + assert_eq!( + rewrite(&m, "arn:aws:sq?:cn-north-1:1:q"), + "arn:aws-cn:sq?:cn-north-1:1:q" + ); + assert_eq!(rewrite(&m, "arn:aws:s?:::b"), "arn:aws-cn:s?:::b"); +} + +#[test] +fn text_rewrite_accepts_an_arn_behind_an_escape() { + // Regression: bucket subresource payloads are rewritten as raw text, where + // `\narn:aws:...` puts `n` before the token. The pre-check accepted the + // file but the rewrite skipped the token, and the directory was marked + // migrated with the stale ARN. + let m = cn(); + let raw = r#"{"Resource":["a\narn:aws:s3:::b/*","\tarn:aws:sns:cn-north-1:1:t"]}"#; + assert_eq!( + rewrite(&m, raw), + r#"{"Resource":["a\narn:aws-cn:s3:::b/*","\tarn:aws-cn:sns:cn-north-1:1:t"]}"# + ); + + let tmp = legacy_dir(); + let dir = tmp.path(); + let policy = dir.join("s3/buckets/b/policy.toml"); + write(&policy, raw); + migrate_data_dir(dir, "cn-north-1").unwrap(); + assert!(!read(&policy).contains("arn:aws:"), "{}", read(&policy)); + assert!(crate::version::arn_partitions_migrated(dir).unwrap()); +} + +#[test] +fn multipart_upload_tagging_and_object_headers_are_kept_verbatim() { + // Regression: a multipart upload stores its tags as `tagging`, which was + // rewritten although object `tags` were kept. + let tmp = legacy_dir(); + let dir = tmp.path(); + let init = dir.join("s3/buckets/b/mpu/u1/init.toml"); + write( + &init, + "key = \"k\"\n\ + tagging = \"owner=arn%3Aaws%3Aiam%3A%3A1%3Arole%2Fx&src=arn:aws:sns:cn-north-1:1:t\"\n\ + content_disposition = \"attachment; filename=arn:aws:iam::1:role/x\"\n\ + sse_kms_key_id = \"arn:aws:kms:cn-north-1:1:key/k\"\n", + ); + let object = dir.join("s3/buckets/b/objects/k/null.toml"); + write( + &object, + "key = \"k\"\n\ + website_redirect_location = \"/arn:aws:iam::1:role/x\"\n\ + sse_kms_key_id = \"arn:aws:kms:cn-north-1:1:key/k\"\n", + ); + + migrate_data_dir(dir, "cn-north-1").unwrap(); + + let init: toml::Table = read(&init).parse().unwrap(); + assert_eq!( + init["tagging"].as_str(), + Some("owner=arn%3Aaws%3Aiam%3A%3A1%3Arole%2Fx&src=arn:aws:sns:cn-north-1:1:t") + ); + assert_eq!( + init["content_disposition"].as_str(), + Some("attachment; filename=arn:aws:iam::1:role/x") + ); + assert_eq!( + init["sse_kms_key_id"].as_str(), + Some("arn:aws-cn:kms:cn-north-1:1:key/k") + ); + let object: toml::Table = read(&object).parse().unwrap(); + assert_eq!( + object["website_redirect_location"].as_str(), + Some("/arn:aws:iam::1:role/x") + ); + assert_eq!( + object["sse_kms_key_id"].as_str(), + Some("arn:aws-cn:kms:cn-north-1:1:key/k") + ); +} diff --git a/website/content/docs/reference/persistence.md b/website/content/docs/reference/persistence.md index 65fa8af95..6cdba23f4 100644 --- a/website/content/docs/reference/persistence.md +++ b/website/content/docs/reference/persistence.md @@ -83,9 +83,9 @@ The first time a newer binary opens such a directory it rewrites those ARNs once - A regional ARN whose region belongs to another partition (`arn:aws:kms:cn-north-1:...`) is rewritten to that partition (`arn:aws-cn:kms:cn-north-1:...`), whatever `--region` the server runs with. - A region-less ARN (`arn:aws:iam::123456789012:role/app`, `arn:aws:s3:::bucket`) is rewritten to the partition of the server's `--region`, and only when that partition is not `aws`. AWS-managed policy ARNs (`arn:aws:iam::aws:policy/...`) keep the `aws` spelling, matching what fakecloud serves for them in every partition. -- The rewrite covers every service's state: resource fields, maps keyed by ARN (SNS topics, tag maps, ...), and ARNs inside stored documents such as IAM, bucket, queue and key policies, which are otherwise left byte-for-byte unchanged. S3 bucket configuration and object metadata are covered too. +- The rewrite covers every service's state: resource fields, maps keyed by ARN (SNS topics, tag maps, ...), and ARNs inside stored documents such as IAM, bucket, queue and key policies, which are otherwise left byte-for-byte unchanged. S3 bucket configuration and object system metadata (such as the SSE-KMS key ARN) are covered too. - Policy wildcards and variables move with the resources they match: `arn:aws:kms:*:123456789012:key/*` becomes `arn:aws-cn:kms:*:123456789012:key/*`, and `arn:aws:iam::${aws:PrincipalAccount}:role/app` becomes `arn:aws-cn:iam::${aws:PrincipalAccount}:role/app` on a China server. -- Payloads and customer data are not rewritten: SQS message bodies and attributes (their MD5 digests would stop matching), ECR image manifests (addressed by digest), DynamoDB items, SSM parameter values, Secrets Manager secrets, SES email template content, SNS published messages, EventBridge published and archived events, CloudWatch dashboard bodies, S3 object keys, user metadata and object tags, CloudWatch Logs events, S3 object bodies, and container data volumes. An item or object keyed by an ARN string stays reachable by the key your application wrote. +- These payloads and customer-data fields are not rewritten: SQS message bodies and attributes (their MD5 digests would stop matching), ECR image manifests (addressed by digest), DynamoDB items, SSM parameter values, Secrets Manager secrets, SES email template content, SNS published messages, EventBridge published and archived events, CloudWatch dashboard bodies, S3 object keys, user metadata, object and multipart-upload tags and client-supplied object headers (`Content-Type`, `Content-Disposition`, ...), CloudWatch Logs events, S3 object bodies, and container data volumes. An item or object keyed by an ARN string stays reachable by the key your application wrote. After the migration, responses return the partition-correct ARN for these resources, the same as for resources created afterwards, and ARN-keyed lookups (for example SNS `GetTopicAttributes`) take the new ARN.