Replace android.WriteFile rule with android.WriteFileRule

The android.WriteFile rule takes careful escaping to produce the
right contents.  Wrap it in an android.WriteFileRule that handles
the escaping.

Test: compare all android.WriteFile outputs
Change-Id: If71a5843af47a37ca61714e1a1ebb32d08536c31
diff --git a/android/apex.go b/android/apex.go
index e70ec4f..276f7a4 100644
--- a/android/apex.go
+++ b/android/apex.go
@@ -598,36 +598,22 @@
 	var fullContent strings.Builder
 	var flatContent strings.Builder
 
-	fmt.Fprintf(&fullContent, "%s(minSdkVersion:%s):\\n", ctx.ModuleName(), minSdkVersion)
+	fmt.Fprintf(&fullContent, "%s(minSdkVersion:%s):\n", ctx.ModuleName(), minSdkVersion)
 	for _, key := range FirstUniqueStrings(SortedStringKeys(depInfos)) {
 		info := depInfos[key]
 		toName := fmt.Sprintf("%s(minSdkVersion:%s)", info.To, info.MinSdkVersion)
 		if info.IsExternal {
 			toName = toName + " (external)"
 		}
-		fmt.Fprintf(&fullContent, "  %s <- %s\\n", toName, strings.Join(SortedUniqueStrings(info.From), ", "))
-		fmt.Fprintf(&flatContent, "%s\\n", toName)
+		fmt.Fprintf(&fullContent, "  %s <- %s\n", toName, strings.Join(SortedUniqueStrings(info.From), ", "))
+		fmt.Fprintf(&flatContent, "%s\n", toName)
 	}
 
 	d.fullListPath = PathForModuleOut(ctx, "depsinfo", "fulllist.txt").OutputPath
-	ctx.Build(pctx, BuildParams{
-		Rule:        WriteFile,
-		Description: "Full Dependency Info",
-		Output:      d.fullListPath,
-		Args: map[string]string{
-			"content": fullContent.String(),
-		},
-	})
+	WriteFileRule(ctx, d.fullListPath, fullContent.String())
 
 	d.flatListPath = PathForModuleOut(ctx, "depsinfo", "flatlist.txt").OutputPath
-	ctx.Build(pctx, BuildParams{
-		Rule:        WriteFile,
-		Description: "Flat Dependency Info",
-		Output:      d.flatListPath,
-		Args: map[string]string{
-			"content": flatContent.String(),
-		},
-	})
+	WriteFileRule(ctx, d.flatListPath, flatContent.String())
 }
 
 // TODO(b/158059172): remove minSdkVersion allowlist
diff --git a/android/api_levels.go b/android/api_levels.go
index bace3d4..08328e1 100644
--- a/android/api_levels.go
+++ b/android/api_levels.go
@@ -225,14 +225,7 @@
 		ctx.Errorf(err.Error())
 	}
 
