From c95b3d11f43ab3a1036d4b0db1b2d9587abfb616 Mon Sep 17 00:00:00 2001 From: Yarden Bar Date: Wed, 9 Sep 2026 14:25:06 -0700 Subject: [PATCH] Add netrc helpers in EvaluatorSettings.pkl (#1817) This adds the `netRcHeaders` helper method that parses netrc file contents, and returns a mapping suitable to be used as Http headers. --- .../org/pkl/core/settings/PklSettings.java | 1 + .../stdlib/evaluatorsettings/HttpNodes.java | 82 ++++++ .../main/java/org/pkl/core/util/Netrc.java | 278 ++++++++++++++++++ .../org/pkl/core/errorMessages.properties | 3 + .../api/evaluatorSettingsModuleNetRc.pkl | 159 ++++++++++ .../api/evaluatorSettingsModuleNetRc.pcf | 211 +++++++++++++ .../org/pkl/core/settings/PklSettingsTest.kt | 55 ++++ .../kotlin/org/pkl/core/util/NetrcTest.kt | 207 +++++++++++++ stdlib/EvaluatorSettings.pkl | 11 + 9 files changed, 1007 insertions(+) create mode 100644 pkl-core/src/main/java/org/pkl/core/stdlib/evaluatorsettings/HttpNodes.java create mode 100644 pkl-core/src/main/java/org/pkl/core/util/Netrc.java create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/input/api/evaluatorSettingsModuleNetRc.pkl create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/output/api/evaluatorSettingsModuleNetRc.pcf create mode 100644 pkl-core/src/test/kotlin/org/pkl/core/util/NetrcTest.kt diff --git a/pkl-core/src/main/java/org/pkl/core/settings/PklSettings.java b/pkl-core/src/main/java/org/pkl/core/settings/PklSettings.java index 32d3705a9..35bd717ba 100644 --- a/pkl-core/src/main/java/org/pkl/core/settings/PklSettings.java +++ b/pkl-core/src/main/java/org/pkl/core/settings/PklSettings.java @@ -98,6 +98,7 @@ public record PklSettings(Editor editor, PklEvaluatorSettings.@Nullable Http htt .addModuleKeyFactory(ModuleKeyFactories.standardLibrary) .addModuleKeyFactory(ModuleKeyFactories.file) .addResourceReader(ResourceReaders.environmentVariable()) + .addResourceReader(ResourceReaders.file()) .addEnvironmentVariables(System.getenv()) .build()) { var module = evaluator.evaluateOutputValueAs(moduleSource, PClassInfo.Settings); diff --git a/pkl-core/src/main/java/org/pkl/core/stdlib/evaluatorsettings/HttpNodes.java b/pkl-core/src/main/java/org/pkl/core/stdlib/evaluatorsettings/HttpNodes.java new file mode 100644 index 000000000..866b6235a --- /dev/null +++ b/pkl-core/src/main/java/org/pkl/core/stdlib/evaluatorsettings/HttpNodes.java @@ -0,0 +1,82 @@ +/* + * Copyright © 2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.core.stdlib.evaluatorsettings; + +import com.oracle.truffle.api.CompilerDirectives.TruffleBoundary; +import com.oracle.truffle.api.dsl.Cached; +import com.oracle.truffle.api.dsl.Specialization; +import com.oracle.truffle.api.nodes.IndirectCallNode; +import java.util.List; +import java.util.Map; +import org.pkl.core.runtime.Identifier; +import org.pkl.core.runtime.VmList; +import org.pkl.core.runtime.VmMapping; +import org.pkl.core.runtime.VmObjectBuilder; +import org.pkl.core.runtime.VmTyped; +import org.pkl.core.runtime.VmUtils; +import org.pkl.core.stdlib.ExternalMethod1Node; +import org.pkl.core.util.Netrc; + +public class HttpNodes { + private HttpNodes() {} + + public abstract static class netRcHeaders extends ExternalMethod1Node { + @Specialization + @TruffleBoundary + protected VmMapping eval(VmTyped self, String text) { + return doParse(text); + } + + @Specialization + @TruffleBoundary + protected VmMapping eval( + VmTyped self, VmTyped resource, @Cached("create()") IndirectCallNode callNode) { + var text = (String) VmUtils.readMember(resource, Identifier.TEXT, callNode); + return doParse(text); + } + + private VmMapping doParse(String text) { + List entries; + try { + entries = Netrc.parse(text); + } catch (Netrc.NetrcParseException e) { + throw exceptionBuilder().evalError("cannotParseNetrc", e.getMessage()).build(); + } + var headersMap = Netrc.toHeadersMap(entries); + return toMapping(headersMap); + } + + private static VmMapping toMapping(Map>> headersMap) { + var outerBuilder = new VmObjectBuilder(headersMap.size()); + for (var entry : headersMap.entrySet()) { + var globPattern = entry.getKey(); + var innerMap = entry.getValue(); + + var innerBuilder = new VmObjectBuilder(innerMap.size()); + for (var headerEntry : innerMap.entrySet()) { + var headerName = headerEntry.getKey(); + var headerValuesList = headerEntry.getValue(); + + innerBuilder.addEntry(headerName, VmList.create(headerValuesList).toListing()); + } + + outerBuilder.addEntry(globPattern, innerBuilder.toMapping()); + } + + return outerBuilder.toMapping(); + } + } +} diff --git a/pkl-core/src/main/java/org/pkl/core/util/Netrc.java b/pkl-core/src/main/java/org/pkl/core/util/Netrc.java new file mode 100644 index 000000000..a5fde6ddf --- /dev/null +++ b/pkl-core/src/main/java/org/pkl/core/util/Netrc.java @@ -0,0 +1,278 @@ +/* + * Copyright © 2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.core.util; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +public final class Netrc { + private static final String NULL_LINE = "\0null_line\0"; + + public static final class NetrcParseException extends Exception { + public NetrcParseException(String message) { + super(message); + } + } + + private Netrc() {} + + public record Entry( + @Nullable String machine, + boolean isDefault, + @Nullable String login, + @Nullable String password, + @Nullable String account) {} + + /** + * Parses the content of a .netrc file into a list of {@link Entry}. + * + * @throws NetrcParseException if the content contains an unclosed quote or invalid escape. + */ + public static List parse(String content) throws NetrcParseException { + var tokens = tokenize(content); + var entries = new ArrayList(); + + String currentMachine = null; + var isDefault = false; + String currentLogin = null; + String currentPassword = null; + String currentAccount = null; + + var i = 0; + while (i < tokens.size()) { + var token = tokens.get(i++); + switch (token.toLowerCase(Locale.ROOT)) { + case "machine" -> { + if (currentMachine != null) { + entries.add( + new Entry( + currentMachine, isDefault, currentLogin, currentPassword, currentAccount)); + } + isDefault = false; + currentLogin = null; + currentPassword = null; + currentAccount = null; + currentMachine = i < tokens.size() ? tokens.get(i++) : null; + } + case "default" -> { + if (currentMachine != null) { + entries.add( + new Entry( + currentMachine, isDefault, currentLogin, currentPassword, currentAccount)); + } + currentLogin = null; + currentPassword = null; + currentAccount = null; + currentMachine = "default"; + isDefault = true; + } + case "login" -> { + if (i < tokens.size()) { + currentLogin = tokens.get(i++); + } + } + case "password" -> { + if (i < tokens.size()) { + currentPassword = tokens.get(i++); + } + } + case "account" -> { + if (i < tokens.size()) { + currentAccount = tokens.get(i++); + } + } + case "macdef" -> { + while (i < tokens.size()) { + if (tokens.get(i).equals(NULL_LINE)) { + i++; + break; + } + i++; + } + } + } + } + if (currentMachine != null || isDefault) { + entries.add( + new Entry(currentMachine, isDefault, currentLogin, currentPassword, currentAccount)); + } + return entries; + } + + /** + * Converts a list of {@link Entry} into a map of host glob pattern to header map (header name to + * list of header values). + */ + public static Map>> toHeadersMap(List entries) { + var result = new LinkedHashMap>>(); + for (var entry : entries) { + var authHeaderValue = computeAuthHeaderValue(entry.login(), entry.password()); + if (authHeaderValue == null) { + continue; + } + var headerMap = Map.of("Authorization", List.of(authHeaderValue)); + if (entry.isDefault()) { + continue; + } + if (entry.machine() != null && !entry.machine().contains("/")) { + result.putIfAbsent("http{,s}://" + escapeGlobPattern(entry.machine()) + "/**", headerMap); + } + } + return result; + } + + private static String escapeGlobPattern(String value) { + var sb = new StringBuilder(); + for (var i = 0; i < value.length(); i++) { + var c = value.charAt(i); + if (c == '?' || c == '*' || c == '[' || c == '{' || c == '\\') { + sb.append('[').append(c).append(']'); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static @Nullable String computeAuthHeaderValue( + @Nullable String login, @Nullable String password) { + if (password == null && login == null) { + return null; + } + var user = login == null ? "" : login; + var pass = password == null ? "" : password; + var credentials = user + ":" + pass; + var encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + return "Basic " + encoded; + } + + public static List tokenize(String content) throws NetrcParseException { + var tokens = new ArrayList(); + var len = content.length(); + var i = 0; + var atLineStart = true; + + while (i < len) { + var c = content.charAt(i); + + if (c == '\n' || (c == '\r' && i < len - 1 && content.charAt(i + 1) == '\n')) { + if (atLineStart) { + tokens.add(NULL_LINE); + } + atLineStart = true; + if (c == '\r') { + i += 2; + } else { + i++; + } + continue; + } else if (isWhitespace(c)) { + i++; + continue; + } + + // lines starting with `#` (after any leading blanks) are treated as comments + if (c == '#' && atLineStart) { + // Skip comment until end of line + i = consumeLineComment(i, content, len); + continue; + } + + // Non-whitespace character encountered + atLineStart = false; + if (c == '"') { + i = consumeQuotedToken(i, content, len, tokens); + } else { + i = consumeUnquotedToken(i, content, len, tokens); + } + } + return tokens; + } + + private static int consumeLineComment(int i, String content, int len) { + while (i < len && content.charAt(i) != '\n') { + i++; + } + return i; + } + + private static int consumeQuotedToken(int i, String content, int len, List tokens) + throws NetrcParseException { + i++; // skip opening quote + var escape = false; + var sb = new StringBuilder(); + while (i < len) { + var ch = content.charAt(i); + if (escape) { + var escapedChar = + switch (ch) { + case 'n' -> '\n'; + case 't' -> '\t'; + case 'r' -> '\r'; + default -> ch; + }; + sb.append(escapedChar); + escape = false; + i++; + continue; + } + switch (ch) { + case '"': + { + tokens.add(sb.toString()); + return i + 1; + } + case '\\': + { + escape = true; + break; + } + default: + { + sb.append(ch); + } + } + i++; + } + var reason = escape ? "invalid escape" : "unclosed quote"; + throw new NetrcParseException(reason); + } + + private static int consumeUnquotedToken(int i, String content, int len, List tokens) { + var sb = new StringBuilder(); + while (i < len) { + var ch = content.charAt(i); + if (isWhitespace(ch)) { + tokens.add(sb.toString()); + return i; + } + sb.append(ch); + i++; + } + tokens.add(sb.toString()); + return i; + } + + private static boolean isWhitespace(char c) { + return c == ' ' || c == '\n' || c == '\t' || c == '\r'; + } +} diff --git a/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties b/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties index 9a3834801..bebb45b91 100644 --- a/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties +++ b/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties @@ -1236,3 +1236,6 @@ Cannot reference `module` type within a type alias body. invalidThisTypeInTypeAlias=\ Cannot reference `this` type within a type alias body. + +cannotParseNetrc=\ +Cannot parse .netrc: {0}. diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/api/evaluatorSettingsModuleNetRc.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/api/evaluatorSettingsModuleNetRc.pkl new file mode 100644 index 000000000..ea6ba8cc6 --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/api/evaluatorSettingsModuleNetRc.pkl @@ -0,0 +1,159 @@ +amends "../snippetTest.pkl" + +import "pkl:EvaluatorSettings" + +local const http = new EvaluatorSettings.Http {} + +examples { + ["netRcHeaders - basic"] { + http.netRcHeaders(""" + machine github.com + login octocat + password secret_pass + """) + } + + ["netRcHeaders - quotes"] { + http.netRcHeaders(""" + machine foo.com + login "bar bar" + password baz + """) + } + + ["netRcHeaders - trailing comments"] { + http.netRcHeaders(""" + machine foo.com + login bar + password foo # some comment + machine bar.com + login bar2 + password foo#some comment + """) + } + + ["netRcHeaders - hash inside quote"] { + http.netRcHeaders(""" + machine foo.com + login "foo # bar" + password "pass#1" + """) + } + + ["netRcHeaders - tabs as whitespace"] { + http.netRcHeaders("machine\tfoo.com\tlogin\tbar\tpassword\tbaz") + } + + ["netRcHeaders - crlf line endings"] { + http.netRcHeaders("machine crlf.example.com\r\n login foo\r\n password bar\r\n") + } + + ["netRcHeaders - entry with login but no password"] { + http.netRcHeaders(""" + machine nologin.com + password onlypass + machine nopass.com + login onlylogin + machine valid.com + login user + password pass + """) + } + + ["netRcHeaders - duplicate machines"] { + http.netRcHeaders(""" + machine foo.com login firstUser password firstPass + machine foo.com login secondUser password secondPass + """) + } + + ["netRcHeaders - machine that collides with keyword"] { + http.netRcHeaders(""" + machine machine login user password pass + """) + } + + ["netRcHeaders - machine default and default keyword"] { + http.netRcHeaders(""" + default login defUser password defPass + machine default login machDefUser password machDefPass + """) + } + + ["netRcHeaders - machine with slash omitted"] { + http.netRcHeaders(""" + machine foo.com/bar login user password pass + machine valid.com login user password pass + """) + } + + ["netRcHeaders - machine with glob characters escaped"] { + http.netRcHeaders(""" + machine foo[bar].com login user password pass + machine star*.com login user password pass + """) + } + + ["netRcHeaders - files with only comments or whitespace"] { + http.netRcHeaders("") + http.netRcHeaders("# only comments\n# second comment") + http.netRcHeaders(" \t \n \n ") + } + + ["netRcHeaders - skipped macdef"] { + http.netRcHeaders(""" + macdef init + echo hello + echo world + + machine foo.com login user1 password pass1 + """) + } + + ["netRcHeaders - macdef as value"] { + http.netRcHeaders(""" + machine foo.com login macdef password pass1 + """) + } + + ["netRcHeaders - empty quotes"] { + http.netRcHeaders(""" + machine foo.com login "" password "" + """) + } + + ["netRcHeaders - escaped quotes and chars"] { + http.netRcHeaders(#""" + machine foo.com login "foo\"bar" password "foo \z" + """#) + } + + ["netRcHeaders - consecutive quotes"] { + http.netRcHeaders(""" + machine foo.com login "foo""bar" password pass + """) + } + + ["netRcHeaders - quoted keywords"] { + http.netRcHeaders(""" + "machine" foo.com "login" user "password" pass + """) + } + + ["netRcHeaders - single line entry"] { + http.netRcHeaders("machine foo.com login bar password pass1") + } + + ["netRcHeaders - trailing machine at EOF"] { + http.netRcHeaders("machine foo.com login bar password pass1 machine") + } + + ["netRcHeaders - unterminated quote error"] { + module.catch(() -> http.netRcHeaders(""" + machine foo.com login "foo + """)) + module.catch(() -> http.netRcHeaders(""" + machine foo.com login "foo\\ + """)) + } +} diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/api/evaluatorSettingsModuleNetRc.pcf b/pkl-core/src/test/files/LanguageSnippetTests/output/api/evaluatorSettingsModuleNetRc.pcf new file mode 100644 index 000000000..c07163cbb --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/api/evaluatorSettingsModuleNetRc.pcf @@ -0,0 +1,211 @@ +examples { + ["netRcHeaders - basic"] { + new { + ["http{,s}://github.com/**"] { + ["Authorization"] { + "Basic b2N0b2NhdDpzZWNyZXRfcGFzcw==" + } + } + } + } + ["netRcHeaders - quotes"] { + new { + ["http{,s}://foo.com/**"] { + ["Authorization"] { + "Basic YmFyIGJhcjpiYXo=" + } + } + } + } + ["netRcHeaders - trailing comments"] { + new { + ["http{,s}://foo.com/**"] { + ["Authorization"] { + "Basic YmFyOmZvbw==" + } + } + ["http{,s}://bar.com/**"] { + ["Authorization"] { + "Basic YmFyMjpmb28jc29tZQ==" + } + } + } + } + ["netRcHeaders - hash inside quote"] { + new { + ["http{,s}://foo.com/**"] { + ["Authorization"] { + "Basic Zm9vICMgYmFyOnBhc3MjMQ==" + } + } + } + } + ["netRcHeaders - tabs as whitespace"] { + new { + ["http{,s}://foo.com/**"] { + ["Authorization"] { + "Basic YmFyOmJheg==" + } + } + } + } + ["netRcHeaders - crlf line endings"] { + new { + ["http{,s}://crlf.example.com/**"] { + ["Authorization"] { + "Basic Zm9vOmJhcg==" + } + } + } + } + ["netRcHeaders - entry with login but no password"] { + new { + ["http{,s}://nologin.com/**"] { + ["Authorization"] { + "Basic Om9ubHlwYXNz" + } + } + ["http{,s}://nopass.com/**"] { + ["Authorization"] { + "Basic b25seWxvZ2luOg==" + } + } + ["http{,s}://valid.com/**"] { + ["Authorization"] { + "Basic dXNlcjpwYXNz" + } + } + } + } + ["netRcHeaders - duplicate machines"] { + new { + ["http{,s}://foo.com/**"] { + ["Authorization"] { + "Basic Zmlyc3RVc2VyOmZpcnN0UGFzcw==" + } + } + } + } + ["netRcHeaders - machine that collides with keyword"] { + new { + ["http{,s}://machine/**"] { + ["Authorization"] { + "Basic dXNlcjpwYXNz" + } + } + } + } + ["netRcHeaders - machine default and default keyword"] { + new { + ["http{,s}://default/**"] { + ["Authorization"] { + "Basic bWFjaERlZlVzZXI6bWFjaERlZlBhc3M=" + } + } + } + } + ["netRcHeaders - machine with slash omitted"] { + new { + ["http{,s}://valid.com/**"] { + ["Authorization"] { + "Basic dXNlcjpwYXNz" + } + } + } + } + ["netRcHeaders - machine with glob characters escaped"] { + new { + ["http{,s}://foo[[]bar].com/**"] { + ["Authorization"] { + "Basic dXNlcjpwYXNz" + } + } + ["http{,s}://star[*].com/**"] { + ["Authorization"] { + "Basic dXNlcjpwYXNz" + } + } + } + } + ["netRcHeaders - files with only comments or whitespace"] { + new {} + new {} + new {} + } + ["netRcHeaders - skipped macdef"] { + new { + ["http{,s}://foo.com/**"] { + ["Authorization"] { + "Basic dXNlcjE6cGFzczE=" + } + } + } + } + ["netRcHeaders - macdef as value"] { + new { + ["http{,s}://foo.com/**"] { + ["Authorization"] { + "Basic bWFjZGVmOnBhc3Mx" + } + } + } + } + ["netRcHeaders - empty quotes"] { + new { + ["http{,s}://foo.com/**"] { + ["Authorization"] { + "Basic Og==" + } + } + } + } + ["netRcHeaders - escaped quotes and chars"] { + new { + ["http{,s}://foo.com/**"] { + ["Authorization"] { + "Basic Zm9vImJhcjpmb28geg==" + } + } + } + } + ["netRcHeaders - consecutive quotes"] { + new { + ["http{,s}://foo.com/**"] { + ["Authorization"] { + "Basic Zm9vOnBhc3M=" + } + } + } + } + ["netRcHeaders - quoted keywords"] { + new { + ["http{,s}://foo.com/**"] { + ["Authorization"] { + "Basic dXNlcjpwYXNz" + } + } + } + } + ["netRcHeaders - single line entry"] { + new { + ["http{,s}://foo.com/**"] { + ["Authorization"] { + "Basic YmFyOnBhc3Mx" + } + } + } + } + ["netRcHeaders - trailing machine at EOF"] { + new { + ["http{,s}://foo.com/**"] { + ["Authorization"] { + "Basic YmFyOnBhc3Mx" + } + } + } + } + ["netRcHeaders - unterminated quote error"] { + "Cannot parse .netrc: unclosed quote." + "Cannot parse .netrc: invalid escape." + } +} diff --git a/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt index ce71e2746..0292b6a4d 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt @@ -17,6 +17,7 @@ package org.pkl.core.settings import java.net.URI import java.nio.file.Path +import java.util.Base64 import kotlin.io.path.createParentDirectories import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatCode @@ -175,6 +176,60 @@ class PklSettingsTest { ) } + @Test + fun `load user settings with netRcHeaders`(@TempDir tempDir: Path) { + val netrcFile = tempDir.resolve(".netrc") + netrcFile.writeString( + """ + machine github.com + login octocat + password secret_pass + """ + .trimIndent() + ) + + val settingsPath = tempDir.resolve("settings.pkl") + settingsPath.writeString( + """ + amends "pkl:settings" + + http { + headers = netRcHeaders(read("${netrcFile.toUri()}")) + } + """ + .trimIndent() + ) + + val settings = PklSettings.load(ModuleSource.path(settingsPath)) + val expectedAuth = + "Basic " + Base64.getEncoder().encodeToString("octocat:secret_pass".toByteArray()) + val expectedHttp = + PklEvaluatorSettings.Http( + null, + null, + mapOf("http{,s}://github.com/**" to mapOf("Authorization" to listOf(expectedAuth))), + ) + + assertThat(settings.http()).isEqualTo(expectedHttp) + } + + @Test + fun `test import EvaluatorSettings`() { + val evaluator = Evaluator.preconfigured() + val module = + evaluator.evaluate( + ModuleSource.text( + """ + import "pkl:EvaluatorSettings" + + res = (new EvaluatorSettings.Http {}).netRcHeaders("") + """ + .trimIndent() + ) + ) + assertThat(module.getProperty("res")).isNotNull + } + private fun checkEquals(expected: Editor, actual: PObject) { assertThat(actual.getProperty("urlScheme") as String).isEqualTo(expected.urlScheme()) } diff --git a/pkl-core/src/test/kotlin/org/pkl/core/util/NetrcTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/util/NetrcTest.kt new file mode 100644 index 000000000..eca944c91 --- /dev/null +++ b/pkl-core/src/test/kotlin/org/pkl/core/util/NetrcTest.kt @@ -0,0 +1,207 @@ +/* + * Copyright © 2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.core.util + +import java.util.Base64 +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test + +class NetrcTest { + + @Test + fun `parse netrc file content`() { + val content = + """ + # Comment line + machine github.com + login octocat + password secret_token_123 + + machine my-artifactory.internal.net + login user + password my_token + + default + login defaultuser + password defaultpass + """ + .trimIndent() + + val entries = Netrc.parse(content) + assertThat(entries) + .containsExactly( + Netrc.Entry("github.com", false, "octocat", "secret_token_123", null), + Netrc.Entry("my-artifactory.internal.net", false, "user", "my_token", null), + Netrc.Entry("default", true, "defaultuser", "defaultpass", null), + ) + + val headersMap = Netrc.toHeadersMap(entries) + val basicGithub = + "Basic " + Base64.getEncoder().encodeToString("octocat:secret_token_123".toByteArray()) + val basicArtifactory = + "Basic " + Base64.getEncoder().encodeToString("user:my_token".toByteArray()) + assertThat(headersMap["http{,s}://github.com/**"]) + .isEqualTo(mapOf("Authorization" to listOf(basicGithub))) + assertThat(headersMap["http{,s}://my-artifactory.internal.net/**"]) + .isEqualTo(mapOf("Authorization" to listOf(basicArtifactory))) + assertThat(headersMap["**"]).isNull() + } + + @Test + fun `parse quotes and comments`() { + val content = + """ + # Header comment + machine example.com login "user with spaces" password "pass#with#hash" # trailing comment + machine "quoted.machine.com" login "quoteduser" password "token_with_quotes" + """ + .trimIndent() + + val entries = Netrc.parse(content) + assertThat(entries) + .containsExactly( + Netrc.Entry("example.com", false, "user with spaces", "pass#with#hash", null), + Netrc.Entry("quoted.machine.com", false, "quoteduser", "token_with_quotes", null), + ) + + val headersMap = Netrc.toHeadersMap(entries) + val expectedBasic = + "Basic " + Base64.getEncoder().encodeToString("user with spaces:pass#with#hash".toByteArray()) + val expectedQuoted = + "Basic " + Base64.getEncoder().encodeToString("quoteduser:token_with_quotes".toByteArray()) + assertThat(headersMap["http{,s}://example.com/**"]) + .isEqualTo(mapOf("Authorization" to listOf(expectedBasic))) + assertThat(headersMap["http{,s}://quoted.machine.com/**"]) + .isEqualTo(mapOf("Authorization" to listOf(expectedQuoted))) + } + + @Test + fun `escape sequences in quoted strings`() { + val content = + """ + machine example.com login "user\nwith\tcontrol\rchars" password "pass\"with\\escapes" + """ + .trimIndent() + + val entries = Netrc.parse(content) + assertThat(entries) + .containsExactly( + Netrc.Entry("example.com", false, "user\nwith\tcontrol\rchars", "pass\"with\\escapes", null) + ) + } + + @Test + fun `case-insensitive keywords`() { + val content = + """ + MACHINE example.com LOGIN myUser PASSWORD myPass ACCOUNT myAccount + DEFAULT LOGIN defUser PASSWORD defPass + """ + .trimIndent() + + val entries = Netrc.parse(content) + assertThat(entries) + .containsExactly( + Netrc.Entry("example.com", false, "myUser", "myPass", "myAccount"), + Netrc.Entry("default", true, "defUser", "defPass", null), + ) + } + + @Test + fun `discard unexpected tokens`() { + val content = + """ + machine example.com login foo bar baz password qux extra1 extra2 + """ + .trimIndent() + + val entries = Netrc.parse(content) + assertThat(entries).containsExactly(Netrc.Entry("example.com", false, "foo", "qux", null)) + } + + @Test + fun `skip macdef sections`() { + val content = + """ + macdef init + echo hello + echo world + + machine foo.com login user1 password pass1 + """ + .trimIndent() + + val entries = Netrc.parse(content) + assertThat(entries).containsExactly(Netrc.Entry("foo.com", false, "user1", "pass1", null)) + } + + @Test + fun `skip macdef sections with crlf`() { + val content = + "macdef init\r\n echo hello\r\n echo world\r\n\r\nmachine foo.com login user1 password pass1\r\n" + + val entries = Netrc.parse(content) + assertThat(entries).containsExactly(Netrc.Entry("foo.com", false, "user1", "pass1", null)) + } + + @Test + fun `ignore duplicate machine entries after first in headers map`() { + val content = + """ + machine foo.com login firstUser password firstPass + machine foo.com login secondUser password secondPass + """ + .trimIndent() + + val entries = Netrc.parse(content) + val headersMap = Netrc.toHeadersMap(entries) + val expectedBasic = + "Basic " + Base64.getEncoder().encodeToString("firstUser:firstPass".toByteArray()) + assertThat(headersMap["http{,s}://foo.com/**"]) + .isEqualTo(mapOf("Authorization" to listOf(expectedBasic))) + } + + @Test + fun `handle empty or comment-only content`() { + assertThat(Netrc.parse("")).isEmpty() + assertThat(Netrc.parse("# only comments\n# second line")).isEmpty() + assertThat(Netrc.toHeadersMap(emptyList())).isEmpty() + } + + @Test + fun `discard default entry in headers map`() { + val content = + """ + default login defUser password defPass + machine foo.com login fooUser password fooPass + """ + .trimIndent() + + val entries = Netrc.parse(content) + val headersMap = Netrc.toHeadersMap(entries) + assertThat(headersMap).doesNotContainKey("**") + assertThat(headersMap).containsKey("http{,s}://foo.com/**") + } + + @Test + fun `handle unclosed quotes and invalid escapes`() { + assertThatThrownBy { Netrc.parse("machine foo.com login \"unclosed") } + .isInstanceOf(Netrc.NetrcParseException::class.java) + assertThatThrownBy { Netrc.parse("machine foo.com login \"invalid\\") } + .isInstanceOf(Netrc.NetrcParseException::class.java) + } +} diff --git a/stdlib/EvaluatorSettings.pkl b/stdlib/EvaluatorSettings.pkl index 6ebf5f2a5..51af7d588 100644 --- a/stdlib/EvaluatorSettings.pkl +++ b/stdlib/EvaluatorSettings.pkl @@ -300,6 +300,17 @@ class Http { /// . @Since { version = "0.32.0" } headers: Mapping? + + /// Returns parsed headers from the given netrc file contents. + /// + /// Example: + /// ``` + /// headers = netRcHeaders(read("/path/to/.netrc")) + /// ``` + @Since { version = "0.33.0" } + external function netRcHeaders( + contents: String | Resource, + ): Mapping } /// Settings that control how Pkl talks to HTTP proxies.