1 module requests.ftp; 2 3 private: 4 import std.ascii; 5 import std.algorithm; 6 import std.conv; 7 import std.datetime; 8 import std.format; 9 import std.exception; 10 import std..string; 11 import std.range; 12 import std.experimental.logger; 13 import std.stdio; 14 import std.path; 15 import std.traits; 16 import std.typecons; 17 18 import requests.uri; 19 import requests.utils; 20 import requests.streams; 21 import requests.base; 22 import requests.request; 23 import requests.connmanager; 24 import requests.rangeadapter; 25 26 public class FTPServerResponseError: Exception { 27 this(string message, string file = __FILE__, size_t line = __LINE__, Throwable next = null) @safe pure nothrow { 28 super(message, file, line, next); 29 } 30 } 31 32 public class FTPResponse : Response { 33 } 34 35 public class FtpAuthentication: Auth { 36 private { 37 string _username, _password; 38 } 39 /// Constructor. 40 /// Params: 41 /// username = username 42 /// password = password 43 /// 44 this(string username, string password) { 45 _username = username; 46 _password = password; 47 } 48 override string userName() { 49 return _username; 50 } 51 override string password() { 52 return _password; 53 } 54 override string[string] authHeaders(string domain) { 55 return null; 56 } 57 } 58 59 enum defaultBufferSize = 8192; 60 61 public struct FTPRequest { 62 private { 63 URI _uri; 64 Duration _timeout = 60.seconds; 65 uint _verbosity = 0; 66 size_t _bufferSize = defaultBufferSize; 67 long _maxContentLength = 5*1024*1024*1024; 68 long _contentLength = -1; 69 long _contentReceived; 70 NetworkStream _controlChannel; 71 string[] _responseHistory; 72 FTPResponse _response; 73 bool _useStreaming; 74 Auth _authenticator; 75 string _method; 76 string _proxy; 77 string _bind; 78 RefCounted!ConnManager _cm; 79 InputRangeAdapter _postData; 80 } 81 mixin(Getter_Setter!Duration("timeout")); 82 mixin(Getter_Setter!uint("verbosity")); 83 mixin(Getter_Setter!size_t("bufferSize")); 84 mixin(Getter_Setter!long("maxContentLength")); 85 mixin(Getter_Setter!bool("useStreaming")); 86 mixin(Getter("contentLength")); 87 mixin(Getter("contentReceived")); 88 mixin(Setter!Auth("authenticator")); 89 mixin(Getter_Setter!string("proxy")); 90 mixin(Getter_Setter!string("bind")); 91 92 @property final string[] responseHistory() @safe @nogc nothrow { 93 return _responseHistory; 94 } 95 this(string uri) { 96 _uri = URI(uri); 97 } 98 99 this(in URI uri) { 100 _uri = uri; 101 } 102 103 ~this() { 104 //if ( _controlChannel ) { 105 // _controlChannel.close(); 106 //} 107 } 108 string toString() const { 109 return "FTPRequest(%s, %s)".format(_method, _uri.uri()); 110 } 111 string format(string fmt) const { 112 import std.array; 113 import std.stdio; 114 auto a = appender!string(); 115 auto f = FormatSpec!char(fmt); 116 while (f.writeUpToNextSpec(a)) { 117 switch(f.spec) { 118 case 'h': 119 // Remote hostname. 120 a.put(_uri.host); 121 break; 122 case 'm': 123 // method. 124 a.put(_method); 125 break; 126 case 'p': 127 // Remote port. 128 a.put("%d".format(_uri.port)); 129 break; 130 case 'P': 131 // Path 132 a.put(_uri.path); 133 break; 134 case 'q': 135 // query parameters supplied with url. 136 a.put(_uri.query); 137 break; 138 case 'U': 139 a.put(_uri.uri()); 140 break; 141 default: 142 throw new FormatException("Unknown Request format spec " ~ f.spec); 143 } 144 } 145 return a.data(); 146 } 147 ushort sendCmdGetResponse(string cmd, NetworkStream __controlChannel) { 148 debug(requests) tracef("cmd to server: %s", cmd.strip); 149 if ( _verbosity >=1 ) { 150 writefln("> %s", cmd.strip); 151 } 152 __controlChannel.send(cmd); 153 string response = serverResponse(__controlChannel); 154 _responseHistory ~= response; 155 return responseToCode(response); 156 } 157 158 ushort responseToCode(string response) pure const @safe { 159 return to!ushort(response[0..3]); 160 } 161 162 void handleChangeURI(in string uri) @safe { 163 // if control channel exists and new URL not match old, then close 164 URI newURI = URI(uri); 165 if ( _controlChannel && 166 (newURI.host != _uri.host || newURI.port != _uri.port || newURI.username != _uri.username)) { 167 _controlChannel.close(); 168 _controlChannel = null; 169 } 170 _uri = newURI; 171 } 172 173 string serverResponse(NetworkStream __controlChannel) { 174 string res, buffer; 175 immutable bufferLimit = 16*1024; 176 __controlChannel.readTimeout = _timeout; 177 scope(exit) { 178 __controlChannel.readTimeout = 0.seconds; 179 } 180 debug(requests) trace("Wait on control channel"); 181 auto b = new ubyte[1]; 182 while ( __controlChannel && __controlChannel.isConnected && buffer.length < bufferLimit ) { 183 ptrdiff_t rc; 184 try { 185 rc = __controlChannel.receive(b); 186 } 187 catch (Exception e) { 188 error("Failed to read response from server"); 189 throw new FTPServerResponseError("Failed to read server responce over control channel", __FILE__, __LINE__, e); 190 } 191 //debug(requests) tracef("Got %d bytes from control socket", rc); 192 if ( rc == 0 ) { 193 error("Failed to read response from server"); 194 throw new FTPServerResponseError("Failed to read server responce over control channel", __FILE__, __LINE__); 195 } 196 buffer ~= b[0..rc]; 197 if ( buffer.endsWith('\n') ){ 198 if ( _verbosity >= 1 ) { 199 buffer. 200 splitLines. 201 each!(l=>writefln("< %s", l)); 202 } 203 auto responseLines = buffer. 204 splitLines. 205 filter!(l => l.length>3 && l[3]==' ' && l[0..3].all!isDigit); 206 if ( responseLines.count > 0 ) { 207 return responseLines.front; 208 } 209 } 210 } 211 throw new FTPServerResponseError("Failed to read server responce over control channel"); 212 assert(0); 213 } 214 ushort tryCdOrCreatePath(string[] path) { 215 /* 216 * At start we stay at original path, we have to create next path element 217 * For example: 218 * path = ["", "a", "b"] - we stay in root (path[0]), we have to cd and return ok 219 * or try to cteate "a" and cd to "a". 220 */ 221 debug(requests) info("Trying to create path %s".format(path)); 222 enforce(path.length>=2, "You called tryCdOrCreate, but there is nothing to create: %s".format(path)); 223 auto next_dir = path[1]; 224 auto code = sendCmdGetResponse("CWD " ~ next_dir ~ "\r\n", _controlChannel); 225 if ( code >= 300) { 226 // try to create, then again CWD 227 code = sendCmdGetResponse("MKD " ~ next_dir ~ "\r\n", _controlChannel); 228 if ( code > 300 ) { 229 return code; 230 } 231 code = sendCmdGetResponse("CWD " ~ next_dir ~ "\r\n", _controlChannel); 232 } 233 if ( path.length == 2 ) { 234 return code; 235 } 236 return tryCdOrCreatePath(path[1..$]); 237 } 238 239 FTPResponse post(R, A...)(string uri, R content, A args) 240 if ( __traits(compiles, cast(ubyte[])content) 241 || (rank!R == 2 && isSomeChar!(Unqual!(typeof(content.front.front)))) 242 || (rank!R == 2 && (is(Unqual!(typeof(content.front.front)) == ubyte))) 243 ) 244 { 245 if ( uri ) { 246 handleChangeURI(uri); 247 } 248 _postData = makeAdapter(content); 249 return post(); 250 } 251 252 FTPResponse post() 253 { 254 string response; 255 ushort code; 256 257 _response = new FTPResponse; 258 _response._startedAt = Clock.currTime; 259 _method = "POST"; 260 261 scope(exit) { 262 _response._finishedAt = Clock.currTime; 263 } 264 265 _response.uri = _uri; 266 _response.finalURI = _uri; 267 268 _controlChannel = _cm.get(_uri.scheme, _uri.host, _uri.port); 269 270 if ( !_controlChannel ) { 271 _controlChannel = new TCPStream(); 272 _controlChannel.connect(_uri.host, _uri.port, _timeout); 273 response = serverResponse(_controlChannel); 274 _responseHistory ~= response; 275 276 code = responseToCode(response); 277 debug(requests) tracef("Server initial response: %s", response); 278 if ( code/100 > 2 ) { 279 _response.code = code; 280 return _response; 281 } 282 // Log in 283 string user, pass; 284 if ( _authenticator ) { 285 user = _authenticator.userName(); 286 pass = _authenticator.password(); 287 } 288 else{ 289 user = _uri.username.length ? _uri.username : "anonymous"; 290 pass = _uri.password.length ? _uri.password : "requests@"; 291 } 292 debug(requests) tracef("Use %s:%s%s as username:password", user, pass[0], replicate("-", pass.length-1)); 293 294 code = sendCmdGetResponse("USER " ~ user ~ "\r\n", _controlChannel); 295 if ( code/100 > 3 ) { 296 _response.code = code; 297 return _response; 298 } else if ( code/100 == 3) { 299 300 code = sendCmdGetResponse("PASS " ~ pass ~ "\r\n", _controlChannel); 301 if ( code/100 > 2 ) { 302 _response.code = code; 303 return _response; 304 } 305 } 306 307 } 308 code = sendCmdGetResponse("PWD\r\n", _controlChannel); 309 string pwd; 310 if ( code/100 == 2 ) { 311 // like '257 "/home/testuser"' 312 auto a = _responseHistory[$-1].split(); 313 if ( a.length > 1 ) { 314 pwd = a[1].chompPrefix(`"`).chomp(`"`); 315 } 316 } 317 scope (exit) { 318 if ( pwd && _controlChannel ) { 319 sendCmdGetResponse("CWD " ~ pwd ~ "\r\n", _controlChannel); 320 } 321 } 322 323 auto path = dirName(_uri.path); 324 if ( path != "/") { 325 path = path.chompPrefix("/"); 326 } 327 code = sendCmdGetResponse("CWD " ~ path ~ "\r\n", _controlChannel); 328 if ( code == 550 ) { 329 // try to create directory end enter it 330 code = tryCdOrCreatePath(dirName(_uri.path).split('/')); 331 } 332 if ( code/100 > 2 ) { 333 _response.code = code; 334 return _response; 335 } 336 337 code = sendCmdGetResponse("PASV\r\n", _controlChannel); 338 if ( code/100 > 2 ) { 339 _response.code = code; 340 return _response; 341 } 342 // something like "227 Entering Passive Mode (132,180,15,2,210,187)" expected 343 // in last response. 344 // Cut anything between ( and ) 345 auto v = _responseHistory[$-1].findSplitBefore(")")[0].findSplitAfter("(")[1]; 346 string host; 347 ushort port; 348 try { 349 ubyte a1,a2,a3,a4,p1,p2; 350 formattedRead(v, "%d,%d,%d,%d,%d,%d", &a1, &a2, &a3, &a4, &p1, &p2); 351 host = std.format.format("%d.%d.%d.%d", a1, a2, a3, a4); 352 port = (p1<<8) + p2; 353 } catch (FormatException e) { 354 error("Failed to parse ", v); 355 _response.code = 500; 356 return _response; 357 } 358 359 auto dataStream = new TCPStream(); 360 scope (exit ) { 361 if ( dataStream !is null ) { 362 dataStream.close(); 363 } 364 } 365 366 dataStream.connect(host, port, _timeout); 367 368 code = sendCmdGetResponse("TYPE I\r\n", _controlChannel); 369 if ( code/100 > 2 ) { 370 _response.code = code; 371 return _response; 372 } 373 374 code = sendCmdGetResponse("STOR " ~ baseName(_uri.path) ~ "\r\n", _controlChannel); 375 if ( code/100 > 1 ) { 376 _response.code = code; 377 return _response; 378 } 379 size_t uploaded; 380 while ( !_postData.empty ) { 381 auto chunk = _postData.front; 382 uploaded += chunk.length; 383 dataStream.send(chunk); 384 _postData.popFront; 385 } 386 debug(requests) tracef("sent"); 387 dataStream.close(); 388 dataStream = null; 389 response = serverResponse(_controlChannel); 390 code = responseToCode(response); 391 if ( code/100 == 2 ) { 392 debug(requests) tracef("Successfully uploaded %d bytes", uploaded); 393 } 394 _response.code = code; 395 return _response; 396 } 397 private auto connectData(string v) 398 { 399 string host; 400 ushort port; 401 402 ubyte a1,a2,a3,a4,p1,p2; 403 formattedRead(v, "%d,%d,%d,%d,%d,%d", &a1, &a2, &a3, &a4, &p1, &p2); 404 host = std.format.format("%d.%d.%d.%d", a1, a2, a3, a4); 405 port = (p1<<8) + p2; 406 407 auto dataStream = new TCPStream(); 408 dataStream.bind(_bind); 409 dataStream.connect(host, port, _timeout); 410 return dataStream; 411 } 412 413 FTPResponse get(string uri = null) { 414 enforce( uri || _uri.host, "FTP URL undefined"); 415 string response; 416 ushort code; 417 418 _response = new FTPResponse; 419 _contentReceived = 0; 420 _method = "GET"; 421 422 _response._startedAt = Clock.currTime; 423 scope(exit) { 424 _response._finishedAt = Clock.currTime; 425 } 426 427 if ( uri ) { 428 handleChangeURI(uri); 429 } 430 431 _response.uri = _uri; 432 _response.finalURI = _uri; 433 434 _controlChannel = _cm.get(_uri.scheme, _uri.host, _uri.port); 435 436 if ( !_controlChannel ) { 437 _controlChannel = new TCPStream(); 438 _controlChannel.bind(_bind); 439 _controlChannel.connect(_uri.host, _uri.port, _timeout); 440 _cm.put(_uri.scheme, _uri.host, _uri.port, _controlChannel); 441 _response._connectedAt = Clock.currTime; 442 response = serverResponse(_controlChannel); 443 _responseHistory ~= response; 444 445 code = responseToCode(response); 446 debug(requests) tracef("Server initial response: %s", response); 447 if ( code/100 > 2 ) { 448 _response.code = code; 449 return _response; 450 } 451 // Log in 452 string user, pass; 453 if ( _authenticator ) { 454 user = _authenticator.userName(); 455 pass = _authenticator.password(); 456 } 457 else{ 458 user = _uri.username.length ? _uri.username : "anonymous"; 459 pass = _uri.password.length ? _uri.password : "requests@"; 460 } 461 debug(requests) tracef("Use %s:%s%s as username:password", user, pass[0], replicate("-", pass.length-1)); 462 463 code = sendCmdGetResponse("USER " ~ user ~ "\r\n", _controlChannel); 464 if ( code/100 > 3 ) { 465 _response.code = code; 466 return _response; 467 } else if ( code/100 == 3) { 468 469 code = sendCmdGetResponse("PASS " ~ pass ~ "\r\n", _controlChannel); 470 if ( code/100 > 2 ) { 471 _response.code = code; 472 return _response; 473 } 474 } 475 } 476 else { 477 _response._connectedAt = Clock.currTime; 478 } 479 480 code = sendCmdGetResponse("PWD\r\n", _controlChannel); 481 string pwd; 482 if ( code/100 == 2 ) { 483 // like '257 "/home/testuser"' 484 auto a = _responseHistory[$-1].split(); 485 if ( a.length > 1 ) { 486 pwd = a[1].chompPrefix(`"`).chomp(`"`); 487 } 488 } 489 scope (exit) { 490 if ( pwd && _controlChannel && !_useStreaming ) { 491 sendCmdGetResponse("CWD " ~ pwd ~ "\r\n", _controlChannel); 492 } 493 } 494 495 auto path = dirName(_uri.path); 496 if ( path != "/") { 497 path = path.chompPrefix("/"); 498 } 499 code = sendCmdGetResponse("CWD " ~ path ~ "\r\n", _controlChannel); 500 if ( code/100 > 2 ) { 501 _response.code = code; 502 return _response; 503 } 504 505 code = sendCmdGetResponse("TYPE I\r\n", _controlChannel); 506 if ( code/100 > 2 ) { 507 _response.code = code; 508 return _response; 509 } 510 511 code = sendCmdGetResponse("SIZE " ~ baseName(_uri.path) ~ "\r\n", _controlChannel); 512 if ( code/100 == 2 ) { 513 // something like 514 // 213 229355520 515 auto s = _responseHistory[$-1].findSplitAfter(" "); 516 if ( s.length ) { 517 try { 518 _contentLength = to!long(s[1]); 519 } catch (ConvException) { 520 debug(requests) trace("Failed to convert string %s to file size".format(s[1])); 521 } 522 } 523 } 524 525 if ( _maxContentLength > 0 && _contentLength > _maxContentLength ) { 526 throw new RequestException("maxContentLength exceeded for ftp data"); 527 } 528 529 code = sendCmdGetResponse("PASV\r\n", _controlChannel); 530 if ( code/100 > 2 ) { 531 _response.code = code; 532 return _response; 533 } 534 // something like "227 Entering Passive Mode (132,180,15,2,210,187)" expected 535 // in last response. 536 // Cut anything between ( and ) 537 auto v = _responseHistory[$-1].findSplitBefore(")")[0].findSplitAfter("(")[1]; 538 539 TCPStream dataStream; 540 try{ 541 dataStream = connectData(v); 542 } catch (FormatException e) { 543 error("Failed to parse ", v); 544 _response.code = 500; 545 return _response; 546 } 547 scope (exit ) { 548 if ( dataStream !is null && !_response._receiveAsRange.activated ) { 549 dataStream.close(); 550 } 551 } 552 553 _response._requestSentAt = Clock.currTime; 554 555 code = sendCmdGetResponse("RETR " ~ baseName(_uri.path) ~ "\r\n", _controlChannel); 556 if ( code/100 > 1 && code/100 < 5) { 557 _response.code = code; 558 return _response; 559 } 560 if ( code/100 == 5) { 561 dataStream.close(); 562 code = sendCmdGetResponse("PASV\r\n", _controlChannel); 563 if ( code/100 > 2 ) { 564 _response.code = code; 565 return _response; 566 } 567 v = _responseHistory[$-1].findSplitBefore(")")[0].findSplitAfter("(")[1]; 568 dataStream = connectData(v); 569 code = sendCmdGetResponse("NLST " ~ _uri.path ~ "\r\n", _controlChannel); 570 if ( code/100 > 1 ) { 571 _response.code = code; 572 return _response; 573 } 574 } 575 576 dataStream.readTimeout = _timeout; 577 while ( true ) { 578 auto b = new ubyte[_bufferSize]; 579 auto rc = dataStream.receive(b); 580 if ( rc <= 0 ) { 581 debug(requests) trace("done"); 582 break; 583 } 584 debug(requests) tracef("got %d bytes from data channel", rc); 585 586 _contentReceived += rc; 587 _response._responseBody.putNoCopy(b[0..rc]); 588 589 if ( _maxContentLength && _response._responseBody.length >= _maxContentLength ) { 590 throw new RequestException("maxContentLength exceeded for ftp data"); 591 } 592 if ( _useStreaming ) { 593 debug(requests) trace("ftp uses streaming"); 594 595 auto __maxContentLength = _maxContentLength; 596 auto __contentLength = _contentLength; 597 auto __contentReceived = _contentReceived; 598 auto __bufferSize = _bufferSize; 599 auto __dataStream = dataStream; 600 auto __controlChannel = _controlChannel; 601 602 _response._contentLength = _contentLength; 603 _response.receiveAsRange.activated = true; 604 _response.receiveAsRange.data.length = 0; 605 _response.receiveAsRange.data = _response._responseBody.data; 606 _response.receiveAsRange.read = delegate ubyte[] () { 607 Buffer!ubyte result; 608 while(true) { 609 // check if we received everything we need 610 if ( __maxContentLength > 0 && __contentReceived >= __maxContentLength ) 611 { 612 throw new RequestException("ContentLength > maxContentLength (%d>%d)". 613 format(__contentLength, __maxContentLength)); 614 } 615 // have to continue 616 auto b = new ubyte[__bufferSize]; 617 ptrdiff_t read; 618 try { 619 read = __dataStream.receive(b); 620 } 621 catch (Exception e) { 622 throw new RequestException("streaming_in error reading from socket", __FILE__, __LINE__, e); 623 } 624 625 if ( read > 0 ) { 626 _response._contentReceived += read; 627 __contentReceived += read; 628 result.putNoCopy(b[0..read]); 629 return result.data; 630 } 631 if ( read == 0 ) { 632 debug(requests) tracef("streaming_in: server closed connection"); 633 __dataStream.close(); 634 code = responseToCode(serverResponse(__controlChannel)); 635 if ( code/100 == 2 ) { 636 debug(requests) tracef("Successfully received %d bytes", _response._responseBody.length); 637 } 638 _response.code = code; 639 sendCmdGetResponse("CWD " ~ pwd ~ "\r\n", __controlChannel); 640 break; 641 } 642 } 643 return result.data; 644 }; 645 debug(requests) tracef("leave streaming get"); 646 return _response; 647 } 648 } 649 dataStream.close(); 650 response = serverResponse(_controlChannel); 651 code = responseToCode(response); 652 if ( code/100 == 2 ) { 653 debug(requests) tracef("Successfully received %d bytes", _response._responseBody.length); 654 } 655 _response.code = code; 656 return _response; 657 } 658 659 FTPResponse execute(Request r) 660 { 661 string method = r.method; 662 _uri = r.uri(); 663 _authenticator = r.authenticator; 664 _maxContentLength = r.maxContentLength; 665 _useStreaming = r.useStreaming; 666 _verbosity = r.verbosity; 667 _cm = r.cm; 668 _postData = r.postData; 669 _bufferSize = r.bufferSize; 670 _proxy = r.proxy; 671 _bind = r.bind; 672 _timeout = r.timeout; 673 674 if ( method == "GET" ) 675 { 676 return get(); 677 } 678 if ( method == "POST" ) 679 { 680 return post(); 681 } 682 assert(0, "Can't handle method %s for ftp request".format(method)); 683 } 684 } 685 686 //package unittest { 687 // import std.process; 688 // 689 // globalLogLevel(LogLevel.info); 690 // bool unreliable_network = environment.get("UNRELIABLENETWORK", "false") == "true"; 691 // 692 // info("testing ftp"); 693 // auto rq = FTPRequest(); 694 // info("ftp post ", "ftp://speedtest.tele2.net/upload/TEST.TXT"); 695 // auto rs = rq.post("ftp://speedtest.tele2.net/upload/TEST.TXT", "test, ignore please\n".representation); 696 // assert(unreliable_network || rs.code == 226); 697 // info("ftp get ", "ftp://speedtest.tele2.net/nonexistent", ", in same session."); 698 // rs = rq.get("ftp://speedtest.tele2.net/nonexistent"); 699 // assert(unreliable_network || rs.code != 226); 700 // info("ftp get ", "ftp://speedtest.tele2.net/1KB.zip", ", in same session."); 701 // rs = rq.get("ftp://speedtest.tele2.net/1KB.zip"); 702 // assert(unreliable_network || rs.code == 226); 703 // assert(unreliable_network || rs.responseBody.length == 1024); 704 // info("ftp post ", "ftp://speedtest.tele2.net/upload/TEST.TXT"); 705 // rs = rq.post("ftp://speedtest.tele2.net/upload/TEST.TXT", "another test, ignore please\n".representation); 706 // assert(unreliable_network || rs.code == 226); 707 // info("ftp get ", "ftp://ftp.iij.ad.jp/pub/FreeBSD/README.TXT"); 708 // try { 709 // rs = rq.get("ftp://ftp.iij.ad.jp/pub/FreeBSD/README.TXT"); 710 // } 711 // catch (ConnectError e) 712 // { 713 // } 714 // assert(unreliable_network || rs.code == 226); 715 // info("ftp get ftp://ftp.iij.ad.jp/pub/FreeBSD/README.TXT with authenticator"); 716 // rq.authenticator = new FtpAuthentication("anonymous", "requests@"); 717 // try { 718 // rs = rq.get("ftp://ftp.iij.ad.jp/pub/FreeBSD/README.TXT"); 719 // } 720 // catch (ConnectError e) 721 // { 722 // } 723 // assert(unreliable_network || rs.code == 226); 724 // assert(unreliable_network || rs.finalURI.path == "/pub/FreeBSD/README.TXT"); 725 // assert(unreliable_network || rq.format("%m|%h|%p|%P|%q|%U") == "GET|ftp.iij.ad.jp|21|/pub/FreeBSD/README.TXT||ftp://ftp.iij.ad.jp/pub/FreeBSD/README.TXT"); 726 // assert(unreliable_network || rs.format("%h|%p|%P|%q|%U") == "ftp.iij.ad.jp|21|/pub/FreeBSD/README.TXT||ftp://ftp.iij.ad.jp/pub/FreeBSD/README.TXT"); 727 // info("testing ftp - done."); 728 //}