mirror of
https://github.com/apple/pkl.git
synced 2026-09-10 11:51:53 +02:00
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.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<Netrc.Entry> 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<String, Map<String, List<String>>> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Entry> parse(String content) throws NetrcParseException {
|
||||
var tokens = tokenize(content);
|
||||
var entries = new ArrayList<Entry>();
|
||||
|
||||
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<String, Map<String, List<String>>> toHeadersMap(List<Entry> entries) {
|
||||
var result = new LinkedHashMap<String, Map<String, List<String>>>();
|
||||
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<String> tokenize(String content) throws NetrcParseException {
|
||||
var tokens = new ArrayList<String>();
|
||||
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<String> 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<String> 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';
|
||||
}
|
||||
}
|
||||
@@ -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}.
|
||||
|
||||
+159
@@ -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\\
|
||||
"""))
|
||||
}
|
||||
}
|
||||
+211
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -300,6 +300,17 @@ class Http {
|
||||
/// <https://pkl-lang.org/main/current/language-reference/index.html#glob-patterns>.
|
||||
@Since { version = "0.32.0" }
|
||||
headers: Mapping<String(isGlobPattern), HttpHeaders>?
|
||||
|
||||
/// 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<String(isGlobPattern), HttpHeaders>
|
||||
}
|
||||
|
||||
/// Settings that control how Pkl talks to HTTP proxies.
|
||||
|
||||
Reference in New Issue
Block a user