-	ctx.Build(pctx, BuildParams{
-		Rule:        WriteFile,
-		Description: "generate " + file.Base(),
-		Output:      file,
-		Args: map[string]string{
-			"content": string(jsonStr[:]),
-		},
-	})
+	WriteFileRule(ctx, file, string(jsonStr))
 }
 
 func GetApiLevelsJson(ctx PathContext) WritablePath {
diff --git a/android/defs.go b/android/defs.go
index 2b1bd85..631dfe8 100644
--- a/android/defs.go
+++ b/android/defs.go
@@ -15,8 +15,12 @@
 package android
 
 import (
+	"strings"
+	"testing"
+
 	"github.com/google/blueprint"
 	_ "github.com/google/blueprint/bootstrap"
+	"github.com/google/blueprint/proptools"
 )
 
 var (
@@ -91,9 +95,9 @@
 	// ubuntu 14.04 offcially use dash for /bin/sh, and its builtin echo command
 	// doesn't support -e option. Therefore we force to use /bin/bash when writing out
 	// content to file.
-	WriteFile = pctx.AndroidStaticRule("WriteFile",
+	writeFile = pctx.AndroidStaticRule("writeFile",
 		blueprint.RuleParams{
-			Command:     "/bin/bash -c 'echo -e $$0 > $out' '$content'",
+			Command:     `/bin/bash -c 'echo -e "$$0" > $out' $content`,
 			Description: "writing file $out",
 		},
 		"content")
@@ -111,3 +115,64 @@
 func init() {
 	pctx.Import("github.com/google/blueprint/bootstrap")
 }
+
+var (
+	// echoEscaper escapes a string such that passing it to "echo -e" will produce the input value.
+	echoEscaper = strings.NewReplacer(
+		`\`, `\\`, // First escape existing backslashes so they aren't interpreted by `echo -e`.
+		"\n", `\n`, // Then replace newlines with \n
+	)
+
+	// echoEscaper reverses echoEscaper.
+	echoUnescaper = strings.NewReplacer(
+		`\n`, "\n",
+		`\\`, `\`,
+	)
+
+	// shellUnescaper reverses the replacer in proptools.ShellEscape
+	shellUnescaper = strings.NewReplacer(`'\''`, `'`)
+)
+
+// WriteFileRule creates a ninja rule to write contents to a file.  The contents will be escaped
+// so that the file contains exactly the contents passed to the function, plus a trailing newline.
+func WriteFileRule(ctx BuilderContext, outputFile WritablePath, content string) {
+	content = echoEscaper.Replace(content)
+	content = proptools.ShellEscape(content)
+	if content == "" {
+		content = "''"
+	}
+	ctx.Build(pctx, BuildParams{
+		Rule:        writeFile,
+		Output:      outputFile,
+		Description: "write " + outputFile.Base(),
+		Args: map[string]string{
+			"content": content,
+		},
+	})
+}
+
+// shellUnescape reverses proptools.ShellEscape
+func shellUnescape(s string) string {
+	// Remove leading and trailing quotes if present
+	if len(s) >= 2 && s[0] == '\'' {
+		s = s[1 : len(s)-1]
+	}
+	s = shellUnescaper.Replace(s)
+	return s
+}
+
+// ContentFromFileRuleForTests returns the content that was passed to a WriteFileRule for use
+// in tests.
+func ContentFromFileRuleForTests(t *testing.T, params TestingBuildParams) string {
+	t.Helper()
+	if g, w := params.Rule, writeFile; g != w {
+		t.Errorf("expected params.Rule to be %q, was %q", w, g)
+		return ""
+	}
+
+	content := params.Args["content"]
+	content = shellUnescape(content)
+	content = echoUnescaper.Replace(content)
+
+	return content
+}
diff --git a/apex/apex_test.go b/apex/apex_test.go
index 532a7aa..321883f 100644
--- a/apex/apex_test.go
+++ b/apex/apex_test.go
@@ -5574,7 +5574,7 @@
 		}
 		`, withManifestPackageNameOverrides([]string{"AppFoo:com.android.foo"}))
 
-	bundleConfigRule := ctx.ModuleForTests("myapex", "android_common_myapex_image").Description("Bundle Config")
+	bundleConfigRule := ctx.ModuleForTests("myapex", "android_common_myapex_image").Output("bundle_config.json")
 	content := bundleConfigRule.Args["content"]
 
 	ensureContains(t, content, `"compression":{"uncompressed_glob":["apex_payload.img","apex_manifest.*"]}`)
@@ -5600,7 +5600,7 @@
 			set: "AppSet.apks",
 		}`)
 	mod := ctx.ModuleForTests("myapex", "android_common_myapex_image")
-	bundleConfigRule := mod.Description("Bundle Config")
+	bundleConfigRule := mod.Output("bundle_config.json")
 	content := bundleConfigRule.Args["content"]
 	ensureContains(t, content, `"compression":{"uncompressed_glob":["apex_payload.img","apex_manifest.*"]}`)
 	s := mod.Rule("apexRule").Args["copy_commands"]
diff --git a/apex/builder.go b/apex/builder.go
index ad673d6..acfb8c5 100644
--- a/apex/builder.go
+++ b/apex/builder.go
@@ -373,14 +373,7 @@
 		panic(fmt.Errorf("error while marshalling to %q: %#v", output, err))
 	}
 
-	ctx.Build(pctx, android.BuildParams{
-		Rule:        android.WriteFile,
-		Output:      output,
-		Description: "Bundle Config " + output.String(),
-		Args: map[string]string{
-			"content": string(j),
-		},
-	})
+	android.WriteFileRule(ctx, output, string(j))
 
 	return output.OutputPath
 }
