Repository: bazelbuild/bazel
Issue: Please add a repository_ctx method for reading files by Label
Labels: type: feature request
P3
team-ExternalDeps

A `repository_ctx` can have Label attributes that point to files in other repositories (useful for tool plumbing), but such attributes are awkward to work with because there's no direct way to read their content.

The best workaround I've found is to use symlinks and run `cat` as a subprocess, which certainly deserves an entry in the [Useless Use of Cat Award](http://porkmail.org/era/unix/award.html).

```python
def _some_repo_rule(ctx):
  ctx.symlink(ctx.attr.genrule_cmd_file, "external-genrule-cmd.sh")
  cat_genrule_cmd = ctx.execute(["cat", "external-genrule-cmd.sh"])
  if cat_genrule_cmd.return_code != 0:
    fail("Failed to read genrule command %r: %s" % (
        ctx.attr.genrule_cmd_file, cat_genrule_cmd.stderr))
  genrule_content = cat_genrule_cmd.stdout
```

That whole mess could, ideally, be simplified down to this:

```python
def _some_repo_rule(ctx):
  genrule_content = ctx.read_file(ctx.attr.genrule_cmd_file)
```

Issue comments:
This sounds related to (though definitely not a duplicate of) #3438
Has there been any progress on this, or prioritization?  It's worse than the issue description when you consider windows compatibility, as "cat" isn't an external executable on that platform (it being a powershell alias to Get-Content, which is also not an external executable file. So for windows, one has to implement platform-specific toolchains to supply a call to powershell with that command, all to read text file contents with a repository_ctx. 
PR: https://github.com/bazelbuild/bazel/pull/7309
What is the motivation for reading the file into Starlark rule?
Getting metadata in that you can use to generate the repository more effectively.  In my case, for example, getting teh appropriate maven metadata (pom files) to ensure that we generate dependencies properly in the generated bazel targets.  Right now I'm using "cat" which doesn't work on windows.  I don't want to have to set up a per-platform toolchain just to read a file. 
I've been reading files in repository rules for two reasons:

1. Sometimes the choice of which artifacts to download is driven by a file from the main workspace. For example, Python or Node.JS projects save their download URLs to "lock files". The only way to access the content of a file from the main workspace is via the symlink hack described in the first post.

2. Some external dependencies require running a setup tool, which outputs files with non-deterministic paths. I want to inspect the log file generated by those tools to discover the paths, so they can be inserted into the generated `BUILD` file.
+1, would be nice to have an OS-independent `cat` like feature in a repository rule. 
This came up in a discussion with @dkelmer earlier on again. Currently, to do cross-platform `cat` in a repository rule, we'll need to do this:

```python
def _cat_file(repository_ctx, filepath):
    # For Windows, use cat from msys.
    cat = "C:\\msys64\\usr\\bin\\cat" if (_is_windows(repository_ctx)) else repository_ctx.which("cat")
    exec_result = repository_ctx.execute([cat, repository_ctx.path(filepath)])
    if (exec_result.return_code != 0):
        fail("Error while trying to read %s: %s" % (filepath, exec_result.stderr))
    return exec_result.stdout
```

It'll be nice to shorten this to `content = repository_ctx.read(file)`.
I mean, it's that or add toolchains. :) 


Pull request review comments:
We want 8859-1 as the default?  
The default is set to UTF-8 in `SkylarkRepositoryContextApi`. I'm using `StandardCharsets.ISO_8859_1` to mean raw bytes, for when the user calls `ctx.read("somefile", encoding="")`.

My assumption is that this function will almost always be used to ingest text files in ASCII / UTF-8, with `encoding = ""` available as an escape hatch for odd cases.
Oh, I see!  Ok.  Gotcha. 
Starlark strings are raw bytes (https://github.com/bazelbuild/starlark/blob/master/spec.md#strings). Therefore this method should not have any encoding passed in.
`repository_ctx.file()` assumes strings are UTF-8:
```java
public void createFile(Object path, String content, Boolean executable, Location location) {
      ...
      try (OutputStream stream = p.getPath().getOutputStream()) {
        stream.write(content.getBytes(StandardCharsets.UTF_8));
      }
```

If the encoding does not default to UTF-8, then `ctx.file("foo", s); ctx.read("foo") == s` will be false.

If we do not allow the user to specify no encoding, then it won't be possible to properly read a binary file.

If you're certain that `ctx.read()` should not have an encoding parameter, should `ctx.file()` also be changed to treat its input as ISO-8859-1 (i.e. plain bytes)?
> repository_ctx.file() assumes strings are UTF-8:

That's a bug in `ctx.file()`

> If we do not allow the user to specify no encoding, then it won't be possible to properly read a binary file.

We should read in ISO-8859-1 - then we get raw bytes.

> If you're certain that ctx.read() should not have an encoding parameter, should ctx.file() also be changed to treat its input as ISO-8859-1 (i.e. plain bytes)?

Yes (this is an incompatible change, so the way to do it is to add `legacy_utf8` boolean to `ctx.file`, defaulting to  `true` and go through incompatible change process to make the default `false`.
Or rename `ctx.file()` to `ctx.write()` to be symmetrical with `read()` and make these paired functions behave in the new way, and deprecate `file()`

Still requires the incompatible change process to deal with the deprecation, but it's probably more instructive and better named.
OK, changes made in new commit:
* Remove `encoding=`, and reads be decoded as ISO-8859-1.
* Add `ctx.write_file()`, which is like `ctx.file()` but encodes with ISO-8859-1.
  * I also had to use a new `workspace_log.proto` message type, because the old `FileEvent` has its contents in `string` instead of `bytes`.
* Renamed new function to `ctx.read_file()` to match.
Let's keep the `ctx.file()` and add `legacy_utf8` as I suggested. Why? Migration will be no-op for most (I expect 100% of) people, where with function rename people will have to modify their code.

Let's keep the name `ctx.read()` the function you are adding.

I am not happy about the new event in BEP. Add bytes to `FileEvent` instead.
@dslomov Above changes applied.
Removing a field is a breaking change to the protocol.
Instead, let's have *two* fields for now: `string content` and `bytes bytes`, deprecating the first one. As part of an incompatible change to make legecy_utf8 default to false, we can set content to empty string and then remove that field.
Add a test with non-Latin-1 UTF-8 characters. Add a test for legacy_utf8=False
Done and done.
After further thought, I've reverted this message to only containing `string content`. The protobuf field has always been set from the `String content` directly, without encoding, so it doesn't make sense for it to change as part of this PR.

It might be reasonable to add a new field `bytes encoded_content` that differs depending on `legacy_utf8` mode, but I think that is best done separately.
This really should be a bool or enum instead of a string. If you use a bool, the call sites should specify the param name in a comment, as in

```
checkInOutputDirectory(/*isRead=*/ true, ...)
```
What are we testing? Should this be named `test_read_file_nonascii`?
These call sites can use some param name comments.
I think a `bool` would be inappropriate here, because there's no reason it wouldn't use more specific error messages for other checks in the future. I only copy-pasted `"write"` into existing callers to avoid significant diffs in unrelated tests.

I'm not familiar enough with Java to know when an enum, set of constants, or inline literals would be more idiomatic. Feel free to append commits to this PR for any stylistic changes (I care only about user-visible behavior).
The pre-existing test `test_file()` verifies the behavior of `repository_ctx.file()`. This test verifies the behavior of `repository_ctx.file()` when the file content contains codepoints outside the ASCII range.
Sure. That sounds like a reasonable cleanup that could be applied to this test file in a separate PR, or internal CL.
I wasn't suggesting changing the error message for the existing case, I just wanted to avoid passing strings if it's only ever allowed to take on two possible values (e.g. to avoid subtle misspellings leading to errors). That said I see that this is only used for the error message, not to control the actual operation being done, so it's fine to leave it a string.
Oh, fair enough. That was my lack of domain knowledge in this area showing through.
Granted they should've been there in the pre-existing code. I mention it because you're creating an additional parameter which makes the readability slightly worse, but I won't block this PR on it.

Patch that resolved the issue:
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/debug/WorkspaceRuleEvent.java b/src/main/java/com/google/devtools/build/lib/bazel/debug/WorkspaceRuleEvent.java
index 3b9ec466777766..06759395996e8a 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/debug/WorkspaceRuleEvent.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/debug/WorkspaceRuleEvent.java
@@ -183,6 +183,26 @@ public static WorkspaceRuleEvent newFileEvent(
     return new WorkspaceRuleEvent(result.build());
   }
 
+  /** Creates a new WorkspaceRuleEvent for a file read event. */
+  public static WorkspaceRuleEvent newReadEvent(
+      String path, String ruleLabel, Location location) {
+    WorkspaceLogProtos.ReadEvent e =
+        WorkspaceLogProtos.ReadEvent.newBuilder()
+            .setPath(path)
+            .build();
+
+    WorkspaceLogProtos.WorkspaceEvent.Builder result =
+        WorkspaceLogProtos.WorkspaceEvent.newBuilder();
+    result = result.setReadEvent(e);
+    if (location != null) {
+      result = result.setLocation(location.print());
+    }
+    if (ruleLabel != null) {
+      result = result.setRule(ruleLabel);
+    }
+    return new WorkspaceRuleEvent(result.build());
+  }
+
   /** Creates a new WorkspaceRuleEvent for an os event. */
   public static WorkspaceRuleEvent newOsEvent(String ruleLabel, Location location) {
     OsEvent e = WorkspaceLogProtos.OsEvent.getDefaultInstance();
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/debug/workspace_log.proto b/src/main/java/com/google/devtools/build/lib/bazel/debug/workspace_log.proto
index 4248911024640e..2ade26a4776aea 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/debug/workspace_log.proto
+++ b/src/main/java/com/google/devtools/build/lib/bazel/debug/workspace_log.proto
@@ -82,6 +82,12 @@ message FileEvent {
   bool executable = 3;
 }
 
+// Information on "read" event in repository_ctx.
+message ReadEvent {
+  // Path to the file to read
+  string path = 1;
+}
+
 // Information on "os" event in repository_ctx.
 message OsEvent {
   // Takes no inputs
@@ -130,5 +136,6 @@ message WorkspaceEvent {
     TemplateEvent template_event = 9;
     WhichEvent which_event = 10;
     ExtractEvent extract_event = 11;
+    ReadEvent read_event = 12;
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/repository/skylark/SkylarkRepositoryContext.java b/src/main/java/com/google/devtools/build/lib/bazel/repository/skylark/SkylarkRepositoryContext.java
index 5ab3b9bd2bbd43..9306c8294b79ed 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/repository/skylark/SkylarkRepositoryContext.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/repository/skylark/SkylarkRepositoryContext.java
@@ -180,7 +180,7 @@ public void symlink(Object from, Object to, Location location)
             fromPath.toString(), toPath.toString(), rule.getLabel().toString(), location);
     env.getListener().post(w);
     try {
-      checkInOutputDirectory(toPath);
+      checkInOutputDirectory("write", toPath);
       makeDirectories(toPath.getPath());
       toPath.getPath().createSymbolicLink(fromPath.getPath());
     } catch (IOException e) {
@@ -192,30 +192,36 @@ public void symlink(Object from, Object to, Location location)
     }
   }
 
-  private void checkInOutputDirectory(SkylarkPath path) throws RepositoryFunctionException {
+  private void checkInOutputDirectory(String operation, SkylarkPath path) throws RepositoryFunctionException {
     if (!path.getPath().getPathString().startsWith(outputDirectory.getPathString())) {
       throw new RepositoryFunctionException(
           new EvalException(
               Location.fromFile(path.getPath()),
-              "Cannot write outside of the repository directory for path " + path),
+              "Cannot " + operation + " outside of the repository directory for path " + path),
           Transience.PERSISTENT);
     }
   }
 
   @Override
-  public void createFile(Object path, String content, Boolean executable, Location location)
+  public void createFile(Object path, String content, Boolean executable, Boolean legacyUtf8, Location location)
       throws RepositoryFunctionException, EvalException, InterruptedException {
     SkylarkPath p = getPath("file()", path);
+    byte[] contentBytes;
+    if (legacyUtf8) {
+      contentBytes = content.getBytes(StandardCharsets.UTF_8);
+    } else {
+      contentBytes = content.getBytes(StandardCharsets.ISO_8859_1);
+    }
     WorkspaceRuleEvent w =
         WorkspaceRuleEvent.newFileEvent(
             p.toString(), content, executable, rule.getLabel().toString(), location);
     env.getListener().post(w);
     try {
-      checkInOutputDirectory(p);
+      checkInOutputDirectory("write", p);
       makeDirectories(p.getPath());
       p.getPath().delete();
       try (OutputStream stream = p.getPath().getOutputStream()) {
-        stream.write(content.getBytes(StandardCharsets.UTF_8));
+        stream.write(contentBytes);
       }
       if (executable) {
         p.getPath().setExecutable(true);
@@ -245,7 +251,7 @@ public void createFileFromTemplate(
             location);
     env.getListener().post(w);
     try {
-      checkInOutputDirectory(p);
+      checkInOutputDirectory("write", p);
       makeDirectories(p.getPath());
       String tpl = FileSystemUtils.readContent(t.getPath(), StandardCharsets.UTF_8);
       for (Map.Entry<String, String> substitution : substitutions.entrySet()) {
@@ -264,6 +270,22 @@ public void createFileFromTemplate(
     }
   }
 
+  @Override
+  public String readFile(Object path, Location location)
+      throws RepositoryFunctionException, EvalException, InterruptedException {
+    SkylarkPath p = getPath("read()", path);
+    WorkspaceRuleEvent w =
+        WorkspaceRuleEvent.newReadEvent(
+            p.toString(), rule.getLabel().toString(), location);
+    env.getListener().post(w);
+    try {
+      checkInOutputDirectory("read", p);
+      return FileSystemUtils.readContent(p.getPath(), StandardCharsets.ISO_8859_1);
+    } catch (IOException e) {
+      throw new RepositoryFunctionException(e, Transience.TRANSIENT);
+    }
+  }
+
   // Create parent directories for the given path
   private void makeDirectories(Path path) throws IOException {
     Path parent = path.getParentDirectory();
@@ -397,7 +419,7 @@ public StructImpl download(
     env.getListener().post(w);
     Path downloadedPath;
     try {
-      checkInOutputDirectory(outputPath);
+      checkInOutputDirectory("write", outputPath);
       makeDirectories(outputPath.getPath());
       downloadedPath =
           httpDownloader.download(
@@ -481,7 +503,7 @@ public StructImpl downloadAndExtract(
 
     // Download to outputDirectory and delete it after extraction
     SkylarkPath outputPath = getPath("download_and_extract()", output);
-    checkInOutputDirectory(outputPath);
+    checkInOutputDirectory("write", outputPath);
     createDirectory(outputPath.getPath());
 
     Path downloadedPath;
diff --git a/src/main/java/com/google/devtools/build/lib/skylarkbuildapi/repository/SkylarkRepositoryContextApi.java b/src/main/java/com/google/devtools/build/lib/skylarkbuildapi/repository/SkylarkRepositoryContextApi.java
index 41360435230840..b15808dcb789ee 100644
--- a/src/main/java/com/google/devtools/build/lib/skylarkbuildapi/repository/SkylarkRepositoryContextApi.java
+++ b/src/main/java/com/google/devtools/build/lib/skylarkbuildapi/repository/SkylarkRepositoryContextApi.java
@@ -138,8 +138,16 @@ public void symlink(Object from, Object to, Location location)
             type = Boolean.class,
             defaultValue = "True",
             doc = "set the executable flag on the created file, true by default."),
+        @Param(
+            name = "legacy_utf8",
+            named = true,
+            type = Boolean.class,
+            defaultValue = "True",
+            doc =
+                "encode file content to UTF-8, true by default. Future versions will change"
+                    + " the default and remove this parameter."),
       })
-  public void createFile(Object path, String content, Boolean executable, Location location)
+  public void createFile(Object path, String content, Boolean executable, Boolean legacyUtf8, Location location)
       throws RepositoryFunctionExceptionT, EvalException, InterruptedException;
 
   @SkylarkCallable(
@@ -189,6 +197,23 @@ public void createFileFromTemplate(
       Location location)
       throws RepositoryFunctionExceptionT, EvalException, InterruptedException;
 
+  @SkylarkCallable(
+      name = "read",
+      doc = "Reads the content of a file on the filesystem.",
+      useLocation = true,
+      parameters = {
+        @Param(
+            name = "path",
+            allowedTypes = {
+              @ParamType(type = String.class),
+              @ParamType(type = Label.class),
+              @ParamType(type = RepositoryPathApi.class)
+            },
+            doc = "path of the file to read from."),
+      })
+  public String readFile(Object path, Location location)
+      throws RepositoryFunctionExceptionT, EvalException, InterruptedException;
+
   @SkylarkCallable(
       name = "os",
       structField = true,


Test patch:
diff --git a/src/test/java/com/google/devtools/build/lib/bazel/repository/skylark/SkylarkRepositoryContextTest.java b/src/test/java/com/google/devtools/build/lib/bazel/repository/skylark/SkylarkRepositoryContextTest.java
index 37ed659c9cd05f..dfe2512cb5f908 100644
--- a/src/test/java/com/google/devtools/build/lib/bazel/repository/skylark/SkylarkRepositoryContextTest.java
+++ b/src/test/java/com/google/devtools/build/lib/bazel/repository/skylark/SkylarkRepositoryContextTest.java
@@ -145,16 +145,16 @@ public void testWhich() throws Exception {
   @Test
   public void testFile() throws Exception {
     setUpContexForRule("test");
-    context.createFile(context.path("foobar"), "", true, null);
-    context.createFile(context.path("foo/bar"), "foobar", true, null);
-    context.createFile(context.path("bar/foo/bar"), "", true, null);
+    context.createFile(context.path("foobar"), "", true, true, null);
+    context.createFile(context.path("foo/bar"), "foobar", true, true, null);
+    context.createFile(context.path("bar/foo/bar"), "", true, true, null);
 
     testOutputFile(outputDirectory.getChild("foobar"), "");
     testOutputFile(outputDirectory.getRelative("foo/bar"), "foobar");
     testOutputFile(outputDirectory.getRelative("bar/foo/bar"), "");
 
     try {
-      context.createFile(context.path("/absolute"), "", true, null);
+      context.createFile(context.path("/absolute"), "", true, true, null);
       fail("Expected error on creating path outside of the repository directory");
     } catch (RepositoryFunctionException ex) {
       assertThat(ex)
@@ -163,7 +163,7 @@ public void testFile() throws Exception {
           .isEqualTo("Cannot write outside of the repository directory for path /absolute");
     }
     try {
-      context.createFile(context.path("../somepath"), "", true, null);
+      context.createFile(context.path("../somepath"), "", true, true, null);
       fail("Expected error on creating path outside of the repository directory");
     } catch (RepositoryFunctionException ex) {
       assertThat(ex)
@@ -172,7 +172,7 @@ public void testFile() throws Exception {
           .isEqualTo("Cannot write outside of the repository directory for path /somepath");
     }
     try {
-      context.createFile(context.path("foo/../../somepath"), "", true, null);
+      context.createFile(context.path("foo/../../somepath"), "", true, true, null);
       fail("Expected error on creating path outside of the repository directory");
     } catch (RepositoryFunctionException ex) {
       assertThat(ex)
@@ -182,10 +182,47 @@ public void testFile() throws Exception {
     }
   }
 
+  @Test
+  public void testRead() throws Exception {
+    setUpContexForRule("test");
+    context.createFile(context.path("foo/bar"), "foobar", true, true, null);
+
+    String content = context.readFile(context.path("foo/bar"), null);
+    assertThat(content).isEqualTo("foobar");
+
+    try {
+      context.readFile(context.path("/absolute"), null);
+      fail("Expected error on reading path outside of the repository directory");
+    } catch (RepositoryFunctionException ex) {
+      assertThat(ex)
+          .hasCauseThat()
+          .hasMessageThat()
+          .isEqualTo("Cannot read outside of the repository directory for path /absolute");
+    }
+    try {
+      context.readFile(context.path("../somepath"), null);
+      fail("Expected error on reading path outside of the repository directory");
+    } catch (RepositoryFunctionException ex) {
+      assertThat(ex)
+          .hasCauseThat()
+          .hasMessageThat()
+          .isEqualTo("Cannot read outside of the repository directory for path /somepath");
+    }
+    try {
+      context.readFile(context.path("foo/../../somepath"), null);
+      fail("Expected error on reading path outside of the repository directory");
+    } catch (RepositoryFunctionException ex) {
+      assertThat(ex)
+          .hasCauseThat()
+          .hasMessageThat()
+          .isEqualTo("Cannot read outside of the repository directory for path /somepath");
+    }
+  }
+
   @Test
   public void testSymlink() throws Exception {
     setUpContexForRule("test");
-    context.createFile(context.path("foo"), "foobar", true, null);
+    context.createFile(context.path("foo"), "foobar", true, true, null);
 
     context.symlink(context.path("foo"), context.path("bar"), null);
     testOutputFile(outputDirectory.getChild("bar"), "foobar");
diff --git a/src/test/shell/bazel/bazel_workspaces_test.sh b/src/test/shell/bazel/bazel_workspaces_test.sh
index 8d21cfa74880a2..b81c255374696f 100755
--- a/src/test/shell/bazel/bazel_workspaces_test.sh
+++ b/src/test/shell/bazel/bazel_workspaces_test.sh
@@ -292,6 +292,75 @@ function test_file() {
   ensure_contains_exactly 'executable: true' 1
 }
 
+function test_file_nonascii() {
+  set_workspace_command 'repository_ctx.file("filefile.sh", "echo fïlëfïlë", True)'
+
+  build_and_process_log --exclude_rule "//external:local_config_cc"
+
+  ensure_contains_exactly 'location: .*repos.bzl:2:3' 1
+  ensure_contains_atleast 'rule: "//external:repo"' 1
+
+  # There are 3 file_event in external:repo as it is currently set up
+  ensure_contains_exactly 'file_event' 3
+  ensure_contains_exactly 'path: ".*filefile.sh"' 1
+  ensure_contains_exactly 'executable: true' 1
+
+  # This test file is in UTF-8, so the string passed to file() is UTF-8.
+  # Protobuf strings are Unicode encoded in UTF-8, so the logged text
+  # is double-encoded relative to the workspace command.
+  #
+  # >>> content = "echo f\u00EFl\u00EBf\u00EFl\u00EB".encode("utf8")
+  # >>> proto_content = content.decode("iso-8859-1").encode("utf8")
+  # >>> print("".join(chr(c) if c <= 0x7F else ("\\" + oct(c)[2:]) for c in proto_content))
+  # echo f\303\203\302\257l\303\203\302\253f\303\203\302\257l\303\203\302\253
+  # >>>
+
+  ensure_contains_exactly 'content: "echo f\\303\\203\\302\\257l\\303\\203\\302\\253f\\303\\203\\302\\257l\\303\\203\\302\\253"' 1
+}
+
+function test_read() {
+  set_workspace_command '
+  content = "echo filefile"
+  repository_ctx.file("filefile.sh", content, True)
+  read_result = repository_ctx.read("filefile.sh")
+  if read_result != content:
+    fail("read(): expected %r, got %r" % (content, read_result))'
+
+  build_and_process_log --exclude_rule "//external:local_config_cc"
+
+  ensure_contains_exactly 'location: .*repos.bzl:4:3' 1
+  ensure_contains_exactly 'location: .*repos.bzl:5:17' 1
+  ensure_contains_atleast 'rule: "//external:repo"' 2
+
+  ensure_contains_exactly 'read_event' 1
+  ensure_contains_exactly 'path: ".*filefile.sh"' 2
+}
+
+function test_read_roundtrip_legacy_utf8() {
+  # See discussion on https://github.com/bazelbuild/bazel/pull/7309
+  set_workspace_command '
+  content = "echo fïlëfïlë"
+  repository_ctx.file("filefile.sh", content, True, legacy_utf8=True)
+  read_result = repository_ctx.read("filefile.sh")
+
+  corrupted_content = "echo fÃ¯lÃ«fÃ¯lÃ«"
+  if read_result != corrupted_content:
+    fail("read(): expected %r, got %r" % (corrupted_content, read_result))'
+
+  build_and_process_log --exclude_rule "//external:local_config_cc"
+}
+
+function test_read_roundtrip_nolegacy_utf8() {
+  set_workspace_command '
+  content = "echo fïlëfïlë"
+  repository_ctx.file("filefile.sh", content, True, legacy_utf8=False)
+  read_result = repository_ctx.read("filefile.sh")
+  if read_result != content:
+    fail("read(): expected %r, got %r" % (content, read_result))'
+
+  build_and_process_log --exclude_rule "//external:local_config_cc"
+}
+
 function test_os() {
   set_workspace_command 'print(repository_ctx.os.name)'