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..943b85798 --- /dev/null +++ b/crates/fakecloud-e2e/tests/arn_partition_migration_persistence.rs @@ -0,0 +1,555 @@ +//! 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(); + + // 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() + .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", "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"); + 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}"); + + 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"); + 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..c3c16aa40 --- /dev/null +++ b/crates/fakecloud-persistence/src/arn_partition.rs @@ -0,0 +1,619 @@ +//! 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 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. 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 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. + +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). + /// + /// 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() || matches!(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() || matches!(b, b'-' | b'*' | b'?') + })?; + 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(['*', '?']) || 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), + } + } + + /// `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, ...) 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(); + 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 !at_token_start(bytes, start) { + 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` 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) { + from += found + prefix.len(); + 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 + } +} + +/// 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 +/// `${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])> { + /// 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 { + 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 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. + "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"], + // 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"], + // 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", + "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"], + // Dashboard bodies: customer documents measured by `size_bytes`. + "cloudwatch" => &["body"], + _ => &[], + } +} + +/// 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, 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)] +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, 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 +/// 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))? { + let Some(name) = entry.file_name() else { + continue; + }; + // `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 Some(service_dir) = name.to_str() else { + continue; + }; + let result = if service_dir == "s3" { + migrate_s3_sidecars(&migration, &entry, 0, false, &mut report) + } else { + migrate_service_dir( + &migration, + &entry, + opaque_keys_for(service_dir), + &mut report, + ) + }; + skip_unreadable(result, &entry, &mut report).map_err(io_err(&entry))?; + } + + crate::version::mark_arn_partitions_migrated(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") { + let result = migrate_json_file(migration, &file, opaque_keys, report); + skip_unreadable(result, &file, report)?; + } + } + 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, +/// 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())) + .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.skipped.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()))?; + write_migrated(path, &out)?; + report.rewritten.push(path.to_path_buf()); + Ok(()) +} + +/// 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, + depth: usize, + in_objects: bool, + report: &mut MigrationReport, +) -> io::Result<()> { + for path in read_dir_sorted(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 + || (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 { + Ok(()) + }; + 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(()), + } + }; + write_migrated(path, rewritten.as_bytes())?; + report.rewritten.push(path.to_path_buf()); + 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 new file mode 100644 index 000000000..13b548ec7 --- /dev/null +++ b/crates/fakecloud-persistence/src/arn_partition_tests.rs @@ -0,0 +1,933 @@ +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", + "", + ] { + 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] +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"); + // 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, + "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.skipped, 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), 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\""); + + // 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()); +} + +#[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()); +} + +#[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]); + } +} + +#[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"}"# + ); +} + +#[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/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..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 @@ -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..78a43a4d8 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(), + skipped = report.skipped.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..6cdba23f4 100644 --- a/website/content/docs/reference/persistence.md +++ b/website/content/docs/reference/persistence.md @@ -73,7 +73,21 @@ 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 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. +- 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. ## S3 object body handling