diff --git a/apex/key.go b/apex/key.go
index d2d5786..43764da 100644
--- a/apex/key.go
+++ b/apex/key.go
@@ -116,7 +116,7 @@
 		partition             string
 	}
 	toString := func(e apexKeyEntry) string {
-		format := "name=%q public_key=%q private_key=%q container_certificate=%q container_private_key=%q partition=%q\\n"
+		format := "name=%q public_key=%q private_key=%q container_certificate=%q container_private_key=%q partition=%q\n"
 		if e.presigned {
 			return fmt.Sprintf(format, e.name, "PRESIGNED", "PRESIGNED", "PRESIGNED", "PRESIGNED", e.partition)
 		} else {
@@ -173,17 +173,9 @@
 
 	var filecontent strings.Builder
 	for _, name := range moduleNames {
-		fmt.Fprintf(&filecontent, "%s", toString(apexKeyMap[name]))
+		filecontent.WriteString(toString(apexKeyMap[name]))
 	}
-
-	ctx.Build(pctx, android.BuildParams{
-		Rule:        android.WriteFile,
-		Description: "apexkeys.txt",
-		Output:      s.output,
-		Args: map[string]string{
-			"content": filecontent.String(),
-		},
-	})
+	android.WriteFileRule(ctx, s.output, filecontent.String())
 }
 
 func apexKeysTextFactory() android.Singleton {
diff --git a/cc/cc_test.go b/cc/cc_test.go
index b803cba..f616cf3 100644
--- a/cc/cc_test.go
+++ b/cc/cc_test.go
@@ -296,8 +296,8 @@
 
 func checkWriteFileOutput(t *testing.T, params android.TestingBuildParams, expected []string) {
 	t.Helper()
-	assertString(t, params.Rule.String(), android.WriteFile.String())
-	actual := strings.FieldsFunc(strings.ReplaceAll(params.Args["content"], "\\n", "\n"), func(r rune) bool { return r == '\n' })
+	content := android.ContentFromFileRuleForTests(t, params)
+	actual := strings.FieldsFunc(content, func(r rune) bool { return r == '\n' })
 	assertArrayString(t, actual, expected)
 }
 
diff --git a/cc/fuzz.go b/cc/fuzz.go
index fe3c12b..dddfe94 100644
--- a/cc/fuzz.go
+++ b/cc/fuzz.go
@@ -278,14 +278,7 @@
 
 	if fuzz.Properties.Fuzz_config != nil {
 		configPath := android.PathForModuleOut(ctx, "config").Join(ctx, "config.json")
-		ctx.Build(pctx, android.BuildParams{
-			Rule:        android.WriteFile,
-			Description: "fuzzer infrastructure configuration",
-			Output:      configPath,
-			Args: map[string]string{
-				"content": fuzz.Properties.Fuzz_config.String(),
-			},
-		})
+		android.WriteFileRule(ctx, configPath, fuzz.Properties.Fuzz_config.String())
 		fuzz.config = configPath
 	}
 
diff --git a/cc/snapshot_utils.go b/cc/snapshot_utils.go
index 238508d..05c06ac 100644
--- a/cc/snapshot_utils.go
+++ b/cc/snapshot_utils.go
@@ -93,13 +93,6 @@
 
 func writeStringToFile(ctx android.SingletonContext, content, out string) android.OutputPath {
 	outPath := android.PathForOutput(ctx, out)
-	ctx.Build(pctx, android.BuildParams{
-		Rule:        android.WriteFile,
-		Output:      outPath,
-		Description: "WriteFile " + out,
-		Args: map[string]string{
-			"content": content,
-		},
-	})
+	android.WriteFileRule(ctx, outPath, content)
 	return outPath
 }
diff --git a/cc/vndk.go b/cc/vndk.go
index 2cac03c..4a005f3 100644
--- a/cc/vndk.go
+++ b/cc/vndk.go
@@ -487,14 +487,7 @@
 	}
 
 	txt.outputFile = android.PathForModuleOut(ctx, filename).OutputPath
-	ctx.Build(pctx, android.BuildParams{
-		Rule:        android.WriteFile,
-		Output:      txt.outputFile,
-		Description: "Writing " + txt.outputFile.String(),
-		Args: map[string]string{
-			"content": strings.Join(list, "\\n"),
-		},
-	})
+	android.WriteFileRule(ctx, txt.outputFile, strings.Join(list, "\n"))
 
 	installPath := android.PathForModuleInstall(ctx, "etc")
 	ctx.InstallFile(installPath, filename, txt.outputFile)
@@ -825,14 +818,7 @@
 	merged = append(merged, addPrefix(filterOutLibClangRt(vndkcore), "VNDK-core: ")...)
 	merged = append(merged, addPrefix(vndkprivate, "VNDK-private: ")...)
 	c.vndkLibrariesFile = android.PathForOutput(ctx, "vndk", "vndk.libraries.txt")
-	ctx.Build(pctx, android.BuildParams{
-		Rule:        android.WriteFile,
-		Output:      c.vndkLibrariesFile,
-		Description: "Writing " + c.vndkLibrariesFile.String(),
-		Args: map[string]string{
-			"content": strings.Join(merged, "\\n"),
-		},
-	})
+	android.WriteFileRule(ctx, c.vndkLibrariesFile, strings.Join(merged, "\n"))
 }
 
 func (c *vndkSnapshotSingleton) MakeVars(ctx android.MakeVarsContext) {
diff --git a/dexpreopt/config.go b/dexpreopt/config.go
index 03accc8..f77246e 100644
--- a/dexpreopt/config.go
+++ b/dexpreopt/config.go
@@ -488,13 +488,7 @@
 		return
 	}
 
-	ctx.Build(pctx, android.BuildParams{
-		Rule:   android.WriteFile,
-		Output: android.PathForOutput(ctx, "dexpreopt_soong.config"),
-		Args: map[string]string{
-			"content": string(data),
-		},
-	})
+	android.WriteFileRule(ctx, android.PathForOutput(ctx, "dexpreopt_soong.config"), string(data))
 }
 
 func (s *globalSoongConfigSingleton) MakeVars(ctx android.MakeVarsContext) {
diff --git a/java/builder.go b/java/builder.go
index 3043e46..cd35245 100644
--- a/java/builder.go
+++ b/java/builder.go
@@ -572,14 +572,7 @@
 }
 
 func GenerateMainClassManifest(ctx android.ModuleContext, outputFile android.WritablePath, mainClass string) {
-	ctx.Build(pctx, android.BuildParams{
-		Rule:        android.WriteFile,
-		Description: "manifest",
-		Output:      outputFile,
-		Args: map[string]string{
-			"content": "Main-Class: " + mainClass + "\n",
-		},
-	})
+	android.WriteFileRule(ctx, outputFile, "Main-Class: "+mainClass+"\n")
 }
 
 func TransformZipAlign(ctx android.ModuleContext, outputFile android.WritablePath, inputFile android.Path) {
diff --git a/java/dexpreopt_bootjars.go b/java/dexpreopt_bootjars.go
index 629d34f..f9975ba 100644
--- a/java/dexpreopt_bootjars.go
+++ b/java/dexpreopt_bootjars.go
@@ -651,14 +651,8 @@
 		updatableBcpPackagesName := "updatable-bcp-packages.txt"
 		updatableBcpPackages := image.dir.Join(ctx, updatableBcpPackagesName)
 
-		ctx.Build(pctx, android.BuildParams{
-			Rule:   android.WriteFile,
-			Output: updatableBcpPackages,
-			Args: map[string]string{
-				// WriteFile automatically adds the last end-of-line.
-				"content": strings.Join(updatablePackages, "\\n"),
-			},
-		})
+		// WriteFileRule automatically adds the last end-of-line.
+		android.WriteFileRule(ctx, updatableBcpPackages, strings.Join(updatablePackages, "\n"))
 
 		rule := android.NewRuleBuilder()
 		rule.MissingDeps(missingDeps)
@@ -720,13 +714,7 @@
 func writeGlobalConfigForMake(ctx android.SingletonContext, path android.WritablePath) {
 	data := dexpreopt.GetGlobalConfigRawData(ctx)
 
-	ctx.Build(pctx, android.BuildParams{
-		Rule:   android.WriteFile,
-		Output: path,
-		Args: map[string]string{
-			"content": string(data),
-		},
-	})
+	android.WriteFileRule(ctx, path, string(data))
 }
 
 // Export paths for default boot image to Make